Files
baya-monorepo/client/CLAUDE.md
T
2026-07-19 21:31:59 +03:30

140 KiB
Raw Blame History

Balinyaar Client — Claude Code Guidelines

The web frontend of Balinyaar, a trust-first home-nursing marketplace in Iran. This file is the engineering contract for everything under client/: providers, routing, data fetching, theming, i18n, cookies, and the rules every change must follow.

  • Repo-wide context and the backend → root CLAUDE.md.
  • Product/domain rules (what to build) → product/ — read the relevant doc before designing a feature; don't infer business rules from code.
  • Visual/design work (brand palette, tokens, component look-and-feel) → the frontend-designer skill. It is the design contract and defers to this file for engineering rules. Don't restate this file there.

Stack

  • Next.js 16 — App Router, Turbopack, React Server Components. Not a static export — the app relies on server components, middleware, and server-side cookies. (next.config.mjs only wires the next-intl plugin + reactStrictMode.)
  • React 19 + TypeScript (strict).
  • MUI v9 (@mui/material) for components and theming; Emotion underneath (RTL via stylis-plugin-rtl).
  • next-intl v4 for i18n — locales fa (default, RTL) and en.
  • TanStack Query v5 for server state; a small AuthContext (React context + reducer, src/context/auth/, seeded with server-read auth state) for auth/session state.
  • notistack for toasts; js-cookie (wrapped) for client cookies.
  • Jest + Testing Library for unit tests.
  • Quality gates: tsc, ESLint 9 (flat config), Prettier.

Commands

Task Command
Dev server npm run dev
Production build npm run build
Type-check npm run type
Lint npm run lint
Lint + autofix npm run lint:fix
Type + lint (the gate) npm run check
Format (Prettier) npm run format
Test (watch) npm test
Test (CI, once) npm run test:ci

Always run npm run check before declaring work done. Run npm run test:ci as well when you touch a component that has a co-located *.test.tsx.

Quality gates: lint & type (how they work)

