Files
baya-monorepo/client/CLAUDE.md
T
2026-07-10 12:51:53 +03:30

63 KiB
Raw Blame History

Balinyaar Client — Claude Code Guidelines

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

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

Stack

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

Commands

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

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

Quality gates: lint & type (how they work)

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

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

Rules for this project:

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

Golden rules (the short list)

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

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

Project Structure

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

client/
├── messages/              # Translation files (add keys to BOTH files)
│   ├── en.json
│   └── fa.json
├── middleware.ts          # next-intl routing middleware (locale detection + redirect)
├── next.config.mjs        # createNextIntlPlugin wires i18n into Next.js
└── src/
    ├── app/
    │   ├── globals.css
    │   ├── fonts/                              # Local font files (woff2) — Mikhak for fa
    │   └── [locale]/
    │       ├── layout.tsx                      # ROOT RSC: renders <html lang/dir> + fonts + setRequestLocale + NextIntlClientProvider + ThemeProvider + AuthProvider (seeded via getServerAuthState)
    │       ├── (private-routes)/
    │       │   ├── layout.tsx                  # 'use client' — wraps PrivateLayout; mounts useSessionRoleSync (hydrates AuthContext roles from /me)
    │       │   ├── select-role/page.tsx        # /select-role — first-use role picker (no public role yet); role router lands here
    │       │   ├── (customer)/                 # Customer (family) app — mobile-first, bottom-tab nav; no URL segment
    │       │   │   ├── layout.tsx              # 'use client' — wraps CustomerLayout
    │       │   │   ├── page.tsx                # / (A5 home — 'use client'; greeting+avatar, search bar, data-driven category grid, first-login onboarding gate + record/profile nudges)
    │       │   │   ├── search/                 # /search — f6 discovery: C1 filter screen (page.tsx: reused category grid + f3 region picker + prominent same-gender facet + Toman price + live-count CTA; useSearchFilters colocated controller) → results/ (C2) → nurse/[nurseId]/ (C3)
