Screen Blueprints — Sessions
Status: Active Last reviewed: 2026-09-11
Current-state blueprints for the sessions 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.
Activity (경기)
- Route:
/(tabs)/(sessions) - Screen:
ActivityScreen—packages/features/activity/src/activity-screen.tsx - Shell: MainTabShell · title
경기(t().common.games) · headerRight: none - Design: ratified
- Verified: 2026-09-16
Purpose. The 경기 tab hub: the viewer's own RSVP'd sessions, open pickup games to browse, bookmarked sessions, and a court directory, as four shell-owned sub-tabs.
Anatomy.
├─ [MainTabShell] title=common.games tabs=[내 경기, 번개 찾기, 저장됨, 테니스장 찾기]
│ (`ActiveTab`; `?tab=` route param opens a pane directly, e.g. routes.activityPickups)
├─ 내 경기 (MyMatchesTab) → [FeedList] useMyParticipatingSessions
│ ├─ header → [ActivityListControls] format/date filters + [SectionHeader title=activityScreen.sectionMyMatches (다가오는 일정 — what the pane holds, never the tab label again) variant="list"]
│ └─ item → [SessionCardV2] × N (useSessionCardModels) → routes.clubSession
├─ 번개 찾기 (PickupFinderTab) → [FeedList] infinite, useOpenPickupsInfinite
│ ├─ header → [ActivityListControls] filters + GPS-defaulted region (dismissible hint) + [SectionHeader title=common.membershipPickup variant="list"]
│ ├─ excludes sessions the viewer already RSVP'd (useMyRsvps)
│ └─ item → [SessionCardV2] × N → routes.sessionPreview
├─ 저장됨 (SavedSessionsTab) → [FeedList] useSavedSessionIds → useSessionsByIds
│ ├─ header → [SectionHeader title=activityScreen.sectionSaved variant="list"]
│ └─ item → [SessionCardV2] × N → routes.clubSession
└─ 테니스장 찾기 (courts) → [VenueDirectoryContent] — same shared body as the `/courts` route
(one implementation, two mount points; see the Court Directory entry below)Bottom CTA: [BottomCtaBand][Button lg primary] "번개 만들기" → routes.createSession('pickup'), rendered ONLY while activeTab === 'pickup_finder'.
States.
- loading:
SkeletonCard/SkeletonRowstack per tab - empty: genuinely empty vs filtered-to-empty (filtered adds a "필터 초기화" action); 저장됨 has no filter-aware branch
- error:
QueryErrorStatewith retry - offline:
OfflineEmptyState - populated list per tab,
usePullToRefresh
Actions.
- switch tab (내 경기 / 번개 찾기 / 저장됨 / 테니스장 찾기)
- tap a
SessionCardV2→ session detail (내 경기/저장됨) or pickup preview (번개 찾기) - 번개 만들기 (band CTA, pickup_finder only) → create-session wizard
- 필터 초기화 (filtered-empty action)
- dismiss the GPS-defaulted region hint (번개 찾기)
Gaps. none found.
Court Directory (테니스장)
- Route:
/courts - Screen:
CourtsDirectoryScreen—packages/features/sessions/src/courts-directory-screen.tsx - Shell: DetailShell · title
테니스장(t().courts.title) · headerRight: none - Design: ratified
- Verified: 2026-09-17
Purpose. Nationwide tennis-venue directory search/browse with region/facility/indoor filters; the same body also mounts inside the 경기 tab's 테니스장 찾기 segment.
Anatomy.
├─ [DetailShell] scroll={false}
└─ [VenueDirectoryContent] (thin wrapper) → [VenueFinder intent="explore"]
├─ [VenueFinderHeader] search bar + region/district cascade + capability/indoor filters
│ + 전체/코트/연습장/샵/즐겨찾기 nav-chip row (accessibilityRole="tab")
├─ query too short → [EmptyState] compact
├─ search mode → [VenueFinderSearchResults] dual-source: spine-curated groups + Naver tail
│ (naverTailEnabled) rows, each → [DirectoryVenueCard] → routes.directoryVenue
└─ browse mode → [VenueFinderBrowseList] rows grouped into 시/도 [SectionHeader] sections
(canonical province order, whole filtered result set) → [DirectoryVenueCard]
(static-map thumbnail, [VenueTrustBadge] only for an owner-certified venue, [VenueSaveButton], distance/kind/district/fact
glyphs) → routes.directoryVenueStates.
- browse mode (province-grouped) vs search mode (flattened, paginated)
- empty — one inclusive result set (no trust filter, no 미확인 badge —
canon/venue-data.mdpublic-venue identity, identical inexploreandpickintent), so the empty state has one cause: no venue matches the location/facility filters releaseBrowseBody: after 2+ minutes hidden (useHiddenPaneRelease), the browse list's mounted rows release to a bare placeholder, remounting from cache on return
Actions.
- search, filter by region/district/capability/indoor
- switch nav chip (전체/코트/연습장/샵/즐겨찾기)
- tap a card → Venue Directory Detail (
routes.directoryVenue) - save/unsave a venue
Gaps. none found.
Notes. 2026-09-17: the 미확인-포함 toggle, its trust-gated empty state and the selectVenueDirectoryEmptyCause utility are gone (VENUE-RESIDUALS); the 경기 tab's 테니스장 찾기 pane is the in-app entry, /courts the web/deep-link address of the same body.
Create Session Wizard
- Route:
/create-session - Screen:
CreateSessionScreen—packages/features/sessions/src/create-session-screen.tsx(re-export shim) →packages/features/sessions/src/create-session/create-session-screen.tsx - Shell: WizardShell · title: per-step (dynamic, e.g.
언제 만나나요?) · headerRight: none (progress dots + close X own the header, per wizard-nav law) - Design: unreviewed
- Verified: 2026-09-17
Purpose. 7-step wizard for scheduling a club session or hosting a pickup (번개) game — collects when/where/format/headcount/options/match-rules, then reviews and submits.
Anatomy.
├─ [WizardShell] stepIndex=nav.step totalSteps=7 onClose=nav.handleDismiss onBack=nav.armedOnBack
│ primaryLabel per step (nextLabel/createButton/backToReview) secondaryLabel="건너뛰기" (step 4 only)
├─ [StepRenderer] switch on nav.step, delegating to per-step files:
│ 0 언제 만나나요? → Step1 (create-session/step1/…, + 정기 일정/지난 일정 prefill sections)
│ 1 어디서 하나요? → Step2 (create-session/step2/…, venue search/pick + 사용 코트 수)
│ 2 어떻게 치나요? → Step3 (구성 → 형식 → 실력(TierRangeSelector, plain 구력 line + credential disclosure) → 성격, converges onto create-club-step4.tsx)
│ 3 몇 명이서 뛰나요? → StepPlayers (경기 수 → 경기 시간 → suggested 모집/최소 인원)
│ 4 추가 옵션 → Step4 (참가비 + RSVP 마감 + 반복, skippable via 건너뛰기)
│ 5 경기 규칙 → Step5 — summary card IS the edit affordance → MatchRulesEditorSheet
│ 6 확인해 주세요 → Step6 review — [Card] of [WizardReviewRow]s, then a [Callout tone="info"]
│ restating the locked-fields note (same sentence as Session Edit's field map) + the
│ auto-cancel threshold (same sentence as StepPlayers' 최소 인원 caption, shown only when
│ draft.minPlayers is set) — usability audit 2026-09-16 U-06/U-20 — ✦ "일정 만들기"
│ (edit-from-review jumps back into any step's scope)States.
- fresh draft (no resume) vs resumed draft (mount-time silent restore, per-step validity gates)
- edit-from-review detour armed (jumped back from step 7)
- field-edit scope open (per-step contextual edit buffer — gates skip/back, no 건너뛰기 while open)
- submitting (
primaryLoadingon the last step) - destructive-exit confirm (step 1 close/back)
Actions.
- primary CTA per step → advance (
nav.handleNext) - 건너뛰기 (step 4 only, hidden while a field-edit scope is open)
- 뒤로가기 to review (armed edit-from-review return)
- 생성하기 (final submit, step 6) →
useHostPickupGame(clubId === 'pickup') oruseCreateSession - tap 경기 규칙 summary card →
MatchRulesEditorSheet(저장 commits without advancing) - step-pill tap → jump to step (forward jumps capped at
firstFailingGatedStep) - header X / hardware back on step 1 → destructive-exit confirm dialog
Gaps. none found.
Session Invite Share Preview
- Route:
/s/[sessionId] - Screen:
SessionInviteRoute(default export) —apps/mobile/app/s/[sessionId].tsx; rendersPublicSessionSharePreviewin-file - Shell: DetailShell · title = session/preview title (dynamic) · headerRight: none
- Design: unreviewed
- Verified: 2026-09-13
Purpose. Universal-link receiver for session invites (twomore://s/<id>, https://twomore.app/s/<id>) — redirects a member/authed viewer straight to club session detail, or renders a curated read-only preview for a non-member/unauthenticated visitor.
Anatomy.
├─ loading → bare [YStack] + [SkeletonHero]
├─ public preview available → [PublicSessionSharePreview]
│ └─ [DetailShell] title=preview.title subtitle=preview.clubName
│ └─ [FlatColumn] > [FactList] 시간 (formatTimeLine) · 장소 ▸ preview.location present
├─ not found (neither session nor preview resolves) → centered plain text (`t().share.sessionNotFound`)
└─ authenticated/member → silent `Redirect` → routes.clubSession(clubId, sessionId)States.
- loading (session query + conditional public-preview query both in flight)
- public preview (unauthenticated or non-member, session exists)
- not found
- authenticated/member redirect (no visible state)
- Offline cold start (query paused, nothing fetched) →
OfflineEmptyStatewith retry, viaQueryBoundary— never the empty state (2026-09-11 offline sweep).
Actions.
- back (DetailShell header, public-preview branch only)
- no other interactive affordances — read-only redirect/preview page
Gaps. none found.
Session Attendance (출석 확인)
- Route:
/sessions/[sessionId]/attendance - Screen:
SessionAttendanceScreen—packages/features/sessions/src/session-attendance-screen.tsx - Shell: DetailShell · title
출석 확인· subtitle참석한 멤버를 확인하고 불참 시에는 X 버튼을 눌러 주세요· headerRight: none - Design: unreviewed
- Verified: 2026-09-16
Purpose. 총무-only roster for correcting session attendance — completion auto-records every confirmed RSVP from match data first, so this screen is mainly for reviewing/fixing exceptions.
Anatomy.
├─ [DetailShell scroll={false}]
├─ [FeedList] header=[Well] summary: attendedCount/participants.length + [ProgressBar] +
│ no-show exception count + "이미 기록됨" hint text (attendanceAutoRecordedHint)
├─ body: [ParticipantRow variant="attendance"] × N — flat row (no Card, U-29), ✓ 참석 / ✗ 불참 per row, per-row pending
└─ [BottomCtaBand] single ActionButton — disabled + unresolved-count label while unresolvedCount > 0,
else 닫기/closeout CTA (appRouter.back())States.
- error →
EmptyState+ retry - loading →
SkeletonFlatPage kind="type" - permission-gated: non-
canManageClub→ShieldAlertEmptyState - empty roster (no confirmed RSVPs) →
EmptyState - populated, unresolved marks present (CTA disabled) vs all resolved (CTA enabled)
- Offline cold start (query paused, nothing fetched) →
OfflineEmptyStatewith retry, via an earlyOfflineEmptyStatereturn — never the empty state (2026-09-11 offline sweep). - missing record: settled but no session row (bad/deleted sessionId) →
NotFoundState(2026-09-16 bug-class fix —!ready || !sessionused to pin the loading skeleton forever instead)
Actions.
- mark a participant 참석 or 불참
- pull to refresh
- closeout CTA (
appRouter.back(), disabled until every confirmed participant is resolved)
Gaps. none found.
Session Edit (일정 수정)
- Route:
/sessions/[sessionId]/edit - Screen:
EditSessionScreen—packages/features/sessions/src/edit-session-screen.tsx - Shell: DetailShell · title
일정 수정(strings.editDetails) · headerRight: none - Design: ratified
- Verified: 2026-09-17
Purpose. Config-only edit form for a session's date/time/location/max-players/description, reached from Session Detail's 호스트 도구 (일정 수정) — reachable only while the session is open. U-06 (2026-09-17): 참가비 joined the editable set too, but CONDITIONALLY — open until the session's first session_payments row, then locked (mirrors the DB trigger trg_block_fee_edit_with_holds, 00404/00649).
Anatomy.
├─ [FlatColumn]
│ └─ [SectionBlock variant="flat" title=설정]
│ └─ [ConfigForm] — per-field [EditFieldRow]s (own dividers, no Card), tap opens a focused
│ editor modal: [TimeEditModal] · [MaxPlayersEditModal] · [CourtsEditModal] ·
│ [CompositionEditModal] · [TierEditModal] (TierRangeSelector, plain 구력 line + credential
disclosure — same pattern as club-settings' InfoRow) · [FeeEditModal] (WonAmountField,
U-06) — its [EditFieldRow] renders `disabled` + a locked caption once `useSessionPayments`
shows any row (or is still loading)
└─ [BottomCtaBand] "저장" ("저장 중" while pending) — errorText/onSave reported up via
onFooterStateChange (ref-trampolined bridge, not rendered inline by ConfigForm)States.
- error →
EmptyState+ retry - loading →
SkeletonHero - locked:
session.status !== 'open'→EmptyState"수정할 수 없어요" + 뒤로가기, no form rendered - editable form
- saving (footer disabled, "저장 중" label, ConfigForm-reported error text)
- Offline cold start (query paused, nothing fetched) →
OfflineEmptyStatewith retry, via an earlyOfflineEmptyStatereturn — never the empty state (2026-09-11 offline sweep). - missing record: settled but no session row (bad/deleted sessionId) →
NotFoundState(2026-09-16 bug-class fix —isLoading || !sessionused to pin the loading skeleton forever instead)
Actions.
- tap a field row → opens that field's edit modal (참가비's row is a no-op tap once locked —
EditFieldRow's owndisabled, U-06) - 저장 (commits the whole payload via the reported
onSave;participationFeeis included only when that row was actually touched this visit) - back
Gaps. none found. (U-06 note: editLockedFieldsNote/the fee policy is shared verbatim with the create-session wizard's own review step — sessionDetail.editLockedFieldsNote is the same key create-session-step6.tsx reads, so both surfaces state the identical rule.)
Pickup Host Dashboard (호스트 관리)
- Route:
/sessions/[sessionId]/host - Screen:
PickupHostDashboardScreen—packages/features/sessions/src/pickup-host-dashboard-screen.tsx - Shell: DetailShell · title
호스트 관리· headerRight: none - Design: unreviewed
- Verified: 2026-09-17
Purpose. Full-screen management surface for a pickup game's host: decide pending guest applications, plus a danger zone to cancel the whole pickup. Only the session's creator (created_by) may view it.
Anatomy.
├─ [FlatColumn]
│ ├─ [SectionBlock variant="flat" title="신청 대기 (N)"] right=[ActionButton "전체 승인"] ▸ 2+ pending
│ │ ├─ loading → [SkeletonHero]
│ │ ├─ empty → [Well] > [EmptyState variant="compact"]
│ │ └─ populated → [RowList dividers] of [ApplicationRow] (avatar, name, relative time,
│ │ 거절/승인 buttons, each confirm-gated)
│ └─ [SectionBlock variant="flat" tone="danger" title="번개 취소하기"]
│ └─ [ActionButton destructive] "번개 취소하기" → appRouter.replace(routes.clubSession)States.
- error →
EmptyState+ retry - loading →
SkeletonHero - missing record: settled but no session row →
NotFoundState(2026-09-16: converted from a bespoke Text+ActionButton to the shared component) - permission-denied (
session.createdBy !== userId) →ShieldAlertEmptyState - no pending applications vs pending-applications list
- Offline cold start (query paused, nothing fetched) →
OfflineEmptyStatewith retry, via an earlyOfflineEmptyStatereturn — never the empty state (2026-09-11 offline sweep).
Actions.
- 승인 / 거절 per applicant (confirm dialog first)
- 전체 승인 (bulk-approve, visible at 2+ pending, confirm dialog first, sequential mutations)
- 번개 취소하기 (destructive, confirm dialog)
Gaps. none found.
Notes. 2026-09-17: application-row hairline is the shared Divider (PD-05 sweep) — pixel-identical.
Session Detail
- Route:
/sessions/[sessionId] - Screen:
SessionDetailScreen—packages/features/sessions/src/session-detail-screen.tsx - Shell: DetailShell · title
sessionTitle(session)(dynamic) · header="collapsing" ▸ a venue/location hero exists, else "default" · headerRight:SessionSaveButton(bookmark toggle) +HeaderActionsMenu(share / group-chat, each conditional) - Design: ratified
- Verified: 2026-09-17
Purpose. The canonical hub for a single session: logistics, participation, matches, and every host tool, with one state-precedence-aware pinned CTA covering the whole lifecycle (RSVP through settlement). This full order is ratified law (2026-09-17, decision log SD-IA, docs/canon/sessions.md); MatchesSection is now gated at the call site (showMatches), matching every sibling section (drift fix — it was the one section still self-gating alone).
Anatomy.
├─ [SessionLiveAnchor] page-level "진행 중" band ▸ isLive (session.status === 'in_progress') — the
│ absolute first flow child, decided 2026-09-17
├─ terminal-state [Banner tone="neutral"] ▸ sessionStrip.band === 'ended' (e.g. cancelled)
├─ [SessionHeroSection] full-bleed [MediaHero] (venue static map) or [TypeHero] fallback,
│ carrying the strip's lead + standing chips in its badge row
├─ [FlatColumn] lead=[StatStrip] 확정 인원 · 내 참가비 · 참석 마감
│ ├─ [LiveParticipantBar] Banner tone="error" ▸ showLiveParticipantBar (in_progress)
│ ├─ [RefundBanners] ▸ showRefundBanners
│ ├─ [NoticeSection] Well tone="primary" ▸ showNotice (session.description)
│ ├─ [PaymentGateBanner] ▸ showPaymentGateBanner (송금 완료 attest + dev-only PortOne button)
│ ├─ [ParticipationSection] 참여: hero count + ProgressBar, expandable roster, 이어보기
│ │ tournament chip, "경기 방식 선택" → FormatChooserSheet
│ ├─ [MatchesSection] 경기 ▸ showMatches (not cancelled): viewer's match rows, 점수 수정 pencil
│ │ pill, "전체 대진표 보기" link
│ ├─ [InfoSection] 정보: FactList of session facts
│ ├─ [HostCalloutSection] host identity card / pending-application banner (always renders)
│ ├─ [HostToolsSection] 호스트 도구 ▸ showHostTools: 일정 수정 · 참가자 관리 · 경기 규칙 ·
│ │ 호스트 관리 (badge=pending count) · 출석 확인 · 경기 종료
│ └─ [DangerZoneSection] 위험 구역 tone="danger" ▸ showDangerZone: 일정 취소 · 일정 삭제
└─ [BottomCtaBand] footer — one state-precedence CTA: refund ack (highest) > 초대하기 (open+host)
> RSVP/cancel (open+participant) > 경기 시작 (locked+host) > cancel/waitlist (locked+participant)
> 경기 보드 (in_progress+host) > 라이브 스코어보드 (in_progress+confirmed) > 정산
(completed+host+paid) > 매너 태그 (completed+confirmed) > noneStates.
- error →
EmptyState+ retry; offline-paused →OfflineEmptyState; loading →SkeletonFlatPage kind="map"; missing record →NotFoundState(2026-09-16: session-detail-screen.tsx is the canonical shape every other by-id screen's not-found branch now matches) - open/locked/in_progress/completed × host/participant, each driving a different band CTA (23-branch
primaryAction) - refund-pending overrides every other CTA state
Actions.
- RSVP / cancel RSVP / join waitlist; 초대하기 (host, open); 경기 시작 (host, locked, confirm-gated)
- 경기 방식 선택 → FormatChooserSheet (round or tournament)
- 점수 수정 (completed match,
canCorrect); "전체 대진표 보기" → Spectator Scorecard - GPS 체크인 / 경기에서 빠지기 or 기권하기 (live participant utilities, in_progress + confirmed)
- 송금 완료 (manual attest) / 결제하기 (PortOne, dev-only)
- 호스트 도구 rows; 일정 취소 / 일정 삭제 (destructive, gated); 정산; 매너 태그 (ModalPanel)
- share / group-chat (find-or-create thread, migration
00309) from header overflow
Gaps. none found.
Match Rules Editor (경기 규칙)
- Route:
/sessions/[sessionId]/match-rules - Screen:
MatchRulesScreen—packages/features/sessions/src/match-rules-screen.tsx - Shell: DetailShell · title
경기 규칙(t().matchRules.title) · headerRight: none - Design: unreviewed
- Verified: 2026-09-16
Purpose. Full-screen editor for a session's default match rules (admin-only) — uses the same shared MatchRulesEditor body as the per-match override sheet, so the field set never drifts between the two entry points.
Anatomy.
├─ [FlatColumn]
│ ├─ [MatchRulesEditor] value/onChange/validationError — no SectionBlock title (whole-page content)
│ └─ [Pressable] "기본값으로 초기화" → resets to KOREAN_DEFAULT_MATCH_RULES
└─ [BottomCtaBand] "저장" → MatchRulesSchema.parse → useUpdateSessionRules → appRouter.back()States.
- error →
EmptyState+ retry - loading →
SkeletonFlatPage kind="type" - permission-gated: non-
canManageClub→ShieldAlertEmptyState - editor (validation error text shown on a failed parse)
- saving (footer disabled)
- Offline cold start (query paused, nothing fetched) →
OfflineEmptyStatewith retry, via an earlyOfflineEmptyStatereturn — never the empty state (2026-09-11 offline sweep). - missing record: settled but no session row (bad/deleted sessionId) →
NotFoundState(2026-09-16 bug-class fix —isLoading || !sessionused to pin the loading skeleton forever instead)
Actions.
- edit any match-rule field
- 기본값으로 초기화 (reset to Korean default)
- 저장 (validates, saves, pops back)
Gaps. none found.
Session Participants (참석자 관리)
- Route:
/sessions/[sessionId]/participants - Screen:
ParticipantsScreen—packages/features/sessions/src/participants-screen.tsx - Shell: DetailShell · title
참석자 관리· subtitle확정, 예비, 취소 상태를 한눈에 확인해요· headerRight: none - Design: unreviewed
- Verified: 2026-09-16
Purpose. Full-screen participant management (replaces the former ParticipantsSheet): confirmed / waitlisted / cancelled rosters, with admin remove and host/club-manager waitlist-admit actions.
Anatomy.
├─ [DetailShell scroll={false}]
└─ [GroupedFeedList] sections: 참석 확정 (N) / 예비 (N) / 취소 (N)
├─ [ParticipantRow variant="rsvp"] × N per section — flat row (no Card, no per-row status
│ badge — the section title already names it, U-29): name, waitlist position/gate badge
│ (only shown where it varies), admit action ▸ canAdmit, [RemoveIconButton] (built-in confirm)
└─ [ParticipantEmptyRow] (Well) — pseudo-item so a zero-count section still shows its headerStates.
- error →
EmptyState+ retry - loading →
SkeletonFlatPage kind="type" - permission-gated: non-
canManageSchedule→ShieldAlertEmptyState - populated (three grouped sections, any of which may render the zero-count
Wellplaceholder) - Offline cold start (query paused, nothing fetched) →
OfflineEmptyStatewith retry, via an earlyOfflineEmptyStatereturn — never the empty state (2026-09-11 offline sweep).
Actions.
- 제거 (remove a participant) —
RemoveIconButton's own built-inConfirmSheet, not a screen-ownedconfirm.show()(usability audit 2026-09-16, U-29) - admit a waitlisted row (
canAdmit = isHost || canManageClub— deliberately narrower than the screen's owncanManageSchedulevisibility gate, mirroring migration00478's server RPC exactly) - pull to refresh
Gaps. none found.
Session Payments (결제 현황 / 비용 정산)
- Route:
/sessions/[sessionId]/payments - Screen:
SessionPaymentsScreen—packages/features/sessions/src/session-payments-screen.tsx - Shell: DetailShell · title
t().common.fee(참가비) — ONE title for both branches (U-14, 2026-09-17) · headerRight: none - Design: unreviewed
- Verified: 2026-09-17
Purpose. Per-session cost-settlement management for 총무/대표 (or the session host): generate/track per-participant payments and confirm or waive them.
Anatomy. (U-14, 2026-09-17: unified the DetailShell title to 참가비 in both branches; the roster section below is retitled 입금 현황 in both branches too; the legacy cost-split section is retitled 정산 with a new rounding-explainer sentence; the pending-row CTA is relabeled 입금 확인, distinct from dues' 납부 확인.)
├─ [QueryBoundary] session/rsvps/payments/profiles → loading=[SkeletonHero]
├─ permission-gated → [EmptyState] ShieldAlert
├─ no payments + fee session → [EmptyState] (payments auto-generate on RSVP, host just waits)
├─ no payments + legacy/free session →
│ [FlatColumn] > [SectionBlock 정산] [FactList] 총 비용/확정 인원/인당 금액
│ + [Text] splitExplainer (rounding-unit sentence, via `t().common.won`) + [LabeledField] amount [StyledInput]
└─ payments exist →
[FeedList] header=(fee session: [MetaSegments] paid-count + pending-count, no middle-dot glue;
legacy/free: [Text] 납부 현황 + count) + [ProgressBar] + [SectionHeader] 입금 현황 (both branches)
└─ [PaymentRow] × N — name, amount, status [Badge] (via sessionPaymentGateState/
SessionPaymentStatusBadge, never inline color logic), 입금 확인/면제 buttons on pending rows
└─ [BottomCtaBand] footer computed ONCE outside the branch switch: 정산 내역 만들기 (generate) OR
closeout CTA (disabled while any row pending) OR noneStates.
- loading (
QueryBoundaryskeleton) - permission-gated (
!canManageDues && !isSessionHost) - no payments — fee session (auto-generated on RSVP) vs legacy/free session (editable amount + generate CTA)
- payments exist (roster list, closeout CTA disabled while any pending)
Actions.
- edit the per-person amount before generating (legacy/free only)
- 정산 내역 만들기 (generate for all confirmed RSVPs)
- 입금 확인 (mark paid) / 면제 (mark waived), per-row pending Set so one mutation doesn't disable the whole roster
- closeout CTA (
appRouter.back(), disabled until all settled) - pull to refresh
Gaps. none found.
Public Pickup Preview
- Route:
/sessions/[sessionId]/preview - Screen:
PublicPickupPreviewScreen—packages/features/sessions/src/public-pickup-preview-screen.tsx - Shell: DetailShell · title = session title (dynamic) · subtitle = date label · headerRight: none
- Design: unreviewed
- Verified: 2026-09-16
Purpose. The curated view a non-member/unauthenticated visitor sees when tapping a pickup card from discovery — enough to decide whether to apply, with an apply-CTA state machine adapting to auth/application status. Bounces to full session detail once the viewer is a member, the host, or approved.
Anatomy.
├─ [QueryBoundary] session/rsvps → loading=[SkeletonFlatPage kind="type"]
├─ [TypeHero] title = venue/place name (fallback: location text, then sessionTitle) titleBadge=
│ [ApprovalModeBadge] facts=[🎾 format/style · 🏷 tier range · 📅 date · ⏰ time]
└─ [FlatColumn]
├─ [SectionBlock 정보] [FactList] 시작 시간 / 최대 인원 / 참가비
├─ [SectionBlock 호스트] → [PickupHostCard] (flat block, no Card, since the Hub-vs-detail law — shared
│ with host-callout-section.tsx; the SectionBlock title is its only frame)
├─ [SilhouetteStrip] (Well) grayscale placeholder circles ≤6 + overflow count + [MetaSegments]
│ attending-count + spots-left (no middle-dot glue)
├─ [LocationTile] [VenueCard] (backing venue row exists) or [VenueRow variant="plain"] fallback
└─ apply-notice textBounce rules (useEffect, top of body): not a pickup, host, club member, or already-approved application → appRouter.replace to full session detail — this screen only renders outside those conditions.
States.
- loading; session not found (
EmptyState) - closed session (cancelled/completed) → disabled "마감된 일정이에요"
- unauthenticated → "로그인하고 신청하기"
- rejected → disabled "거절됨"
- pending → amber pending text + disabled button + 신청 취소 ghost action
- no application/cancelled → primary apply CTA (verb varies by auto vs manual approval mode)
Actions.
- tap apply CTA (verb: 바로 참가하기 / 참가 신청)
- 신청 취소 (cancel a pending application)
- 로그인하고 신청하기 → login route
- back
Gaps. none found.
Spectator Scorecard (경기 스코어카드)
- Route:
/sessions/[sessionId]/scorecard - Screen:
SpectatorScorecardScreen—packages/features/sessions/src/spectator-scorecard-screen.tsx - Shell: DetailShell · title
sessionTitle(session)(dynamic; loading fallback경기 스코어카드,t().scorecard.title) · headerRight: none · no subtitle (date/location moved off the header into a bodyMetaSegmentsrow, 2026-09-14 no-middle-dot sweep —DetailShell.subtitleis a plain string with no line-array slot) - Design: ratified
- Verified: 2026-09-17
Purpose. Read-only-for-spectators view of a session's match state (draws/live/rounds, plus standings/ladder for score-driven formats), plus an admin-only per-match rules override editor.
Anatomy.
├─ [DetailShell scroll={false}]
├─ [SessionLiveAnchor] page-level "진행 중" band ▸ isLive — first child, above the tabs (pinned
│ for free: no ScrollView at this level, only each tab's own pane scrolls); decided 2026-09-17
├─ ▸ [MetaSegments] date + location facts (no middle-dot glue) — only when either is present
└─ [SegmentedTabs preload="all"] items dynamic:
대진표 (always) · LIVE (always, badge=live count) · 라운드 결과 (always) ·
순위 ▸ isScoreDrivenStyle · 킹코트 ▸ generationStyleId === 'king_of_court'
├─ [DrawsTab] scheduled matches by round; admin row-tap → per-match [MatchRulesEditorSheet]
├─ [LiveTab] in_progress matches with current scores
├─ [RoundsTab] completed matches by round, winner highlighted
├─ [StandingsTab] ▸ score-driven only
└─ [KingOfCourtLadder] ▸ king_of_court onlyDefault tab derived once from match state (getDefaultTab): live (any in_progress) → rounds (any completed) → draws.
States.
- error →
EmptyState+ retry - offline-paused with no cache →
WifiOff"연결이 없어요" paper-backupEmptyState - loading →
SkeletonCardstack - per-tab empty states (no draws / no in-progress / no completed rounds)
- per-match rules editor open (admin) vs session-default rules
- missing record: settled but no session row (bad/deleted sessionId) →
NotFoundState(2026-09-16 bug-class fix —resolvedSessionused to fall back to a placeholder Session shape and render the full scorecard scaffold around it)
Actions.
- switch tabs
- admin: tap a scheduled match row → per-match rules editor (save / clear-to-session-default / reset)
- pull to refresh (user-gesture only — data itself polls independently via
useLiveSessionData) - tap a match row →
MatchProfilesSheet
Gaps. none found.