Both gates are plain CLI tools. There is no next lint — it was removed in Next 16; calling it silently does nothing.

  • npm run typetsc --noEmit. Config in tsconfig.json: strict on, noEmit, @/*src/*.
  • npm run linteslint . driven by flat config in eslint.config.mjs. That config spreads eslint-config-next (core-web-vitals + typescript + react + react-hooks + jsx-a11y + import) and applies eslint-config-prettier last so ESLint never fights Prettier on formatting.
  • npm run check runs type then lint. Keep it green.

Rules for this project:

  • This project is flat-config only. Do not add .eslintrc* files — put any rule changes in eslint.config.mjs.
  • ESLint owns correctness, Prettier owns formatting. Don't add stylistic ESLint rules.
  • No unused variables or imports. @typescript-eslint/no-unused-vars is raised from eslint-config-next's default warn to error (in eslint.config.mjs), so dead code fails npm run check. Delete unused code rather than disabling the rule; prefix a deliberately-unused binding with _ (e.g. _event, catch (_err)) to opt out.
  • Prefer fixing code over silencing the linter. When a disable is genuinely correct — e.g. a deliberate browser-only read after mount that trips react-hooks/set-state-in-effect — use a scoped // eslint-disable-next-line <rule> with a one-line reason, never a file-wide disable.
  • Pin to ESLint 9. ESLint 10 currently crashes with this Next 16 toolchain (scopeManager.addGlobals is not a function). import/no-cycle is also disabled — its TS resolver has an interface mismatch here (see the note in eslint.config.mjs).

Golden rules (the short list)

A change is "done" only if it respects all of these — each has a full section below.

  1. Never add a layout above [locale]. src/app/[locale]/layout.tsx is the root layout (it renders <html>/<body>). A layout above it freezes lang/dir/messages on the default locale.
  2. Respect the server/client boundary. Never import next/headers, next-intl/server, or @/lib/cookies/server from a client component; never import @/lib/cookies/client from an RSC.
  3. No hard-coded UI strings. Every user-visible string is a key in both messages/en.json and messages/fa.json.
  4. Fetch only through clientFetch/serverFetch (@/lib/api) — never raw fetch(). Domain calls live in src/services/{domain}/apis/.
  5. Cookies only through the cookie manager (@/lib/cookies/*) — never document.cookie, js-cookie, localStorage, or sessionStorage for app/auth state.
  6. Colors come from tokens.css (var(--…)), never hard-coded in sx. Use the pre-built APP_THEME_LTR/APP_THEME_RTL; never call createTheme() in a component.
  7. MUI v9 API only. Use sx={{ mb: 4 }}, not mb={4} as a direct prop. No MUI-v5/v6-only props (useFlexGap, flexWrap on Stack, storageWindow, InitColorSchemeScript, …).
  8. Shared components get a co-located *.test.tsx. (A component imported from >1 place.)
  9. Magic strings become named constants (src/constants/ or a co-located constants.ts).
  10. npm run check is green and translations stay in sync before you finish.
  11. No dead code; comment the why, not the what. Unused vars/imports are lint errors — remove them. Don't add comments that restate the code; comment only a non-obvious decision, constraint, or trade-off. See Comments & dead code below.

Project Structure

This section is the canonical description of the client's architecture. When a change adds, removes, or renames a route group, provider, or top-level src/ folder, update this tree in the same change (root CLAUDE.md working agreement #7).

client/
├── messages/              # Translation files (add keys to BOTH files)
│   ├── en.json
│   └── fa.json
├── middleware.ts          # next-intl routing middleware (locale detection + redirect)
├── next.config.mjs        # createNextIntlPlugin wires i18n into Next.js
└── src/
    ├── app/
    │   ├── globals.css
    │   ├── fonts/                              # Local font files (woff2) — Mikhak for fa
    │   ├── global-error.tsx                    # Special file above [locale] — replaces the root layout on a root-level crash; renders its own <html>, so it CANNOT use next-intl. The one sanctioned static-string exception (minimal, bilingual fa+en).
    │   └── [locale]/
    │       ├── layout.tsx                      # ROOT RSC: renders <html lang/dir> + fonts + setRequestLocale + NextIntlClientProvider + ThemeProvider + AuthProvider (seeded via getServerAuthState) + generateMetadata (the '%s | برند' title template)
    │       ├── error.tsx                       # Branded, localized error boundary for the whole [locale] segment — reset() retries, a "go home" link escapes
    │       ├── not-found.tsx                   # Branded, localized 404 (RSC) — reached via the [...rest] catch-all below
    │       ├── [...rest]/page.tsx               # Catch-all — calls notFound() so any unmatched path under a locale renders not-found.tsx (next-intl's recommended 404 pattern)
    │       ├── (private-routes)/
    │       │   ├── layout.tsx                  # 'use client' — wraps PrivateLayout; mounts useSessionRoleSync (hydrates AuthContext roles from /me)
    │       │   ├── _chrome/SidebarShellSkeleton.tsx  # Private (`_`-prefixed, not a route) shared loading.tsx skeleton for the 3 sidebar shells (nurse/admin/partner)
    │       │   ├── select-role/page.tsx        # /select-role — first-use role picker (no public role yet); role router lands here
    │       │   ├── (customer)/                 # Customer (family) app — mobile-first, bottom-tab nav; no URL segment
    │       │   │   ├── layout.tsx              # 'use client' — RoleGuard(expected=customer) → CustomerLayout
    │       │   │   ├── loading.tsx             # Route-group loading skeleton (header + search bar + category-tile row + card stack)
    │       │   │   ├── page.tsx                # Thin RSC — generateMetadata (shell.customer_app) + renders HomeScreen
    │       │   │   ├── HomeScreen.tsx          # 'use client' — ui-phase-4: A5 home body — tappable search entry (routes to C1; the free-text field never worked, upgrade path noted for REQ-041's `q` param), a quiet TrustStrip, the data-driven category grid, a completeness-gated + session-dismissible patient-record nudge, and a rebook shortcut row (useBookingList + per-card useBookingDetail → deep-links to the nurse's C3 profile)
    │       │   │   ├── search/                 # /search — f6 discovery, ui-phase-4 redesign: C1 filter screen (page.tsx: reused category grid + f3 region picker + the shared GenderToggle `allowAny` + a Jalali day-chip date-intent strip (JalaliDatePicker `chips` variant + a full-grid popover) + Toman price + a sticky live-count CTA (StickyActionBar) that turns into a non-CTA "no matches" message at zero results; useSearchFilters colocated controller) → results/ (C2) → nurse/[nurseId]/ (C3)
    │       │   │   │   ├── page.tsx            # Thin RSC — generateMetadata (search.title) + renders SearchScreen
    │       │   │   │   ├── SearchScreen.tsx    # 'use client' — C1 search & filter body; hydrates from the FULL carried URL (searchParamsToFilters + a client-only `province_id` convenience param for the cascading-select prefill), not just `category_id`; pushes the filter set + `province_id`/`date` to C2 as URL query params
    │       │   │   │   ├── useSearchFilters.ts # C1 colocated filter controller — seeds every field from the initial URL (category/region/gender/price/date), not just category; derives the canonical NurseSearchFilters (debounced Toman price → IRR)
    │       │   │   │   ├── results/page.tsx    # C2 results, ui-phase-4 redesign — a tappable filter-recap chip row (category/region/gender/price, each deep-linking back to C1 with the ENTIRE carried query string) + a static "مرتب‌شده بر اساس امتیاز" caption (the dead one-option sort dropdown is gone) + NurseResultCard.Skeleton twins; all four states (skeleton/empty-relax/error/populated); load-more; filters live in the URL (the cache key)
    │       │   │   │   └── nurse/[nurseId]/page.tsx  # C3 nurse profile, ui-phase-4 dossier redesign — header now shows completed-visits count; a tappable TrustBadge (nurseId prop) + the shared VerificationPanel section (fed by useNurseTrustBadge) + attribute chips; a f13 tab strip: «خدمات» (ServicePriceRow list + an optional latest-review snippet) / «نظرات» (ReviewsPanel — published-only fractional aggregate+count + infinite list via services/reviews); a sticky bottom CTA bar (StickyActionBar, price-from beside "درخواست رزرو") survives the infinite reviews list and hands off to /bookings/request (f7); no gender chip (the public profile DTO doesn't serve `nurseGender` yet — REQ-042, never render the client's placeholder stub)
    │       │   │   ├── bookings/           # ui-phase-5 lifecycle redesign — the /bookings tabs are now the lifecycle home (no request is orphaned once left)
    │       │   │   │   ├── page.tsx            # Thin RSC — generateMetadata (booking.list_title) + renders BookingsScreen
    │       │   │   │   ├── BookingsScreen.tsx  # 'use client' — three tabs («در انتظار پاسخ»/«فعال»/«گذشته»): pending wires useCustomerRequests (mini-countdown per row, deep-links to C5); active/past split useBookingList('customer') client-side by status over one growing-pageSize "load more" query (the C2 pattern); AccentCard rows (status-tone borderInlineStart) are fully tappable (role=button+keyboard); a completed row without a review shows a compact star-strip CTA (useReviewEligibility, gated to completed rows)
    │       │   │   │   ├── [id]/page.tsx       # /bookings/[id] — f8 customer booking detail (BookingDetailView viewerRole="customer") + f10 cancel/refund entry (CustomerBookingActions) + f13 review entry (LeaveReviewCta: on a completed/closed booking, «ثبت نظر» → review page, flips to a passive "under review" affordance once reviewed — reuses the cached booking + my-review query)
    │       │   │   │   ├── request/page.tsx    # /bookings/request — C4 request form, ui-phase-5 redesign: sticky nurse-identity bar (avatar/name/rating/TrustBadge/gender) + a «چه اتفاقی می‌افتد؟» 3-step strip (reuses C5's StepperHeader); JalaliDateIntentPicker date + tappable morning/afternoon/evening/custom time-window chips (kills the end≤start error class); touched-on-blur field errors + a disabled-CTA "what's missing" caption (the old submit-gated `attempted` dead code is gone); a compact address row with a «تغییر» toggle back to the select (no more fake-map preview); accepts `patient_id`/`address_id` recovery params from C5's re-request handoff
    │       │   │   │   ├── request/[id]/page.tsx # /bookings/request/[id] — C5 tracker, ui-phase-5 redesign: CountdownTimer progress ring (`windowStart=createdAt`) + humanized «حدود N ساعت/دقیقه» framing above the coarse threshold; the cancel-request dialog is now `ConfirmDialog` with the destructive/dismiss labels fixed («نه، نگه دار» vs «بله، انصراف از درخواست» — the old defect had them swapped); rejected/expired terminal cards offer «درخواست دوباره با زمان دیگر» (reopens C4 prefilled) + «پرستاران مشابه» (region+gender-carried search), gated by a keyword heuristic over the freeform `nurseRejectionReason` (REQ-044 proposes a real code); converted → booking deep-link (bookingId, REQ-017)
    │       │   │   │   ├── [id]/invoice/page.tsx # /bookings/[id]/invoice — f9 commission invoice (b11), ui-phase-6 fiscal-grade pass: number + Shamsi date + buyer/service/visit-date recap (client-joined off useCustomerProfile + useBookingDetail) + payment method/transaction reference + seller fiscal-identity block (all REQ-049), reconciling lines with the VAT-on-commission line, read-only مودیان state; A4 `@page` print stylesheet + print-only footer; pdfUrl download or window.print receipt
    │       │   │   │   ├── [id]/cancel/page.tsx # /bookings/[id]/cancel — f10 cancellation flow, ui-phase-5 added off-ramps above the disclosure («تغییر زمان»/«گفتگو با پشتیبانی» → ContactSupportDialog, category coordination/support) + a one-line nurse-impact note; the reason select no longer pre-defaults to 'changed_mind' (empty until chosen, confirm gated); CancellationPolicyDisclosure unchanged
    │       │   │   │   ├── [id]/refund_status/page.tsx # /bookings/[id]/refund_status — f10 customer refund status (RefundStatusCard): pending → on-its-way → completed, BNPL ~710-day ETA, failed=contact-support; polls only while non-terminal
    │       │   │   │   ├── [id]/review/page.tsx # /bookings/[id]/review — f13 leave-a-review (b14), ui-phase-5 added a context recap (service/Shamsi date/nurse avatar off the cached booking) + the moderation-expectation note up front (not only post-submit); `useMyReviewForBooking` now gated `{ enabled: reviewable }` like the detail page; RatingInput + body + ReviewTagSelector; gated on completed/closed + server can_review + 1:1; on submit → persistent "under review" (pending_moderation, never public here); already-reviewed shows the review state, never a 2nd form (services/reviews)
    │       │   │   │   └── checkout/           # f9 checkout flow (C5 accept CTA lands on page.tsx with ?request_id=), ui-phase-6 trust-forward redesign
    │       │   │   │       ├── page.tsx        # C6 خلاصه و پرداخت — identity moment (nurse avatar+TrustBadge, REQ-046), prominent `<Money size="xl">` total, served reconciling breakdown (PriceBreakdown), EscrowExplainer, safe-area-aware sticky pay bar (StickyActionBar: total+CTA+secure-gateway trust line), both CTAs disable during initiate, explicit «بازگشت به درخواست» text link, un-baked continue arrow (endIcon="forward")
    │       │   │   │       ├── return/page.tsx  # return-from-gateway — confirm return → staged 2-node pending progress (StatusTimeline «بازگشت از درگاه ✓ → در انتظار تایید بانک» + duration hint, replacing the old spinner+chip+title stack) → succeeded (invalidate + hand off) / failed retry / window-expired, all via the shared PaymentStateCard (on the real path the PSP redirectUrl is absolute)
    │       │   │   │       ├── confirmation/page.tsx # payment success rebuilt as a receipt (ui-phase-6): copyable LTR کد پیگیری + copy-to-clipboard, Shamsi paid-at, method, booking reference (all REQ-046, hidden gracefully on the real path), EscrowExplainer, a "what happens next" 2-step StatusTimeline, real loading/error states (never a silently missing amount); «مشاهده رزرو» + «دانلود فاکتور»; REUSED by f11 (?method=bnpl reads the settled BnplOrderStatus instead of the payment outcome)
    │       │   │   │       └── bnpl/           # f11 BNPL installment checkout (the alternate branch off C6, reached with ?request_id=), ui-phase-6 honesty + polish pass
    │       │   │   │           ├── page.tsx    # D1→D4 stateful wizard (StepperHeader): D1 method/provider · D2 plan · D3 eligibility · D4 schedule+contract → provider handoff; card fall-back → C6 everywhere; terminal cards via the shared PaymentStateCard
    │       │   │   │           ├── MethodStep.tsx      # D1 روش پرداخت — payable amount + full-card option + provider option cards (from useBnplOptions, never hardcoded); provider mark via BnplProviderLogo (replaces the two-letter glyph stand-in)
    │       │   │   │           ├── PlanStep.tsx        # D2 انتخاب طرح — single-select BnplPlanCard group; the «مبلغ کل» header only renders once a plan is selected and **names** it («مبلغ کل با طرح {plan}») + the fee delta, never a silent plans[0] default
    │       │   │   │           ├── EligibilityStep.tsx # D3 اعتبارسنجی — کد ملی + prefilled موبایل (readOnly presentation, not disabled) + consent gate → useCheckEligibility (in-progress spinner + «در حال استعلام اعتبار…» label) → approved(ceiling)/declined(+card)
    │       │   │   │           ├── ScheduleStep.tsx    # D4 تایید طرح و قرارداد — served repayment rows (InstallmentScheduleRow) + ownership note + contract-consent gate → useIssueBnplToken handoff
    │       │   │   │           ├── gateway/page.tsx    # dev provider-handoff harness (TEST HARNESS; mock redirectUrl points here) → return; env-gated `notFound()` outside `development` (ui-phase-6 — was reachable in production builds)
    │       │   │   │           └── return/page.tsx     # settle (useAcceptBnplSchedule) → invalidate → reused confirmation (?method=bnpl) / retry / card; the invalid-link CTA label now matches its destination (ui-phase-6 fix)
    │       │   │   ├── patients/page.tsx       # /patients — the E1 «حلقهٔ مراقبت» (care-circle) list/CRUD, ui-phase-9 redesign: copy-level rename from «بیماران» (route/service/i18n key names unchanged), `FormDialogShell` (full-screen-on-mobile) replacing the `maxWidth="sm"` dialog, `PatientForm`'s `onDirtyChange` wired to its discard-confirm; error/empty/loading states unchanged (already fixed pre-phase-9); tapping a `PatientCard` (now a pressable surface with a chevron, not an invisible button) opens the E2 record (f13)
    │       │   │   ├── patients/[id]/record/page.tsx # /patients/[id]/record — f13 E2 care-record viewer (b14), ui-phase-9 rebuild: reused PatientHeader (now with an avatar) + ownership banner + 4 tabs (داروها/روتین/سوابق/وظایف). The whole-list free-text edit mode is gone — each medication/routine/task row opens a per-item responsive sheet (`Drawer anchor="bottom"` mobile / `Dialog` desktop, its own dirty-gated discard-confirm) with structured dose amount+unit/frequency-preset+time-of-day-chip fields (REQ-027 addendum); سوابق (read-only nurse visit-note history, VisitNoteCard) is now a Shamsi month-grouped timeline with a booking link + task-done summary when derivable; access-denied is a first-class non-leaking state gated BEFORE any clinical fetch (services/patientRecords) — preserved verbatim
    │       │   │   ├── addresses/page.tsx      # /addresses — F3 address book, ui-phase-9 pass: `isError`→`ErrorState` (was silently absent), `FormDialogShell` replacing the cramped dialog, `AddressCard`'s new pin-quality cue (`hasPin`/pin_set/pin_missing)
    │       │   │   ├── wallet/                 # /wallet — ui-phase-6 rebuilt into the customer money hub (page.tsx → WalletScreen.tsx: 4 Tabs, one shared `CONTENT_MAX_WIDTH`, no local width override): «پرداخت‌ها» (WalletPaymentHistory — card `usePaymentHistory` (REQ-047) + BNPL down-payment rows merged via the co-located `useWalletHistoryRows`), «اقساط» (WalletInstallments — the unchanged f11 D5 provider-reported outstanding balance + due list + early-pay hand-off, now section-only, no own heading/width), «استردادها» (WalletRefunds — `useMyRefunds`, REQ-048, one RefundStatusCard per refund), «رسیدها» (WalletReceipts — client-derived invoice deep-links off the same merged history rows, no endpoint)
    │       │   │   ├── profile/page.tsx        # /profile — ui-phase-9 rebuild into the customer's account hub (still the single route — sub-sections are `FormDialogShell` sheets, not sub-routes): `ProfileSummary` identity header (avatar/initials + name + server-masked phone), grouped tappable rows (اطلاعات شخصی/نشانی‌ها/زبان/اعلان‌ها/پشتیبانی/خروج — the زبان row owns the server-stored `preferredLanguage` and hosts the phase-2 `LocaleSwitcher`, never a second locale mechanism), an emergency-contact status card (tel:-only link when complete, a warm nudge otherwise), `ActorSwitcher` preserved; sign-out goes through `useLogout()` only. No national-ID.
    │       │   │   ├── support/tickets/        # /support/tickets — f14 "My Tickets" inbox (TicketInboxScreen role="customer") ↔ support/tickets/[id]/page.tsx thread (TicketThreadScreen); thin role-passing wrappers over @/components/messaging
    │       │   │   └── notifications/page.tsx  # /notifications — f14 notification center (NotificationCenter role="customer"); the TopBar bell deep-links here
    │       │   ├── nurse/                      # Nurse app (/nurse/…) — sidebar shell
    │       │   │   ├── layout.tsx              # 'use client' — RoleGuard(expected=nurse) → NurseLayout
    │       │   │   ├── loading.tsx             # → ../_chrome/SidebarShellSkeleton
    │       │   │   ├── page.tsx                # /nurse (dashboard) — thin RSC (generateMetadata nav.dashboard) rendering NurseDashboardScreen.tsx
    │       │   │   ├── NurseDashboardScreen.tsx # ui-phase-7 — the «امروز» operational home replacing the old PlaceholderScreen: greeting+TrustBadge, NextVisitCard (useTodaySessions), RequestsStrip (useNurseRequestInbox, the most time-critical widget — sorts above earnings), EarningsSnapshotCard (useNurseEarningsBalance, signed net + eligible), DashboardActivationSlot, NotificationsEntryRow (useUnreadCount) — every widget is a read of an already-cached query, four-state pattern throughout
    │       │   │   ├── DashboardActivationSlot.tsx # ui-phase-8 — the named composition point from ui-phase-7's hand-off, now filled with the shared `ActivationChecklist` (same component as `/nurse/services`) instead of the old single-row verification banner
    │       │   │   ├── requests/               # /nurse/requests — f7 incoming booking-requests inbox, ui-phase-7 redesign: page.tsx = decision-first cards (service+price headline when served — REQ-050, mock-tolerant) + an urgency-tinted CountdownTimer pill (teal/amber/terracotta tiers) + three tabs (در انتظار/پاسخ‌داده merged-client-side page-1-only/منقضی, shared Pager) ↔ requests/[id]/page.tsx detail (only customerNotes + masked city/district; accept now behind a ConfirmDialog + a post-accept payment-window countdown; reject-with-reason invalidate inbox+detail)
    │       │   │   ├── profile/                # /nurse/profile — B7 profile bootstrap, ui-phase-8 pass: page.tsx adds editable education level/field (select + "سایر" free-text fallback) + specializations (chips, shared `SPECIALTY_PRESETS` vocab), a beforeunload guard on a staged-but-unsaved avatar, and a link into preview/ ↔ preview/page.tsx «نمایهٔ عمومی من» — the C3 trust-dossier pieces (TrustBadge/VerificationPanel/ServicePriceRow) composed entirely from the nurse's OWN cached data (own profile + useMyVariants + useServiceAreas + own badge), so it renders truthfully pre-publish
    │       │   │   ├── services/               # /nurse/services — B7 services half: offerings list ↔ variant builder (page.tsx switches mode; MyServicesList + VariantBuilder + PublishGate co-located). ui-phase-8: MyServicesList mounts the shared `ActivationChecklist` + a link to the profile preview above the list; PublishGate rewritten as the REAL `set_accepting_bookings` toggle (state-driven guidance/start/pause — no more no-op snackbar); VariantBuilder's step-3 renders the actual `VariantCard` as a live listing preview, option groups are chips (not a wrapped ToggleButtonGroup), and a 409 duplicate offers "edit the existing listing" (resolved via `optionSetSignature` against the cached `useMyVariants` list)
    │       │   │   ├── coverage/page.tsx       # /nurse/coverage — F3 coverage-area editor. ui-phase-8: the separate whole-city/districts scope toggle is gone — `CascadingRegionSelect`'s own district level (its «کل شهر» empty option) is the ONLY control for the whole-city choice; `removeArea` now has an onError toast
    │       │   │   ├── bank/page.tsx           # /nurse/bank — payout IBAN + ownership states (pending/verified/mismatch; `BankStatusPanel` unchanged). ui-phase-8: restructured as an accounts section — a persistent «افزودن حساب دیگر» CTA once ≥1 account exists (never dead-ends a nurse switching banks), an explicit pending-inquiry copy, and a real error state (never the empty-state form) on a failed query
    │       │   │   ├── verification/           # /nurse/verification — f5 trust flow: ONE cached VerificationStatus query, four views. ui-phase-8: rebuilt as a single vertical journey — one progress metaphor everywhere (the old flat "X از Y" meter + the B4/B5/B6 3-step `StepperHeader` are both gone)
    │       │   │   │   ├── page.tsx            # B3 hub — grouped step cards (هویت/مدارک حرفه‌ای/بانک, `VerificationChecklist`) + `TrustBadgePreviewPanel` («این نشان را خانواده‌ها می‌بینند», fills per group) + single continue CTA + not_started/approved states; dev-only mock admin-decision sim
    │       │   │   │   ├── identity/page.tsx   # B4 — national-ID (checksum) + card/selfie local capture → automated KYC + chained Shahkar; a cheap CSS `CaptureGuideFrame` (viewfinder corners / oval) + static hint per capture, `VerificationJourneyHeader` replaces the old StepperHeader
    │       │   │   │   ├── credentials/page.tsx # B5 — hydrates INO/specialties/registry fields from `status.credentialSubmission` (REQ-056, mock-tolerant) so a returning nurse sees a submitted summary, never blank fields; the INO number locks into a "شمارهٔ نظام ثبت شد" row (never re-prompted, never re-sent blank — the raw value is never read back by design); Jalali `JalaliDateField`s replace the native `type="date"` issue/expiry inputs; the submit gate considers server-side document state too, so a returning nurse is never dead-ended on a disabled button with no explanation
    │       │   │   │   ├── review/page.tsx     # B6 — under-review; a Shamsi submitted timestamp (REQ-055, mock-tolerant) + a `StatusTimeline` what-happens-next (بررسی توسط کارشناس → نتیجه در ۲۴–۴۸ ساعت → فعال‌سازی نشان) + `VerificationJourneyHeader`
    │       │   │   │   ├── VerificationChecklist.tsx  # B3 body: grouped step cards over `groupedDisplaySteps` (co-located, page-only)
    │       │   │   │   ├── TrustBadgePreviewPanel.tsx # B3's payoff — live TrustBadge + a per-group fill indicator, never fakes a state beyond `ownBadgeState`
    │       │   │   │   ├── VerificationJourneyHeader.tsx # the ONE progress header B4/B5/B6 share — group name + «بازگشت به مسیر تأیید» (reused across 3 sibling pages)
    │       │   │   │   └── verificationSteps.ts       # step→label/chip/route helpers + synthetic mobile step + ui-phase-8's group→steps folding (`groupedDisplaySteps`/`groupStatus`/`GROUP_ORDER`) — keeps rendering data-driven
    │       │   │   ├── visits/                 # /nurse/visits — f8 EVV, ui-phase-7 day-surface + detail pass: page.tsx = Shamsi «امروز، …» date anchor + ویزیت امروز today-sessions feed (per-session check-in/out via useEvvController + advisory EvvStatusBanner; 60s refetchInterval; SessionCard's EVV CTA is now the full-width hero action + a confirm step on check-out) ↔ visits/[id]/page.tsx nurse booking detail (BookingDetailView viewerRole="nurse": address card + geo: map link, an in-visit «در حال ویزیت» banner promoting check-out, EVV controls + gated care card) + f13 NurseVisitNotesPanel.tsx (co-located, BELOW the EVV banner: today's task checklist + free-text note composer + read-only continuity history — APPEND-ONLY, never wires useUpdateCareRecord; services/patientRecords)
    │       │   │   ├── earnings/               # /nurse/earnings — f12 nurse earnings (read-only), ui-phase-7 pass: page.tsx = EarningsBalanceHeader (net payable balance + 4 buckets, negative "owed back") + a «برداشت بعدی» ForecastLine (server-served only) + an accessible ButtonBase ExplainerCard (aria-expanded, registered `expand` chevron) + state-segmented EarningsRow list (deep-links to /nurse/visits/[id], shared Pager) ↔ payouts/page.tsx (PayoutHistoryRow list) → payouts/[id]/page.tsx (payout/batch reconciliation detail: money decomposition + masked IBAN + booking links); failed-payout reasons now map through `services/payouts/failureReasons.ts` (mapped label headline, raw code demoted to a secondary LTR caption)
    │       │   │   ├── support/tickets/        # /nurse/support/tickets — f14 nurse "My Tickets" (same TicketInboxScreen/TicketThreadScreen, role="nurse") ↔ support/tickets/[id]/page.tsx
    │       │   │   └── notifications/page.tsx  # /nurse/notifications — f14 notification center (role="nurse"); the nurse-shell bell deep-links here
    │       │   ├── admin/                      # Admin/backoffice (/admin/…) — desktop sidebar shell (f15). Every screen is role-gated via useAdminCapabilities(); the sidebar hides a console the current admin role can't act on (server still enforces). ui-phase-11: every queue page adopts `useAdminListState` (URL-synced filters+page, `@/hooks`) behind a `<Suspense>` wrapper.
    │       │   │   ├── layout.tsx              # 'use client' — RoleGuard(expected=admin) → AdminLayout (capability-gated nav)
    │       │   │   ├── loading.tsx             # → ../_chrome/SidebarShellSkeleton
    │       │   │   ├── page.tsx                # Thin RSC — generateMetadata (admin.overview_title) + renders AdminOverviewScreen
    │       │   │   ├── AdminOverviewScreen.tsx # 'use client' — f15 overview landing: a capability-gated grid of console cards
    │       │   │   ├── verification/           # /admin/verification — ui-phase-11 rebuild: page.tsx = status **Tabs** with server counts (when served, REQ-062) + a name/phone search behind the draft-vs-applied Apply/Clear pattern + a client-computed SLA-colored waiting-time column (`WAITING_TIME_WARNING_HOURS`/`_ALARM_HOURS`) ↔ [nurseId]/page.tsx per-nurse case (unchanged DocumentViewer signed-URL docs, pass/reject+reason per step, structured credential entry with `JalaliDateField` issued/expires, Approve enabled only when all steps pass) + «پرونده بعدی/قبلی» next/prev case nav (re-derives the queue's cached page via `queueFilters.ts`) + arrow-key bindings — client never writes is_verified
    │       │   │   ├── tickets/                # /admin/tickets — ui-phase-11: page.tsx adds an activity column + a results footer ↔ [id]/page.tsx admin thread gains close/reopen/assign-to-me (`useCloseTicket`/`useReopenTicket`/`useAssignTicket`, gated behind `TICKET_LIFECYCLE_ENABLED` + `canManageTickets` — REQ-063, no live route yet), opens scrolled to the newest message (`useThreadScroll`), and the composer turns amber (`--bal-warning-soft`) + relabels its send button in internal-note mode so a note can't be posted publicly by mistake; AdminMessageBubble still renders isInternal notes distinctly; RefundPanel opens from a refund ticket
    │       │   │   ├── payouts/                # /admin/payouts — ui-phase-11: page.tsx fixes the local-midnight UTC off-by-one on the window default, adopts `JalaliDateField` for the period inputs, and the run-confirm shows the batch total/count/date (from the already-fetched preview) behind `ConfirmDialog`'s new typed-confirmation gate (type «تایید» or the amount) ↔ [batchId]/page.tsx per-nurse rows + failed-payout retry + transfer-reference reconcile, unified onto `PageHeader`
    │       │   │   ├── reviews/page.tsx        # /admin/reviews — f15 moderation queue: publish/hide/reject (reason on hide/reject); low-rating flag; client never computes the aggregate; ui-phase-11 adopts `useAdminListState`
    │       │   │   ├── config/page.tsx         # /admin/config — f15 config editor: typed input by data_type + 01 rate validation + audited-save dialog + change-history drawer; ui-phase-11 gives both the list and the history drawer real page state (was hard-wired to page 1) and drops the dead `dataType==='int'||'decimal'?'text':'text'` ternary
    │       │   │   ├── holidays/page.tsx       # /admin/holidays — f15 Iranian-holiday manager (is_bank_closed toggle; client never computes the payout shift); ui-phase-11 adopts `JalaliDateField` for the holiday date, seeds new-holiday date from a real local-date helper (was a lying `TODAY_ISO = ''`), and real page state
    │       │   │   ├── alerts/page.tsx         # /admin/alerts — f15 internal support-alert worklist (assign/resolve); NEVER surfaced to a non-admin; ui-phase-11 fixes assign-to-self's `?? 1` fallback — the button disables with a "loading your account" tooltip until the real id hydrates — and adopts `useAdminListState`
    │       │   │   ├── audit/page.tsx          # /admin/audit — f15 append-only audit viewer (filtered, paginated, expandable changedFields diff; no edit/delete); ui-phase-11 adopts `useAdminListState` + `JalaliDateField` for from/to + batch-resolves actor names (`useUserLookup`) for `AuditLogRow`'s new `actorLabel` prop (falls back to `#id`)
    │       │   │   ├── partners/               # /admin/partners — f15 partner-center management (page.tsx: list + create) ↔ [id]/page.tsx detail (verify/activate/suspend + edit + sponsored-nurse roster + assign-nurse; IBAN write-then-masked); ui-phase-11 replaces both the admin-user and sponsored-nurse raw-id `TextField`s with `UserPicker`/`NursePicker` (name+masked-phone+id search, §3.2) and unifies the detail header onto `PageHeader`
    │       │   │   ├── roles/page.tsx          # /admin/roles — f15 RBAC grant/revoke grid (DEFERRED-IF-MISSING — mock-backed until the b15 role endpoints land); ui-phase-11 replaces the raw numeric-id grant `TextField` with `UserPicker` — the confirm/revoke copy now names the resolved person, never `#42`
    │       │   │   ├── users/page.tsx          # /admin/users — ui-phase-11: a real read-first directory (was a `PlaceholderScreen`) — search by name/phone over the same admin user-directory seam `UserPicker` uses (REQ-061, mock-backed), role chips, a per-row link into the audit log filtered to that user
    │       │   │   └── notifications/page.tsx  # /admin/notifications — still a placeholder; NOT in `AdminLayout`'s nav (no real feed yet, ui-phase-10/11) so it satisfies "no placeholder reachable from admin nav"
    │       │   └── partner/                    # Partner-center portal (/partner/…) — a SEPARATE authz scope (f15). A center admin is not a Balinyaar admin; each page resolves the caller's OWN center (useMyPartnerCenter → access-denied on 403/404).
    │       │       ├── layout.tsx              # 'use client' — RoleGuard (no expected role — hydration-only) → PartnerLayout (own partner nav; self-gates via useMyPartnerCenter)
    │       │       ├── loading.tsx             # → ../_chrome/SidebarShellSkeleton
    │       │       ├── page.tsx                # Thin RSC — generateMetadata (partner.home_title) + renders PartnerHomeScreen
    │       │       ├── PartnerHomeScreen.tsx   # 'use client' — center home: onboarding/verification state banner + license fields + is_merchant_of_record indicator
    │       │       ├── nurses/page.tsx         # /partner/nurses — the center's sponsored nurses (verification badge)
    │       │       ├── bookings/                # /partner/bookings — ui-phase-11: page.tsx localizes the 7 booking-status codes onto `StatusChip` (was raw English wire codes, e.g. `pending_payment`) in both the table and filter, adopts `useAdminListState`, and rows link to ↔ [id]/page.tsx (new) — a scoped read-only detail (dates, status timeline via the shared `StatusTimeline`, patient display name only — no clinical data; REQ-064, mock-backed)
    │       │       └── settlement/page.tsx     # /partner/settlement — rendered ONLY when is_merchant_of_record: per-booking commission invoices (commission/VAT decomposition via PartnerSettlementRow, signed-URL PDF, masked IBAN); non-MoR shows the "settlement via Balinyaar" state; ui-phase-11 adds a client-side «خروجی CSV» export (`utils/toCsv.ts`, UTF-8 BOM + CRLF for Excel) of the current result set
    │       ├── (customer-focused)/             # ui-phase-3 — chrome-free counterpart to (customer) for can't-tab-away flows; same URL space (route groups add no segment)
    │       │   ├── layout.tsx                  # 'use client' — RoleGuard(expected=customer) → FocusedLayout (no BottomBar/bell/sidebar)
    │       │   └── onboarding/                 # /onboarding — moved here from (customer) so the A3→A4 wizard can't be tabbed away from mid-setup
    │       │       ├── page.tsx                # Thin RSC — generateMetadata (onboarding.welcome_title) + renders OnboardingScreen
    │       │       └── OnboardingScreen.tsx    # 'use client' — welcome moment (not a stepper step) → relation (4 distinct icons: elderly/favorite/infant/account) → patient (StepperHeader 2 steps)
    │       └── (public-routes)/
    │           ├── layout.tsx                  # 'use client' — wraps PublicLayout
    │           ├── loading.tsx                 # Auth-card-shaped skeleton (brand mark + a card-sized block)
    │           ├── login/                      # /login — phone-OTP login (A1/A2 customer, B1/B2 nurse switch)
    │           │   ├── page.tsx                # Thin RSC — generateMetadata (auth.customer_title) + renders LoginScreen
    │           │   └── LoginScreen.tsx         # 'use client' — the actual LoginFlow body
    │           ├── terms/page.tsx              # /terms — draft Terms of Service (ui-phase-3; DRAFT COPY, needs human/legal review before launch)
    │           └── privacy/page.tsx            # /privacy — draft Privacy Policy (ui-phase-3; DRAFT COPY, needs human/legal review before launch)
    ├── components/        # Shared UI components (each with .test.tsx if imported >1 place)
    │   ├── common/            # Foundational primitives (import from @/components or @/components/common)
    │   │   ├── AppButton/, AppIconButton/, AppIcon/, AppLink/, AppAlert/, AppLoading/  # house-default MUI wrappers (see frontend-designer skill §4)
    │   │   ├── ErrorBoundary.tsx  # class component wrapping page content in the shell; PRESENTATIONAL — no next-intl import, caller passes title/body/retryLabel (see "Presentational purity" below)
    │   │   ├── EmptyState/    # icon+title+body+action — the one "nothing here" pattern (replaces hand-rolled dashed-border Paper blocks)
    │   │   ├── ErrorState/    # message+retryLabel(required)+onRetry — the one "this query failed" pattern; PRESENTATIONAL, no next-intl import (same reason as ErrorBoundary)
    │   │   ├── QueryStateGate/ # wraps a query's skeleton/error/empty/children branching in the fixed skeleton→error→empty→children order; requires retryLabel
    │   │   ├── PageHeader/    # title+subtitle+actions+optional back button (backTo/backLabel); ui-phase-11 added `meta` (a chip-row slot below the title, distinct from the button-oriented `actions`) and `onBack` (a callback alternative to `backTo` — pairs with `useAdminBackToList` for `router.back()`-with-fallback semantics; takes precedence over `backTo` when both are given)
    │   │   ├── ConfirmDialog/ # promoted from admin/ — required-reason gating + busy-disable, now usable by any actor; ui-phase-11 added `requireTypedConfirmation`/`typedConfirmationLabel`/`typedConfirmationPlaceholder` — confirm stays disabled until the typed value matches one of the given strings, the guard for an irreversible money-moving action (the admin payout run confirm)
    │   │   ├── SurfaceCard/   # flat Paper wrapper, padding: 'sm'|'md'|'lg'
    │   │   ├── AccentCard/    # SurfaceCard + tone → 4px borderInlineStart accent (primary/secondary/success/error/warning/info/trust/neutral)
    │   │   ├── Money/         # <Money amountIrr size tone deduction hideUnit strikethrough> — the one money-rendering primitive (wraps utils/money.ts); size gained `xl` (h4) in ui-phase-6 for the checkout/confirmation prominent-total hero; imports next-intl (see jest.config.ts transformIgnorePatterns note below)
    │   │   ├── StatusTimeline/ # ordered TimelineNode[] (completed/current/pending/failed) with animated pulse on current (respects prefers-reduced-motion)
    │   │   ├── JalaliDatePicker/ # calendarEngine.ts (jalaali-js-backed Jalali↔Gregorian) + grid/chips variants (ui-phase-4: chips variant takes optional `todayLabel`/`tomorrowLabel` overrides — the C1 «امروز»/«فردا» date-intent strip), RTL-aware keyboard nav
    │   │   ├── JalaliDateField/  # read-only TextField + Popover wrapping JalaliDatePicker
    │   │   ├── JalaliDateIntentPicker/ # ui-phase-5 — extracted from C1's local date-intent widget (near-day chip strip + a calendar-icon Popover entry into the full grid) so C4's real required date field reuses it too, not just C1's intent-only one; caller-owned today/tomorrow/pick-other labels (tested)
    │   │   ├── StickyActionBar/ # ui-phase-4 — a `position:sticky` bottom-pinned action-bar shell for a scrolling screen's primary CTA (C1's live-count CTA, C3's booking CTA); composes with the shell's existing BottomBar safe-area padding rather than reimplementing `env(safe-area-inset-bottom)`
    │   │   ├── LocaleSwitcher/   # ui-2 fa/en toggle preserving the current route (`router.replace(pathname, {locale})` via `@/i18n/navigation`); sidebar footers, the customer profile hub, the public shell (tested)
    │   │   ├── Pager/            # ui-phase-7 — the shared prev/next "page X of Y" control (common namespace i18n) replacing the near-identical inline pagers hand-rolled per list screen (nurse inbox tabs, payout history/earnings) (tested)
    │   │   ├── InitialsAvatar/   # ui-phase-9 — warm auto-colored initials for a person with no photo: deterministic name-hash → one of 6 `--bal-avatar-*` token pairs (tokens.css, both scheme blocks); `aria-hidden` (decorative next to a visible name); used by `PatientHeader` and (via `ProfileSummary`'s new `initialsFallback` prop) the customer account hub (tested)
    │   │   ├── FormDialogShell/  # ui-phase-9 — full-screen-below-`sm` form dialog (app-bar header + close) with a dirty-gated discard-confirm on close/backdrop/escape; the shared primitive behind the patient/address add-edit dialogs (the hosted form reports `dirty` via an `onDirtyChange` prop) (tested)
    │   │   ├── RouteFadeIn/   # ui-phase-12 — the one route-content fade/slide primitive (motion pass): wraps `{children}`, keyed on the locale-stripped pathname so it remounts (replays the CSS `bal-fade-in` keyframe, globals.css) on navigation but never on an in-place re-render; mounted inside the `ErrorBoundary` in all five shells (tested)
    │   │   └── index.tsx      # barrel — keep next-intl-importing primitives (Money) below the presentational ones so the poisoning risk stays visible in review
    │   ├── admin/              # f15 backoffice + partner composites (import from `@/components/admin`): AdminDataTable (v2, ui-phase-11 — optional per-column server-param `sort`/`sortable` + `TableSortLabel`, `stickyHeader` scroll viewport, `minWidth`, a `footer` line), AdminPager (page/pageCount; the admin `page_indicator` i18n key regained its `{total}`), AdminPageHeader/AdminEmptyState/AdminErrorState, ConfirmDialog (thin alias), ConfigRow/AuditLogRow (ui-phase-11 — expand chevron rotates + `aria-expanded` + button semantics, new `actorLabel` prop resolved via a batch id→name lookup)/SupportAlertCard (ui-phase-11 — `assignSelfDisabled`/`assignSelfDisabledTitle` so "assign to me" never falls back to a guessed user)/PartnerSettlementRow, DocumentViewer, RefundPanel, AdminMessageBubble, and (ui-phase-11) `UserPicker`/`NursePicker` — async name/phone `Autocomplete` over the admin user directory (REQ-061, mock-backed), replacing every raw numeric-id `TextField` on an audited action; each option renders name+masked-phone+id, never a bare id
    │   ├── PlaceholderScreen/ # Empty-state scaffold for not-yet-built screens
    │   ├── OtpInput/          # OTP code input (auto-advance, paste, RTL-safe)
    │   ├── PhoneNumberField/  # Iranian mobile field (digit-normalizing, LTR-in-RTL, maskIranMobile)
    │   ├── StepperHeader/     # Progress header for onboarding/verification flows
    │   ├── StatusChip/        # Semantic status chip (verified/pending/rejected/…) off --bal-* tokens
    │   ├── GenderToggle/      # Required male/female toggle (never defaulted) — drives same-gender matching; ui-phase-4 added an opt-in `allowAny` mode (discriminated-union props) adding a third «فرقی ندارد» option for C1's search facet — the default booking-context contract is unchanged
    │   ├── ConditionChips/    # Multi-select patient-condition chips (stable codes, translated labels)
    │   ├── RelationSelect/    # Single-select relation radio cards (parent/spouse/child/self)
    │   ├── PatientForm/       # A4 patient form (first/last name, age/gender/conditions/relation) — reused create+edit; ui-phase-9 split the single full-name field into first/last (lastName falls back to firstName when blank — the wire requires it) and added an `onDirtyChange` prop for `FormDialogShell`'s discard-confirm
    │   ├── PatientCard/       # E1 care-circle summary card (composes the shared PatientHeader, now with its avatar) + edit/archive actions; ui-phase-9 replaced the invisible tap target with a pressable `ButtonBase` surface (hover/press + a trailing chevron) and added an optional `lastVisitLabel` teaser (never populated without a `patientId` on the cached bookings list — REQ-057)
    │   ├── BankStatusPanel/   # Nurse bank-account ownership state (pending/verified/mismatch), masked IBAN
    │   ├── CategoryTile/      # f4 tappable service-category tile (icon+label; `selected` state for the builder) — Home grid + builder step 1 (tested)
    │   ├── PriceDisplay/      # f4 price renderer: money-util Toman + i18n unit label + unit-aware estimated total (never a total from price alone) (tested)
    │   ├── VariantCard/       # f4 nurse offering card: display_name, PriceDisplay, active/deactivated distinction, edit/deactivate (no delete); ui-phase-8 added `interactive={false}` for a read-only preview use (the builder's live listing preview + the profile preview) (tested)
    │   ├── ActivationChecklist/ # ui-phase-8 — the unified go-live tracker (`useActivationChecklist` hook, self-fetching): five already-cached queries folded into rows — two-tier honesty (identity/profile/services/coverage drive search visibility; bank drives "getting paid", labelled separately and never gates search) — collapses to a compact «فعال در جستجو» state once everything passes AND accepting-bookings is on; mounted on `/nurse/services` and the dashboard's `DashboardActivationSlot`, one shared component; `useActivationChecklist` is also consumed directly by `PublishGate` so the go-live gate and the checklist never compute the conditions twice (tested)
    │   ├── TrustBadge/        # f5 public trust signal (verified/unverified/expired) off --bal-* tokens — nurse profile + reused by f6 search/public profile; ui-phase-4 added an opt-in `nurseId` prop that makes the badge tappable, opening a bottom-sheet/dialog explainer (the shared VerificationPanel, fed by a LAZY useNurseTrustBadge(nurseId) fetch) — split into an inner `InteractiveTrustBadge` so the default (no `nurseId`) mode calls no query hook at all and needs no QueryClientProvider in its callers' tests (tested)
    │   ├── DocumentUpload/    # f5 reusable doc uploader: client type/size validation, progress %, success/retry, re-upload on reject; server-metadata truth (local-capture mode too) (tested)
    │   ├── VerificationPanel/ # ui-phase-4 — shared "what Balinyaar verified" explainer (`src/components/VerificationPanel/`): one row per TrustBadge.credentialTypes[] (i18n off the verification namespace's step_* codes) + the approval date, fed by useNurseTrustBadge; used standalone on the C3 profile AND inside TrustBadge's tap-to-explain dialog; reused unchanged by phase 8's public-profile preview (tested)
    │   ├── NurseResultCard/   # f6 C2 result card, ui-phase-4 v2 anatomy: avatar+name, tappable verified TrustBadge, a service/variant label (category name until REQ-040's `variantDisplayName` lands), a quiet nurse-gender chip + completed-visits count, rating+review count, optional distance chip, an optional one-line top-review tag (REQ-040), and "from X تومان/unit" via PriceDisplay; presentational + memoized (tested)
    │   ├── ServicePriceRow/   # f6 C3 service line: localised name + PriceDisplay (money util + i18n unit label); reused by the booking summary later (tested)
    │   ├── CountdownTimer/     # f7 pure presentational countdown to a server-frozen UTC deadline; owns its own 1s tick (only it re-renders), stops + shows elapsed text at zero, locale digits LTR; v2 (ui-phase-1) progress ring (`windowStart`) + urgency tiers + humanized coarse mode — C5's response countdown (ui-phase-5) is the ring's first live consumer (`windowStart=createdAt`) (tested)
    │   ├── BookingRequestSummaryCard/ # f7 engagement summary (nurse+rating, patient, priced service, address, Shamsi time) — shared by C5 + nurse detail + later f8 booking detail; ui-phase-5 bidi-isolated the date·time-range label (`dir="ltr"` span, matching SessionCard's precedent) (tested)
    │   ├── PriceBreakdown/    # f9 reconciling money breakdown (rows + bold total, all IRR digit-strings via the money util; dev-guard console.errors when rows ≠ total) — C6 + invoice now, f10/f11 refund/BNPL later; ui-phase-6 switched the row amounts to `<Money>` too (every row now carries «تومان», not just the total) (tested)
    │   ├── EscrowNotice/      # f9 product-mandated escrow trust callout (verbatim fa copy, --bal-info tone, lock icon) — the untouchable inner sentence; C6/confirmation now wrap it in `EscrowExplainer` (tested)
    │   ├── EscrowExplainer/   # ui-phase-6 — wraps `EscrowNotice` with an optional «چطور کار می‌کند؟» expander: a 3-step visual (پرداخت ← امانت نزد بالین‌یار ← آزادسازی) grounded in product/payments/escrow-ledger.md + the cancellation/refund implication; checkout + confirmation (tested)
    │   ├── PaymentStateCard/  # ui-phase-6 — the one terminal/wait-state card (icon/tone/title/body/actions) replacing the four copy-pasted private `MessageCard`/`StateCard` functions across the card + BNPL checkout/return flows (tested)
    │   ├── BnplProviderLogo/  # ui-phase-6 — providerCode → bundled SVG (none licensed yet) falling back to a designed tinted-monogram roundel, replacing the two-letter text-glyph stand-in (`DG`/`SP`/…) in D1's MethodStep (tested)
    │   ├── PaymentStatusBadge/ # f9 b10 payment status (pending/succeeded/failed) → StatusChip kind + payment.pstatus_* label (tested)
    │   ├── CancellationPolicyDisclosure/ # f10 pre-confirm cancel disclosure: policy-tier label (off cancellation_policy_code) + refund %/fee % + PriceBreakdown refund-vs-fee split (reconciles) + multi-session refundable/locked breakdown + admin-approval explainer + RefundEtaBanner (tested)
    │   ├── RefundStatusCard/  # f10 customer refund view: 3-step stepper (submitted→on-its-way→completed) + refunded amount + optional fee-leg split + masked ref + failed=contact-support (no retry); reused on booking detail + refund-status page (tested)
    │   ├── RefundEtaBanner/   # f10 per-channel refund ETA — bnpl_revert surfaces the ~710 business-day window honestly (never instant), psp_card/manual wording; one branch on refund_channel (tested)
    │   ├── BnplPlanCard/      # f11 D2 installment-plan option card (terracotta): term/installments + served monthly amount, a plain پیش‌پرداخت amount row, and مجموع بازپرداخت with the fee delta vs. paying in full spelled out in Toman (ui-phase-6 replaced the percent-only label + `LinearProgress` bar — a static fact must not look like loading); single-select (tested)
    │   ├── InstallmentScheduleRow/ # f11 repayment row: down-payment(«امروز»)/installment + Shamsi due date + served amount (every row carries «تومان» since ui-phase-6) + optional provider-reported status chip; reused by D4 schedule + D5 wallet due list (tested)
    │   ├── EarningsBalanceHeader/ # f12 nurse net payable balance + 4-bucket breakdown (pending/eligible/paid/clawback off --bal-{warning,info,success,error}); renders a negative net as an explicit "owed back" state (magnitude only, never a bare minus) (tested)
    │   ├── EarningsRow/       # f12 one earnings item: three-amount «gross  commission = your payout» breakdown via PriceBreakdown + one of four visually-distinct state chips + state affordance (pending→display-only dispute-window CountdownTimer, eligible→awaiting-batch, paid→paid_at+ref+payout link, clawback_applied→net explanation); deep-links to /nurse/visits/[id] (tested)
    │   ├── PayoutHistoryRow/  # f12 one nurse_payouts row: net transferred + payout-status chip (pending/submitted/paid/failed) + period + masked IBAN (last-4, dir=ltr) + transfer ref + read-only failure banner (no nurse retry) (tested)
    │   ├── RatingInput/       # f13 15 star input/display (custom, on AppIcon "star"; filled=var(--bal-warning), empty=var(--bal-divider)); interactive=radiogroup of radios, readOnly=static role="img"; used by the review form + review/my-review display (tested)
    │   ├── ReviewTagSelector/ # f13 multi-select review-tag chip group (selected=MUI palette primary, unselected=outlined); i18n-free (caller passes labelFor(code)); codes stay keyed off the stable vocabulary, never off the wire (tested)
    │   ├── VisitNoteCard/     # f13 one read-only nurse visit note: nurse name + Shamsi date + body + done/not-done task-result chips; presentational (caller formats the date); reused by the E2 سوابق tab + the nurse continuity view (tested)
    │   ├── PatientHeader/     # f13 patient identity block (avatar + name + relation chip + "age · gender" meta + condition chips) extracted from PatientCard so the E1 card and the E2 record viewer share one header; tolerates null relation / empty conditions; ui-phase-9 added an `InitialsAvatar` slot (tested)
    │   ├── ProfileSummary/    # ui-2 the one identity card for chrome: avatar+name+masked phone+role label+optional TrustBadge, vertical (nurse sidebar) or `compact` horizontal chip (admin/partner TopBar); presentational — callers source data from useMe/profiles; replaces the starter UserInfo; ui-phase-9 added `initialsFallback` (renders `InitialsAvatar` instead of MUI's generic silhouette when there's no `avatarUrl`) — the customer account hub's identity header (tested)
    │   ├── booking/           # f8 post-payment engagement composites (import from @/components/booking). BookingDetailView (both-roles smart container; ui-phase-5 hero: next-upcoming-session headline off the embedded sessions, a nurse-identity row, a client-only `.ics` add-to-calendar download (ics.ts, no backend seam), an EVV "پرستار در محل است" presence headline while checked in, role-conditioned EVV+gated care; ui-phase-7 added a standalone AddressCard below the hero — a `geo:`/Neshan-web map deep-link when the frozen snapshot carries lat/lng, a quiet nurse-only fallback note otherwise (REQ-051) — and a nurse-only in-visit «در حال ویزیت» banner promoting the check-out action), BookingStatusTimeline (server-truth 7-status timeline over the shared vertical StatusTimeline — the ui-phase-1 swap off StepperHeader), SessionList→SessionCard (per-session schedule/status/EVV CTA; ui-phase-5 aligned its card shell to SurfaceCard; ui-phase-7 made the EVV CTA the full-width hero action + an optional `serviceLabel` line, REQ-052), EvvStatusBanner (advisory in/out-of-range/no-gps), CareInstructionsCard (decrypted clinical read), CheckOutConfirmButton (ui-phase-7 — the shared check-out action + lightweight confirm dialog, used by both SessionCard and BookingDetailView's in-visit banner so "check-out ends the visit and starts the payout clock" always gets one confirm step), BookingMoneySummary (gross/commission/payout display-only); useEvvController (GPS-capture + check-in/out orchestration), format.ts + statusKind.ts + ics.ts helpers (kept internal — not in the barrel). Each composite tested; the BookingDetailView test proves the customer never fires the care query (two-stage-disclosure gate)
    │   ├── geography/         # F3 geo composites: CascadingRegionSelect, AddressForm, AddressCard (ui-phase-9 added a pin-quality cue — hasPin/pinSetLabel/pinMissingLabel), and the map-pin picker boundary — `AddressMapPicker` now branches on `NESHAN_WEB_KEY` (`@/config`): real Neshan tiles via `NeshanMap` (Leaflet, dynamically imported `ssr:false`; search box + locate-me + draggable pin + reverse-geocoded preview via `services/geography/neshan.ts`'s direct third-party fetch client) when set, else the original bounded-canvas grid stand-in (kept, not deleted, for dev/CI/jsdom) — `{ latitude, longitude }` in/out is identical either way so `AddressForm` never changed (each tested; `NeshanMap` itself isn't unit-tested — jsdom+Leaflet integration — and is unreachable in tests since `NEXT_PUBLIC_NESHAN_KEY` is unset in CI)
    │   ├── messaging/         # f14 tickets composites (import from @/components/messaging), ui-phase-10 messaging-app rebuild. Screens shared by the customer+nurse pages (role decides chrome): TicketInboxScreen (status filter chips + load-more, EmergencyPlaybookRow instead of a permanent banner), TicketThreadScreen (+ TicketConversationPanel, key={ticketId} — owns the one usePostMessage/draft both TicketMessageList and MessageComposer share, so retry/discard and the composer are one pipeline), TicketMessageList (date separators/author-grouped bubbles/centered system events via useThreadScroll — opens at the newest message, "new message" pill), ContactSupportDialog (new-ticket → shows referenceCode), MessageComposer (controlled; pointer-aware Enter semantics, attachment affordance gated behind `TICKETS_ATTACHMENTS_ENABLED`), BookingSupportEntry (page-local glue on f8 booking detail — reuses the cached booking + care query, no refetch; still mounts the full alarm-red EmergencyBanner, untouched). Pure/tested: MessageBubble (mine/theirs, RTL-mirrored, hh:mm-only, failed-send retry-in-place + discard, `role="alert"` on failure), TicketListCard (prominent referenceCode + unread pill + last-message preview + relative time, mock-tolerant when the enrichment fields are absent), EmergencyBanner (nurse post-confirmation tel: playbook only), EmergencyPlaybookRow (the inbox's compact neutral emergency row). Helpers: statusKind.ts, authorLabel.ts, clientMessageId.ts, useThreadScroll (reusable scroll-orchestration hook, exported for phase 11's admin thread)
    │   ├── notifications/     # f14 notification composites (import from @/components/notifications), ui-phase-10 pass. NotificationBell (chrome container — subscribes to the polling count so only it re-renders; opens NotificationBellPopover on the nurse desktop shell instead of navigating, everywhere else still navigates) → NotificationBellView (pure, tested, ref-forwarding so the container can anchor the popover), NotificationBellPopover (5-recent preview, fetches on open, exported for phase 11's admin shell once it has a feed), NotificationRow (pure, tested: per-kind tinted icon container, navigable rows get a trailing chevron, non-navigable rows render as a plain non-rippling surface), NotificationCenter (shared page body: unread-first, day-grouped امروز/دیروز/این‌هفته with relative timestamps, mark-read-on-open + mark-all, deep-links via notificationDeepLink). Helper: notificationIcon.ts (+ notificationTint). Admin's bell entry is hidden (no real feed yet, `AdminLayout.tsx`) until phase 11 ships one.
    │   └── auth/              # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard, BrandMark, AuthSplash, RoleGuard (role-aware shell guard, tested), AuthAccountError (/me-failed recovery), useCountdown, useWebOtp (ui-phase-3 WebOTP autofill seam), AuthIllustration + TrustBullets (ui-phase-3 CSS/SVG login-hero treatment)
    ├── i18n/
    │   ├── routing.ts     # defineRouting — locales: ['en', 'fa'], defaultLocale: 'fa'
    │   ├── request.ts     # getRequestConfig — loads messages/${locale}.json
    │   └── navigation.ts  # ui-2 createNavigation(routing) — Link/usePathname/useRouter/redirect/getPathname. ALL chrome navigation goes through this: usePathname is locale-stripped (so unprefixed ROUTES.* compare directly) and Link/router add the locale automatically — no manual `/${locale}` prefixing, no middleware redirect hop
    ├── layout/  # ui-2 rewrite — per-actor branded chrome + correct locale-aware navigation
    │   ├── PrivateLayout.tsx   # authenticated wrapper (passthrough today); actor chrome lives in the shells below
    │   ├── CustomerLayout.tsx  # 'use client' — customer shell: contextual TopBar (brand lockup on the 5 root tabs, title+back on pushed routes) + mobile BottomBar, replaced by an inline desktop top-nav (CustomerDesktopNav) at ≥md; ui-phase-10 added a `useSupportUnreadTotal`-driven Badge on the root-tab support icon (renders only when a signal exists — mock-only until REQ-059)
    │   ├── NurseLayout.tsx     # 'use client' — nurse workspace via TopBarAndSideBarLayout: grouped sidebar (امروز/حرفهٔ من/مالی/پشتیبانی) + ProfileSummary identity card + ActorSwitcher, 5-tab mobile BottomBar («بیشتر» opens the same sidebar drawer); ui-phase-10 added `badgeCount` (useSupportUnreadTotal) on the support sidebar item
    │   ├── AdminLayout.tsx     # 'use client' — admin shell via TopBarAndSideBarLayout: sectioned sidebar (اعتماد/مالی/پشتیبانی/سیستم, useAdminCapabilities-gated, unchanged gating), TopBar identity chip (fine-grained role); no notification bell (ui-phase-10 — admin has no real feed yet, re-add via `NotificationBellPopover` once phase 11 ships one)
    │   ├── PartnerLayout.tsx   # 'use client' — partner portal via TopBarAndSideBarLayout; TopBar identity chip shows the center's own name (useMyPartnerCenter, skeleton while resolving); ui-phase-11 adds a compact merchant-of-record `StatusChip` beside it, persistent across every portal page
    │   ├── PublicLayout.tsx    # unauthenticated shell — minimal corner strip (logo + LocaleSwitcher + dark toggle), no sidebar/bottom bar; AuthCard renders its own larger BrandMark
    │   ├── FocusedLayout.tsx   # ui-phase-3 — chrome-free shell for can't-tab-away flows (today: onboarding): a slim logo strip + content, no BottomBar/bell/sidebar; the route group above it still applies RoleGuard
    │   ├── TopBarAndSideBarLayout.tsx  # 'use client' — the nurse/admin/partner engine: a fixed TopBar (useRouteTitle) + SideBar rendered as flex-row siblings (mobile temporary Drawer + desktop `variant="permanent"` Drawer switched by CSS `sx` breakpoints only — no `useIsMobile` structural branching, so desktop first paint already has the sidebar); optional `identity`/`sidebarIdentity`/`mobileBottomBar` slots
    │   ├── routeTitle.tsx      # ui-2 static route→title map (longest-prefix over ROUTES.*, off the `nav` namespace) + `PageTitleProvider`/`usePageTitleOverride` per-page dynamic-title slot (area phases feed real names in later) + `useRouteTitle`; `isCustomerRootTab`/`CUSTOMER_ROOT_TABS` for the customer header's brand-lockup-vs-title branch
    │   ├── matchActivePath.ts  # ui-2 shared longest-prefix, winner-takes-all active-path matcher (tested) — used by SideBarNavList and BottomBar so a nested route still lights up its parent tab, never a sibling
    │   ├── config.ts
    │   ├── index.ts
    │   └── components/
    │       ├── TopBar.tsx      # title | titleNode override, align ('start' breadcrumb-style | 'center'), optional secondaryRow (the customer desktop top-nav)
    │       ├── SideBar.tsx     # renders both Drawers (mobile temporary + desktop permanent) off one content tree; close handler wired to the nav list only (dark-mode/locale toggles never close it); brand header + optional identity slot
    │       ├── SideBarNavList.tsx  # renders `ListSubheader` sections when items share a `group`; selection computed once via matchActivePath and passed down
    │       ├── SideBarNavItem.tsx  # navigates via `@/i18n/navigation`'s Link — one navigation, no redirect hop; renders `LinkToPage.badgeCount` as a small Badge on the icon when > 0 (ui-phase-10, the nurse support entry)
    │       ├── BrandLockup.tsx     # ui-2 compact horizontal logo+wordmark — customer header (root tabs) + every sidebar shell's drawer header
    │       ├── ActorSwitcher.tsx   # ui-2 dual customer+nurse session switcher (renders nothing for a single-role session); nurse sidebar + customer profile hub (tested)
    │       ├── DarkModeButton.tsx  # 'use client' — only subscriber to useColorScheme()
    │       └── index.tsx
    ├── lib/
    │   ├── api/
    │   │   ├── client.ts      # clientFetch<T> — throws ApiError on error; use in hooks/client components; silent-refreshes + retries once on 401
    │   │   ├── server.ts      # serverFetch<T> — throws ApiError on error; use in RSCs/Server Actions
    │   │   ├── types.ts       # ApiEnvelope<T> + unwrap(), Paginated<T>, PageParams — shared wire types
    │   │   ├── refresh.ts     # attemptTokenRefresh — single-flight silent refresh used by clientFetch's 401 branch
    │   │   └── errors.ts      # ApiError class (status, message, code)
    │   ├── auth/
    │   │   ├── token.ts       # decodeJwtPayload / isTokenAlive — edge-safe, shared with middleware (no next/headers)
    │   │   ├── session.ts     # persistAuthTokens / clearAuthTokens — client token-cookie writers (shared by auth hooks + fetch refresh)
    │   │   └── server.ts      # getServerAuthState — access-token cookie → AuthState for AuthProvider
    │   ├── query/
    │   │   ├── queryClient.ts # makeQueryClient factory + getQueryClient() SSR-safe singleton
    │   │   └── QueryProvider.tsx  # 'use client' — QueryClientProvider + ReactQueryDevtools
    │   └── cookies/           # Cookie manager — strict server/client separation
    │       ├── constants.ts   # COOKIE_NAMES, CookieOptions, AUTH_*_COOKIE_OPTIONS
    │       ├── server.ts      # getServerCookie, getThemeMode, setServerCookie
    │       ├── client.ts      # getClientCookie, setClientCookie, deleteClientCookie
    │       └── index.ts       # Re-exports constants ONLY (never server/client)
    ├── services/          # Domain services — no top-level barrel; import directly from the file
    │   ├── auth/          # Phone-OTP auth: requestOtp/verifyOtp/refresh/logout/me/selectRole + role router (routing.ts: resolveRoleDestination + ui-phase-3's resolvePostLoginDestination for the validated `?next=` returnUrl) + useSessionRoleSync + useRoleHydration (resolved-vs-pending role state for RoleGuard)
    │   ├── patients/      # Care-recipient CRUD (b3 PatientDto + client-augmented relation/conditions), soft-archive; age.ts helper
    │   ├── profiles/      # Customer + nurse profile get/upsert + avatar (behind the ProfilesApi seam). ui-phase-8 added `setAcceptingBookings` (`useSetAcceptingBookings`) — the real, previously-unwired `POST nurse_profiles/set_accepting_bookings` go-live switch (mock mirrors the flip; real+mock both invalidate `profileKeys.nurse()`)
    │   ├── nurse/         # Nurse payout bank accounts + IBAN(Sheba) util (iban.ts) + ownership-inquiry states
    │   ├── geography/     # F3 cached province→city→district reference lookups (Infinity staleTime, shared geographyKeys; reused by addresses, coverage & later search)
    │   ├── addresses/     # F3 customer address book CRUD + set-primary (single-primary invariant; invalidate-on-mutation)
    │   ├── serviceAreas/  # F3 nurse coverage areas add/remove (areaExists dup-guard; districtId=null = whole city)
    │   ├── catalog/       # F4 catalog skeleton + nurse pricing variants (b5). Reference data (categories, category option groups) cached session-long like geography (Infinity staleTime); myVariants invalidated on mutation. useServiceCategories/useCategoryOptionGroups/useMyVariants/useCreateVariant/useUpdateVariant/useSetVariantActive; seam+mock+client; names.ts locale-label helper
    │   ├── search/        # F6 family discovery (b7). The **filter object IS the query key** (searchKeys.results + canonicalizeSearchFilters): identical/reverted filters reuse cache with zero network (keepPreviousData avoids flashing). useNurseSearch/useNurseProfile/useDebouncedValue; filterParams.ts = the shared C1↔C2 URL (de)serializer; seam+mock(PRIMARY)+client. Mock supplies name/avatar/distance/profile/reviews that b7's index row + b5/b6 reads don't yet expose (gap filed in for-backend.md). Every returned row is verified-by-invariant — the UI never re-filters
    │   ├── verification/  # F5 nurse trust flow (b6). ONE cached status() query drives B3+B6; every mutation invalidates it. useVerificationStatus/useStartVerification/useSubmitIdentity/useRunBankVerification/useUploadVerificationDocument/useSubmitCredentials/useNurseTrustBadge; seam+mock(primary)+client; validation.ts (national-ID checksum); types export ownBadgeState/publicBadgeState/isApproved
    │   ├── bookingRequests/ # F7 pre-payment request lifecycle (b8). Money-free create→accept/reject/cancel + role-scoped inbox + single get. useCreateBookingRequest/useBookingRequest(polls until terminal)/useNurseRequestInbox/useCustomerRequests/useAccept/useReject/useCancel; seam+mock(PRIMARY, shared in-memory state machine — customer create ↔ nurse inbox ↔ accept flips C5; lazy expiry sweep)+client. Server-frozen UTC deadlines rendered by CountdownTimer (never recomputed); two-stage disclosure (nurse `get(id,'nurse')` masks address); variantPrice client-augmented (REQ-013). Contract-live but mock-primary because inputs (search/patients/addresses) are mock-primary
    │   ├── bookings/        # F8 post-payment engagement (b9) — the SIBLING of bookingRequests, NOT a rename. useBookingDetail/useBookingSessions(select over detail — sessions are embedded)/useBookingList/useTodaySessions/useSessionEvv/useCareInstructions(enabled-gated)/useCheckInVisit/useCheckOutVisit; seam+mock(PRIMARY, seeded confirmed bookings + sessions + care + EVV state machine)+client(1:1 b9)+serverApi(RSC-prefetch seam, real-path). evv/locationProvider.ts = the ILocationProvider GPS seam (real navigator.geolocation vs mock coords by NEXT_PUBLIC_EVV_MOCK_GPS in_range|out_of_range|denied). Money display-only (gross=commission+payout server-side); timeline=server truth; care read gated to assigned nurse; EVV mismatch/denial advisory (never blocks); EVV mutations invalidate detail+session+today+list
    │   ├── payment/         # F9 checkout & card capture (b10) + customer invoice read (b11). useCheckoutSummary/useInitiatePayment(caller owns the per-ATTEMPT Idempotency-Key)/useConfirmGatewayReturn/usePaymentOutcome(backoff poll, stops on terminal + bounded attempts)/useInvoice(immutable, long staleTime, 404=not-issued not error)/usePaymentHistory(ui-phase-6, wallet «پرداخت‌ها», REQ-047); invalidations.ts = the one post-capture cache transition (request detail/lists + bookings lists/detail + summary/outcome — never a blanket refetch); seam+mock(PRIMARY — the conversion trigger bridging the f7↔f8 mock stores: capture converts the request, inserts a confirmed booking, issues the b11-shaped invoice)+client (initiate/invoice = real b10/b11 contract; summary = REQ-016 proposed route; outcome = mapped booking_requests/get, REQ-017). Money = served IRR digit-strings; rows reconcile by construction; a 409 on the money path is benign convergence, never a toast. ui-phase-6 added `nurseAvatarUrl`/`nurseVerified` on `CheckoutSummaryDto` + `trackingCode`/`paidAt` on `PaymentOutcomeDto` (REQ-046) and `paymentMethod`/`transactionReference`/`sellerFiscalIdentity` on `InvoiceDto` (REQ-049) — mock-populated, `null` on the real path until served
    │   ├── refunds/         # F10 customer cancellation + refund status (b11). resolveCancellationPolicy/cancelBooking/getRefundByBooking/getRefund/getMyRefunds(ui-phase-6, wallet «استردادها», REQ-048). useCancellationPolicyPreview/useCancelBooking/useRefundStatus(polls only while non-terminal)/useMyRefunds; invalidations.ts primes the fresh refund + invalidates booking detail/lists on cancel; seam+mock(PRIMARY — reads the f8 bookings store to resolve tier+per-session refundability, flips the booking cancelled, drives card-immediate/BNPL-processing refunds)+client. Contract is admin-only (REQ-019/020/021 fill the customer cancel command, policy preview, refund-by-booking + decomposition). Money = IRR digit-strings, BigInt; refund %+fee disclosed before confirm; refunds never self-issued
    │   ├── bnpl/            # F11 BNPL installment checkout (b12) — the alternate branch off C6. useBnplOptions/useCheckEligibility/useBnplSchedule/useIssueBnplToken/useAcceptBnplSchedule(invalidates booking+checkout+wallet)/useBnplOrder(bounded backoff poll)/useWalletInstallments; invalidations.ts reuses f9 invalidateAfterPaymentSuccess + the wallet key; seam+mock(PRIMARY)+client. Mock = the settle bridge: reuses the f9 conversion (mockInsertConvertedBooking + mockMarkBookingRequestConverted) — a settled BNPL order is a card payment net-of-fee — and seeds a provider-reported Wallet plan (D5). Contract serves only eligibility/initiate/status; options/schedule/wallet-installments/D3-KYC/customer-bookingId are REQ-022/023/024 gaps mocked behind the seam. Money = served IRR digit-strings (the mock computes plan/schedule with BigInt; components only format). D5 is provider-reported status, NOT a Balinyaar ledger; early-pay hands off to the provider. ui-phase-6: no API-shape change, UI-only honesty/polish pass (BnplPlanCard Toman rows + fee delta, PlanStep names the selected plan, BnplProviderLogo, eligibility progress feedback, gateway harness env-gated)
    │   ├── payouts/         # F12 nurse earnings & payout history (b13) — read-only, no mutations. useNurseEarningsBalance/useNurseEarnings(state,page)/useNursePayoutHistory(page)/useNursePayoutDetail(id); the state-filter + page are part of the query key (tabs/pages cache separately, keepPreviousData); seam+mock(PRIMARY)+client. b13 serves only GET nurse_payouts/history; the four-bucket earnings summary, per-booking earnings list + money-state, and nurse-readable payout detail (batch context + booking links + failureReason) are REQ-025 gaps mocked behind the seam. EarningsState (pending|eligible|paid|clawback_applied) is a client display model derived server-side; PayoutStatus is the contract's pending|submitted|paid|failed. Money = IRR digit-strings (gross=commission+payout; net=grossclawback; Σ booking-links=grossEarnings); the net payable balance is SIGNED (may be negative "owed back", never clamped); eligibility/dates/amounts are server truth (never computed client-side); the BNPL provider commission never appears (payment-method-invariant). MOCK_SCENARIO toggles the negative-balance demo
    │   ├── reviews/         # F13 moderated reviews (b14). useNurseReviews(infinite, published-only aggregate+list)/useReviewEligibility(bookingId)/useMyReviewForBooking(bookingId)/useCreateReview(invalidates eligibility+myReview, NEVER the public list); seam+mock(PRIMARY)+client. b14 serves submit + GET nurses/{id}/reviews (both mapped 1:1); review-eligibility + my-review-for-booking are REQ-026 gaps and moderation is admin-only (f15), so the mock reads a booking from the shared f8 bookings store (mockGetBookingForReview) to gate on a completed booking, tracks the submission for the persistent "under review" state, seeds a per-nurse published list, and recomputes the aggregate from published (never a stored sum). A pending_moderation review is NEVER injected into a public list/aggregate. Dev-only __mockPublishSubmittedReview stands in for the f15 admin queue. Tag chip labels are i18n keys off REVIEW_TAG_CODES, never off the wire
    │   ├── patientRecords/  # F13 continuity-of-care (b14) — patient-scoped, NOT booking-scoped. usePatientCareRecord(family record)/useRecordAccess(gates before any clinical fetch)/usePatientHistory(paged visit-note history)/useUpdateCareRecord(CUSTOMER-only edit → setQueryData)/useCreateVisitNote(NURSE-only append → invalidates history). seam+mock(PRIMARY)+client. The nurse-authored visit-note history/append (getPatientHistory/createVisitNote) are REAL b14 (GET/POST patients/{id}/care_records, mapped 1:1; the append folds the ticked task checklist into the note body); the family-owned editable record (medications/routine/tasks) + the access check have NO backend (REQ-027) and are mocked. Nurse is APPEND-ONLY (never wires useUpdateCareRecord). Access-denied (canView=false / 403) is a first-class non-leaking state; MOCK_FOREIGN_PATIENT_ID=8888 exercises it. Clinical text is never logged/localStorage/query-string
    │   ├── tickets/         # F14 tickets — the ONLY sanctioned post-booking channel (b15). useMyTickets/useTicket+useTicketThread(select over detail; both poll every TICKET_THREAD_REFETCH_INTERVAL while a thread is mounted, ui-phase-10)/useOpenTicket(invalidates lists)/usePostMessage(OPTIMISTIC + retry-in-place, ui-phase-10: onMutate is idempotent on clientMessageId — a retry flips an existing `failed` bubble back to `sending` instead of appending a duplicate; onError no longer rolls back, it flips the bubble to `sendStatus:'failed'` in place so the text + a retry/discard affordance survive; onSettled also invalidates the unread-total badge)/useDiscardFailedMessage(non-mutation cache removal for the composer's discard-and-retype)/useSupportUnreadTotal(chrome badge, §3.1 — mock sums unread, real returns null until REQ-059). seam+mock(PRIMARY)+client(maps b15 1:1). **is_internal NEVER modelled in the user-app types** — both mappers DROP any internal message (server-strip mimic); no internal affordance anywhere. Mock stores an internal note it never returns (no-leak demo), seeds a booking-linked coordination ticket (idempotent for coordination+bookingId → "jump to existing"), tracks the last viewer so an optimistic message reconciles as mine, MOCK_SEND_FAIL_SENTINEL='/fail' drives the failure→retry path. `unreadCount`/`lastMessageAt` are REAL (REQ-028, delivered); `lastMessagePreview`/`lastAuthorRole` are the newer REQ-059 gap → mock-only, card degrades gracefully without them
    │   ├── notifications/   # F14 in-app notification center (b1) — polled, no push. useNotifications(unread-first, growing limit, now takes an optional `{enabled}` so the ui-phase-10 bell popover can fetch only on open)/useUnreadCount(the POLLING bell: refetchInterval 60s + staleTime 45s + refetchOnFocus, auth-gated — count only, list never polled)/useMarkNotificationRead+useMarkAllRead(OPTIMISTIC setQueryData flips isRead + decrements/zeros the cached count, rollback on error, invalidate on settle). seam+mock(PRIMARY)+client(maps b1 1:1). data_json is a TYPED contract: parseNotificationData(type,dataJson)→discriminated NotificationData union (snake/camel tolerant, degrades to {kind:'none'} on malformed/unknown/missing id — never trusts a blob); notificationDeepLink(n,role) centralises the role-aware route (null when nothing to open). Mock seeds every deep-link class + __mockPushNotification for the bell-increment demo
    │   ├── admin/         # F15 backoffice-owned data (b1 + b15): config, holidays, audit, support-alerts, RBAC, and (ui-phase-11) a user directory. usePlatformConfigs/useUpdatePlatformConfig/useConfigChangeHistory/useHolidays/useUpsertHoliday/useAuditLogs/useSupportAlerts/useAssignSupportAlert/useResolveSupportAlert/useAdminRoles/useGrantRole/useRevokeRole/useUserSearch/useUserLookup (ui-phase-11 — `searchUsers`/`lookupUsers` on `AdminApi`, backing `UserPicker`/`NursePicker` + `AuditLogRow`'s actor-name resolve); seam+mock(PRIMARY)+client. Filters+page in each key (worklist filters cache separately). Mock-primary: config updatedAt/updatedBy + rich audit filters + the whole RBAC surface + the user directory are gaps (REQ-029/030/031/061). support_alerts are internal-only — never rendered outside an admin route
    │   ├── partnerCenter/ # F15 partner centers (b15): admin management + the center-scoped portal. usePartnerCenters/usePartnerCenter/useCenterSponsoredNurses/useCreate/useUpdate/useVerify/useSetActive/useAssignNurse (admin) + useMyPartnerCenter/useMySponsoredNurses/useMySponsoredBookings/useMySettlement (portal); seam+mock(PRIMARY)+client. settlement_iban masked last-4 (write-then-masked); merchant-of-record gates the settlement view; VAT on the commission line only (config vat_rate); deriveCenterState(isActive,verifiedAt). Mock-primary: portal split reads + activate/suspend + invoice total are gaps (REQ-032/033)
    │   │   # Admin-endpoint ADDITIONS to existing domains (the staff lens — NOT new domains):
    │   │   #  verification → useVerificationQueue/useVerificationCase/useVerificationDocumentUrl(on-demand signed URL)/useDecideStep/useApproveVerification/useRejectVerification (b6; REQ-034)
    │   │   #  refunds      → useRefundPreview/useInitiateRefund/useApproveRefund/useRejectRefund (b11, ticket-linked; REQ-035)
    │   │   #  payouts      → usePayoutBatches/usePayoutBatchDetail/usePreviewPayoutBatch/useRunPayoutBatch(idempotency-keyed)/useRetryPayout/useRecordTransferReference (b13; REQ-036)
    │   │   #  reviews      → useModerationQueue/useModerateReview (b14; REQ-037)
    │   │   #  tickets      → useAdminTickets/useAdminTicket/useAdminTicketThread/usePostAdminMessage (b15; the ADMIN ticket types carry isInternal — the user-app types deliberately do NOT)
    │   └── {domain}/
    │       ├── types.ts       # Request/response types + the domain's Api interface (the seam)
    │       ├── keys.ts        # React Query key factory (hierarchical)
    │       ├── constants.ts   # Mock toggle + staleTime (when the domain has a mock)
    │       ├── apis/
    │       │   ├── clientApi.ts   # Real impl wrapping clientFetch (unwraps ApiEnvelope via unwrap())
    │       │   ├── mockApi.ts     # In-memory impl behind the same interface (until the endpoint lands)
    │       │   ├── serverApi.ts   # serverFetch calls (only when an RSC needs it)
    │       │   └── index.ts       # Selects real vs mock by config — the seam hooks import
    │       └── hooks/
    │           └── use{Action}.ts # One hook per file — useQuery (deliberate staleTime) or useMutation (invalidates)
    ├── context/           # React context providers
    │   └── auth/          # AuthContext — AuthProvider (server-seeded) + reducer + useAuth
    ├── theme/
    │   ├── ThemeProvider.tsx      # MuiThemeProvider wrapper (RTL cache) + ColorSchemeCookieSync
    │   ├── colors.ts              # BRAND, LIGHT_PALETTE, DARK_PALETTE (incl. success/error/warning/info)
    │   ├── direction.ts           # getDirection(locale) → 'ltr' | 'rtl'
    │   ├── theme.ts               # APP_THEME_LTR / APP_THEME_RTL (static, created once) — the `components` brand pass + teal-tinted `shadows` array + responsiveFontSizes()
    │   ├── tokens.css             # CSS custom properties — [data-mui-color-scheme] selectors + the dark @media fallback (no-flash boot, no script — see "Theme System" below)
    │   ├── typography.ts          # TYPOGRAPHY_LTR (Space Grotesk) / TYPOGRAPHY_RTL (Mikhak) — shared size scale, 500/700 weight system
    │   └── index.ts               # Public re-exports (ThemeProvider, getDirection, APP_THEME_*)
    ├── constants/         # App-wide constants (routes.ts w/ actor paths, roles.ts, headers.ts, policy.ts — ui-phase-12's single-sourced trust-copy numbers pending REQ-065)
    ├── hooks/             # incl. auth.ts → useIsAuthenticated / useActorRole (role-aware chrome); ui-phase-11 added `useAdminListState.ts` — URL-synced worklist state (`useSearchParams`-based; callers need a `<Suspense>` boundary) mirroring **applied** filters+page into the URL (draft-vs-applied; `apply`/`applyFilters`/`clear`/`goToPage`) + `useAdminBackToList` (a real `router.back()` with a list-route fallback), adopted by every admin/partner queue page
    ├── utils/             # incl. money.ts (IRR/Toman, integer-safe) + date.ts (Shamsi display) + number.ts (localeTag/formatNumber/formatRelativeTime/formatClock — the one home for locale-ternary formatting) + toEnglishDigits + toCsv.ts (ui-phase-11 — dependency-free CSV serializer, CRLF + comma/quote escaping; backs the partner settlement CSV export, UTF-8-BOM-prefixed for Excel)
    └── config.ts

Server / Client Component Boundaries

There is NO src/app/layout.tsx. src/app/[locale]/layout.tsx is the application's root layout — it renders <html> and <body>. This is intentional and load-bearing (see below); do not re-introduce a layout above the [locale] segment.

Root / locale layout (src/app/[locale]/layout.tsx) is an RSC that owns the document shell, all i18n, and theme context. It:

  • Sources the locale from the URL param (params.locale), validated against routing.locales (falls back to defaultLocale). No header reads.
  • Renders <html lang dir> (dir from getDirection(locale)) plus data-mui-color-scheme from getThemeMode().
  • Loads the Mikhak font and attaches its CSS-variable class to <html> only for fa (see Fonts).
  • Calls setRequestLocale(locale) so server components deeper in the tree can call getLocale() / getTranslations() reliably.
  • Calls getMessages({ locale }) with the locale passed explicitly so getRequestConfig receives it via Promise.resolve(locale) (not through the React.cache read), avoiding any cache-ordering race.
  • Wraps children with NextIntlClientProvider, AuthProvider (seeded with server-read auth state), and ThemeProvider.
  • Exports generateStaticParams so Next.js can enumerate locale routes at build time.

WHY <html> MUST live in [locale]/layout.tsx and not a layout above it: a layout above the [locale] segment is shared between /fa and /en. Next.js statically caches it at build time with defaultLocale ('fa') and never re-renders it on a client-side locale switch (the segment doesn't change). Its lang/dir/messages therefore freeze on 'fa'/'rtl' for every route, including /en. The [locale] layout is the lowest boundary keyed on the locale param, so it is the only place where <html lang dir> reliably tracks the active locale.

Route-group layouts ((private-routes)/layout.tsx, (public-routes)/layout.tsx) are 'use client' — they only wrap a layout component and need no server capabilities.

Never import from next/headers, next-intl/server, or @/lib/cookies/server in a client component. The build will fail.


Per-page metadata (the client-page pattern)

The root layout (src/app/[locale]/layout.tsx) exports a locale-aware generateMetadata that sets a title template — '%s | بالین‌یار' (fa) / '%s | Balinyaar' (en) — plus a default title and description. Any route that wants its own tab title supplies the %s: make page.tsx a thin RSC (no 'use client') that exports generateMetadata (or a static metadata when the title needs no translation lookup) and renders a co-located 'use client' body component holding all the page's logic/JSX, named <PageName>Screen.tsx (e.g. HomeScreen.tsx, SearchScreen.tsx) in the same folder — so its existing relative imports keep working unchanged. The screen's returned title composes automatically into the root template; page.tsx itself never renders <title> or touches document.title. Only the 7 landing pages (customer home, /login, /search, /bookings, /nurse, /admin, /partner) have adopted this so far — the rest is deferred to the area phases (311).

import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import HomeScreen from './HomeScreen';

export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
  const { locale } = await params;
  const t = await getTranslations({ locale, namespace: 'shell' });
  return { title: t('customer_app') };
}

export default function Page() {
  return <HomeScreen />;
}

i18n (next-intl v4)

Adding translations:

  1. Add the key to messages/en.json AND messages/fa.json. Both files must always be in sync.
  2. Top-level keys are namespaces: "nav", "common", etc.

Using translations in client components:

import { useTranslations } from 'next-intl';

function MyComponent() {
  const t = useTranslations('nav'); // namespace
  return <span>{t('home')}</span>;  // key
}

Using translations in Server Components:

import { getTranslations } from 'next-intl/server';

async function MyServerComponent() {
  const t = await getTranslations('nav');
  return <span>{t('home')}</span>;
}

Established namespaces and where they're used:

  • 'nav' — the actor shells (CustomerLayout/NurseLayout/AdminLayout) build their nav from here
  • 'common'DarkModeButton.tsx (dark/light labels), shared words (loading, retry, currency_toman, …)
  • 'shell' — actor-shell titles + the not-yet-built placeholder body
  • 'patients' — the E1 patient list/CRUD (list, card, add/edit dialog, archive)
  • 'onboarding' — the A3→A4 wizard (ui-phase-3 added welcome_* for the pre-wizard welcome moment) + the shared enum labels (relation/condition/gender codes → labels)
  • 'home' — the A5 family home (greeting + avatar, search bar, category grid, record/profile nudges)
  • 'profile' — the customer profile + emergency contact
  • 'nurseProfile' — the nurse B7 profile bootstrap (photo/bio/years + unverified placeholder); ui-phase-8 added editable education level/field + specialties (education_*, specializations_label), the public-profile preview (preview_*), and the avatar/save error copy
  • 'activation' — ui-phase-8 — the shared ActivationChecklist row labels (row_identity/row_profile/row_services/row_coverage/row_bank + the "getting paid, not search-visibility" hint) and the collapsed «فعال در جستجو» state; consumed by ActivationChecklist wherever it's mounted (services page, dashboard slot)
  • 'bank' — the nurse payout bank settings (IBAN form + the three ownership states); ui-phase-8 added the accounts-section copy (add_another, load_error) and an explicit pending-inquiry duration hint
  • 'geo' — the shared cascading province→city→district dropdowns (CascadingRegionSelect: level labels, "whole city", cascade hints)
  • 'address' — the customer address book + add/edit form (title/street, map-pin helper, set-primary, empty/delete states) + the profile-hub link
  • 'coverage' — the nurse coverage-area editor (chips, duplicate + "won't appear in search" warnings); ui-phase-8 dropped the now-unused scope_*/district_required keys once the separate scope toggle was removed, added remove_error
  • 'catalog'shared catalog vocabulary: the five price_unit labels + count nouns + the estimated-total label (read by PriceDisplay; f6 reuses it customer-side)
  • 'services' — the f4 nurse Services & prices surface (offerings list, the variant builder steps/fields/validation, the duplicate-listing warning, deactivate confirm); ui-phase-8 added the live-preview (preview_heading/preview_untitled) and 409-recovery (duplicate_edit_existing) copy, replaced publish_* with the state-driven go-live copy (publish_start_accepting/publish_pause_accepting/publish_live_*/publish_unmet_intro — the old no-op publish_done/publish_cta are gone)
  • 'search' — the f6 discovery flow (C1/C2/C3): filter section labels, the same-gender facet + hint, sort/count (ICU plural), all four result states + "relax filters" suggestions, card labels (rating/distance/from-price), profile badges (تاییدشده/نظام پرستاری)/attribute chips/specialty codes/services/latest review, and the "درخواست رزرو" CTA
  • 'booking' — the f7 booking-request flow (C4 form fields/validation, C5 tracker steps + dual-countdown + terminal-state copy, the nurse inbox + detail, gender labels, per-status labels, summary-card captions) and the f8 post-payment engagement (booking-status timeline labels bstatus_*, session-status labels sstatus_*, the EVV banner variants evv_banner_{in_range,out_of_range,no_gps} + check-in/out CTAs + GPS-acquiring copy, the care-instructions section labels care_* + the customer "visible to your nurse only" copy, the money summary money_*, the dispute-window note, the bookings list list_*); consumed by the C4/C5 pages, the nurse requests pages, the f8 booking-detail/EVV pages, and the shared BookingRequestSummaryCard + booking/ composites
  • 'verification' — the f5 nurse trust flow: B3/B4/B5/B6 copy, per-step labels + status labels (keyed off code, never derived), the DocumentUpload state chrome, TrustBadge labels, the honesty-sensitive manual-vs-auto copy, shared-SIM/mismatch messages; ui-phase-8 replaced journey_identity/journey_credentials/journey_review with the journey-group labels (group_identity/group_credentials/group_bank/group_review, shared by VerificationJourneyHeader), added the payoff panel copy (payoff_*), the B4 capture hints (capture_hint_card/capture_hint_selfie), the B5 hydration copy (ino_number_submitted/ino_number_change/credentials_needs_document), and the B6 timeline labels (review_timeline_*)
  • 'payment' — the f9 checkout & invoice surface: C6 labels (breakdown rows هزینه خدمت/کارمزد بالین‌یار/مالیات/مبلغ کل, the verbatim escrow copy escrow_notice, «ادامه پرداخت ←», the BNPL seam), the card-flow states (initiating/redirecting/pending/failed/expired/already-paid), the confirmation + invoice screens (VAT-on-commission line, مودیان moadian_* states), pstatus_* transaction-status labels, and the dev mock-gateway harness copy; consumed by the checkout pages, the invoice page, EscrowNotice, and PaymentStatusBadge
  • 'refunds' — the f10 customer cancellation + refund-status surface: policy-tier labels keyed off cancellation_policy_code (policy_*), the lead-time + refund %/fee % disclosure, the refund-vs-fee breakdown rows, the multi-session refundable/locked reasons (reason_*), the admin-approval explainer, the three refund-status step + chip labels (step_*/rstatus_*), the per-channel ETA copy (eta_*bnpl_revert 710-business-day window / psp_card / manual), and the failed/contact-support copy; consumed by the cancel + refund-status pages and CancellationPolicyDisclosure/RefundStatusCard/RefundEtaBanner
  • 'bnpl' — the f11 BNPL installment checkout (D1D5): the ownership-truth copy (ownership_note/contract_note/provider_owned_note/paid_via_installments — the agreement is customer↔provider, provider-financed, Balinyaar paid in full), provider names/taglines keyed off provider_{code}, the method/plan/eligibility/schedule labels, ICU-number plan params (plan_term_months/plan_installments/plan_fee/down_payment_percent/installment_n — Persian digits on fa), the declined/error copy + card fall-back, the D5 wallet outstanding-balance/due-list/status_* labels, and the handoff/settle states; consumed by the D1D4 wizard + gateway/return pages, WalletInstallments, the reused confirmation, and BnplPlanCard/InstallmentScheduleRow
  • 'payouts' — the f12 nurse earnings & payout-history surface: the balance header (balance_net_*/balance_owed_* — the negative "owed back" state + hint) + four buckets (bucket_*), the cadence/dispute-window explainer (explainer_* — weekly batches, EVV+72h gate, method-invariant), the state tabs + earnings-state chip labels (tab_*/estate_* for pending/eligible/paid/clawback_applied), the nurse-framed three-amount breakdown (amount_gross/amount_commission/amount_your_payout) + clawback net explanation (clawback_*), the per-state affordances (pending_affordance/dispute_window_*/eligible_affordance/paid_on), the payout-status labels (pstatus_* for pending/submitted/paid/failed) + batch-status labels (bstatus_*), the read-only failure banner (failure_*), and the detail money decomposition + booking-links copy (detail_*/gross_earnings_label/net_amount_label); consumed by the /nurse/earnings pages and EarningsBalanceHeader/EarningsRow/PayoutHistoryRow
  • 'reviews' — the f13 leave-a-review flow + the C3 reviews tab: the form labels (title/rating_label/body_label/tags_label/submit), the review-tag labels keyed off the code (tag_{punctual,professional,clean,kind,communicative} — never off the wire), the not-eligible reasons (reason_*), the moderation-status labels (status_pending_moderation/status_published/status_hidden/status_rejected), the persistent "under review" + my-review copy, the booking-detail CTA (cta_leave/cta_under_review/cta_view_review), the aggregate count (count ICU plural), the masked author fallback (author_masked), and the list empty/error/load-more; consumed by the review page, the C3 ReviewsPanel, and the LeaveReviewCta
  • 'records' — the f13 E2 care-record viewer + the nurse visit-note panel: the ownership banner, the four tab labels (tab_{medications,routine,history,tasks}), the access-denied + not-found cards, the editable-record field labels (med_*/routine_*/task_*) + empty states, the paged-history controls (prev/next/page_of) + visit-note author fallback, and the nurse composer copy (notes_title/tasks_checklist_title/note_*/continuity_title); shared enum labels (relation/gender/condition) are REUSED from onboarding/patients, never re-keyed; consumed by the E2 record page + NurseVisitNotesPanel + VisitNoteCard
  • 'tickets' — the f14 messaging surface (tickets are the only post-booking channel): the inbox (title/contact_support/empty_*/error_body/filter_all/load_more), the category + status labels keyed off the code (category_{support,coordination,refund,emergency}/status_{open,closed}), the linked-entity hints (linked_booking/linked_refund with {id}), ref_code_label, the new-ticket dialog (new_ticket_title/category_label/subject_label/message_label/submit/created_*/view_thread), the thread (back_to_tickets/thread_*/closed_notice/day_today/day_yesterday/new_message_pill), the composer (sending/send/send_failed/composer_placeholder/discard_failed/attach_photo — the last gated off by TICKETS_ATTACHMENTS_ENABLED), the author-role labels (author_{customer,nurse,support,system}admin→support), and both emergency surfaces: the nurse post-confirmation playbook (emergency_title/emergency_body/emergency_call {name}/emergency_call_generic/emergency_open_ticket) + open_from_booking, and the ui-phase-10 inbox compact row (emergency_row_title/emergency_row_body — rewritten to never instruct calling a number the inbox can't show); consumed by the ticket screens, MessageBubble/TicketListCard/EmergencyBanner/EmergencyPlaybookRow/ContactSupportDialog/MessageComposer/BookingSupportEntry
  • 'notifications' — the f14 notification center + bell: title, empty_*, error_body, retry, mark_all_read, load_more, the day-group headers (group_today/group_yesterday/group_this_week, ui-phase-10), view_all (the bell popover's link to the full center), and the polled-bell aria (bell_aria with {count, number}); the row title/body are server-rendered copy, not keys. Consumed by NotificationCenter + NotificationBell + NotificationBellPopover
  • 'auth' — the phone-OTP login flow, role router, RoleGuard (loading/account_error_*/guard_denied), and SelectRole screen (common.brand/brand_tagline for the wordmark); ui-phase-3 added the login-hero trust_* bullets, the consent line (consent_line, t.rich with <terms>/<privacy> tags), and select-role's role_add_later_note
  • 'legal' — ui-phase-3's /terms//privacy static pages: terms_title/privacy_title, draft_banner (the human/legal-review flag shown on-page), terms_intro/privacy_intro, and terms_sections/privacy_sections (arrays of {title, body} read via t.raw, not flat keys — the one namespace with structured JSON values). Consumed only by the two legal pages
  • 'admin' — the f15 backoffice consoles: verification queue/case, refund panel, payout dashboard/detail, review moderation, config editor + change-history, holiday manager, support-alert board, audit viewer, admin ticket queue/thread, RBAC grid, and admin-side partner management. Includes the Persian legal terms (پروانه تأسیس / مسئول فنی / نماد اعتماد الکترونیکی) and the enum-label prefixes keyed off the stable code (step_*/agg_*/atype_*/astatus_*/sev_*/htype_*/dtype_*/batch_status_*/pstatus_*/channel_*/rstatus_*/mstatus_*/center_state_*/role_*/tcat_*/tstatus_*). Consumed by the /admin/* screens + the @/components/admin composites
  • 'partner' — the f15 partner-center portal (a separate authz scope): center home/onboarding-state, sponsored nurses/bookings, and the merchant-of-record settlement/invoice view (سامانه مودیان, commission/VAT decomposition). Consumed by the /partner/* screens + PartnerSettlementRow

Namespace conventions for the phases to come (seed each when its feature lands, in both locale files): none — MVP namespaces complete (f15 seeded admin + partner). Keep top-level keys as namespaces and both files in sync.

Never hard-code UI strings in English. Any user-visible text must have a translation key in both locale files.

Persian orthography is a checked-in style guide, not tribal knowledge. client/messages/STYLE.md (ui-phase-12) is the binding decision record for brand spelling (ZWNJ: «بالین‌یار»), تأیید's hamza form, the one جستجو spelling, ZWNJ rules, the domain glossary, the shell-naming system, the verification-pipeline-vs-KYC-step naming split, and the digits/policy-number-interpolation rules. npm run lint:copy (client/scripts/check-copy.mjs, part of npm run check) greps fa.json for the banned variants on every run — a regression fails the gate immediately, it doesn't need re-discovering.


The cookie manager in src/lib/cookies/ is split into three files to prevent cross-environment bundling:

File Use from Purpose
constants.ts anywhere COOKIE_NAMES, CookieOptions, COLOR_SCHEME_COOKIE_OPTIONS
server.ts Server Components, Server Actions, Route Handlers only getServerCookie, getThemeMode, setServerCookie
client.ts client components / useEffect only getClientCookie, setClientCookie, deleteClientCookie
index.ts anywhere Re-exports constants.ts only — safe barrel

Rules:

  • Import constants via the barrel: import { COOKIE_NAMES } from '@/lib/cookies'
  • Import server utils directly: import { getThemeMode } from '@/lib/cookies/server'
  • Import client utils directly: import { setClientCookie } from '@/lib/cookies/client'
  • Never import server.ts in a client component; never import client.ts in an RSC.
  • COOKIE_NAMES.COLOR_SCHEME = 'color-scheme' — the single source of truth for the theme cookie name. Do not redeclare it anywhere.

Constants

Rule: every magic string or configurable value must be a named constant — never inline.

A value is "magic" if its meaning isn't obvious from the literal alone: cookie names, event names, localStorage keys, route paths, query-param names, numeric timeouts, API endpoint slugs.

Where to define:

  • Cookie names / options: src/lib/cookies/constants.ts
  • Feature-scope constants: co-locate in a constants.ts next to that feature's files
  • App-wide constants (used across multiple features): src/constants/ — one file per concern (routes.ts, events.ts, etc.). constants/policy.ts (ui-phase-12) is this pattern applied to legally-sensitive numbers that trust-critical copy states in plain language (the payout dispute-window hours, the cancellation lead-time hours, the refund ETA day range) — real server config with no public/authenticated read yet (REQ-065), single-sourced here and fed into message keys as ICU params ({hours}, {minDays}/{maxDays}) instead of being baked into the string.

Rules:

  1. Import the constant; never copy-paste the string value.
  2. When renaming, update the constant definition — the rest of the codebase follows automatically.

Theme System

How it works (end-to-end, no-flash — CSS only, no boot script)

The no-flash mechanism is pure CSS, matching how every other color decision in this app is made — no inline <script>, no Storage.prototype patching. Two visitor cases:

Returning visitor (cookie present):

  1. getThemeMode() (lib/cookies/server.ts) reads the 'color-scheme' cookie → returns { colorScheme: 'light'|'dark', defaultMode: colorScheme }.
  2. Root layout sets data-mui-color-scheme={colorScheme} on <html> server-side.
  3. tokens.css's explicit [data-mui-color-scheme='light'|'dark'] blocks match immediately — correct on the very first paint, before any JS runs.

First-ever visitor (no cookie yet):

  1. getThemeMode() returns { colorScheme: undefined, defaultMode: 'system' }.
  2. Root layout renders <html> without the data-mui-color-scheme attribute at all (data-mui-color-scheme={undefined} — React omits it).
  3. tokens.css has a @media (prefers-color-scheme: dark) block scoped to :root:not([data-mui-color-scheme]) — it only applies while the attribute is absent, and paints the OS-preferred scheme immediately, with zero JS.
  4. Once React hydrates, <MuiThemeProvider defaultMode="system"> resolves the same media query and stamps the attribute itself. The CSS values already match what was painted, so there is nothing to visibly flip.
  5. ColorSchemeCookieSync in ThemeProvider.tsx writes the cookie via useColorScheme().colorScheme on mount, so the next visit is a "returning visitor".

Trade-off, by design: this covers the dominant visual surface — every --bal-* token (page/paper background, text, dividers, all brand colors) — because that's what tokens.css's media-query fallback drives. MUI's own generated --mui-palette-* variables (consumed by a bare color="primary" fill, e.g. a contained Button, or the default MuiTabs indicator) do not get the same free fallback — MUI's colorSchemeSelector supports either attribute-based or 'media'-based generation, not both at once — so on a cookie-less first visit with OS dark on, a raw MUI-primary fill can very briefly show the light value until hydration (self-corrects same frame; disableTransitionOnChange means it snaps, never animates). Prefer sourcing colors from var(--bal-*) over theme.vars.palette.* in new styleOverrides — most of theme.ts's components block already does — to keep this gap as small as possible.

Critical MUI v9 rules

colorSchemeSelector must be the explicit attribute name:

// theme.ts
cssVariables: {
  colorSchemeSelector: 'data-mui-color-scheme', // CORRECT
  // colorSchemeSelector: 'data',               // WRONG — produces boolean data-dark/data-light
},

The shorthand 'data' in MUI v9 generates [data-%s]data-dark="" / data-light="" (boolean attributes). Our tokens.css uses [data-mui-color-scheme="dark"] which never matches boolean attributes. Always use the explicit attribute name.

Never use storageWindow={null}: In MUI v9's localStorageManager, the check is if (!storageWindow && typeof window !== 'undefined')null is falsy, so it silently overrides to window. This prop is a no-op in browsers. The Storage.prototype patch in ColorSchemeScript is the correct intercept.

Never use MUI's InitColorSchemeScript: It reads from localStorage, which diverges from our cookie (especially in 'system' mode), and it's a script — this app's no-flash boot is CSS-only (see "How it works" above). Don't add any pre-paint script for color scheme; extend the tokens.css media-query fallback instead if a new token needs the same first-visit treatment.

MUI v9 localStorage key defaults (different from v5/v6):

  • Mode key: 'mode' (was 'mui-mode')
  • Color scheme key: 'color-scheme' (was 'mui-color-scheme')
  • HTML attribute: 'data-color-scheme' (was 'data-mui-color-scheme')

We override all of these via colorSchemeSelector: 'data-mui-color-scheme' in the theme and the Storage.prototype patch.

Color tokens

All theme-aware colors live in src/theme/tokens.css under [data-mui-color-scheme] selectors. Do not add color values to inline sx props or component styles — add a CSS variable to tokens.css and reference it via var(--my-token).

This includes feedback colors: --bal-success, --bal-error, --bal-warning, --bal-info (each with a *-contrast text token). These drive the toast variants (see Toast Notifications) and are the place to source any success/error/warning/info color — the MUI palette does not define semantic colors, so prefer these tokens over MUI's defaults for brand consistency.

Pre-built theme objects

APP_THEME_LTR and APP_THEME_RTL are created once at module load. Never call createTheme() inside a component or hook — pass the appropriate pre-built theme to MuiThemeProvider.

Motion & the reduced-motion gate

Durations/easing live as tokens in tokens.css (--bal-motion-fast/-base/-slow, --bal-easing-standard); theme.ts points MuiDialog/MuiDrawer/MuiPopover/MuiMenu's defaultProps.transitionDuration at the same numbers (in one place) instead of MUI's per-variant defaults. RouteFadeIn (components/common/RouteFadeIn/) is the one route-content fade/slide primitive, mounted inside the ErrorBoundary in all five shells — new pages get the motion for free, no per-page wiring. prefers-reduced-motion: reduce has exactly one gate, in src/app/globals.css: a universal *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; … } media-query block. This is deliberately a global CSS reset, not a token-only zeroing (tokens.css also zeroes the duration tokens, but that alone wouldn't reach MUI's own JS-driven Dialog/Drawer/Menu/Collapse transitions, which don't read the CSS custom properties) — never add a second, component-local reduced-motion branch; extend this one rule if a new motion primitive needs the same treatment.

Toggle components

DarkModeToggleButton and DarkModeFormSwitch in src/layout/components/DarkModeButton.tsx are the only components that subscribe to useColorScheme(). When the user toggles:

  1. setMode('dark') is called
  2. Storage.prototype.setItem intercept fires → writes 'color-scheme'='dark' cookie synchronously
  3. MUI sets data-mui-color-scheme="dark" on <html>
  4. CSS variables resolve → browser repaints. No React re-render above the button.

Use colorScheme (not mode) for the isDark check — mode can be 'system' even when dark is active.


Direction (RTL / LTR)

Derived from locale via getDirection(locale) in src/theme/direction.ts:

  • RTL locales: fa, ar, he, ur
  • All others: ltr

ThemeProvider accepts a dir prop and selects the matching pre-built theme (APP_THEME_RTL for RTL). The RTL Emotion cache uses stylis-plugin-rtl to mirror all generated CSS.

src/app/[locale]/layout.tsx sets dir={dir} on <html> and passes dir to ThemeProvider. Because that layout is keyed on the [locale] URL param, changing locale re-renders it with a fresh dir — on both hard and soft navigation, no client-side state. Do not move the <html dir> render to a layout above [locale]; such a layout is shared across locales, gets statically cached with the default locale, and dir freezes on 'rtl' for /en.

Default locale is fa (RTL). The middleware redirects bare / to /fa/. English is explicitly accessed at /en/.


Fonts

Fonts are loaded per locale — the Persian face is never shipped to English pages:

Locale Font CSS variable Source Loaded when
fa (RTL) Mikhak --font-mikhak next/font/local — woff2 files in src/app/fonts/ only on fa routes
en (LTR) Space Grotesk --font-space-grotesk next/font/google — self-hosted at build time, preload: false only on en routes

Typography exports:

  • TYPOGRAPHY_LTR — Space Grotesk headings, system font body (used by APP_THEME_LTR)
  • TYPOGRAPHY_RTL — Mikhak for all text including body (used by APP_THEME_RTL, ensures full Persian glyph coverage)
  • Both share one size/line-height scale (SIZE_SCALE in typography.ts), wrapped in responsiveFontSizes() (theme.ts) for per-breakpoint heading scaling. Weight system: 700 for headings + buttons, 500 for in-text emphasis (subtitles, labels), 400 body — never 600, neither font loads that weight (see typography.ts's header comment).
  • There is no TYPOGRAPHY alias anymore — import TYPOGRAPHY_LTR/TYPOGRAPHY_RTL explicitly.

Rules:

  • Both fonts are declared with preload: false, and each .variable class is attached to <html> only for its own locale (Mikhak on fa, Space Grotesk on en) — never both, never neither. A next/font loader called in the root layout would otherwise preload on every route, and preload: false ensures the font file only downloads when its locale actually renders.
  • Mikhak's woff2 files live in src/app/fonts/ (not public/) — next/font/local resolves paths relative to the calling file (src/app/[locale]/layout.tsx) at build time. Space Grotesk needs no local files — next/font/google fetches + self-hosts it at build time.
  • Never load fonts inside components — all font loading lives in src/app/[locale]/layout.tsx.
  • To add a new local font, add woff2 files to src/app/fonts/, declare via localFont in src/app/[locale]/layout.tsx, attach its .variable class conditionally on the matching locale, and update the BRAND_FONT_VARIABLE_* constants in typography.ts.

Unit Testing

Rule: every shared component must have a co-located test file.

A component is "shared" if it is imported from more than one place (page, layout, or other component).

Coverage baseline for shared components:

  1. It renders without crashing.
  2. Every documented prop produces the correct HTML attribute or CSS class.
  3. User interactions (click, change) call the expected callbacks.

Test location: src/components/ComponentName/ComponentName.test.tsx next to the component. Test wrapper: wrap with <ThemeProvider> if the component uses MUI theming. Do NOT mock MUI components — test against the rendered DOM.

Enforcement: before removing or renaming a shared component, check whether src/**/*.test.{ts,tsx} files import it. If so, update or delete those tests too.

Presentational purity in components/common

next-intl (and its use-intl dependency) ship ESM-only builds. jest.config.ts widens next/jest's default transformIgnorePatterns (which otherwise treats all of node_modules as untransformed CommonJS) to allow next-intl/use-intl/@formatjs/ intl-messageformat through — but that only fixes real, unmocked imports; it doesn't make the dependency free. Any component at the top of the @/components/common barrel that imports next-intl at module scope forces every test file that transitively imports the barrel to deal with it, even tests that never touch translations.

So: ErrorBoundary and ErrorState are deliberately caller-owned — they take title/body/retryLabel/message as required string props instead of calling useTranslations internally, specifically so they stay import-safe at the top of the barrel. QueryStateGate inherits the same retryLabel requirement by composition. Money is the sanctioned exception — it already had 30+ call sites depending on its locale-aware API before this was noticed, so the fix went the other way (widen the Jest transform) rather than stripping next-intl from it. When adding a new components/common primitive: prefer the caller-owned-copy pattern by default, and only reach for useTranslations inside it if the component is genuinely leaf-level (nothing else in the barrel needs to stay import-safe around it).


Comments & dead code

  • No dead code. Unused variables, imports, parameters, and private members are lint errors (@typescript-eslint/no-unused-vars, raised to error — see Quality gates). Delete them; don't comment them out and don't silence the rule. Prefix a deliberately-unused binding with _ to opt out.
  • Comment the why, never the what. Code should read for itself — a comment that restates what the code already says is noise. Don't write // set the access token above setClientCookie(...), or JSDoc that just echoes a function's name.
  • Do add a tight comment when a decision is genuinely non-obvious from the code: a workaround for a framework quirk, a business rule, an ordering or security constraint, a deliberate deviation. Explain why it is this way. The comments in src/app/[locale]/layout.tsx (why <html> lives in the [locale] layout) and src/lib/auth/token.ts (why the JWT exp check is UX-only, never a security boundary) are the model to follow.
  • Prefer a clearer name or a small helper over a comment whenever that removes the need for it.

Anti-patterns (do not do these)

  • Do not read localStorage or document.cookie in render functions — use useEffect or server-side cookies() from next/headers.
  • Do not call createTheme() inside a component or hook — use APP_THEME_LTR / APP_THEME_RTL.
  • Do not use storageWindow={null} on MuiThemeProvider — it is silently ignored in MUI v9.
  • Do not use InitColorSchemeScript from MUI — use ColorSchemeScript from @/theme.
  • Do not set colorSchemeSelector: 'data' — use 'data-mui-color-scheme'.
  • Do not check mode === 'dark' for "is dark active" — use colorScheme === 'dark'.
  • Do not hard-code UI strings — add translation keys to both messages/en.json and messages/fa.json.
  • Do not add a src/app/layout.tsx or any layout above the [locale] segment. Such a layout is shared across locales, gets statically cached at build time with defaultLocale ('fa'), and never re-renders on a locale switch — so <html lang/dir>, messages, providers, and fonts placed there freeze on 'fa'/'rtl' for /en. src/app/[locale]/layout.tsx is the root layout (it renders <html>/<body>) precisely because it is the lowest boundary keyed on the locale param.
  • Do not call getMessages() without passing { locale } explicitly — getMessages({ locale }) passes the locale directly to getRequestConfig via Promise.resolve(locale), bypassing potential React.cache ordering issues.
  • Do not remove setRequestLocale(locale) from src/app/[locale]/layout.tsx — without it, getLocale() called by deeper server components always returns defaultLocale.
  • Do not add notFound() to src/app/[locale]/layout.tsx — unknown locale URLs are handled by middleware (redirect to defaultLocale); a hard 404 here breaks fallback behavior.
  • Do not import TYPOGRAPHY — use TYPOGRAPHY_LTR or TYPOGRAPHY_RTL explicitly.
  • Do not load fonts inside components or pages — all next/font declarations belong in src/app/[locale]/layout.tsx, with the .variable class attached conditionally per locale (Mikhak only for fa).
  • Do not import @/lib/cookies/server in client components or @/lib/cookies/client in RSCs.
  • Do not call fetch() directly in components or services — use serverFetch (RSC/Server Actions) or clientFetch (hooks/Client Components) from @/lib/api.
  • Do not create a top-level barrel at src/services/index.ts — imports should make the domain origin clear (e.g. import { useLogin } from '@/services/auth', not import { useLogin } from '@/services').
  • Each domain does have an index.ts that re-exports its hooks (e.g. src/services/auth/index.ts). Do not export types, keys, or apis/* from this barrel — only hooks.
  • Do not mix clientFetch and serverFetch in the same file — keep clientApi.ts and serverApi.ts separate; Next.js enforces the environment boundary at build time.
  • Do not toast inside hooks for 401/403/5xx — those are already toasted by clientFetch. Only toast in onError for domain-specific 4xx messages.
  • Do not call js-cookie (Cookies.*) directly — use the central client cookie manager (@/lib/cookies/client).
  • Do not read or write document.cookie directly — use the central client cookie manager.
  • Do not store auth tokens in sessionStorage or localStorage — use cookies via @/lib/cookies/client.
  • Do not pass flexWrap or useFlexGap as direct props to MUI Stack — these are not valid Stack props in MUI v9 and cause a TypeScript overload error. Use sx={{ flexWrap: 'wrap' }} instead. useFlexGap was a MUI v5 opt-in and does not exist in v9.
  • Do not use mui old api which cause errors

API Fetch Services

Central fetch primitives live in src/lib/api/:

File Use from Purpose
client.ts hooks, client components clientFetch<T> — throws ApiError on error
server.ts RSCs, Server Actions only serverFetch<T> — throws ApiError on error
errors.ts anywhere ApiError class (status, message, code)

Error contract — clientFetch:

  • 401 — toast "session expired", clear cookies, redirect to login (no throw; page navigates away)
  • 403 — toast "forbidden", throw ApiError
  • 5xx — toast "server error", throw ApiError
  • Other 4xx — throw ApiError, no toast; the calling hook owns the user-facing message
  • Network failure — toast "network error", throw ApiError

Error contract — serverFetch:

  • All errors throw ApiError (no toast — server can't fire browser events)
  • RSC callers decide whether to notFound(), redirect(), or let the error propagate to an error boundary

Domain API calls live in src/services/{domain}/apis/clientApi.ts (or serverApi.ts). Never call raw fetch() directly.

The services/{domain} reference pattern (copy auth / patients)

Every domain follows the same shape: types.ts (wire types + the domain's Api interface), keys.ts (hierarchical React Query key factory), apis/ (implementations + a selecting index.ts), hooks/ (one hook per file), and a barrel index.ts that re-exports hooks only (never types/keys/apis).

  • Caching is deliberate: set a staleTime on reads so revisiting a screen doesn't refetch; mutations invalidate the affected list key (queryClient.invalidateQueries) or setQueryData — never leave the cache stale. See services/patients/hooks/*.
  • Reference data is cached for the whole session: rarely-changing lookups (the geo province→city→district hierarchy) use an Infinite staleTime + a shared, hierarchical key factory (geographyKeys) so each level is fetched once and served from cache across every consumer (the address form, the coverage editor, and later search) — never refetched on a dropdown open. Contrast with mutable lists (addresses, coverage areas) which invalidate on every mutation. See services/geography/*. Reuse this pattern for future reference data; do not reinvent per-consumer fetching. services/catalog (f4) is the second long-lived cached reference domain: admin-seeded categories + a category's option groups/values use the same Infinite staleTime/gcTime (CATALOG_REFERENCE_*) so the Home grid and every builder step read them from cache; the nurse's own variant list is the mutable side — mutations invalidate catalogKeys.myVariantsLists().
  • Mock behind a seam: when the backend endpoint isn't live, implement the domain's Api interface twice — a real clientApi.ts and an in-memory mockApi.ts — and select in apis/index.ts by a config flag (USE_{DOMAIN}_MOCK). Hooks import the selected api; the swap is one line. Record every mock in dev/shared-working-context/reports/mocks-registry.md.
  • De-mock status (refinement-phase-4): 14 domains are now REAL (USE_*_MOCK = false): auth, geography, patients, profiles, nurse (bank), addresses, serviceAreas, catalog, search, bookingRequests, bookings, payment, reviews, notifications, tickets. Flipping them required updating each clientApi.ts to consume the fields Phase-3 delivered (search name/avatar/distance + nurses/{id}/profile; patient relation/conditions; address provinceId; booking-request variantPrice/bookingId; ticket unreadCount/lastMessageAt/clientMessageId; review my_review mapper; profile avatarUrl/preferredLanguage + a multipart avatar upload now that clientFetch passes FormData bodies through). 7 domains stay mocked because a precondition REQ is deferred/unsafe: verification (REQ-034 admin queue), refunds (REQ-035 admin preview), payouts (REQ-036 admin preview), admin (REQ-031 RBAC roles), bnpl (REQ-022/024 options/schedule/wallet), partnerCenter (REQ-032/033/038 portal reads + /me signal), patientRecords (REQ-027 endpoints exist but the client family-record id model is string vs the wire's int — the customer-edit PUT is write-unsafe until reconciled). Note: the EVV_GPS_MODE seam auto-selects off (real navigator.geolocation) once USE_BOOKINGS_MOCK=false.
  • The wire envelope: the server wraps responses in ApiEnvelope<T> ({ isSuccess, statusCode, message, requestId, data }, camelCase — see lib/api/types.ts). clientFetch returns the raw body, so a real clientApi reads the payload via unwrap(). Types are derived from dev/contracts/ + dev/contracts/openapi/swagger.v1.json, mirroring the wire exactly.
  • Money & dates: format via @/utilsformatIrrToToman/formatIrr/parseIrr (IRR strings, integer-safe BigInt) and formatShamsiDate/formatShamsiDateTime (UTC ISO → Persian calendar). Money is never a float.

Auth Cookies & session state

Cookie Constant TTL Set by
access_token COOKIE_NAMES.ACCESS_TOKEN 15 min persistAuthTokens (src/lib/auth/session.ts) — via useVerifyOtp, useRefresh, useSelectRole, and the fetch-layer silent refresh
refresh_token COOKIE_NAMES.REFRESH_TOKEN 7 days same as above

The credential is phone-OTP — there is no username/password anywhere; email is never a login key. The login flow lives in src/components/auth/ (LoginFlowPhoneStep/OtpStep) at /login, over the services/auth domain (requestOtp/verifyOtp/refresh/logout/getMe/selectRole).

Role router: after a successful verify, RoleRouter (src/components/auth/) reads /me and navigates — customer→family app, nurse→nurse app, empty roles→/select-role, admin→admin console — showing the branded splash while /me loads so the wrong shell never flashes. The routing decision is the pure resolveRoleDestination(me, intendedRole) in src/services/auth/routing.ts (unit-tested). The middleware still owns the auth gate; the router only decides which app.

Role-aware shell guard (resolved-vs-pending hydration). Every private shell — (customer), nurse, admin, partner — wraps its layout in RoleGuard (src/components/auth/RoleGuard.tsx). This exists because the core role bug is conflating "/me hasn't resolved yet" with "the user has no nurse/admin role": a fresh /me in-flight used to fall through the DEFAULT_ROLE = customer fallback and flash a nurse the customer app (or strand them there if /me failed). RoleGuard reads useRoleHydration() (services/auth, a discriminated loading | error | ready over useMe) and:

  • loading → a neutral brand splash (never the customer shell as a stand-in);
  • error (/me failed, e.g. API down) → AuthAccountError with retry (never a silent customer fallback — a transient error must not downgrade a nurse/admin);
  • role mismatch → redirect to the caller's real app via resolveRoleDestination (the single "which app" source) with a guard_denied toast, instead of rendering a shell they lack the role for.

A shell passes expected={APP_ROLES.*}; the partner portal passes no expected (it isn't an AppRole — it self-gates on useMyPartnerCenter, so RoleGuard there only hardens hydration). The guard is UX/chrome, not security — the server authorizes every endpoint; a dual customer+nurse session holds both roles and moves freely between the family and nurse apps. useActorRole()'s DEFAULT_ROLE fallback is now only a last resort (the guard ensures roles are hydrated before a shell renders), never the loading state.

Session state lives in AuthContext (src/context/auth/), now carrying SessionUser { id?, phone, roles: AppRole[] }. The root layout resolves the session on the server with getServerAuthState() (src/lib/auth/server.ts) — which reads the access_token cookie and checks the JWT exp via the shared isTokenAlive (src/lib/auth/token.ts) — and passes it to <AuthProvider initialState={…}>, so the first render already knows whether the user is authenticated. Roles are not derivable from the opaque JWE token server-side, so the server seeds isAuthenticated only; useSessionRoleSync() (mounted in the private-routes layout) hydrates currentUser.roles from /me — the single source the shells read via useActorRole(). invalidateQueries(authKeys.me()) runs on login; removeQueries(authKeys.all) on logout.

Lifecycle:

  • Written by persistAuthTokens after verify/refresh/select-role, which also dispatch LOG_IN to keep AuthContext in sync without a reload.
  • Deleted by useLogout() (src/services/auth/hooks/useLogout.ts) — the single logout path: revoke the server session, clear both cookies, LOG_OUT, drop the /me cache, redirect — and by clientFetch when a 401 can't be recovered by a refresh.
  • Read on the server by serverFetch / getServerAuthState via getServerCookie.
  • Read on the client by clientFetch via getClientCookie (to attach Authorization: Bearer).

Silent refresh: clientFetch attempts one single-flight attemptTokenRefresh (src/lib/api/refresh.ts) on a 401 and retries the request once; a failed refresh (unknown/expired/reused token → the server revokes the session) clears tokens and redirects to /login. The refresh/OTP endpoints are excluded from this retry.

Middleware (middleware.ts) gates private routes with the same isTokenAlive helper before render. On redirect it appends the attempted locale-stripped path + query as ?next= (RETURN_URL_PARAM) so a deep link survives the round trip; LoginFlow reads it and RoleRouter resolves it via resolvePostLoginDestination (services/auth/routing.ts) — same-origin-relative + role-permitting only, else it falls back to resolveRoleDestination (never an open redirect).

Security posture — current limits and best-practice follow-ups. The flow above is the intended client design, but some hardening needs server coordination — don't silently "fix" it client-only:

  • Tokens are non-httpOnly cookies (JS-readable) so clientFetch can attach the bearer header — this trades XSS-hardening for the bearer pattern. Real hardening (httpOnly cookies set by the server + a same-origin proxy) spans the server.
  • The middleware check is UX-only, not a security boundary: it decodes the JWT and checks exp but does not verify the signature. The API is the only authority; never gate real authorization on the middleware or isTokenAlive.
  • Role gating is coarse for chrome, fine for the backoffice: the shells pick chrome from the collapsed currentUser.roles (useActorRole). f15 adds useAdminCapabilities() (@/hooks) — a memoized selector over the session's fine-grained roleCodes (hydrated from /me by useSessionRoleSync; super_admin/ admin/support/finance/moderation) that returns per-console booleans (canVerify/canRefund/ canPayout/canModerate/canConfig/canManageAlerts/canManageTickets/canManagePartners/canViewAudit/ canManageRoles). The AdminLayout nav and every admin action hide/disable on it so a role never sees a control that will 403 — but it is a display convenience only; the server authorizes every command (never gate real authz on it). Cross-actor route access still isn't hard-guarded client-side; add route guards when a phase needs them. The partner portal is a separate scope — its pages resolve the caller's own center via useMyPartnerCenter() (a 403/404 renders a non-leaking access-denied state), never a raw id.
  • Signed URLs are fetched on demand, never cached long-lived (f15): verification documents load via a short-lived signed URL fetched by useVerificationDocumentUrl(documentId) (short staleTime, retry:false) — DocumentViewer re-requests it on expiry/error rather than reading the embedded URL from the long-lived case query. Reuse this pattern for any short-lived signed asset (invoice PDFs, etc.).
  • Refresh-token rotation is wired client-side (fetch-layer silent refresh + useRefresh), matching the server's rotation + reuse-detection. The refresh_token cookie TTL (7d) is shorter than the server session default (30d) — a follow-up can align the cookie maxAge to refreshExpiresAt.

Toast Notifications (notistack)

<SnackbarProvider> wraps all children inside ThemeProvider in src/app/[locale]/layout.tsx.

In React components/hooks — use notistack directly:

import { useSnackbar } from 'notistack'
const { enqueueSnackbar } = useSnackbar()
enqueueSnackbar('Saved!', { variant: 'success' })

Outside React (plain functions, fetch services) — use the event bridge:

import { dispatchToast } from '@/lib/toast'
dispatchToast('Something went wrong', 'error')

dispatchToast fires a window CustomEvent (app:toast). ToastBridge (a zero-UI 'use client' component inside SnackbarProvider) listens and calls enqueueSnackbar.

ToastBridge is already rendered in [locale]/layout.tsx — do not add another instance.

Toast colors follow the theme. NotistackProvider maps every notistack variant to a styled(MaterialDesignContent) whose backgroundColor/color come from the --bal-{success,error,warning,info} (+ *-contrast) tokens in tokens.css. Because those tokens are defined on <html>, they cascade into notistack's Portal and switch with the color scheme automatically. Never hard-code a toast color — adjust the tokens instead.

Direction is inherited, not passed. notistack's Portal mounts under <body>, so it inherits dir from <html dir> (set per-locale in the root layout). Do not pass a dir prop to SnackbarProvider — it is not a valid prop (TS error) and is unnecessary:

<NotistackProvider>{children}</NotistackProvider>

Every mutation needs an onError toast. Every mutation whose failure is not already surfaced inline or by the fetch layer (401/403/5xx are auto-toasted by clientFetch) must have an onError toast — a mutation that only handles onSuccess is a defect.


Route Constants

Named path constants live in src/constants/routes.ts:

ROUTES.LOGIN  = '/login'
ROUTES.HOME   = '/'
PUBLIC_PATHS  = [ROUTES.LOGIN, ...]  // paths that bypass middleware auth check

Import from the barrel: import { ROUTES, PUBLIC_PATHS } from '@/constants'.

To add a new public route, append it to PUBLIC_PATHS — the middleware picks it up automatically.


src/lib/cookies/client.ts uses js-cookie internally. The exported API is unchanged:

Function Purpose
getClientCookie(name) Read a cookie by name
setClientCookie(name, value, options?) Write a cookie; options is CookieOptions with maxAge in seconds
deleteClientCookie(name, path?) Delete a cookie
getColorSchemeCookie() Typed helper for the theme cookie

CookieOptions type is defined in src/lib/cookies/constants.tsmaxAge is in seconds (converted to expires: Date internally when calling js-cookie).

  • Do not document.title = title in the render body of any component — it causes ReferenceError: document is not defined during build-time prerendering.