│       │   │   │   ├── page.tsx            # C1 search & filter; reads ?category_id preselect; pushes filter set to C2 as URL query params
│       │   │   │   ├── useSearchFilters.ts # C1 colocated filter controller (debounced Toman price → IRR; derives the canonical NurseSearchFilters)
│       │   │   │   ├── results/page.tsx    # C2 results — rating-sorted NurseResultCard list; all four states (skeleton/empty-relax/error/populated); load-more; filters live in the URL (the cache key)
│       │   │   │   └── nurse/[nurseId]/page.tsx  # C3 nurse profile — badges (TrustBadge + نظام پرستاری) + attribute chips + ServicePriceRow list + latest review; "درخواست رزرو" hands off to /bookings/request (f7)
    │       │   │   ├── onboarding/page.tsx     # /onboarding — A3→A4 wizard (relation → first patient)
    │       │   │   ├── bookings/
    │       │   │   │   ├── page.tsx            # /bookings — f8 رزروها list (useBookingList('customer')); rows → booking detail
    │       │   │   │   ├── [id]/page.tsx       # /bookings/[id] — f8 customer booking detail (BookingDetailView viewerRole="customer") + f10 cancel/refund entry (CustomerBookingActions: Cancel CTA while cancellable, refund section once cancelled — reuses the cached booking query)
    │       │   │   │   ├── request/page.tsx    # /bookings/request — f7 C4 request form (patient/variant/address/date/time + first-class caregiver-gender + stage-1 notes); C3 hands off the nurse/variant/required_gender here → creates a request → C5
    │       │   │   │   ├── request/[id]/page.tsx # /bookings/request/[id] — f7 C5 awaiting screen: summary card + 3-step tracker + polled status; response countdown → (accept) 30-min payment countdown + checkout CTA / (reject/expire/cancel) terminal cards; converted → booking deep-link (bookingId, REQ-017)
    │       │   │   │   ├── [id]/invoice/page.tsx # /bookings/[id]/invoice — f9 commission invoice (b11): number + Shamsi date, reconciling lines with the VAT-on-commission line, read-only مودیان state; pdfUrl download or window.print receipt
    │       │   │   │   ├── [id]/cancel/page.tsx # /bookings/[id]/cancel — f10 cancellation flow: policy-fee disclosure (CancellationPolicyDisclosure) + reason + acknowledge → confirm → useCancelBooking → refund status
    │       │   │   │   ├── [id]/refund_status/page.tsx # /bookings/[id]/refund_status — f10 customer refund status (RefundStatusCard): pending → on-its-way → completed, BNPL ~710-day ETA, failed=contact-support; polls only while non-terminal
    │       │   │   │   └── 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) + disabled BNPL seam (f11)
    │       │   │   │       ├── gateway/page.tsx # dev mock-gateway page — TEST HARNESS standing in for the PSP redirect (mock redirectUrl points here; success/failure buttons drive both return branches)
    │       │   │   │       ├── return/page.tsx  # return-from-gateway — confirm return → pending-callback poll (backoff, stops on terminal) → succeeded (invalidate + hand off) / failed retry / window-expired
    │       │   │   │       └── confirmation/page.tsx # payment success — «مشاهده رزرو» (booking detail) + «دانلود فاکتور» (invoice)
    │       │   │   ├── patients/page.tsx       # /patients — E1 list/CRUD (add/edit dialog reusing PatientForm, soft-archive)
    │       │   │   ├── addresses/page.tsx      # /addresses — F3 address book (cascading region dropdowns + map-pin picker, set-primary)
    │       │   │   ├── wallet/page.tsx         # /wallet
    │       │   │   └── profile/page.tsx        # /profile — customer profile + emergency contact (no national-ID)
    │       │   ├── nurse/                      # Nurse app (/nurse/…) — sidebar shell
    │       │   │   ├── layout.tsx              # 'use client' — wraps NurseLayout
    │       │   │   ├── page.tsx                # /nurse (dashboard)
    │       │   │   ├── requests/               # /nurse/requests — f7 incoming booking-requests inbox (page.tsx: pending list, per-request countdown + gender chip + notes preview) ↔ requests/[id]/page.tsx detail (only customerNotes + masked city/district; accept/reject-with-reason invalidate inbox+detail)
    │       │   │   ├── profile/page.tsx        # /nurse/profile — B7 profile bootstrap (avatar+bio+years; unverified placeholder)
    │       │   │   ├── services/               # /nurse/services — B7 services half: offerings list ↔ variant builder (page.tsx switches mode; MyServicesList + VariantBuilder + PublishGate co-located; PublishGate is the f5 verification-gated go-live)
    │       │   │   ├── coverage/page.tsx       # /nurse/coverage — F3 coverage-area editor (whole-city/district areas, dup-blocked)
    │       │   │   ├── bank/page.tsx           # /nurse/bank — payout IBAN + ownership states (pending/verified/mismatch); the f5 bank_account_verification step deep-links here
    │       │   │   ├── verification/           # /nurse/verification — f5 trust flow: ONE cached VerificationStatus query, four views
    │       │   │   │   ├── page.tsx            # B3 hub — "X از Y" meter + data-driven checklist (StatusChip rows) + single continue CTA + not_started/approved states; dev-only mock admin-decision sim
    │       │   │   │   ├── identity/page.tsx   # B4 — national-ID (checksum) + card/selfie local capture → automated KYC + chained Shahkar
    │       │   │   │   ├── credentials/page.tsx # B5 — INO number + specialty chips + a DocumentUpload per manual step (data-driven) → in_review
    │       │   │   │   ├── review/page.tsx     # B6 — under-review (same status query, condensed mini-checklist)
    │       │   │   │   ├── VerificationChecklist.tsx  # B3 body: meter + step rows (co-located, page-only)
    │       │   │   │   └── verificationSteps.ts       # step→label/chip/route helpers + synthetic mobile step (keeps rendering data-driven)
    │       │   │   └── visits/                 # /nurse/visits — f8 EVV: page.tsx = ویزیت امروز today-sessions feed (per-session check-in/out via useEvvController + advisory EvvStatusBanner) ↔ visits/[id]/page.tsx nurse booking detail (BookingDetailView viewerRole="nurse": EVV controls + gated care card)
    │       │   └── admin/                      # Admin/backoffice (/admin/…) — desktop sidebar shell
    │       │       ├── layout.tsx              # 'use client' — wraps AdminLayout
    │       │       ├── page.tsx                # /admin (overview)
    │       │       ├── users/page.tsx          # /admin/users
    │       │       └── notifications/page.tsx  # /admin/notifications
    │       └── (public-routes)/
    │           ├── layout.tsx                  # 'use client' — wraps PublicLayout
    │           └── login/page.tsx              # /login — phone-OTP login (A1/A2 customer, B1/B2 nurse switch)
    ├── components/        # Shared UI components (each with .test.tsx if imported >1 place)
    │   ├── PlaceholderScreen/ # Empty-state scaffold for not-yet-built screens
    │   ├── OtpInput/          # OTP code input (auto-advance, paste, RTL-safe)
    │   ├── PhoneNumberField/  # Iranian mobile field (digit-normalizing, LTR-in-RTL, maskIranMobile)
    │   ├── StepperHeader/     # Progress header for onboarding/verification flows
    │   ├── StatusChip/        # Semantic status chip (verified/pending/rejected/…) off --bal-* tokens
    │   ├── GenderToggle/      # Required male/female toggle (never defaulted) — drives same-gender matching
    │   ├── ConditionChips/    # Multi-select patient-condition chips (stable codes, translated labels)
    │   ├── RelationSelect/    # Single-select relation radio cards (parent/spouse/child/self)
    │   ├── PatientForm/       # A4 patient form (name/age/gender/conditions/relation) — reused create+edit
    │   ├── PatientCard/       # E1 patient summary card + edit/archive actions
    │   ├── BankStatusPanel/   # Nurse bank-account ownership state (pending/verified/mismatch), masked IBAN
    │   ├── CategoryTile/      # f4 tappable service-category tile (icon+label; `selected` state for the builder) — Home grid + builder step 1 (tested)
    │   ├── PriceDisplay/      # f4 price renderer: money-util Toman + i18n unit label + unit-aware estimated total (never a total from price alone) (tested)
    │   ├── VariantCard/       # f4 nurse offering card: display_name, PriceDisplay, active/deactivated distinction, edit/deactivate (no delete) (tested)
    │   ├── TrustBadge/        # f5 public trust signal (verified/unverified/expired) off --bal-* tokens — nurse profile + reused by f6 search/public profile (tested)
    │   ├── DocumentUpload/    # f5 reusable doc uploader: client type/size validation, progress %, success/retry, re-upload on reject; server-metadata truth (local-capture mode too) (tested)
    │   ├── NurseResultCard/   # f6 C2 result card: avatar+name, reused verified TrustBadge, rating+review count, optional distance chip, "from X تومان/unit" via PriceDisplay; presentational + memoized (tested)
    │   ├── ServicePriceRow/   # f6 C3 service line: localised name + PriceDisplay (money util + i18n unit label); reused by the booking summary later (tested)
    │   ├── CountdownTimer/     # f7 pure presentational countdown to a server-frozen UTC deadline; owns its own 1s tick (only it re-renders), stops + shows elapsed text at zero, locale digits LTR (tested)
    │   ├── BookingRequestSummaryCard/ # f7 engagement summary (nurse+rating, patient, priced service, address, Shamsi time) — shared by C5 + nurse detail + later f8 booking detail (tested)
    │   ├── PriceBreakdown/    # f9 reconciling money breakdown (rows + bold total, all IRR digit-strings via the money util; dev-guard console.errors when rows ≠ total) — C6 + invoice now, f10/f11 refund/BNPL later (tested)
    │   ├── EscrowNotice/      # f9 product-mandated escrow trust callout (verbatim fa copy, --bal-info tone, lock icon) — C6 now, f10/f11 reuse the identical message (tested)
    │   ├── PaymentStatusBadge/ # f9 b10 payment status (pending/succeeded/failed) → StatusChip kind + payment.pstatus_* label (tested)
    │   ├── CancellationPolicyDisclosure/ # f10 pre-confirm cancel disclosure: policy-tier label (off cancellation_policy_code) + refund %/fee % + PriceBreakdown refund-vs-fee split (reconciles) + multi-session refundable/locked breakdown + admin-approval explainer + RefundEtaBanner (tested)
    │   ├── RefundStatusCard/  # f10 customer refund view: 3-step stepper (submitted→on-its-way→completed) + refunded amount + optional fee-leg split + masked ref + failed=contact-support (no retry); reused on booking detail + refund-status page (tested)
    │   ├── RefundEtaBanner/   # f10 per-channel refund ETA — bnpl_revert surfaces the ~710 business-day window honestly (never instant), psp_card/manual wording; one branch on refund_channel (tested)
    │   ├── booking/           # f8 post-payment engagement composites (import from @/components/booking). BookingDetailView (both-roles smart container, role-conditioned EVV+gated care), BookingStatusTimeline (server-truth 7-status timeline over StepperHeader), SessionList→SessionCard (per-session schedule/status/EVV CTA), EvvStatusBanner (advisory in/out-of-range/no-gps), CareInstructionsCard (decrypted clinical read), BookingMoneySummary (gross/commission/payout display-only); useEvvController (GPS-capture + check-in/out orchestration), format.ts + statusKind.ts helpers. Each composite tested; the BookingDetailView test proves the customer never fires the care query (two-stage-disclosure gate)
    │   ├── geography/         # F3 geo composites: CascadingRegionSelect, AddressMapPicker (map-pin stand-in), AddressForm, AddressCard (each tested)
    │   └── auth/              # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard, BrandMark, AuthSplash, useCountdown
    ├── i18n/
    │   ├── routing.ts     # defineRouting — locales: ['en', 'fa'], defaultLocale: 'fa'
    │   └── request.ts     # getRequestConfig — loads messages/${locale}.json
    ├── layout/
    │   ├── PrivateLayout.tsx   # authenticated wrapper (passthrough today); actor chrome lives in the shells below
    │   ├── CustomerLayout.tsx  # 'use client' — customer shell: TopBar + BottomBar (5-tab); useTranslations('nav')
    │   ├── NurseLayout.tsx     # 'use client' — nurse shell via TopBarAndSideBarLayout; useTranslations('nav')
    │   ├── AdminLayout.tsx     # 'use client' — admin shell via TopBarAndSideBarLayout (persistent sidebar)
    │   ├── PublicLayout.tsx    # unauthenticated shell
    │   ├── TopBarAndSideBarLayout.tsx  # 'use client' — TopBar + SideBar composition (nurse/admin engine)
    │   ├── config.ts
    │   ├── index.ts
    │   └── components/
    │       ├── TopBar.tsx
    │       ├── SideBar.tsx
    │       ├── SideBarNavList.tsx
    │       ├── SideBarNavItem.tsx
    │       ├── DarkModeButton.tsx  # 'use client' — only subscriber to useColorScheme()
    │       └── index.tsx
    ├── lib/
    │   ├── api/
    │   │   ├── client.ts      # clientFetch<T> — throws ApiError on error; use in hooks/client components; silent-refreshes + retries once on 401
    │   │   ├── server.ts      # serverFetch<T> — throws ApiError on error; use in RSCs/Server Actions
    │   │   ├── types.ts       # ApiEnvelope<T> + unwrap(), Paginated<T>, PageParams — shared wire types
    │   │   ├── refresh.ts     # attemptTokenRefresh — single-flight silent refresh used by clientFetch's 401 branch
    │   │   └── errors.ts      # ApiError class (status, message, code)
    │   ├── auth/
    │   │   ├── token.ts       # decodeJwtPayload / isTokenAlive — edge-safe, shared with middleware (no next/headers)
    │   │   ├── session.ts     # persistAuthTokens / clearAuthTokens — client token-cookie writers (shared by auth hooks + fetch refresh)
    │   │   └── server.ts      # getServerAuthState — access-token cookie → AuthState for AuthProvider
    │   ├── query/
    │   │   ├── queryClient.ts # makeQueryClient factory + getQueryClient() SSR-safe singleton
    │   │   └── QueryProvider.tsx  # 'use client' — QueryClientProvider + ReactQueryDevtools
    │   └── cookies/           # Cookie manager — strict server/client separation
    │       ├── constants.ts   # COOKIE_NAMES, CookieOptions, AUTH_*_COOKIE_OPTIONS
    │       ├── server.ts      # getServerCookie, getThemeMode, setServerCookie
    │       ├── client.ts      # getClientCookie, setClientCookie, deleteClientCookie
    │       └── index.ts       # Re-exports constants ONLY (never server/client)
    ├── services/          # Domain services — no top-level barrel; import directly from the file
    │   ├── auth/          # Phone-OTP auth: requestOtp/verifyOtp/refresh/logout/me/selectRole + role router (routing.ts) + useSessionRoleSync
    │   ├── patients/      # Care-recipient CRUD (b3 PatientDto + client-augmented relation/conditions), soft-archive; age.ts helper
    │   ├── profiles/      # Customer + nurse profile get/upsert + avatar (behind the ProfilesApi seam)
    │   ├── nurse/         # Nurse payout bank accounts + IBAN(Sheba) util (iban.ts) + ownership-inquiry states
    │   ├── geography/     # F3 cached province→city→district reference lookups (Infinity staleTime, shared geographyKeys; reused by addresses, coverage & later search)
    │   ├── addresses/     # F3 customer address book CRUD + set-primary (single-primary invariant; invalidate-on-mutation)
    │   ├── serviceAreas/  # F3 nurse coverage areas add/remove (areaExists dup-guard; districtId=null = whole city)
    │   ├── catalog/       # F4 catalog skeleton + nurse pricing variants (b5). Reference data (categories, category option groups) cached session-long like geography (Infinity staleTime); myVariants invalidated on mutation. useServiceCategories/useCategoryOptionGroups/useMyVariants/useCreateVariant/useUpdateVariant/useSetVariantActive; seam+mock+client; names.ts locale-label helper
    │   ├── search/        # F6 family discovery (b7). The **filter object IS the query key** (searchKeys.results + canonicalizeSearchFilters): identical/reverted filters reuse cache with zero network (keepPreviousData avoids flashing). useNurseSearch/useNurseProfile/useDebouncedValue; filterParams.ts = the shared C1↔C2 URL (de)serializer; seam+mock(PRIMARY)+client. Mock supplies name/avatar/distance/profile/reviews that b7's index row + b5/b6 reads don't yet expose (gap filed in for-backend.md). Every returned row is verified-by-invariant — the UI never re-filters
    │   ├── verification/  # F5 nurse trust flow (b6). ONE cached status() query drives B3+B6; every mutation invalidates it. useVerificationStatus/useStartVerification/useSubmitIdentity/useRunBankVerification/useUploadVerificationDocument/useSubmitCredentials/useNurseTrustBadge; seam+mock(primary)+client; validation.ts (national-ID checksum); types export ownBadgeState/publicBadgeState/isApproved
    │   ├── bookingRequests/ # F7 pre-payment request lifecycle (b8). Money-free create→accept/reject/cancel + role-scoped inbox + single get. useCreateBookingRequest/useBookingRequest(polls until terminal)/useNurseRequestInbox/useCustomerRequests/useAccept/useReject/useCancel; seam+mock(PRIMARY, shared in-memory state machine — customer create ↔ nurse inbox ↔ accept flips C5; lazy expiry sweep)+client. Server-frozen UTC deadlines rendered by CountdownTimer (never recomputed); two-stage disclosure (nurse `get(id,'nurse')` masks address); variantPrice client-augmented (REQ-013). Contract-live but mock-primary because inputs (search/patients/addresses) are mock-primary
    │   ├── bookings/        # F8 post-payment engagement (b9) — the SIBLING of bookingRequests, NOT a rename. useBookingDetail/useBookingSessions(select over detail — sessions are embedded)/useBookingList/useTodaySessions/useSessionEvv/useCareInstructions(enabled-gated)/useCheckInVisit/useCheckOutVisit; seam+mock(PRIMARY, seeded confirmed bookings + sessions + care + EVV state machine)+client(1:1 b9)+serverApi(RSC-prefetch seam, real-path). evv/locationProvider.ts = the ILocationProvider GPS seam (real navigator.geolocation vs mock coords by NEXT_PUBLIC_EVV_MOCK_GPS in_range|out_of_range|denied). Money display-only (gross=commission+payout server-side); timeline=server truth; care read gated to assigned nurse; EVV mismatch/denial advisory (never blocks); EVV mutations invalidate detail+session+today+list
    │   ├── payment/         # F9 checkout & card capture (b10) + customer invoice read (b11). useCheckoutSummary/useInitiatePayment(caller owns the per-ATTEMPT Idempotency-Key)/useConfirmGatewayReturn/usePaymentOutcome(backoff poll, stops on terminal + bounded attempts)/useInvoice(immutable, long staleTime, 404=not-issued not error); invalidations.ts = the one post-capture cache transition (request detail/lists + bookings lists/detail + summary/outcome — never a blanket refetch); seam+mock(PRIMARY — the conversion trigger bridging the f7↔f8 mock stores: capture converts the request, inserts a confirmed booking, issues the b11-shaped invoice)+client (initiate/invoice = real b10/b11 contract; summary = REQ-016 proposed route; outcome = mapped booking_requests/get, REQ-017). Money = served IRR digit-strings; rows reconcile by construction; a 409 on the money path is benign convergence, never a toast
    │   ├── refunds/         # F10 customer cancellation + refund status (b11). resolveCancellationPolicy/cancelBooking/getRefundByBooking/getRefund. useCancellationPolicyPreview/useCancelBooking/useRefundStatus(polls only while non-terminal); invalidations.ts primes the fresh refund + invalidates booking detail/lists on cancel; seam+mock(PRIMARY — reads the f8 bookings store to resolve tier+per-session refundability, flips the booking cancelled, drives card-immediate/BNPL-processing refunds)+client. Contract is admin-only (REQ-019/020/021 fill the customer cancel command, policy preview, refund-by-booking + decomposition). Money = IRR digit-strings, BigInt; refund %+fee disclosed before confirm; refunds never self-issued
    │   └── {domain}/
    │       ├── types.ts       # Request/response types + the domain's Api interface (the seam)
    │       ├── keys.ts        # React Query key factory (hierarchical)
    │       ├── constants.ts   # Mock toggle + staleTime (when the domain has a mock)
    │       ├── apis/
    │       │   ├── clientApi.ts   # Real impl wrapping clientFetch (unwraps ApiEnvelope via unwrap())
    │       │   ├── mockApi.ts     # In-memory impl behind the same interface (until the endpoint lands)
    │       │   ├── serverApi.ts   # serverFetch calls (only when an RSC needs it)
    │       │   └── index.ts       # Selects real vs mock by config — the seam hooks import
    │       └── hooks/
    │           └── use{Action}.ts # One hook per file — useQuery (deliberate staleTime) or useMutation (invalidates)
    ├── context/           # React context providers
    │   └── auth/          # AuthContext — AuthProvider (server-seeded) + reducer + useAuth
    ├── theme/
    │   ├── ThemeProvider.tsx      # MuiThemeProvider wrapper (RTL cache) + ColorSchemeCookieSync
    │   ├── colors.ts              # BRAND, LIGHT_PALETTE, DARK_PALETTE
    │   ├── light.ts / dark.ts     # LIGHT_THEME / DARK_THEME ThemeOptions (consumed by theme.ts)
    │   ├── direction.ts           # getDirection(locale) → 'ltr' | 'rtl'
    │   ├── theme.ts               # APP_THEME_LTR / APP_THEME_RTL (static, created once)
    │   ├── tokens.css             # CSS custom properties — [data-mui-color-scheme] selectors
    │   ├── typography.ts          # TYPOGRAPHY_LTR (Space Grotesk) / TYPOGRAPHY_RTL (Mikhak)
    │   └── index.ts               # Public re-exports (ThemeProvider, getDirection, APP_THEME_*) — note: no ColorSchemeScript is exported/rendered today (doc drift below)
    ├── constants/         # App-wide constants (routes.ts w/ actor paths, roles.ts, headers.ts)
    ├── hooks/             # incl. auth.ts → useIsAuthenticated / useActorRole (role-aware chrome)
    ├── utils/             # incl. money.ts (IRR/Toman, integer-safe) + date.ts (Shamsi display) + toEnglishDigits
    └── config.ts

