106 KiB
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.mjsonly wires the next-intl plugin +reactStrictMode.) - React 19 + TypeScript (
strict). - MUI v9 (
@mui/material) for components and theming; Emotion underneath (RTL viastylis-plugin-rtl). - next-intl v4 for i18n — locales
fa(default, RTL) anden. - 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 intsconfig.json:stricton,noEmit,@/*→src/*.npm run lint→eslint .driven by flat config ineslint.config.mjs. That config spreadseslint-config-next(core-web-vitals + typescript + react + react-hooks + jsx-a11y + import) and applieseslint-config-prettierlast so ESLint never fights Prettier on formatting.npm run checkruns 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 ineslint.config.mjs. - ESLint owns correctness, Prettier owns formatting. Don't add stylistic ESLint rules.
- No unused variables or imports.
@typescript-eslint/no-unused-varsis raised from eslint-config-next's defaultwarntoerror(ineslint.config.mjs), so dead code failsnpm 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-cycleis also disabled — its TS resolver has an interface mismatch here (see the note ineslint.config.mjs).
Golden rules (the short list)
A change is "done" only if it respects all of these — each has a full section below.
- Never add a layout above
[locale].src/app/[locale]/layout.tsxis the root layout (it renders<html>/<body>). A layout above it freezeslang/dir/messages on the default locale. - Respect the server/client boundary. Never import
next/headers,next-intl/server, or@/lib/cookies/serverfrom a client component; never import@/lib/cookies/clientfrom an RSC. - No hard-coded UI strings. Every user-visible string is a key in both
messages/en.jsonandmessages/fa.json. - Fetch only through
clientFetch/serverFetch(@/lib/api) — never rawfetch(). Domain calls live insrc/services/{domain}/apis/. - Cookies only through the cookie manager (
@/lib/cookies/*) — neverdocument.cookie,js-cookie,localStorage, orsessionStoragefor app/auth state. - Colors come from
tokens.css(var(--…)), never hard-coded insx. Use the pre-builtAPP_THEME_LTR/APP_THEME_RTL; never callcreateTheme()in a component. - MUI v9 API only. Use
sx={{ mb: 4 }}, notmb={4}as a direct prop. No MUI-v5/v6-only props (useFlexGap,flexWraponStack,storageWindow,InitColorSchemeScript, …). - Shared components get a co-located
*.test.tsx. (A component imported from >1 place.) - Magic strings become named constants (
src/constants/or a co-locatedconstants.ts). npm run checkis green and translations stay in sync before you finish.- 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' — the actual A5 home body (moved out of page.tsx for the metadata pattern; see "Per-page metadata" below)
│ │ │ ├── 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 # Thin RSC — generateMetadata (search.title) + renders SearchScreen
│ │ │ │ ├── SearchScreen.tsx # 'use client' — C1 search & filter body; 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 # Thin RSC — generateMetadata (booking.list_title) + renders BookingsScreen
│ │ │ │ ├── BookingsScreen.tsx # 'use client' — f8 رزروها list body (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 ~7–10-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
│ │ │ │ ├── return/page.tsx # return-from-gateway — confirm return → pending-callback poll (backoff, stops on terminal) → succeeded (invalidate + hand off) / failed retry / window-expired (the payment mock-gateway harness was removed in refinement-phase-4 when USE_PAYMENT_MOCK flipped; on the real path the PSP redirectUrl is absolute)
│ │ │ │ ├── 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)
│ │ │ ├── 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) — RSC; generateMetadata (nav.dashboard) inline (no split — already a Server Component)
│ │ │ ├── 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)
│ │ │ ├── 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).
│ │ │ ├── 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 — f15 review queue (page.tsx: status-filtered nurse worklist) ↔ [nurseId]/page.tsx per-nurse case (DocumentViewer signed-URL docs, pass/reject+reason per step, structured credential entry, Approve enabled only when all steps pass — client never writes is_verified)
│ │ │ ├── tickets/ # /admin/tickets — f15 global ticket queue (page.tsx: filter status/category/referenceCode) ↔ [id]/page.tsx admin thread (AdminMessageBubble renders isInternal notes distinctly; internal-note composer; RefundPanel opens from a refund ticket)
│ │ │ ├── payouts/ # /admin/payouts — f15 batch dashboard (page.tsx: batches + preview-next-batch dialog → run, idempotency-keyed) ↔ [batchId]/page.tsx per-nurse rows + failed-payout retry + transfer-reference reconcile
│ │ │ ├── reviews/page.tsx # /admin/reviews — f15 moderation queue: publish/hide/reject (reason on hide/reject); low-rating flag; client never computes the aggregate
│ │ │ ├── config/page.tsx # /admin/config — f15 config editor: typed input by data_type + 0–1 rate validation + audited-save dialog + change-history drawer
│ │ │ ├── holidays/page.tsx # /admin/holidays — f15 Iranian-holiday manager (is_bank_closed toggle; client never computes the payout shift)
│ │ │ ├── alerts/page.tsx # /admin/alerts — f15 internal support-alert worklist (assign/resolve); NEVER surfaced to a non-admin
│ │ │ ├── audit/page.tsx # /admin/audit — f15 append-only audit viewer (filtered, paginated, expandable changedFields diff; no edit/delete)
│ │ │ ├── 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)
│ │ │ ├── roles/page.tsx # /admin/roles — f15 RBAC grant/revoke grid (DEFERRED-IF-MISSING — mock-backed until the b15 role endpoints land)
│ │ │ ├── users/page.tsx # /admin/users
│ │ │ └── notifications/page.tsx # /admin/notifications
│ │ └── 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/page.tsx # /partner/bookings — the bookings the center legally covers (read-only summaries)
│ │ └── 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
│ └── (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
├── 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)
│ │ ├── ConfirmDialog/ # promoted from admin/ — required-reason gating + busy-disable, now usable by any actor
│ │ ├── 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); 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, RTL-aware keyboard nav
│ │ ├── JalaliDateField/ # read-only TextField + Popover wrapping JalaliDatePicker
│ │ ├── 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)
│ │ └── index.tsx # barrel — keep next-intl-importing primitives (Money) below the presentational ones so the poisoning risk stays visible in review
│ ├── 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 ~7–10 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 1–5 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)
│ ├── 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 (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)
│ ├── messaging/ # f14 tickets composites (import from @/components/messaging). Screens shared by the customer+nurse pages (role decides chrome): TicketInboxScreen, TicketThreadScreen (+ TicketMessageList), ContactSupportDialog (new-ticket → shows referenceCode), MessageComposer (optimistic send, draft-preserving), BookingSupportEntry (page-local glue on f8 booking detail — reuses the cached booking + care query, no refetch). Pure/tested: MessageBubble (mine/theirs, RTL-mirrored, never any internal-note styling), TicketListCard (prominent referenceCode + unread indicator + null-safe link), EmergencyBanner (post-confirmation tel: playbook, no VoIP seam). Helpers: statusKind.ts, authorLabel.ts
│ ├── notifications/ # f14 notification composites (import from @/components/notifications). NotificationBell (chrome container — subscribes to the polling count so only it re-renders) → NotificationBellView (pure, tested), NotificationRow (pure, tested: unread emphasis + server title/body), NotificationCenter (shared page body: unread-first, mark-read-on-open + mark-all, deep-links via notificationDeepLink). Helper: notificationIcon.ts
│ └── auth/ # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard, BrandMark, AuthSplash, RoleGuard (role-aware shell guard, tested), AuthAccountError (/me-failed recovery), useCountdown
├── 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
│ ├── NurseLayout.tsx # 'use client' — nurse workspace via TopBarAndSideBarLayout: grouped sidebar (امروز/حرفهٔ من/مالی/پشتیبانی) + ProfileSummary identity card + ActorSwitcher, 5-tab mobile BottomBar («بیشتر» opens the same sidebar drawer)
│ ├── AdminLayout.tsx # 'use client' — admin shell via TopBarAndSideBarLayout: sectioned sidebar (اعتماد/مالی/پشتیبانی/سیستم, useAdminCapabilities-gated, unchanged gating), TopBar identity chip (fine-grained role) + bell
│ ├── PartnerLayout.tsx # 'use client' — partner portal via TopBarAndSideBarLayout; TopBar identity chip shows the center's own name (useMyPartnerCenter, skeleton while resolving)
│ ├── PublicLayout.tsx # unauthenticated shell — minimal corner strip (logo + LocaleSwitcher + dark toggle), no sidebar/bottom bar; AuthCard renders its own larger BrandMark
│ ├── 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
│ ├── 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) + 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)
│ ├── 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=gross−clawback; Σ 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(one detail(id) = the whole thread; no message pagination)/useTicketThread(select over detail)/useOpenTicket(invalidates lists)/usePostMessage(OPTIMISTIC: onMutate append pending, onError rollback+keep composer draft, onSuccess replace by clientMessageId, onSettled invalidate). 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. Wire summary lacks unreadCount/lastMessageAt (REQ-028) → mock-only
│ ├── notifications/ # F14 in-app notification center (b1) — polled, no push. useNotifications(unread-first, growing limit)/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. usePlatformConfigs/useUpdatePlatformConfig/useConfigChangeHistory/useHolidays/useUpsertHoliday/useAuditLogs/useSupportAlerts/useAssignSupportAlert/useResolveSupportAlert/useAdminRoles/useGrantRole/useRevokeRole; 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 are gaps (REQ-029/030/031). 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)
├── hooks/ # incl. auth.ts → useIsAuthenticated / useActorRole (role-aware chrome)
├── 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
└── 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 againstrouting.locales(falls back todefaultLocale). No header reads. - Renders
<html lang dir>(dirfromgetDirection(locale)) plusdata-mui-color-schemefromgetThemeMode(). - Loads the Mikhak font and attaches its CSS-variable class to
<html>only forfa(see Fonts). - Calls
setRequestLocale(locale)so server components deeper in the tree can callgetLocale()/getTranslations()reliably. - Calls
getMessages({ locale })with the locale passed explicitly sogetRequestConfigreceives it viaPromise.resolve(locale)(not through the React.cache read), avoiding any cache-ordering race. - Wraps children with
NextIntlClientProvider,AuthProvider(seeded with server-read auth state), andThemeProvider. - Exports
generateStaticParamsso 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 (3–11).
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:
- Add the key to
messages/en.jsonANDmessages/fa.json. Both files must always be in sync. - 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 + 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 fiveprice_unitlabels + count nouns + the estimated-total label (read byPriceDisplay; 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 labelsbstatus_*, session-status labelssstatus_*, the EVV banner variantsevv_banner_{in_range,out_of_range,no_gps}+ check-in/out CTAs + GPS-acquiring copy, the care-instructions section labelscare_*+ the customer "visible to your nurse only" copy, the money summarymoney_*, the dispute-window note, the bookings listlist_*); consumed by the C4/C5 pages, the nurse requests pages, the f8 booking-detail/EVV pages, and the sharedBookingRequestSummaryCard+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 copyescrow_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, andPaymentStatusBadge'refunds'— the f10 customer cancellation + refund-status surface: policy-tier labels keyed offcancellation_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_revert7–10-business-day window /psp_card/manual), and the failed/contact-support copy; consumed by the cancel + refund-status pages andCancellationPolicyDisclosure/RefundStatusCard/RefundEtaBanner'bnpl'— the f11 BNPL installment checkout (D1–D5): 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 offprovider_{code}, the method/plan/eligibility/schedule labels, ICU-numberplan params (plan_term_months/plan_installments/plan_fee/down_payment_percent/installment_n— Persian digits onfa), the declined/error copy + card fall-back, the D5 wallet outstanding-balance/due-list/status_*labels, and the handoff/settle states; consumed by the D1–D4 wizard + gateway/return pages,WalletInstallments, the reused confirmation, andBnplPlanCard/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/earningspages andEarningsBalanceHeader/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 (countICU plural), the masked author fallback (author_masked), and the list empty/error/load-more; consumed by the review page, the C3ReviewsPanel, and theLeaveReviewCta'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 fromonboarding/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), the category + status labels keyed off the code (category_{support,coordination,refund,emergency}/status_{open,closed}), the linked-entity hints (linked_booking/linked_refundwith{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), the composer (sending/send/send_failed/composer_placeholder), the author-role labels (author_{customer,nurse,support,system}—admin→support), and the emergency playbook (emergency_title/emergency_body/emergency_call {name}/emergency_call_generic/emergency_open_ticket) +open_from_booking; consumed by the ticket screens,MessageBubble/TicketListCard/EmergencyBanner/ContactSupportDialog/MessageComposer/BookingSupportEntry'notifications'— the f14 notification center + bell:title,empty_*,error_body,retry,mark_all_read,load_more, and the polled-bell aria (bell_ariawith{count, number}); the rowtitle/bodyare server-rendered copy, not keys. Consumed byNotificationCenter+NotificationBell'auth'— the phone-OTP login flow, role router, RoleGuard (loading/account_error_*/guard_denied), and SelectRole screen (common.brand/brand_taglinefor the wordmark)'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/admincomposites'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.
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.tsin a client component; never importclient.tsin 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.tsnext to that feature's files - App-wide constants (used across multiple features):
src/constants/— one file per concern (routes.ts,events.ts, etc.)
Rules:
- Import the constant; never copy-paste the string value.
- 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):
getThemeMode()(lib/cookies/server.ts) reads the'color-scheme'cookie → returns{ colorScheme: 'light'|'dark', defaultMode: colorScheme }.- Root layout sets
data-mui-color-scheme={colorScheme}on<html>server-side. 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):
getThemeMode()returns{ colorScheme: undefined, defaultMode: 'system' }.- Root layout renders
<html>without thedata-mui-color-schemeattribute at all (data-mui-color-scheme={undefined}— React omits it). tokens.csshas 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.- 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. ColorSchemeCookieSyncinThemeProvider.tsxwrites the cookie viauseColorScheme().colorSchemeon 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.
Toggle components
DarkModeToggleButton and DarkModeFormSwitch in src/layout/components/DarkModeButton.tsx are the only components that subscribe to useColorScheme(). When the user toggles:
setMode('dark')is calledStorage.prototype.setItemintercept fires → writes'color-scheme'='dark'cookie synchronously- MUI sets
data-mui-color-scheme="dark"on<html> - 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 byAPP_THEME_LTR)TYPOGRAPHY_RTL— Mikhak for all text including body (used byAPP_THEME_RTL, ensures full Persian glyph coverage)- Both share one size/line-height scale (
SIZE_SCALEintypography.ts), wrapped inresponsiveFontSizes()(theme.ts) for per-breakpoint heading scaling. Weight system: 700 for headings + buttons, 500 for in-text emphasis (subtitles, labels), 400 body — never600, neither font loads that weight (seetypography.ts's header comment). - There is no
TYPOGRAPHYalias anymore — importTYPOGRAPHY_LTR/TYPOGRAPHY_RTLexplicitly.
Rules:
- Both fonts are declared with
preload: false, and each.variableclass is attached to<html>only for its own locale (Mikhak onfa, Space Grotesk onen) — never both, never neither. Anext/fontloader called in the root layout would otherwise preload on every route, andpreload: falseensures the font file only downloads when its locale actually renders. - Mikhak's woff2 files live in
src/app/fonts/(notpublic/) — 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 vialocalFontinsrc/app/[locale]/layout.tsx, attach its.variableclass conditionally on the matching locale, and update theBRAND_FONT_VARIABLE_*constants intypography.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:
- It renders without crashing.
- Every documented prop produces the correct HTML attribute or CSS class.
- 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 toerror— 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 tokenabovesetClientCookie(...), 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) andsrc/lib/auth/token.ts(why the JWTexpcheck 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
localStorageordocument.cookiein render functions — useuseEffector server-sidecookies()fromnext/headers. - Do not call
createTheme()inside a component or hook — useAPP_THEME_LTR/APP_THEME_RTL. - Do not use
storageWindow={null}onMuiThemeProvider— it is silently ignored in MUI v9. - Do not use
InitColorSchemeScriptfrom MUI — useColorSchemeScriptfrom@/theme. - Do not set
colorSchemeSelector: 'data'— use'data-mui-color-scheme'. - Do not check
mode === 'dark'for "is dark active" — usecolorScheme === 'dark'. - Do not hard-code UI strings — add translation keys to both
messages/en.jsonandmessages/fa.json. - Do not add a
src/app/layout.tsxor any layout above the[locale]segment. Such a layout is shared across locales, gets statically cached at build time withdefaultLocale('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.tsxis 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 togetRequestConfigviaPromise.resolve(locale), bypassing potential React.cache ordering issues. - Do not remove
setRequestLocale(locale)fromsrc/app/[locale]/layout.tsx— without it,getLocale()called by deeper server components always returnsdefaultLocale. - Do not add
notFound()tosrc/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— useTYPOGRAPHY_LTRorTYPOGRAPHY_RTLexplicitly. - Do not load fonts inside components or pages — all next/font declarations belong in
src/app/[locale]/layout.tsx, with the.variableclass attached conditionally per locale (Mikhak only forfa). - Do not import
@/lib/cookies/serverin client components or@/lib/cookies/clientin RSCs. - Do not call
fetch()directly in components or services — useserverFetch(RSC/Server Actions) orclientFetch(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', notimport { useLogin } from '@/services'). - Each domain does have an
index.tsthat re-exports its hooks (e.g.src/services/auth/index.ts). Do not exporttypes,keys, orapis/*from this barrel — only hooks. - Do not mix
clientFetchandserverFetchin the same file — keepclientApi.tsandserverApi.tsseparate; 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 inonErrorfor 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.cookiedirectly — use the central client cookie manager. - Do not store auth tokens in
sessionStorageorlocalStorage— use cookies via@/lib/cookies/client. - Do not pass
flexWraporuseFlexGapas direct props to MUIStack— these are not valid Stack props in MUI v9 and cause a TypeScript overload error. Usesx={{ flexWrap: 'wrap' }}instead.useFlexGapwas 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
staleTimeon reads so revisiting a screen doesn't refetch; mutations invalidate the affected list key (queryClient.invalidateQueries) orsetQueryData— never leave the cache stale. Seeservices/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. Seeservices/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 InfinitestaleTime/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 invalidatecatalogKeys.myVariantsLists(). - Mock behind a seam: when the backend endpoint isn't live, implement the domain's
Apiinterface twice — a realclientApi.tsand an in-memorymockApi.ts— and select inapis/index.tsby a config flag (USE_{DOMAIN}_MOCK). Hooks import the selectedapi; the swap is one line. Record every mock indev/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 eachclientApi.tsto consume the fields Phase-3 delivered (search name/avatar/distance +nurses/{id}/profile; patient relation/conditions; addressprovinceId; booking-requestvariantPrice/bookingId; ticketunreadCount/lastMessageAt/clientMessageId; reviewmy_reviewmapper; profileavatarUrl/preferredLanguage+ a multipart avatar upload now thatclientFetchpassesFormDatabodies 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 +/mesignal),patientRecords(REQ-027 endpoints exist but the client family-recordidmodel isstringvs the wire'sint— the customer-edit PUT is write-unsafe until reconciled). Note: theEVV_GPS_MODEseam auto-selectsoff(realnavigator.geolocation) onceUSE_BOOKINGS_MOCK=false. - The wire envelope: the server wraps responses in
ApiEnvelope<T>({ isSuccess, statusCode, message, requestId, data }, camelCase — seelib/api/types.ts).clientFetchreturns the raw body, so a realclientApireads the payload viaunwrap(). Types are derived fromdev/contracts/+dev/contracts/openapi/swagger.v1.json, mirroring the wire exactly. - Money & dates: format via
@/utils—formatIrrToToman/formatIrr/parseIrr(IRR strings, integer-safe BigInt) andformatShamsiDate/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.
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 (
/mefailed, e.g. API down) →AuthAccountErrorwith 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 aguard_deniedtoast, 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
persistAuthTokensafter verify/refresh/select-role, which also dispatchLOG_INto keepAuthContextin 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/mecache, redirect — and byclientFetchwhen a 401 can't be recovered by a refresh. - Read on the server by
serverFetch/getServerAuthStateviagetServerCookie. - Read on the client by
clientFetchviagetClientCookie(to attachAuthorization: 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
clientFetchcan 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
expbut does not verify the signature. The API is the only authority; never gate real authorization on the middleware orisTokenAlive. - Role gating is coarse for chrome, fine for the backoffice: the shells pick chrome from the collapsed
currentUser.roles(useActorRole). f15 addsuseAdminCapabilities()(@/hooks) — a memoized selector over the session's fine-grainedroleCodes(hydrated from/mebyuseSessionRoleSync;super_admin/admin/support/finance/moderation) that returns per-console booleans (canVerify/canRefund/canPayout/canModerate/canConfig/canManageAlerts/canManageTickets/canManagePartners/canViewAudit/canManageRoles). TheAdminLayoutnav 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 viauseMyPartnerCenter()(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)(shortstaleTime,retry:false) —DocumentViewerre-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. Therefresh_tokencookie TTL (7d) is shorter than the server session default (30d) — a follow-up can align the cookiemaxAgetorefreshExpiresAt.
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.
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 = titlein the render body of any component — it causesReferenceError: document is not definedduring build-time prerendering.