Files
baya-monorepo/client/CLAUDE.md
T
2026-07-10 16:58:15 +03:30

748 lines
76 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](../CLAUDE.md).
- Product/domain rules (what to build) → [`product/`](../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 type``tsc --noEmit`. Config in `tsconfig.json`: `strict` on, `noEmit`, `@/*``src/*`.
- `npm run lint``eslint .` 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
│ └── [locale]/
│ ├── layout.tsx # ROOT RSC: renders <html lang/dir> + fonts + setRequestLocale + NextIntlClientProvider + ThemeProvider + AuthProvider (seeded via getServerAuthState)
│ ├── (private-routes)/
│ │ ├── layout.tsx # 'use client' — wraps PrivateLayout; mounts useSessionRoleSync (hydrates AuthContext roles from /me)
│ │ ├── 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' — wraps CustomerLayout
│ │ │ ├── page.tsx # / (A5 home — 'use client'; greeting+avatar, search bar, data-driven category grid, first-login onboarding gate + record/profile nudges)
│ │ │ ├── search/ # /search — f6 discovery: C1 filter screen (page.tsx: reused category grid + f3 region picker + prominent same-gender facet + Toman price + live-count CTA; useSearchFilters colocated controller) → results/ (C2) → nurse/[nurseId]/ (C3)
│ │ │ │ ├── page.tsx # C1 search & filter; reads ?category_id preselect; pushes filter set to C2 as URL query params
│ │ │ │ ├── useSearchFilters.ts # C1 colocated filter controller (debounced Toman price → IRR; derives the canonical NurseSearchFilters)
│ │ │ │ ├── results/page.tsx # C2 results — rating-sorted NurseResultCard list; 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 — badges (TrustBadge + نظام پرستاری) + attribute chips + a f13 tab strip: «خدمات» (ServicePriceRow list) / «نظرات» (ReviewsPanel — published-only aggregate+count + infinite list via services/reviews); "درخواست رزرو" hands off to /bookings/request (f7)
│ │ │ ├── onboarding/page.tsx # /onboarding — A3→A4 wizard (relation → first patient)
│ │ │ ├── bookings/
│ │ │ │ ├── page.tsx # /bookings — f8 رزروها list (useBookingList('customer')); rows → booking detail
│ │ │ │ ├── [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 — f7 C4 request form (patient/variant/address/date/time + first-class caregiver-gender + stage-1 notes); C3 hands off the nurse/variant/required_gender here → creates a request → C5
│ │ │ │ ├── request/[id]/page.tsx # /bookings/request/[id] — f7 C5 awaiting screen: summary card + 3-step tracker + polled status; response countdown → (accept) 30-min payment countdown + checkout CTA / (reject/expire/cancel) terminal cards; converted → booking deep-link (bookingId, REQ-017)
│ │ │ │ ├── [id]/invoice/page.tsx # /bookings/[id]/invoice — f9 commission invoice (b11): number + Shamsi date, reconciling lines with the VAT-on-commission line, read-only مودیان state; pdfUrl download or window.print receipt
│ │ │ │ ├── [id]/cancel/page.tsx # /bookings/[id]/cancel — f10 cancellation flow: policy-fee disclosure (CancellationPolicyDisclosure) + reason + acknowledge → confirm → useCancelBooking → refund status
│ │ │ │ ├── [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): 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=)
│ │ │ │ ├── page.tsx # C6 خلاصه و پرداخت — acceptance badge, served reconciling breakdown (PriceBreakdown), EscrowNotice, payment-window countdown, «ادامه پرداخت ←» (idempotency-key-per-attempt) + «پرداخت اقساطی» → f11 BNPL wizard
│ │ │ │ ├── gateway/page.tsx # dev mock-gateway page — TEST HARNESS standing in for the PSP redirect (mock redirectUrl points here; success/failure buttons drive both return branches)
│ │ │ │ ├── return/page.tsx # return-from-gateway — confirm return → pending-callback poll (backoff, stops on terminal) → succeeded (invalidate + hand off) / failed retry / window-expired
│ │ │ │ ├── confirmation/page.tsx # payment success — «مشاهده رزرو» (booking detail) + «دانلود فاکتور» (invoice); REUSED by f11 (?method=bnpl adds «پرداخت‌شده با اقساط» — a settled BNPL order is a card payment net-of-fee)
│ │ │ │ └── bnpl/ # f11 BNPL installment checkout (the alternate branch off C6, reached with ?request_id=)
│ │ │ │ ├── page.tsx # D1→D4 stateful wizard (StepperHeader): D1 method/provider · D2 plan · D3 eligibility · D4 schedule+contract → provider handoff; card fall-back → C6 everywhere
│ │ │ │ ├── MethodStep.tsx # D1 روش پرداخت — payable amount + full-card option + provider option cards (from useBnplOptions, never hardcoded)
│ │ │ │ ├── PlanStep.tsx # D2 انتخاب طرح — single-select BnplPlanCard group (served monthly/down-payment)
│ │ │ │ ├── EligibilityStep.tsx # D3 اعتبارسنجی — کد ملی + prefilled موبایل + consent gate → useCheckEligibility → 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
│ │ │ │ └── return/page.tsx # settle (useAcceptBnplSchedule) → invalidate → reused confirmation (?method=bnpl) / retry / card
│ │ │ ├── patients/page.tsx # /patients — E1 list/CRUD (add/edit dialog reusing PatientForm, soft-archive); tapping a PatientCard opens the E2 record (f13)
│ │ │ ├── patients/[id]/record/page.tsx # /patients/[id]/record — f13 E2 care-record viewer (b14): reused PatientHeader + ownership banner + 4 tabs (داروها/روتین/سوابق/وظایف). Family-owned & patient-scoped — customer edits medications/routine/tasks (useUpdateCareRecord); سوابق = read-only nurse visit-note history (VisitNoteCard); access-denied is a first-class non-leaking state gated BEFORE any clinical fetch (services/patientRecords)
│ │ │ ├── addresses/page.tsx # /addresses — F3 address book (cascading region dropdowns + map-pin picker, set-primary)
│ │ │ ├── wallet/ # /wallet — f11 D5 پیگیری اقساط (page.tsx = thin shell → WalletInstallments.tsx: provider-reported outstanding balance + due list + early-pay provider hand-off; self-contained for f12 nurse-earnings later)
│ │ │ └── profile/page.tsx # /profile — customer profile + emergency contact (no national-ID)
│ │ ├── nurse/ # Nurse app (/nurse/…) — sidebar shell
│ │ │ ├── layout.tsx # 'use client' — wraps NurseLayout
│ │ │ ├── page.tsx # /nurse (dashboard)
│ │ │ ├── requests/ # /nurse/requests — f7 incoming booking-requests inbox (page.tsx: pending list, per-request countdown + gender chip + notes preview) ↔ requests/[id]/page.tsx detail (only customerNotes + masked city/district; accept/reject-with-reason invalidate inbox+detail)
│ │ │ ├── profile/page.tsx # /nurse/profile — B7 profile bootstrap (avatar+bio+years; unverified placeholder)
│ │ │ ├── services/ # /nurse/services — B7 services half: offerings list ↔ variant builder (page.tsx switches mode; MyServicesList + VariantBuilder + PublishGate co-located; PublishGate is the f5 verification-gated go-live)
│ │ │ ├── coverage/page.tsx # /nurse/coverage — F3 coverage-area editor (whole-city/district areas, dup-blocked)
│ │ │ ├── bank/page.tsx # /nurse/bank — payout IBAN + ownership states (pending/verified/mismatch); the f5 bank_account_verification step deep-links here
│ │ │ ├── verification/ # /nurse/verification — f5 trust flow: ONE cached VerificationStatus query, four views
│ │ │ │ ├── page.tsx # B3 hub — "X از Y" meter + data-driven checklist (StatusChip rows) + 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
│ │ │ │ ├── credentials/page.tsx # B5 — INO number + specialty chips + a DocumentUpload per manual step (data-driven) → in_review
│ │ │ │ ├── review/page.tsx # B6 — under-review (same status query, condensed mini-checklist)
│ │ │ │ ├── VerificationChecklist.tsx # B3 body: meter + step rows (co-located, page-only)
│ │ │ │ └── verificationSteps.ts # step→label/chip/route helpers + synthetic mobile step (keeps rendering data-driven)
│ │ │ ├── visits/ # /nurse/visits — f8 EVV: page.tsx = ویزیت امروز today-sessions feed (per-session check-in/out via useEvvController + advisory EvvStatusBanner) ↔ visits/[id]/page.tsx nurse booking detail (BookingDetailView viewerRole="nurse": 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): page.tsx = EarningsBalanceHeader (net payable balance + 4 buckets, negative "owed back") + cadence/dispute-window explainer + state-segmented EarningsRow list (deep-links to /nurse/visits/[id]) ↔ payouts/page.tsx (PayoutHistoryRow list) → payouts/[id]/page.tsx (payout/batch reconciliation detail: money decomposition + masked IBAN + booking links)
│ │ └── admin/ # Admin/backoffice (/admin/…) — desktop sidebar shell
│ │ ├── layout.tsx # 'use client' — wraps AdminLayout
│ │ ├── page.tsx # /admin (overview)
│ │ ├── users/page.tsx # /admin/users
│ │ └── notifications/page.tsx # /admin/notifications
│ └── (public-routes)/
│ ├── layout.tsx # 'use client' — wraps PublicLayout
│ └── login/page.tsx # /login — phone-OTP login (A1/A2 customer, B1/B2 nurse switch)
├── components/ # Shared UI components (each with .test.tsx if imported >1 place)
│ ├── 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
│ ├── ConditionChips/ # Multi-select patient-condition chips (stable codes, translated labels)
│ ├── RelationSelect/ # Single-select relation radio cards (parent/spouse/child/self)
│ ├── PatientForm/ # A4 patient form (name/age/gender/conditions/relation) — reused create+edit
│ ├── PatientCard/ # E1 patient summary card (composes the shared PatientHeader) + edit/archive actions + optional onOpen tap-to-open (→ f13 E2 record)
│ ├── 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) (tested)
│ ├── TrustBadge/ # f5 public trust signal (verified/unverified/expired) off --bal-* tokens — nurse profile + reused by f6 search/public profile (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)
│ ├── NurseResultCard/ # f6 C2 result card: avatar+name, reused verified TrustBadge, rating+review count, optional distance chip, "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 (tested)
│ ├── BookingRequestSummaryCard/ # f7 engagement summary (nurse+rating, patient, priced service, address, Shamsi time) — shared by C5 + nurse detail + later f8 booking detail (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 (tested)
│ ├── EscrowNotice/ # f9 product-mandated escrow trust callout (verbatim fa copy, --bal-info tone, lock icon) — C6 now, f10/f11 reuse the identical message (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 + interest-free/fee sub-label + served monthly amount + down-payment indicator; single-select (tested)
│ ├── InstallmentScheduleRow/ # f11 repayment row: down-payment(«امروز»)/installment + Shamsi due date + served amount + 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 (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 (tested)
│ ├── booking/ # f8 post-payment engagement composites (import from @/components/booking). BookingDetailView (both-roles smart container, role-conditioned EVV+gated care), BookingStatusTimeline (server-truth 7-status timeline over StepperHeader), SessionList→SessionCard (per-session schedule/status/EVV CTA), EvvStatusBanner (advisory in/out-of-range/no-gps), CareInstructionsCard (decrypted clinical read), BookingMoneySummary (gross/commission/payout display-only); useEvvController (GPS-capture + check-in/out orchestration), format.ts + statusKind.ts helpers. Each composite tested; the BookingDetailView test proves the customer never fires the care query (two-stage-disclosure gate)
│ ├── geography/ # F3 geo composites: CascadingRegionSelect, AddressMapPicker (map-pin stand-in), AddressForm, AddressCard (each tested)
│ └── auth/ # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard, BrandMark, AuthSplash, useCountdown
├── i18n/
│ ├── routing.ts # defineRouting — locales: ['en', 'fa'], defaultLocale: 'fa'
│ └── request.ts # getRequestConfig — loads messages/${locale}.json
├── layout/
│ ├── PrivateLayout.tsx # authenticated wrapper (passthrough today); actor chrome lives in the shells below
│ ├── CustomerLayout.tsx # 'use client' — customer shell: TopBar + BottomBar (5-tab); useTranslations('nav')
│ ├── NurseLayout.tsx # 'use client' — nurse shell via TopBarAndSideBarLayout; useTranslations('nav')
│ ├── AdminLayout.tsx # 'use client' — admin shell via TopBarAndSideBarLayout (persistent sidebar)
│ ├── PublicLayout.tsx # unauthenticated shell
│ ├── TopBarAndSideBarLayout.tsx # 'use client' — TopBar + SideBar composition (nurse/admin engine)
│ ├── config.ts
│ ├── index.ts
│ └── components/
│ ├── TopBar.tsx
│ ├── SideBar.tsx
│ ├── SideBarNavList.tsx
│ ├── SideBarNavItem.tsx
│ ├── 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) + useSessionRoleSync
│ ├── 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)
│ ├── 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); 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
│ ├── refunds/ # F10 customer cancellation + refund status (b11). resolveCancellationPolicy/cancelBooking/getRefundByBooking/getRefund. useCancellationPolicyPreview/useCancelBooking/useRefundStatus(polls only while non-terminal); 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
│ ├── 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
│ └── {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
│ ├── light.ts / dark.ts # LIGHT_THEME / DARK_THEME ThemeOptions (consumed by theme.ts)
│ ├── direction.ts # getDirection(locale) → 'ltr' | 'rtl'
│ ├── theme.ts # APP_THEME_LTR / APP_THEME_RTL (static, created once)
│ ├── tokens.css # CSS custom properties — [data-mui-color-scheme] selectors
│ ├── typography.ts # TYPOGRAPHY_LTR (Space Grotesk) / TYPOGRAPHY_RTL (Mikhak)
│ └── index.ts # Public re-exports (ThemeProvider, getDirection, APP_THEME_*) — note: no ColorSchemeScript is exported/rendered today (doc drift below)
├── constants/ # App-wide constants (routes.ts w/ actor paths, roles.ts, headers.ts)
├── hooks/ # incl. auth.ts → useIsAuthenticated / useActorRole (role-aware chrome)
├── utils/ # incl. money.ts (IRR/Toman, integer-safe) + date.ts (Shamsi display) + toEnglishDigits
└── 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.
---
## 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:**
```tsx
import { useTranslations } from 'next-intl';
function MyComponent() {
const t = useTranslations('nav'); // namespace
return <span>{t('home')}</span>; // key
}
```
**Using translations in Server Components:**
```tsx
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 + 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)
- `'bank'` — the nurse payout bank settings (IBAN form + the three ownership states)
- `'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 (whole-city/specific-district scope, chips, duplicate + "won't appear in search" warnings)
- `'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)
- `'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, the publish-gate + shared-SIM/mismatch messages
- `'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`
- `'auth'` — the phone-OTP login flow, role router, and SelectRole screen (`common.brand`/`brand_tagline` for the wordmark)
**Namespace conventions for the phases to come** (seed each when its feature lands, in both locale
files): `notifications`, `admin`. 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.
---
## Cookie Manager
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.)
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)
1. **Request arrives**`getThemeMode()` reads `'color-scheme'` cookie → returns `{ colorScheme, defaultMode }`
2. **Root layout** sets `data-mui-color-scheme={colorScheme}` on `<html>` server-side
3. **`<ColorSchemeScript />`** in `<head>` runs before any paint:
- Reads the same cookie, sets `data-mui-color-scheme` (handles edge cases where server attr might differ)
- Patches `Storage.prototype` — routes MUI's `localStorage` writes for key `'mode'` to our cookie; reads return `null` so MUI always trusts the `defaultMode` prop
4. **`<MuiThemeProvider defaultMode={defaultMode}>`** mounts — uses the server-derived mode, not localStorage
5. **`ColorSchemeCookieSync`** in ThemeProvider writes the cookie via `useColorScheme().colorScheme` on mount (safety net for first-visit system mode)
### Critical MUI v9 rules
**`colorSchemeSelector` must be the explicit attribute name:**
```ts
// 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). Use `ColorSchemeScript` from `@/theme` instead.
**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`.
### 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` | (not currently wired — falls back to the system stack) | — |
**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)
- `TYPOGRAPHY` — alias for `TYPOGRAPHY_LTR` (deprecated, prefer the explicit exports)
**Rules:**
- Mikhak is declared with `preload: false`, and its `.variable` class is attached to `<html>` **only when `locale === 'fa'`**. Both are required: a `next/font` loader called in the root layout would otherwise preload on every route (including `/en`), and `preload: false` ensures the woff2 only downloads when Persian text actually renders.
- Font 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.
- Never load fonts inside components — all font loading lives in `src/app/[locale]/layout.tsx`.
- To add a new font, add woff2 files to `src/app/fonts/`, declare via `localFont`/`localFont`-equivalent in `src/app/[locale]/layout.tsx`, attach its `.variable` class conditionally on the matching locale, and update `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.
---
## 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`.
- **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 `@/utils` — `formatIrrToToman`/`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/` (`LoginFlow` → `PhoneStep`/`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*.
**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.
**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:** the shells pick chrome from `currentUser.roles`, but cross-actor route access
isn't hard-guarded client-side yet (the server authorizes each call). Add route guards when a phase needs
them.
- **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:
```tsx
import { useSnackbar } from 'notistack'
const { enqueueSnackbar } = useSnackbar()
enqueueSnackbar('Saved!', { variant: 'success' })
```
**Outside React** (plain functions, fetch services) — use the event bridge:
```ts
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:
```tsx
<NotistackProvider>{children}</NotistackProvider>
```
---
## Route Constants
Named path constants live in `src/constants/routes.ts`:
```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.
---
## Client Cookie Manager (js-cookie)
`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.ts` — `maxAge` 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.