Server / Client Component Boundaries

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

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

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

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

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

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


i18n (next-intl v4)

Adding translations:

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

Using translations in client components:

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 five price_unit labels + count nouns + the estimated-total label (read by PriceDisplay; f6 reuses it customer-side)
  • 'services' — the f4 nurse Services & prices surface (offerings list, the variant builder steps/fields/validation, the duplicate-listing warning, deactivate confirm)
  • 'search' — the f6 discovery flow (C1/C2/C3): filter section labels, the same-gender facet + hint, sort/count (ICU plural), all four result states + "relax filters" suggestions, card labels (rating/distance/from-price), profile badges (تاییدشده/نظام پرستاری)/attribute chips/specialty codes/services/latest review, and the "درخواست رزرو" CTA
  • 'booking' — the f7 booking-request flow (C4 form fields/validation, C5 tracker steps + dual-countdown + terminal-state copy, the nurse inbox + detail, gender labels, per-status labels, summary-card captions) and the f8 post-payment engagement (booking-status timeline labels bstatus_*, session-status labels sstatus_*, the EVV banner variants evv_banner_{in_range,out_of_range,no_gps} + check-in/out CTAs + GPS-acquiring copy, the care-instructions section labels care_* + the customer "visible to your nurse only" copy, the money summary money_*, the dispute-window note, the bookings list list_*); consumed by the C4/C5 pages, the nurse requests pages, the f8 booking-detail/EVV pages, and the shared BookingRequestSummaryCard + booking/ composites
  • 'verification' — the f5 nurse trust flow: B3/B4/B5/B6 copy, per-step labels + status labels (keyed off code, never derived), the DocumentUpload state chrome, TrustBadge labels, the honesty-sensitive manual-vs-auto copy, the publish-gate + shared-SIM/mismatch messages
  • 'payment' — the f9 checkout & invoice surface: C6 labels (breakdown rows هزینه خدمت/کارمزد بالین‌یار/مالیات/مبلغ کل, the verbatim escrow copy escrow_notice, «ادامه پرداخت ←», the BNPL seam), the card-flow states (initiating/redirecting/pending/failed/expired/already-paid), the confirmation + invoice screens (VAT-on-commission line, مودیان moadian_* states), pstatus_* transaction-status labels, and the dev mock-gateway harness copy; consumed by the checkout pages, the invoice page, EscrowNotice, and PaymentStatusBadge
  • 'refunds' — the f10 customer cancellation + refund-status surface: policy-tier labels keyed off cancellation_policy_code (policy_*), the lead-time + refund %/fee % disclosure, the refund-vs-fee breakdown rows, the multi-session refundable/locked reasons (reason_*), the admin-approval explainer, the three refund-status step + chip labels (step_*/rstatus_*), the per-channel ETA copy (eta_*bnpl_revert 710-business-day window / psp_card / manual), and the failed/contact-support copy; consumed by the cancel + refund-status pages and CancellationPolicyDisclosure/RefundStatusCard/RefundEtaBanner
  • 'auth' — the phone-OTP login flow, role router, and SelectRole screen (common.brand/brand_tagline for the wordmark)

