Skip to content

Screen Blueprints — Clubs

Status: Active Last reviewed: 2026-09-17

Current-state blueprints for the clubs screens, read from the components. Conventions and the screen index live in screen-blueprint.md. History lives in git and rebuild-log.md — not here.


Club List (클럽)

  • Route: /(tabs)/(clubs)
  • Screen: ClubListScreenpackages/features/clubs/src/club-list-screen.tsx
  • Shell: MainTabShell · title t().common.club ("클럽") · headerRight: ClubJoinHeaderAction (HeaderTextAction label t().common.inviteCode "초대 코드" → routes.joinClub — U-09, replaces the bare Key icon)
  • Design: ratified
  • Verified: 2026-09-17

Purpose. Every signed-in user's clubs tab — manage the clubs they belong to (내 클럽) and discover new ones (탐색), from two PillNav panes.

Anatomy.

├─ [MainTabShell] title="클럽" headerRight=[ClubJoinHeaderAction] (HeaderTextAction "초대 코드")
├─ tabs = [내 클럽, 탐색] (SegmentedTabs, preload="visited")
├─ ▸ 내 클럽 → [MyClubsTab]
│   ├─ [MyClubListControls] header → [SectionHeader title=clubList.sectionMemberClubs] (가입한 클럽 — the pane's own title, never the tab label)
│   ├─ [GroupedFeedList]
│   │   ├─ section (title="") → [ClubCard isMember] rows, session-sorted
│   │   └─ ▸ section "게스트 클럽" (t().clubList.sectionGuestClubs) — only when ≥1 active club_guests invite
│   └─ each member ClubCard shows up to 2 clickable upcoming-session rows (`sessionCardModelsById`)
├─ ▸ 탐색 → [DiscoverTab]
│   ├─ [ClubDiscoveryControls] search box + filter chips
│   ├─ [SectionHeader] title=t().clubList.sectionDiscoverResults
│   └─ [FeedList] of [ClubCard isMember=false] rows, infinite scroll
└─ [BottomCtaBand] ✦ "클럽 만들기" (t().common.createClub) → routes.createClub — 내 클럽 tab only; action="secondary" while 내 클럽 has no member/guest club, "primary" once populated (U-10, myClubsEmpty lifted from MyClubsTab)

States.

  • 내 클럽 loading → 4× SkeletonCard; error → QueryErrorState; empty → EmptyState (Users, variant full, title t().common.noClubsYet / subtitle noMyClubsSubtitle, primary t().common.browseClubsroutes.discoverClubs, secondary t().clubList.joinWithInviteCodeAction "초대 코드로 가입" → routes.joinClub — U-10 usability audit).
  • 탐색 loading → 4× SkeletonCard; error → QueryErrorState; empty → EmptyState (Compass, variant compact, noDiscoverResults).
  • ?tab=discover/?tab=my (routes.discoverClubs) opens the matching pane on mount and on later param changes.
  • Archived clubs render a Banner (tone muted, text bannerArchived) inside the discover-branch ClubCard only (member branch gates this at the card level too).
  • Offline cold start (query paused, nothing fetched) → OfflineEmptyState with retry, via the list's isPausedOffline + offlineState — never the empty state (2026-09-11 offline sweep).

Actions.

  • Header HeaderTextAction "초대 코드" → routes.joinClub (invite-code join; U-09, replaces the bare Key icon).
  • Tab switch 내 클럽 ↔ 탐색 (SegmentedTabs).
  • 탐색: type in search box / toggle filter chips → refetches useViewerDiscoverClubs.
  • Tap a ClubCard (either tab) → routes.club/routes.clubPreview (card-internal navigation, not this screen).
  • 내 클럽 only: "클럽 만들기" BottomCtaBand CTA → routes.createClub; empty-state secondary CTA "초대 코드로 가입" → routes.joinClub.
  • Pull-to-refresh both panes; 탐색 infinite scroll (onEndReached).

Gaps. none found.


Club Admin (관리)

  • Route: /clubs/[clubId]/admin
  • Screen: ClubAdminScreenpackages/features/clubs/src/club-admin-screen.tsx (renders AdminTabpackages/features/clubs/src/club-detail/admin-tab.tsx)
  • Shell: DetailShell · title t().clubDetail.tabAdmin ("관리") · headerRight: none
  • Design: unreviewed
  • Verified: 2026-09-16

Purpose. The 관리 console for a club's owner/총무 — a routed screen (not a tab) reached from the club-detail top bar's ⋯ menu, surfacing an attention snapshot plus navigation into every admin sub-surface.

Anatomy.

├─ [DetailShell] title="관리" onBack
├─ non-admin guard: effect → appRouter.replace(routes.club(clubId)) once ctx.ready && !isAnyAdmin
└─ ▸ ctx.ready && isAnyAdmin → [AdminTab clubId role]
    ├─ [FlatColumn] lead = ▸ canManageClub → [AdminSnapshotStrip] (5 cells: 송금 확인/가입 신청/회비 미납/신고/출석 주의)
    ├─ ▸ showsOperationsSection (any of canManageMembers/Dues/Schedule/canManageClub)
    │   └─ [SectionBlock variant="flat" title="운영"] → [RowList]
    │       ├─ ▸ canManageMembers → [AdminNavRow] 멤버 관리 → routes.clubMembers
    │       ├─ ▸ canManageClub → [AdminNavRow] 출석 현황 → routes.clubAttendance
    │       ├─ ▸ canManageDues → [AdminNavRow] 회비 관리 → routes.clubDues
    │       ├─ ▸ canManageSchedule → [AdminNavRow] 일정 관리 → routes.clubSessions
    │       ├─ ▸ canManageClub → [AdminNavRow] 코트 관리 → routes.clubVenues
    │       └─ ▸ canManageClub → [AdminNavRow] 도전 과제 (interClub) → routes.clubChallenges
    └─ ▸ showsSettingsSection (canManageClub)
        └─ [SectionBlock variant="flat" title="설정"] → [RowList]
            ├─ ▸ canManageClub && entitlements.growthAnalyticsEnabled → [AdminNavRow] 성장 분석 → routes.clubAnalytics
            └─ ▸ canManageClub → [AdminNavRow testID="club.admin.settings-row"] 클럽 설정 → routes.clubSettings (prefetches club)

States.

  • Non-admin deep link: renders nothing (no skeleton) then bounces to routes.club(clubId).
  • ctx.ready false: DetailShell renders with no children (no skeleton of its own — the screen has none).
  • Each row's parent section renders only when ≥1 of its rows is visible to the caller's role; 성장 분석 additionally gates on the growthAnalyticsEnabled entitlement.
  • 멤버 관리 / 클럽 설정 rows prefetch (prefetchClubMembers/prefetchClub) on press before navigating.
  • Offline cold start (query paused, nothing fetched) → OfflineEmptyState with retry, via an early OfflineEmptyState return — never the empty state (2026-09-11 offline sweep).

Actions.

  • Every AdminNavRow → its listed route (see anatomy).

Gaps. none found.


Club Album (앨범)

  • Route: /clubs/[clubId]/album
  • Screen: ClubAlbumScreenpackages/features/clubs/src/club-album-screen.tsx
  • Shell: DetailShell · title strings.albumTitle · headerRight: none
  • Design: unreviewed
  • Verified: 2026-09-14

Purpose. The club's full approved photo album — a member-facing viewing surface reached from the album banner on the club home place surface, not a nav tab.

Anatomy.

├─ [DetailShell] title=albumTitle onBack contentContainerStyle=SCROLL_CONTENT.list (full-bleed, content padding zeroed)
└─ ▸ loading → 9× [Skeleton] tiles (3×3 grid)
   ▸ error → [QueryErrorState]
   ▸ empty (0 approved media) → [EmptyState] icon=Image variant="compact" title=albumEmpty
   ▸ populated → [XStack flexWrap] 3-column grid of [RemoteImage] tiles, cover fit, newest-first

States. loading / error / empty / populated as above. No lightbox or tap handler on tiles.

  • Offline cold start (query paused, nothing fetched) → OfflineEmptyState with retry, via an early OfflineEmptyState return — never the empty state (2026-09-11 offline sweep).

Actions. Back only — tiles are display-only; upload/approval management lives on the settings (관리) side, not here.

Gaps. none found.


Club Analytics (성장 분석)

  • Route: /clubs/[clubId]/analytics
  • Screen: ClubAnalyticsScreenpackages/features/clubs/src/club-analytics-screen.tsx
  • Shell: DetailShell · title strings.pageTitle · headerRight: none
  • Design: unreviewed
  • Verified: 2026-09-13

Purpose. A growth-analytics dashboard for owners/총무 — profile completeness, discovery-funnel conversion, and operations health — gated behind canManageClub AND the growthAnalyticsEnabled entitlement (a tiered feature).

Anatomy.

├─ [DetailShell] title=pageTitle onBack scroll
└─ [QueryBoundary] queries=ctx.queries(+analyticsQ when eligible)
    ├─ ▸ !canManageClub → [EmptyState] ShieldAlert noPermissionTitle/Subtitle
    ├─ ▸ canManageClub && !growthAnalyticsEnabled → [EmptyState] TrendingUp lockedTitle/Subtitle
    ├─ ▸ eligible, no data → [EmptyState] TrendingUp emptyTitle/Subtitle
    └─ ▸ eligible, data → [GrowthAnalyticsContent] → [FlatColumn]
        ├─ [SectionBlock "프로필"] → [Well] completeness % + [ProgressBar] + media/application [Badge]s
        ├─ [SectionBlock "퍼널"] → 2× [StatStrip] (profileViews/joinTaps w/ delta; applicationStarts/Submits w/ conversion %)
        ├─ [SectionBlock "발견"] → [RowList]: 노출 횟수 (+ day-count badge), 세션 관심; ▸ topSources sub-list
        └─ [SectionBlock "운영"] → 2× [StatStrip] (sessionsThisMonth/activeMembers; attendanceRate/duesRate %)

States. Loading (QueryBoundary) → 3× SkeletonCard; permission/entitlement/empty gates as above; each MetricValue cell shows a two-line value+meta via StatStrip's valueSlot.

Actions. Back only — a read-only dashboard, no CTAs or row taps.

Gaps. none found.


Club Attendance (출석 현황)

  • Route: /clubs/[clubId]/attendance
  • Screen: ClubAttendanceScreenpackages/features/clubs/src/club-attendance-screen.tsx
  • Shell: DetailShell · title i.attendanceDashboardTitle ("출석 현황") · headerRight: none
  • Design: unreviewed
  • Verified: 2026-09-17
  • **Notes:**2026-09-17: person/club name ListRows pass titleLines={NAME_LINES} (U-13, two-line proper names)

Purpose. A 총무/owner attendance dashboard — per-member attendance rate plus a flagged-member (≥3 strikes) drill-down where an admin can excuse individual strike records. Non-admins are blocked entirely.

Anatomy.

├─ [DetailShell] title="출석 현황" onBack scroll=false
└─ [QueryBoundary] queries=[...ctx.queries, attendanceQ]
    ├─ ▸ !canManageClub → [EmptyState] ShieldAlert (centered, full permission block)
    └─ ▸ canManageClub → [FeedList]
        ├─ header → [StatStrip] 평균 출석률 % / 주의 (flagged count, warning tone if >0) / 추적 (active members)
        ├─ rows → [AttendanceMemberRow] [AvatarBubble]+name, trailing=▸[Badge "주의"] + [MiniBarMeter 5-bar] + rate%
        └─ [AttendanceExcuseModal] ([ModalPanel], opened only by tapping a FLAGGED row)
            ├─ member header + strike count + strikeRecordsSubtitle
            └─ [StrikeRecordRow] per record: session/date + [AttendanceStatusBadge] + "사유 인정" [ActionButton]

States. QueryBoundary loading → 3× SkeletonCard; permission gate → EmptyState; list empty (no records at all) → EmptyState (ClipboardCheck, attendanceDashboardEmpty); non-flagged rows carry no onPress at all (no press feedback); modal: loading → 2× SkeletonRow, empty → plain centered text, per-record excuse-pending disables that record's button only.

Actions. Tap a flagged row → opens the excuse modal; in-modal "사유 인정" per record → confirm (excuseConfirmTitle, naming the member) → useExcuseAttendance (bug-class fix, 2026-09-16: confirm-consequential-mutation flagged the previously-direct .mutate call); 취소 closes the modal; pull-to-refresh on the list.

Gaps. none found.


Board Post Detail (게시글)

  • Route: /clubs/[clubId]/board/[postId]
  • Screen: PostDetailScreenpackages/features/clubs/src/post-detail-screen.tsx (subcomponents under post-detail/*)
  • Shell: DetailShell · title strings.board.postDetailTitle · headerRight: none
  • Design: ratified
  • Verified: 2026-09-16

Purpose. The full view of one club board post — author, body, images, reactions, threaded comments, with a pinned comment composer — reached by tapping a post card in the club home feed.

Anatomy.

├─ [KeyboardAvoidingView] (iOS "padding"; Android no-op, relies on app.json pan mode)
└─ [DetailShell] title=postDetailTitle onBack scroll=false footer=▸[CommentComposer] (authed only)
    └─ [FeedList]
        ├─ header → [PostDetailHeader]: [TypeHero] author + [TrustTierBadge] + club·relative-time facts
        │   ├─ [FlatColumn]: body lines (+ ▸ resolvedTitle heading, ▸ audience [Badge]) → full-bleed images
        │   ├─ ▸ [Pressable] "세션으로 이동" (session_link posts with a linkUrl) → routes.clubSession
        │   ├─ [PostActionRow] 5-type reaction picker, comment count, share (authed only)
        │   └─ [SectionBlock "댓글"] meta=comment count → ▸ "이전 댓글 더 보기" [ActionButton]
        └─ rows → [MemoCommentRow] (avatar, header, body, reply/delete), hand-applied divider `edge`/`hairline`

States. Post loading → 2× SkeletonCard; post error → QueryErrorState; missing record: post not found/deleted → NotFoundState (postDeleted/postDeletedSubtitle; 2026-09-16 converted from a bespoke Newspaper EmptyState); unauthenticated viewer → comments list renders empty and no composer footer; comments loading → 2× SkeletonRow; comments error → QueryErrorState; comments empty (authed, 0) → plain centered text; reply-target mode active while replying to a comment.

  • Offline cold start (query paused, nothing fetched) → OfflineEmptyState with retry, via the list's isPausedOffline + offlineState — never the empty state (2026-09-11 offline sweep).

Actions. React (5 types) via PostActionRow; reply to a comment (author+name captured as replyTarget, shown as a composer chip); "이전 댓글 더 보기" loads earlier comments; "세션으로 이동" navigates to the linked session; share via PostActionRow; submit/cancel in CommentComposer; pull-to-refresh (post+comments+reactions); mark-post-read fires once per postId on mount via a viewability threshold.

Gaps. none found.


Board Create Post (글쓰기)

  • Route: /clubs/[clubId]/board/create
  • Screen: PostCreateScreenpackages/features/clubs/src/post-create-screen.tsx
  • Shell: DetailShell · title t().board.createPost · headerRight: none
  • Design: unreviewed
  • Verified: 2026-09-17

Purpose. The 글쓰기 compose surface for a club's board — category, title, body, up to 4 photos, and an admin-only audience selector.

Anatomy.

├─ [DetailShell] title="글쓰기" onBack footer=[BottomCtaBand]✦게시
└─ [YStack backgroundColor="$card"] full-bleed compose ground
    ├─ category [SelectionChip] row — 자유/일정 후기/가입인사 (all) + ▸공지/운영진방 (canManageClub); `?category=` deep-links preselect
    ├─ bare title [StyledInput] over a [Divider] hairline (maxLength 80)
    ├─ bare multiline body [StyledInput] filling remaining space (maxLength 4000)
    ├─ photo tile row — up to 4 [Image] tiles with per-photo upload spinner / [RemoveIconButton]; add-photo [Pressable] tile
    ├─ ▸ audience [SelectionChip] row (회원/공개/운영진 전용) — admins only, hidden when category="officials" (forced official)
    └─ ▸ inline submit error [Text]

States. Non-admin viewer: admin-only categories clamp to 자유; 운영진방 selected forces audience='official' and hides the audience row; photo uploading shows a spinner overlay and hides remove until done; submit disabled while body empty / no user·club / any photo uploading; submit pending → CTA loading; submit error → inline red text; unmount with unposted uploaded photos auto-deletes them via useRemoveClubPostMedia.

Actions. Select category chip; type title/body; add/remove photo (resize-then-upload pipeline); select audience chip (admins, non-official); submit → useCreatePost then appRouter.replace(routes.clubPost(...)).

Gaps. none found.

Notes. 2026-09-17: photo pick routes through usePickImages (one permission dialog, resize) — no visual change.


Club Challenges (도전 과제)

  • Route: /clubs/[clubId]/challenges
  • Screen: ClubChallengesScreenpackages/features/clubs/src/club-challenges-screen.tsx
  • Shell: DetailShell · title strings.title ("도전 과제") · headerRight: none
  • Design: ratified
  • Verified: 2026-09-17

Purpose. The inter-club challenge dashboard — every challenge tied to the club, grouped by status, with a club-wide win/loss/in-progress StatStrip and an accept flow for pending challenges the club can respond to.

Anatomy.

├─ [DetailShell] title="도전 과제" onBack refreshControl
└─ ▸ loading → 2×[SkeletonCard] ▸ error → [QueryErrorState] ▸ empty → [EmptyState] Swords noChallenges
   ▸ populated → [FlatColumn] lead=[StatStrip] 승(success)/패(warning)/진행 중
       ├─ ▸ [SectionBlock "대기중"] → [RowList dividers] of [ChallengeBlock] (proposed)
       ├─ ▸ [SectionBlock "진행중"] → [RowList dividers] of [ChallengeBlock] (accepted/in_progress)
       └─ ▸ [SectionBlock "완료"] → [RowList dividers] of [ChallengeBlock] (completed/declined/cancelled)
   [ChallengeAcceptModal] (opened by tapping an acceptable pending block)

ChallengeBlock: title + type Badge, centered scoreboard Text, date-range meta, winner/tie line — a bottom hairline (no Card), tappable only when canAccept (viewer club is proposed + away side).

States. As listed above; only pending+away-side blocks are Pressable variant="row", every other block is a static display block; accept modal open/close + acceptMutation.isPending.

  • Offline cold start (query paused, nothing fetched) → OfflineEmptyState with retry, via an early OfflineEmptyState return — never the empty state (2026-09-11 offline sweep).

Actions. Tap an acceptable pending block → opens ChallengeAcceptModal; confirm → useAcceptChallenge; pull-to-refresh.

Gaps. none found.


Club Dues (회비)

  • Route: /clubs/[clubId]/dues
  • Screen: ClubDuesScreenpackages/features/clubs/src/club-dues-screen.tsx (delegates to ClubDuesBody, also embedded by club-detail/dues-pane.tsx)
  • Shell: DetailShell · title admin: t().common.manageDues / member: labels.memberView.pageTitle · headerRight: none
  • Design: unreviewed
  • Verified: 2026-09-17

Purpose. Monthly dues management — a full admin roster for marking members paid/waived/noted and generating a month's dues, plus a read-only self-service view for members to see their own status and self-attest a payment.

Anatomy. (U-14, 2026-09-17: the kebab → DuesActionModal options sheet is RETIRED — replaced by inline pills directly on DuesRow, mirroring the session-payments roster's PaymentRow grammar; a row tap now opens the note editor directly. U-15, 2026-09-17: the roster section header gains a "전체 납부 확인" ActionButton in its right slot — mirrors pickup-host-dashboard-screen.tsx's bulk-approve precedent — visible only when 2+ current-period rows are unpaid/partial; one confirm names the count + total remaining, then one mark_dues_paid_bulk RPC call (SECURITY INVOKER, RLS-authorised — migration 00648) marks all of them paid.)

├─ [DetailShell] title=(role-dependent) onBack scroll=false
└─ [ClubDuesBody] → [QueryBoundary] queries=role-dependent set
    ├─ ▸ canManageDues → [AdminDuesRoster]
    │   ├─ header → [AdminDuesListHeader]: month selector → [StatStrip] 완납/미납/부분납 + [ProgressBar]
    │   │   → [SectionHeader] "이번 달 회비" right=▸[ActionButton]"전체 납부 확인" (2+ eligible only)
    │   ├─ [FeedList] of [DuesRow] canManage — inline pills: unpaid/partial → [납부 확인][면제];
    │   │   paid (current month only, U-04b) → [납부 취소] (confirm naming the member →
    │   │   `useUnmarkDuesPaid` / `unmark_dues_paid` RPC, manual rail, never a confirmed
    │   │   payment_history-backed record); waived → none; row itself is Pressable → opens
    │   │   [DuesNoteModal] (sub-line shows the note, or a muted "메모 추가" cue when empty);
    │   │   inline confirm CTA takes priority over the status pills on a pending self-attest
    │   ├─ ▸ [BottomCtaBand]✦"이번 달 회비 생성" — only when dues.length===0 for the month
    │   └─ [DuesNoteModal]
    └─ ▸ !canManageDues → [MemberDuesRoster]
        ├─ header → [MemberDuesListHeader]: month selector → ▸ [DuesBankCard] (입금 계좌) when configured
        ├─ [FeedList] of [DuesRow] canManage=false — self-attest CTA on unpaid rows
        └─ [DuesAttestModal]

States. QueryBoundary loading → SkeletonHero + 3× SkeletonCard; admin/member split entirely on ctx.role.hasPermission('manage_dues'); per-row confirmingIds isolates one row's confirm-in-flight; member pending self-attest shows a "확인 대기" badge and blocks re-attest.

Actions. Prev/next month; admin inline pills → mark paid / waive / unmark (useUpdateDuesStatus / useUnmarkDuesPaid); admin row tap → note editor; admin inline confirm on a pending self-attest; admin "이번 달 회비 생성" (useGenerateDues, empty month only); admin "전체 납부 확인" bulk mark-paid (useMarkDuesPaidBulk, 2+ unpaid/partial rows only, U-15); member self-attest a payment (useAttestDuesPayment).

Gaps. none found.


Club Detail — place surface (클럽 홈)

  • Route: /clubs/[clubId]
  • Screen: ClubDetailScreenpackages/features/clubs/src/club-detail-screen.tsx (6 panes under club-detail/*)
  • Shell: PlaceShell (CollapsingTopBar + CollapsingHeaderLayer) · title club.name · headerRight: XStack gap="$1" of ▸ HeaderTextAction label t().common.admin "관리" → routes.clubAdmin (isAnyAdmin only, U-05) + HeaderActionsMenu (공유/나가기 — 관리 moved out of the menu)
  • Design: ratified
  • Verified: 2026-09-17

Purpose. The club's home hub for every member — a collapsing-banner place surface with 6 content panes; non-members are bounced to the read-only preview.

Anatomy.

├─ [PlaceShell] (bottom-inset-only; banner extends under the status bar)
└─ [ClubDetailBody]
    ├─ [QueryBoundary] queries=ctx.queries → loading=[SkeletonHero]+3×[SkeletonCard]; !club (missing record) → [NotFoundState] (2026-09-16: converted from a bespoke centered Text+ActionButton to the shared component)
    ├─ [SegmentedTabs chrome="none" preload="visited"] items=[홈(badge=unreadPostCount)·일정·채팅·기록·멤버·회비]
    │   ├─ 홈 → [HomeTab] (feed: mixed posts + 00491 categories + auto rows [경기 결과·사진 업로드])
    │   ├─ 일정 → [ClubSessionsPane] + [SessionSegmentChips] (예정/완료)
    │   ├─ 채팅 → ▸authLoading [InterimPane loading] ▸userId [ClubChatPane] ▸else [InterimPane]
    │   ├─ 기록 → [ClubRecordsPane] (wraps `RankingTab`)
    │   ├─ 멤버 → [ClubMembersPane] (reuses club-members-screen's MemberRow/modals; header: member count + ▸U-25 조 legend (shown once the club has 2+ active groups) + ▸[ListRow "복식 파트너 추천"] → routes.clubPartners, member-gated)
    │   └─ 회비 → [ClubDuesPane] (embeds `ClubDuesBody`)
    ├─ [CollapsingHeaderLayer] → [ClubPlaceHeader]: [ClubAlbumBanner] → [ClubIdentityHeader] → [PillNav underline] (6 items) — ONE instance, translates with scroll, never remounts on tab switch
    ├─ ▸ pinned chip copy (only once the inline chip row scrolls under the collapsed bar, 홈/일정 only) → [ClubFeedChips] / [SessionSegmentChips] + [Divider]
    ├─ [CollapsingTopBar] collapsed⇄expanded, right=[XStack gap=$1]: ▸[HeaderTextAction "관리"] isAnyAdmin → routes.clubAdmin (U-05, left of ⋯) + [HeaderActionsMenu] (공유 · ▸나가기 non-owner members, destructive)
    └─ contextual [BottomCtaBand] ✦ "글쓰기" (홈, isMember) or "일정 만들기" (일정, canManageSchedule)

States. Loading/not-found as above; non-member → appRouter.replace(routes.clubPreview) (no visible in-place state); legacy ?tab=admin deep link bounces to routes.clubAdmin instead of an in-shell tab; 채팅 pane: authLoading → loading InterimPane, authed → ClubChatPane, else → InterimPane (effectively unreachable — screen is member-gated); header collapsed↔expanded is scroll-driven with an a11y announcement on the transition into collapsed; panes mount only once headerHeight > 0 (one-frame gate).

Actions.HeaderTextAction "관리" (isAnyAdmin only) → routes.clubAdmin (U-05); HeaderActionsMenu: 공유 (useShareClubInvite) / ▸나가기 (useLeaveClub, destructive confirm); nav switch across the 6 panes; 홈's mission card step 2 switches this SAME surface to 일정 (handleNavChange, not a push); "글쓰기"/"일정 만들기" BottomCtaBand CTAs.

Gaps. none found.


Join Review Detail (가입 심사)

  • Route: /clubs/[clubId]/join-review/[userId]
  • Screen: JoinReviewDetailScreenpackages/features/clubs/src/join-review-detail-screen.tsx
  • Shell: DetailShell · title = candidate display name · headerRight: none
  • Design: ratified
  • Verified: 2026-09-16

Purpose. The admin's per-candidate evidence dossier for the guest→member join-review pipeline — attendance history, co-players, manner tags, recommendations — ending in an approve/reject decision on the pending club_join_request.

Anatomy.

├─ [DetailShell ground="canvas"] title=candidateName onBack footer=[BottomCtaBand]
│   └─ [XStack gap=CTA_PAIR_GAP]: 거절 (neutral, flex=1, testID join-review.reject) + 승인 (primary, flex=2, testID join-review.approve)
├─ [TypeHero] leading=[AvatarBubble size=HERO] title=candidateName facts=[👤게스트, 🕐 first-attended, 🎾 N회 참석]
└─ [FlatColumn]
    ├─ [SectionBlock "근거"] → [FactList]: 첫·마지막 참석 / 함께 친 멤버 (+[AvatarStack]) / 받은 매너 (Badge chips) / 가입 추천 (+[AvatarStack])
    └─ [SectionBlock "신청 메시지"] → plain paragraph (request.message or noMessage)

States. Offline cold-start → OfflineEmptyState; loading (ctx/summary/request) → SkeletonFlatPage kind="type"; query error → QueryErrorState; non-admin deep link → EmptyState (client mirror of the RPC's own admin gate); missing record: request not found/already decided (no club_join_request row, e.g. a non-applicant id) → NotFoundState (notFoundTitle/notFoundSubtitle; 2026-09-16 converted from a bespoke EmptyState); every evidence field falls back to its own "없어요"/"기록 없음" string when empty; decide.isPending disables both CTA buttons.

Actions. 거절 → useDecideJoinRequest({ accept: false }); 승인 → useDecideJoinRequest({ accept: true }); both onSuccessappRouter.back().

Gaps. none found.


Club Members (멤버 관리)

  • Route: /clubs/[clubId]/members
  • Screen: ClubMembersScreenpackages/features/clubs/src/club-members-screen.tsx (rows/header split under club-members/* and members/*)
  • Shell: DetailShell · title t().clubMembers.pageTitle · headerRight: HeaderIconButton (Users icon → routes.dmCreateGroup, only when userId && activeMembers.length > 0)
  • Design: ratified
  • Verified: 2026-09-17

Purpose. Member management for club admins — view the full roster grouped by role, review pending guest→member join requests, browse/recommend guests, and (admin only) change role, assign 조, or remove a member.

Anatomy.

├─ [DetailShell] title="멤버 관리" onBack scroll=false headerRight=▸[HeaderIconButton Users]
└─ [GroupedFeedList] header=[MembersListHeader]
    ├─ [TypeHero] title=clubName facts=[👥 총 N명]
    ├─ ▸ U-25 조 legend (Text, shown once the club has 2+ active groups)
    ├─ ▸ [SectionHeader "심사 대상"+count] → [JoinReviewRow] per pending request → routes.joinReview (canReviewJoinRequests, count>0 only)
    ├─ ▸ [SectionHeader "추천할 만한 게스트"+count] → [RecommendGuestRow] with 추천/추천됨 toggle (rows>0 only)
    └─ ▸ entry row: [ListRow "게스트 지명"] (admin) → [NominateGuestModal]
    role-group sections ([SectionDivider] between each): 대표 → 총무 → 경기이사 → 멤버 (멤버 collapsed by default, header doubles as count+chevron toggle)
    └─ [MemberRow] avatar, displayName, trust-tier badge, ▸ group name (only when U-25's legend shows) — kebab (admin only) → [MemberActionModal]
[MemberActionModal] ("역할 변경"/"조 지정"/"추방하기") → [MemberRoleModal] / [MemberGroupModal] / destructive-confirm remove

States. Offline cold-start → OfflineEmptyState; !ctx.readySkeletonHero+3×SkeletonCard; query error → QueryErrorState; empty roster → EmptyState (compact, edge case — owner always present); 멤버 group collapsed↔expanded (accessibilityState.expanded); canManageMembers gates kebab visibility per row; canReviewJoinRequests = literal ctx.role.isAdmin, independent of the configurable canManageMembers permission; U-25 조 legend + row group-name meta (showsGroupTag) hide together below 2 active groups.

Actions. Kebab → change role (confirm, naming the member + new role — bug-class fix, 2026-09-16: confirm-consequential-mutation flagged the previously-direct useUpdateMemberRole.mutate call on picker selection) / assign 조 / remove (destructive confirm); "게스트 지명" → search modal → nominate; join-review row → routes.joinReview; recommend-guest toggle → useRecommendMembership/useUnrecommendMembership; headerRight Users icon → routes.dmCreateGroup seeded with every active member; 멤버 header tap → expand/collapse; pull-to-refresh. (복식 파트너 추천 MOVED to the club-detail 멤버 pane, usability audit 2026-09-16 U-35 — no longer an action on this screen.)

Gaps. none found.


Partner Suggester (복식 파트너 추천)

  • Route: /clubs/[clubId]/partners
  • Screen: PartnerSuggesterScreenpackages/features/clubs/src/partner-suggester-screen.tsx
  • Shell: DetailShell · title strings.pageTitle · headerRight: none
  • Design: unreviewed
  • Verified: 2026-09-17
  • **Notes:**2026-09-17: person/club name ListRows pass titleLines={NAME_LINES} (U-13, two-line proper names)

Purpose. Ranked doubles partner candidates for the current member — scores the rest of the club roster by ELO proximity (40%), chemistry (30%), trust (15%), attendance (15%). No standalone nav entry point; reached only from the club-detail 멤버 pane's "복식 파트너 추천" row (moved off the full members screen, usability audit 2026-09-16 U-35).

Anatomy.

├─ [DetailShell] title=pageTitle onBack scroll=false
└─ [FeedList] header=[Text subtitle]
    └─ per candidate — [CandidateCard] (hairline-separated block peer, no boxed Card):
        ├─ [ListRow] leading=[AvatarBubble MD (the member's photo/initials — graphics-lead law, not a sparkle glyph)] title=displayName sub=[tier label, "ELO N"] trailing=[score, "궁합"] → routes.profileDetail; the list rails via SCROLL_CONTENT.railedList (list-rows-own-rail)
        ├─ [TrustTierBadge variant="compact"]
        ├─ 4× [BreakdownRow] label+[ProgressBar]+"N/max" — ELO 유사도(40)/케미(30)/신뢰(15)/출석(15)
        └─ matchup-history [Badge] ("함께 N승 M패" or noHistory) + ▸[ProgressBar variant="muted"] when matches were played

States. Loading (no cached candidates) → 3× SkeletonCard; error → QueryErrorState; empty → EmptyState (Users, variant full); pull-to-refresh; a candidate with no matchup history shows the badge with no progress bar (also true when history exists but wins+losses===0).

  • Offline cold start (query paused, nothing fetched) → OfflineEmptyState with retry, via the list's isPausedOffline + offlineState — never the empty state (2026-09-11 offline sweep).

Actions. Candidate row tap → routes.profileDetail(profile.id); pull-to-refresh; header back.

Gaps. none found.


Public Club Profile (클럽 미리보기)

  • Route: /clubs/[clubId]/preview
  • Screen: PublicClubProfileScreenpackages/features/clubs/src/public-club-profile-screen.tsx (sections under public-club-profile/*)
  • Shell: DetailShell · title club.name (header="collapsing") · headerRight: HeaderActionsMenu (공유 only)
  • Design: ratified
  • Verified: 2026-09-17

Purpose. The public preview a non-member sees when tapping a club in discovery — identity, activity, description, gallery, and a pinned join CTA. Existing members are bounced to the full club-detail screen.

Anatomy.

├─ [DetailShell ground="canvas" header="collapsing"] title=club.name headerRight=[HeaderActionsMenu 공유]
│   footer=▸[JoinCtaBand] (policy-driven single CTA)
└─ [QueryBoundary] queries=[clubQ, statsQ] loading=[SkeletonFlatPage kind="album"]
    ├─ [MediaHero kind="album"] title=club.name titleBadge=[ClubJoinabilityBadge] facts=up to 4 glyph facts (🏷tier · 🎯focus · 📍region · 👥members)
    │   └─ [ClubProfileAlbumPager] swipeable photo pager (dots + N/M pill)
    ├─ ▸ capacity+faces LEADING row ([AvatarStack] + capacity pill, chevron for members) → routes.clubMembers, disabled for non-members
    └─ [FlatColumn]
        ├─ ▸ [Banner tone="neutral"] archived notice (club.archivedAt set)
        ├─ [SectionBlock "클럽 정보"] → [FactList] (형식/실력/가입 방식/▸홈 코트/회비)
        ├─ [SectionBlock "정기 일정" meta=▸"외 N개"] → [ActivityCardSection]
        ├─ ▸ [SectionBlock "참여 가능한 일정"] → [GuestSessionsSection] (active guest only)
        ├─ ▸ [SectionBlock "소개"] → description paragraph
        ├─ [SectionBlock "갤러리"] → [GalleryCardSection] (3-up thumbs)
        └─ ▸ [Well] application panel (title+body+[StyledInput] 500-char cap) — only when applyPanelOpen

States. Loading → SkeletonFlatPage; missing record: club not found/non-discoverable (RLS-filtered) → NotFoundState (notFound; 2026-09-16 converted from a bespoke Users EmptyState); member visiting this route → immediate appRouter.replace(routes.club(clubId)) (never renders content); archived → Banner; join-policy drives the footer CTA (open→가입하기, approval→가입 신청하기/다시 신청하기 opens the inline apply panel, invite_only→pushes routes.joinClub).

Actions. Tap next-session row → routes.clubSession; capacity row → routes.clubMembers (members only); apply-panel submit → useApplyToClub; open join → useJoinOpenClub; invite-only → routes.joinClub; every growth-funnel step fires useRecordClubGrowthEvent (profile_view/join_tap/application_start/application_submit/session_interest); pull-to-refresh.

Gaps. none found.

Notes. 2026-09-17: badge-class text dropped its per-site weight override (role badge 600, PD-03 sweep).


Club Sessions (일정 관리)

  • Route: /clubs/[clubId]/sessions
  • Screen: ClubSessionsScreenpackages/features/clubs/src/club-sessions-screen.tsx
  • Shell: DetailShell · title club?.name ?? common.events · subtitle club?.name ? common.events : undefined (일정, second header line — never glued onto the title) · headerRight: none
  • Design: ratified
  • Verified: 2026-09-16

Purpose. The full session list for a club — every open/locked, in-progress, and completed session across three tabs. Mounted from the admin 일정 관리 row but viewable by any member.

Anatomy.

├─ [DetailShell] title=club.name subtitle="일정" onBack scroll=false
├─ [SegmentedTabs preload="all"] items=[모집 중, 진행 중, 완료]
│   ├─ 모집 중 → [SinglePageTab statuses=[open,locked]] → [FeedList] of [SessionCardV2]
│   ├─ 진행 중 → [SinglePageTab statuses=[in_progress]] → [FeedList] of [SessionCardV2]
│   └─ 완료 → [CompletedTab] → infinite [FeedList] of [SessionCardV2]
└─ ▸ [BottomCtaBand] ✦ "일정 만들기" — canManageSchedule only

States. Per-pane loading (no cache) → 3× SkeletonCard; per-pane error → QueryErrorState; per-pane empty: 모집 중/진행 중 → EmptyState compact; 완료 → EmptyState compact (emptyCompleted); 완료 pane infinite-scroll loadingMore; canManageSchedule hides the bottom CTA entirely for non-managers; pull-to-refresh per pane.

  • Offline cold start (query paused, nothing fetched) → OfflineEmptyState with retry, via the list's isPausedOffline + offlineState — never the empty state (2026-09-11 offline sweep).

Actions. Session row tap → routes.clubSession(item.clubId, item.id); tab switch (모집 중/진행 중/완료); "일정 만들기" BottomCtaBand CTA (testID club.sessions.create-session-cta) → routes.createSession.

Gaps. none found.


Club Settings (클럽 설정)

  • Route: /clubs/[clubId]/settings
  • Screen: ClubSettingsScreenpackages/features/clubs/src/club-settings-screen.tsx (sections under settings/*)
  • Shell: DetailShell · title t().clubSettingsScreen.pageTitle · headerRight: none
  • Design: ratified
  • Verified: 2026-09-17

Purpose. Club configuration for admins — images, info, bank, regular meets, 조 groups, guest policy, planning, attendance toggles, operations shortcuts, and the danger zone. Gated to canManageClub.

Anatomy.

├─ [DetailShell] title="클럽 설정" onBack keyboardShouldPersistTaps="handled"
└─ [FlatColumn]
    ├─ [ImageSection] — banner (16:9) + logo (circle) upload/remove, pending-upload spinner overlays
    ├─ [GallerySection] — photo grid (approve/reject/archive/feature), gallery/featured limit labels from entitlements
    ├─ ▸ [InfoSection] — name/description/court-info inline-edit rows ([EditFieldModal]) + isDiscoverable toggle + tier-range editor; region row is courts-derived read-only (→ Club Venues) once any court has a district, else the legacy free-text district [EditFieldModal] row (migration 00650)
    ├─ [BankSection] — club bank/dues account
    ├─ [RegularMeetsSection] — recurring meet schedule
    ├─ [ClubGroupsSection] — 조 (sub-group) management
    ├─ ▸ [ClubGuestsSection] — guest policy
    ├─ ▸ [ClubPlanningSection] — 정원 (`maxMembers`) editable `Stepper` (U-32 follow-up; bounds MIN/MAX_RECRUIT_TARGET) + planning tools
    ├─ ▸ [AttendanceSection] — tracking + cancellation-penalty toggles
    ├─ [OperationsSection] — next managed session / create session / match board shortcuts
    └─ ▸ [DangerZoneSection] (owner only) — 대표 권한 넘기기 ([TransferOwnershipModal]) + 클럽 보관 (archive, destructive confirm)
[EditFieldModal] shared inline-edit surface for name/description/tagline/adm2Slug/courtInfo

States. Loading → SkeletonHero+3×SkeletonCard; error → QueryErrorState; no permission → EmptyState (ShieldAlert, full); image-upload pending states per kind; gallery-limit-reached and media-permission-denied confirm dialogs; DangerZoneSection hidden for non-owners, transfer row hidden when no other active-member candidate exists; EditFieldModal stays open on a failed save (await mutateAsync).

  • Offline cold start (query paused, nothing fetched) → OfflineEmptyState with retry, via the list's isPausedOffline + offlineState — never the empty state (2026-09-11 offline sweep).

Actions. Pick/remove banner/logo photo; pick gallery photos (multi-select up to CLUB_MEDIA_PICK_LIMIT, decision PHOTO-ROW — uploaded sequentially with one summary toast, not one per photo) / remove a gallery photo; toggle discoverable/tracking/penalty; inline-edit any field via EditFieldModal; the region row navigates to Club Venues once courts-derived (else opens the legacy free-text EditFieldModal); adjust 정원 (ClubPlanningSection's maxMembers Stepper, U-32 follow-up) — writes through useUpdateClub directly, no modal; jump to next managed session / create session / match board (OperationsSection); transfer ownership; archive club (both destructive-confirmed).

Gaps. none found.


Club Venues (코트 관리)

  • Route: /clubs/[clubId]/venues
  • Screen: ClubVenuesScreenpackages/features/clubs/src/club-venues-screen.tsx
  • Shell: DetailShell · title common.manageVenues (테니스장 관리) · subtitle club.name (second header line, never glued onto the title) · headerRight: none
  • Design: ratified
  • Verified: 2026-09-16

Purpose. Club-scoped venue (court) management for admins — list, add, edit, and delete the club's registered venues, onto the venue-provenance canon (docs/canon/venue-data.md, canon/components.md "Venue-pick canon set"): Naver leads every venue fact, so the ONE way to add a venue is picking it through the shared finder, never a free-text name+address form.

Anatomy.

├─ ▸ !canManageClub → [DetailShell] title="테니스장 관리" (no subtitle) → [EmptyState] MapPin noPermission
└─ [DetailShell] title="테니스장 관리" subtitle=club.name onBack scroll=false
    ├─ [FeedList] of [VenueRow variant="plain"] — name/surface/courtCount/district/address, trailing=[수정 ActionButton]+[삭제 ActionButton]; row tap → routes.venue
    ├─ [BottomCtaBand] ✦ "코트 추가" (Plus icon) → opens [VenueSearchTakeover] (commitMode="immediate", clubId, pickedVenueIds=club's registered spine ids)
    └─ [EditVenueModal] ([ModalPanel]) — OPERATIONAL fields only: [FactList] read-only 테니스장 이름/주소 (provenance-owned) + editable 코트 수 (blank=unknown)/메모

States. No permission → EmptyState (full); loading (no venues yet) → 2× SkeletonCard; error → QueryErrorState; empty → EmptyState (MapPin, compact); edit modal closed/open (prefilled from the tapped venue); court-count validation error only after the field is touched; delete → destructive confirm before useDeleteVenue; a pick of an already-registered venue (shows selected in the takeover via pickedVenueIds) is a no-op with a toast (createClubScreen.courtAlreadyPicked), never a second row; an id-less Naver pick's optimistic first emission creates nothing — the screen waits for the adopt flow's resolved second emission (Naver adopt-on-pick double-emit contract, correlated by rowId/adoptionId) before calling useCreateVenue exactly once; pull-to-refresh.

  • Offline cold start (query paused, nothing fetched) → OfflineEmptyState with retry, via the list's isPausedOffline + offlineState — never the empty state (2026-09-11 offline sweep).

Actions. Row tap → routes.venue(item.id); 수정 → EditVenueModal (court count / notes only) → useUpdateVenue; 삭제 → confirm → useDeleteVenue; "코트 추가" footer CTA → VenueSearchTakeover: picking a result → useCreateVenue with the mapped input (venueInputFromPick, packages/app/src/domain/entities/venue.entity.ts — the same mapping the create-club wizard's submit orchestration uses); the overlay's keyboard 검색-key onSearchSubmit (free text) → useCreateVenue with name only, no coords.

Gaps. none found.


Create Club Wizard (클럽 만들기)

  • Route: /create-club
  • Screen: CreateClubScreenpackages/features/clubs/src/create-club-screen.tsx (steps: create-club-step{1..8}.tsx + create-club-step-guests.tsx; internals under create-club/*)
  • Shell: WizardShell · testID create-club.wizard · headerRight: n/a (WizardShell owns close/back)
  • Design: ratified
  • Verified: 2026-09-17

Purpose. The 9-step wizard for creating a new club — name/branding, region/courts, schedule, style, headcount targets, dues, description, membership/guest policy, then review and submit.

Anatomy.

├─ [WizardShell] stepIndex=nav.step totalSteps=9 onClose=nav.handleDismiss onBack=nav.armedOnBack
│   primaryLabel/primaryDisabled/secondaryLabel driven by nav + the 5 edit-scope slots (panel/field buffers)
└─ [ScrollView ref=scrollRef] → renderStep(nav.step):
    ├─ 0 [Step1] 이름·꾸미기 — name input → banner color → logo fork (업로드 | 문양) → 빠르게 만들기 toggle (U-32: seeds 구성/형식/실력/성격/게스트-허용, shortens the sequence below)
    ├─ 1 [Step2] 지역 — venue search/pick (up to 5), each pick via an AttachedPanel (홈 코트 toggle/swap/trash/이름)
    ├─ 2 [Step3] 일정 — 정기 일정 있어요/아직 없어요 fork; per-venue 장소→요일→반복→TimeBandSlider loop
    ├─ 3 [Step4] 스타일 — 구성 → 형식 → 실력(TierRangeSelector, plain 구력 line + credential disclosure) → 성격, sequential reveal
    ├─ 4 [Step5] 인원 — 1인당 경기 수 → 참석률 가정 → 모집 목표 (derived headcount)
    ├─ 5 [Step6] 회비 (skippable) — 코트비/운영비 → 월 회비 계산 (suggested amount)
    ├─ 6 [Step7] 소개 (skippable) — 소개 TextInput + 공개 설정 toggle
    ├─ 7 [StepGuests] 멤버십·게스트 (required) — 가입 방식 + 게스트 허용/비허용 fork → 참가비 + 방문 횟수 제한
    └─ 8 [Step8] 확인 — WizardReviewRow-per-section review, tap-to-edit jumps back armed with "돌아가기" → 클럽 만들기 submit

States. Silent draft auto-resume on mount (fresh draft) with one action-toast ("이어서 작성 중이에요" / undo → 처음부터); a stale draft (past TTL) discards silently; per-step validity gates the primary CTA (isClubStepValid); edit-from-review armed state relabels the primary CTA to "검토로 돌아가기" and hides the secondary; an open edit-scope (panel or field buffer) collapses the footer to a single 저장 CTA; step 2/3 use a venueViewport scroll-target mechanism for mid-list reveals; live venue-court-count overlay (draftForReceipts) feeds steps 5/6/8 and the submit payload; 빠르게 만들기 ON (U-32, draft.quickStart) shrinks the WizardShell progress bar to 4 segments and the navigable sequence to [0, 1, 2, 확인] (stepSequenceFor, create-club/quick-start.ts) — steps 3–7 render only while it's off; review rows the toggle seeded show a 기본값 Badge next to their value until edited away from the seeded default.

Actions. Per-step field edits (see anatomy); 빠르게 만들기 toggle (step 0) → applyQuickStartDefaults seeds 구성/형식/실력/성격/게스트-허용 and re-sequences the wizard; toggling it back off only clears the flag (seeded answers stay); 건너뛰기 on steps 5 (회비) and 6 (소개) only; header X → destructive-exit confirm (discards the draft); step-dot tap → jump to any step ≤ maxStepReached; step 8 row tap → jump back to that step armed with a return-to-review flag; final CTA → useClubSubmit's handleSubmit.

Gaps. none found.


Join Club (클럽 가입하기)

  • Route: /join-club
  • Screen: JoinClubScreenpackages/features/clubs/src/join-club-screen.tsx
  • Shell: DetailShell · title contextTitle ("{club명} 가입하기" if arrived from a club, else "클럽 가입하기") · headerRight: none
  • Design: ratified
  • Verified: 2026-09-17

Purpose. Join a club by entering its invite code — club-agnostic, but shows origin-club context (name/avatar) when the user arrived via a specific club's "초대코드로 가입하기" action.

Anatomy.

├─ [DetailShell] title=contextTitle onBack footer=[BottomCtaBand]✦가입하기
└─ [FlatColumn] centered
    ├─ ▸ origin-club identity row ([AvatarBubble]+name) — only when originClubId is known
    ├─ hint [Text] (contextHint or generic inviteCodeHint)
    ├─ large letter-spaced [Text] mirror of the typed code
    ├─ [StyledInput] invite code entry (maxLength 10, autoCapitalize characters, autoFocus)
    └─ ▸ [Banner tone="error"] on failure

States. Origin-club context present vs absent (title/hint variant, identity row shown/hidden); code length < 4 or mutation pending → CTA disabled; error: already a member (ConflictError/join_already_member hint) / invalid code (join_invalid_invite_code hint) / generic fallback; success + same club as origin → appRouter.replace(routes.club); success + a DIFFERENT club than origin → a confirm dialog ("다른 클럽에 가입됐어요") before navigating.

Actions. Type invite code (normalizeInviteCodeInput, clears error); "가입하기" CTA → useJoinClub.mutate({ inviteCode }); header back.

Gaps. none found.


Leaderboard Share Redirect

  • Route: /lb/[clubId]
  • Screen: LeaderboardShareRouteapps/mobile/app/lb/[clubId].tsx
  • Shell: none (bare YStack states, no AppHeader/DetailShell)
  • Design: unreviewed
  • Verified: 2026-09-13

Purpose. Universal-link receiver for club leaderboard share links (twomore://lb/<clubId> / https://twomore.app/lb/<clubId>) — an unauthenticated deep-link/OG-share target that resolves the club and redirects into the canonical club-detail route; it never renders a leaderboard itself.

Anatomy.

├─ ▸ loading (useClub isLoading) → [YStack padding] → [SkeletonHero]
├─ ▸ !club (not found/unresolvable) → [YStack centered] → [Text] t().share.clubNotFound
└─ ▸ resolved → [Redirect] → routes.club(club.id)

States. Loading / not-found / resolved, as above — three mutually exclusive branches, no composed page.

  • Offline cold start (query paused, nothing fetched) → OfflineEmptyState with retry, via QueryBoundary — never the empty state (2026-09-11 offline sweep).

Actions. None — the only "action" is the automatic Redirect once the club resolves.

Gaps. none found.

Markdown remains the source of truth. Run yarn docs:check before handoff.