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:
ClubListScreen—packages/features/clubs/src/club-list-screen.tsx - Shell: MainTabShell · title
t().common.club("클럽") · headerRight:ClubJoinHeaderAction(HeaderTextActionlabelt().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, variantfull, titlet().common.noClubsYet/ subtitlenoMyClubsSubtitle, primaryt().common.browseClubs→routes.discoverClubs, secondaryt().clubList.joinWithInviteCodeAction"초대 코드로 가입" →routes.joinClub— U-10 usability audit). - 탐색 loading → 4×
SkeletonCard; error →QueryErrorState; empty →EmptyState(Compass, variantcompact,noDiscoverResults). ?tab=discover/?tab=my(routes.discoverClubs) opens the matching pane on mount and on later param changes.- Archived clubs render a
Banner(tonemuted, textbannerArchived) inside the discover-branchClubCardonly (member branch gates this at the card level too). - Offline cold start (query paused, nothing fetched) →
OfflineEmptyStatewith retry, via the list'sisPausedOffline+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: "클럽 만들기"
BottomCtaBandCTA →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:
ClubAdminScreen—packages/features/clubs/src/club-admin-screen.tsx(rendersAdminTab—packages/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.readyfalse:DetailShellrenders 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
growthAnalyticsEnabledentitlement. - 멤버 관리 / 클럽 설정 rows prefetch (
prefetchClubMembers/prefetchClub) on press before navigating. - Offline cold start (query paused, nothing fetched) →
OfflineEmptyStatewith retry, via an earlyOfflineEmptyStatereturn — 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:
ClubAlbumScreen—packages/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-firstStates. loading / error / empty / populated as above. No lightbox or tap handler on tiles.
- Offline cold start (query paused, nothing fetched) →
OfflineEmptyStatewith retry, via an earlyOfflineEmptyStatereturn — 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:
ClubAnalyticsScreen—packages/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:
ClubAttendanceScreen—packages/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 passtitleLines={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:
PostDetailScreen—packages/features/clubs/src/post-detail-screen.tsx(subcomponents underpost-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) →
OfflineEmptyStatewith retry, via the list'sisPausedOffline+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:
PostCreateScreen—packages/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:
ClubChallengesScreen—packages/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) →
OfflineEmptyStatewith retry, via an earlyOfflineEmptyStatereturn — 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:
ClubDuesScreen—packages/features/clubs/src/club-dues-screen.tsx(delegates toClubDuesBody, also embedded byclub-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:
ClubDetailScreen—packages/features/clubs/src/club-detail-screen.tsx(6 panes underclub-detail/*) - Shell: PlaceShell (
CollapsingTopBar+CollapsingHeaderLayer) · titleclub.name· headerRight:XStack gap="$1"of ▸HeaderTextActionlabelt().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:
JoinReviewDetailScreen—packages/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 onSuccess → appRouter.back().
Gaps. none found.
Club Members (멤버 관리)
- Route:
/clubs/[clubId]/members - Screen:
ClubMembersScreen—packages/features/clubs/src/club-members-screen.tsx(rows/header split underclub-members/*andmembers/*) - Shell: DetailShell · title
t().clubMembers.pageTitle· headerRight:HeaderIconButton(Users icon →routes.dmCreateGroup, only whenuserId&&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 removeStates. Offline cold-start → OfflineEmptyState; !ctx.ready → SkeletonHero+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:
PartnerSuggesterScreen—packages/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 passtitleLines={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 playedStates. 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) →
OfflineEmptyStatewith retry, via the list'sisPausedOffline+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:
PublicClubProfileScreen—packages/features/clubs/src/public-club-profile-screen.tsx(sections underpublic-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 applyPanelOpenStates. 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:
ClubSessionsScreen—packages/features/clubs/src/club-sessions-screen.tsx - Shell: DetailShell · title
club?.name ?? common.events· subtitleclub?.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 onlyStates. 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) →
OfflineEmptyStatewith retry, via the list'sisPausedOffline+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:
ClubSettingsScreen—packages/features/clubs/src/club-settings-screen.tsx(sections undersettings/*) - 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/courtInfoStates. 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) →
OfflineEmptyStatewith retry, via the list'sisPausedOffline+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:
ClubVenuesScreen—packages/features/clubs/src/club-venues-screen.tsx - Shell: DetailShell · title
common.manageVenues(테니스장 관리) · subtitleclub.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) →
OfflineEmptyStatewith retry, via the list'sisPausedOffline+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:
CreateClubScreen—packages/features/clubs/src/create-club-screen.tsx(steps:create-club-step{1..8}.tsx+create-club-step-guests.tsx; internals undercreate-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 "돌아가기" → 클럽 만들기 submitStates. 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:
JoinClubScreen—packages/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 failureStates. 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:
LeaderboardShareRoute—apps/mobile/app/lb/[clubId].tsx - Shell: none (bare
YStackstates, noAppHeader/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) →
OfflineEmptyStatewith retry, viaQueryBoundary— 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.