Namespace conventions for the phases to come (seed each when its feature lands, in both locale files): onboarding, verification, search, booking, payment, bnpl, reviews, notifications, admin. Keep top-level keys as namespaces and both files in sync.

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


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

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

Rules:

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

Constants

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

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

Where to define:

  • Cookie names / options: src/lib/cookies/constants.ts
  • Feature-scope constants: co-locate in a constants.ts next to that feature's files
  • App-wide constants (used across multiple features): src/constants/ — one file per concern (routes.ts, events.ts, etc.)

Rules:

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

Theme System

How it works (end-to-end, no-flash)

  1. Request arrivesgetThemeMode() reads 'color-scheme' cookie → returns { colorScheme, defaultMode }
  2. Root layout sets data-mui-color-scheme={colorScheme} on <html> server-side
  3. <ColorSchemeScript /> in <head> runs before any paint:
    • Reads the same cookie, sets data-mui-color-scheme (handles edge cases where server attr might differ)
    • Patches Storage.prototype — routes MUI's localStorage writes for key 'mode' to our cookie; reads return null so MUI always trusts the defaultMode prop
  4. <MuiThemeProvider defaultMode={defaultMode}> mounts — uses the server-derived mode, not localStorage
  5. ColorSchemeCookieSync in ThemeProvider writes the cookie via useColorScheme().colorScheme on mount (safety net for first-visit system mode)

Critical MUI v9 rules

colorSchemeSelector must be the explicit attribute name:

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

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

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

Never use MUI's InitColorSchemeScript: It reads from localStorage which diverges from our cookie (especially in 'system' mode). Use ColorSchemeScript from @/theme instead.

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

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

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

Color tokens

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

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

Pre-built theme objects

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

Toggle components

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

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

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


Direction (RTL / LTR)

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

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

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

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

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


Fonts

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

Locale Font CSS variable Source Loaded when
fa (RTL) Mikhak --font-mikhak next/font/local — woff2 files in src/app/fonts/ only on fa routes
en (LTR) Space Grotesk --font-space-grotesk (not currently wired — falls back to the system stack)

Typography exports:

  • TYPOGRAPHY_LTR — Space Grotesk headings, system font body (used by APP_THEME_LTR)
  • TYPOGRAPHY_RTL — Mikhak for all text including body (used by APP_THEME_RTL, ensures full Persian glyph coverage)
  • TYPOGRAPHY — alias for TYPOGRAPHY_LTR (deprecated, prefer the explicit exports)

Rules:

  • Mikhak is declared with preload: false, and its .variable class is attached to <html> only when locale === 'fa'. Both are required: a next/font loader called in the root layout would otherwise preload on every route (including /en), and preload: false ensures the woff2 only downloads when Persian text actually renders.
  • Font files live in src/app/fonts/ (not public/). next/font/local resolves paths relative to the calling file (src/app/[locale]/layout.tsx) at build time.
  • Never load fonts inside components — all font loading lives in src/app/[locale]/layout.tsx.
  • To add a new font, add woff2 files to src/app/fonts/, declare via localFont/localFont-equivalent in src/app/[locale]/layout.tsx, attach its .variable class conditionally on the matching locale, and update BRAND_FONT_VARIABLE_* constants in typography.ts.

Unit Testing

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

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

Coverage baseline for shared components:

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

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

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


Comments & dead code

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

Anti-patterns (do not do these)

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

API Fetch Services

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

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

Error contract — clientFetch:

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

Error contract — serverFetch:

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

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

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

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

  • Caching is deliberate: set a staleTime on reads so revisiting a screen doesn't refetch; mutations invalidate the affected list key (queryClient.invalidateQueries) or setQueryData — never leave the cache stale. See services/patients/hooks/*.
  • Reference data is cached for the whole session: rarely-changing lookups (the geo province→city→district hierarchy) use an Infinite staleTime + a shared, hierarchical key factory (geographyKeys) so each level is fetched once and served from cache across every consumer (the address form, the coverage editor, and later search) — never refetched on a dropdown open. Contrast with mutable lists (addresses, coverage areas) which invalidate on every mutation. See services/geography/*. Reuse this pattern for future reference data; do not reinvent per-consumer fetching. services/catalog (f4) is the second long-lived cached reference domain: admin-seeded categories + a category's option groups/values use the same Infinite staleTime/gcTime (CATALOG_REFERENCE_*) so the Home grid and every builder step read them from cache; the nurse's own variant list is the mutable side — mutations invalidate catalogKeys.myVariantsLists().
  • Mock behind a seam: when the backend endpoint isn't live, implement the domain's Api interface twice — a real clientApi.ts and an in-memory mockApi.ts — and select in apis/index.ts by a config flag (USE_{DOMAIN}_MOCK). Hooks import the selected api; the swap is one line. Record every mock in dev/shared-working-context/reports/mocks-registry.md.
  • The wire envelope: the server wraps responses in ApiEnvelope<T> ({ isSuccess, statusCode, message, requestId, data }, camelCase — see lib/api/types.ts). clientFetch returns the raw body, so a real clientApi reads the payload via unwrap(). Types are derived from dev/contracts/ + dev/contracts/openapi/swagger.v1.json, mirroring the wire exactly.
  • Money & dates: format via @/utilsformatIrrToToman/formatIrr/parseIrr (IRR strings, integer-safe BigInt) and formatShamsiDate/formatShamsiDateTime (UTC ISO → Persian calendar). Money is never a float.

Auth Cookies & session state

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

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

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

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

Lifecycle:

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

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

Middleware (middleware.ts) gates private routes with the same isTokenAlive helper before render.

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

  • Tokens are non-httpOnly cookies (JS-readable) so clientFetch can attach the bearer header — this trades XSS-hardening for the bearer pattern. Real hardening (httpOnly cookies set by the server + a same-origin proxy) spans the server.
  • The middleware check is UX-only, not a security boundary: it decodes the JWT and checks exp but does not verify the signature. The API is the only authority; never gate real authorization on the middleware or isTokenAlive.
  • Role gating is coarse: the shells pick chrome from currentUser.roles, but cross-actor route access isn't hard-guarded client-side yet (the server authorizes each call). Add route guards when a phase needs them.
  • Refresh-token rotation is wired client-side (fetch-layer silent refresh + useRefresh), matching the server's rotation + reuse-detection. The refresh_token cookie TTL (7d) is shorter than the server session default (30d) — a follow-up can align the cookie maxAge to refreshExpiresAt.

Toast Notifications (notistack)

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

In React components/hooks — use notistack directly:

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>

Route Constants

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

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

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

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


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

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

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

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