# Balinyaar Client — Claude Code Guidelines The web frontend of **Balinyaar**, a trust-first home-nursing marketplace in Iran. This file is the **engineering contract** for everything under `client/`: providers, routing, data fetching, theming, i18n, cookies, and the rules every change must follow. - Repo-wide context and the backend → root [CLAUDE.md](../CLAUDE.md). - Product/domain rules (what to build) → [`product/`](../product/) — read the relevant doc before designing a feature; don't infer business rules from code. - Visual/design work (brand palette, tokens, component look-and-feel) → the **frontend-designer** skill. It is the *design* contract and defers to this file for *engineering* rules. Don't restate this file there. ## Stack - **Next.js 16** — App Router, Turbopack, React Server Components. **Not a static export** — the app relies on server components, middleware, and server-side cookies. (`next.config.mjs` only wires the next-intl plugin + `reactStrictMode`.) - **React 19** + **TypeScript** (`strict`). - **MUI v9** (`@mui/material`) for components and theming; **Emotion** underneath (RTL via `stylis-plugin-rtl`). - **next-intl v4** for i18n — locales `fa` (default, RTL) and `en`. - **TanStack Query v5** for server state; a small **AuthContext** (React context + reducer, `src/context/auth/`, seeded with server-read auth state) for auth/session state. - **notistack** for toasts; **js-cookie** (wrapped) for client cookies. - **Jest** + **Testing Library** for unit tests. - Quality gates: **tsc**, **ESLint 9** (flat config), **Prettier**. ## Commands | Task | Command | | --- | --- | | Dev server | `npm run dev` | | Production build | `npm run build` | | Type-check | `npm run type` | | Lint | `npm run lint` | | Lint + autofix | `npm run lint:fix` | | **Type + lint (the gate)** | `npm run check` | | Format (Prettier) | `npm run format` | | Test (watch) | `npm test` | | Test (CI, once) | `npm run test:ci` | **Always run `npm run check` before declaring work done.** Run `npm run test:ci` as well when you touch a component that has a co-located `*.test.tsx`. ## Quality gates: lint & type (how they work) Both gates are plain CLI tools. **There is no `next lint`** — it was removed in Next 16; calling it silently does nothing. - `npm run type` → `tsc --noEmit`. Config in `tsconfig.json`: `strict` on, `noEmit`, `@/*` → `src/*`. - `npm run lint` → `eslint .` driven by **flat config** in `eslint.config.mjs`. That config spreads `eslint-config-next` (core-web-vitals + typescript + react + react-hooks + jsx-a11y + import) and applies `eslint-config-prettier` last so ESLint never fights Prettier on formatting. - `npm run check` runs type then lint. Keep it green. Rules for this project: - **This project is flat-config only.** Do not add `.eslintrc*` files — put any rule changes in `eslint.config.mjs`. - **ESLint owns correctness, Prettier owns formatting.** Don't add stylistic ESLint rules. - **No unused variables or imports.** `@typescript-eslint/no-unused-vars` is raised from eslint-config-next's default `warn` to **`error`** (in `eslint.config.mjs`), so dead code fails `npm run check`. Delete unused code rather than disabling the rule; prefix a deliberately-unused binding with `_` (e.g. `_event`, `catch (_err)`) to opt out. - **Prefer fixing code over silencing the linter.** When a disable is genuinely correct — e.g. a deliberate browser-only read after mount that trips `react-hooks/set-state-in-effect` — use a scoped `// eslint-disable-next-line ` 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 ``/``). 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 + 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 │ │ │ │ └── request/page.tsx # /bookings/request — f6→f7 booking handoff target (DEFERRED→f7 stub; echoes carried nurse/variant/required_gender intent) │ │ │ ├── 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) │ │ │ ├── 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/page.tsx # /nurse/visits (EVV) │ │ └── 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) │ ├── 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 — throws ApiError on error; use in hooks/client components; silent-refreshes + retries once on 401 │ │ ├── server.ts # serverFetch — throws ApiError on error; use in RSCs/Server Actions │ │ ├── types.ts # ApiEnvelope + unwrap(), Paginated, 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 │ └── {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 `` and ``. 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 `` (`dir` from `getDirection(locale)`) plus `data-mui-color-scheme` from `getThemeMode()`. - Loads the Mikhak font and attaches its CSS-variable class to `` **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 `` 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 `` reliably tracks the active locale. **Route-group layouts** (`(private-routes)/layout.tsx`, `(public-routes)/layout.tsx`) are `'use client'` — they only wrap a layout component and need no server capabilities. **Never** import from `next/headers`, `next-intl/server`, or `@/lib/cookies/server` in a client component. The build will fail. --- ## i18n (next-intl v4) **Adding translations:** 1. Add the key to `messages/en.json` AND `messages/fa.json`. Both files must always be in sync. 2. Top-level keys are namespaces: `"nav"`, `"common"`, etc. **Using translations in client components:** ```tsx import { useTranslations } from 'next-intl'; function MyComponent() { const t = useTranslations('nav'); // namespace return {t('home')}; // key } ``` **Using translations in Server Components:** ```tsx import { getTranslations } from 'next-intl/server'; async function MyServerComponent() { const t = await getTranslations('nav'); return {t('home')}; } ``` **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 f6→f7 booking-request handoff placeholder (title + "arrives next phase" + carried nurse/variant/gender echo); f7 fills it out - `'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 - `'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. --- ## Cookie Manager The cookie manager in `src/lib/cookies/` is split into three files to prevent cross-environment bundling: | File | Use from | Purpose | |------|----------|---------| | `constants.ts` | anywhere | `COOKIE_NAMES`, `CookieOptions`, `COLOR_SCHEME_COOKIE_OPTIONS` | | `server.ts` | Server Components, Server Actions, Route Handlers only | `getServerCookie`, `getThemeMode`, `setServerCookie` | | `client.ts` | client components / `useEffect` only | `getClientCookie`, `setClientCookie`, `deleteClientCookie` | | `index.ts` | anywhere | Re-exports `constants.ts` only — safe barrel | **Rules:** - Import constants via the barrel: `import { COOKIE_NAMES } from '@/lib/cookies'` - Import server utils directly: `import { getThemeMode } from '@/lib/cookies/server'` - Import client utils directly: `import { setClientCookie } from '@/lib/cookies/client'` - Never import `server.ts` in a client component; never import `client.ts` in an RSC. - `COOKIE_NAMES.COLOR_SCHEME = 'color-scheme'` — the single source of truth for the theme cookie name. Do not redeclare it anywhere. --- ## Constants **Rule: every magic string or configurable value must be a named constant — never inline.** A value is "magic" if its meaning isn't obvious from the literal alone: cookie names, event names, localStorage keys, route paths, query-param names, numeric timeouts, API endpoint slugs. Where to define: - **Cookie names / options**: `src/lib/cookies/constants.ts` - **Feature-scope constants**: co-locate in a `constants.ts` next to that feature's files - **App-wide constants** (used across multiple features): `src/constants/` — one file per concern (`routes.ts`, `events.ts`, etc.) Rules: 1. Import the constant; never copy-paste the string value. 2. When renaming, update the constant definition — the rest of the codebase follows automatically. --- ## Theme System ### How it works (end-to-end, no-flash) 1. **Request arrives** → `getThemeMode()` reads `'color-scheme'` cookie → returns `{ colorScheme, defaultMode }` 2. **Root layout** sets `data-mui-color-scheme={colorScheme}` on `` server-side 3. **``** in `` 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. **``** mounts — uses the server-derived mode, not localStorage 5. **`ColorSchemeCookieSync`** in ThemeProvider writes the cookie via `useColorScheme().colorScheme` on mount (safety net for first-visit system mode) ### Critical MUI v9 rules **`colorSchemeSelector` must be the explicit attribute name:** ```ts // theme.ts cssVariables: { colorSchemeSelector: 'data-mui-color-scheme', // CORRECT // colorSchemeSelector: 'data', // WRONG — produces boolean data-dark/data-light }, ``` The shorthand `'data'` in MUI v9 generates `[data-%s]` → `data-dark=""` / `data-light=""` (boolean attributes). Our `tokens.css` uses `[data-mui-color-scheme="dark"]` which never matches boolean attributes. Always use the explicit attribute name. **Never use `storageWindow={null}`:** In MUI v9's `localStorageManager`, the check is `if (!storageWindow && typeof window !== 'undefined')` — `null` is falsy, so it silently overrides to `window`. This prop is a no-op in browsers. The `Storage.prototype` patch in `ColorSchemeScript` is the correct intercept. **Never use MUI's `InitColorSchemeScript`:** It reads from localStorage which diverges from our cookie (especially in 'system' mode). Use `ColorSchemeScript` from `@/theme` instead. **MUI v9 localStorage key defaults (different from v5/v6):** - Mode key: `'mode'` (was `'mui-mode'`) - Color scheme key: `'color-scheme'` (was `'mui-color-scheme'`) - HTML attribute: `'data-color-scheme'` (was `'data-mui-color-scheme'`) We override all of these via `colorSchemeSelector: 'data-mui-color-scheme'` in the theme and the Storage.prototype patch. ### Color tokens All theme-aware colors live in `src/theme/tokens.css` under `[data-mui-color-scheme]` selectors. Do not add color values to inline `sx` props or component styles — add a CSS variable to `tokens.css` and reference it via `var(--my-token)`. This includes feedback colors: `--bal-success`, `--bal-error`, `--bal-warning`, `--bal-info` (each with a `*-contrast` text token). These drive the toast variants (see Toast Notifications) and are the place to source any success/error/warning/info color — the MUI palette does **not** define semantic colors, so prefer these tokens over MUI's defaults for brand consistency. ### Pre-built theme objects `APP_THEME_LTR` and `APP_THEME_RTL` are created once at module load. Never call `createTheme()` inside a component or hook — pass the appropriate pre-built theme to `MuiThemeProvider`. ### Toggle components `DarkModeToggleButton` and `DarkModeFormSwitch` in `src/layout/components/DarkModeButton.tsx` are the **only** components that subscribe to `useColorScheme()`. When the user toggles: 1. `setMode('dark')` is called 2. `Storage.prototype.setItem` intercept fires → writes `'color-scheme'='dark'` cookie synchronously 3. MUI sets `data-mui-color-scheme="dark"` on `` 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 `` 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 `` 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 `` **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 `` 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 `` 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 ``, messages, providers, and fonts placed there freeze on 'fa'/'rtl' for `/en`. `src/app/[locale]/layout.tsx` is the root layout (it renders ``/``) 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` — throws `ApiError` on error | | `server.ts` | RSCs, Server Actions only | `serverFetch` — 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` (`{ isSuccess, statusCode, message, requestId, data }`, camelCase — see `lib/api/types.ts`). `clientFetch` returns the raw body, so a real `clientApi` reads the payload via `unwrap()`. Types are derived from `dev/contracts/` + `dev/contracts/openapi/swagger.v1.json`, mirroring the wire exactly. - **Money & dates:** format via `@/utils` — `formatIrrToToman`/`formatIrr`/`parseIrr` (IRR strings, integer-safe BigInt) and `formatShamsiDate`/`formatShamsiDateTime` (UTC ISO → Persian calendar). Money is never a float. --- ## Auth Cookies & session state | Cookie | Constant | TTL | Set by | |--------|----------|-----|--------| | `access_token` | `COOKIE_NAMES.ACCESS_TOKEN` | 15 min | `persistAuthTokens` (`src/lib/auth/session.ts`) — via `useVerifyOtp`, `useRefresh`, `useSelectRole`, and the fetch-layer silent refresh | | `refresh_token` | `COOKIE_NAMES.REFRESH_TOKEN` | 7 days | same as above | **The credential is phone-OTP** — there is no username/password anywhere; email is never a login key. The login flow lives in `src/components/auth/` (`LoginFlow` → `PhoneStep`/`OtpStep`) at `/login`, over the `services/auth` domain (`requestOtp`/`verifyOtp`/`refresh`/`logout`/`getMe`/`selectRole`). **Role router:** after a successful verify, `RoleRouter` (`src/components/auth/`) reads `/me` and navigates — customer→family app, nurse→nurse app, empty roles→`/select-role`, admin→admin console — showing the branded splash while `/me` loads so the wrong shell never flashes. The routing decision is the **pure** `resolveRoleDestination(me, intendedRole)` in `src/services/auth/routing.ts` (unit-tested). The middleware still owns the auth gate; the router only decides *which app*. **Session state lives in `AuthContext`** (`src/context/auth/`), now carrying `SessionUser { id?, phone, roles: AppRole[] }`. The root layout resolves the session on the server with `getServerAuthState()` (`src/lib/auth/server.ts`) — which reads the `access_token` cookie and checks the JWT `exp` via the shared `isTokenAlive` (`src/lib/auth/token.ts`) — and passes it to ``, 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) `` wraps all children inside `ThemeProvider` in `src/app/[locale]/layout.tsx`. **In React components/hooks** — use notistack directly: ```tsx import { useSnackbar } from 'notistack' const { enqueueSnackbar } = useSnackbar() enqueueSnackbar('Saved!', { variant: 'success' }) ``` **Outside React** (plain functions, fetch services) — use the event bridge: ```ts import { dispatchToast } from '@/lib/toast' dispatchToast('Something went wrong', 'error') ``` `dispatchToast` fires a `window` CustomEvent (`app:toast`). `ToastBridge` (a zero-UI `'use client'` component inside `SnackbarProvider`) listens and calls `enqueueSnackbar`. `ToastBridge` is already rendered in `[locale]/layout.tsx` — do not add another instance. **Toast colors follow the theme.** `NotistackProvider` maps every notistack variant to a `styled(MaterialDesignContent)` whose `backgroundColor`/`color` come from the `--bal-{success,error,warning,info}` (+ `*-contrast`) tokens in `tokens.css`. Because those tokens are defined on ``, 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 ``, so it inherits `dir` from `` (set per-locale in the root layout). Do **not** pass a `dir` prop to `SnackbarProvider` — it is not a valid prop (TS error) and is unnecessary: ```tsx {children} ``` --- ## Route Constants Named path constants live in `src/constants/routes.ts`: ```ts ROUTES.LOGIN = '/login' ROUTES.HOME = '/' PUBLIC_PATHS = [ROUTES.LOGIN, ...] // paths that bypass middleware auth check ``` Import from the barrel: `import { ROUTES, PUBLIC_PATHS } from '@/constants'`. To add a new public route, append it to `PUBLIC_PATHS` — the middleware picks it up automatically. --- ## Client Cookie Manager (js-cookie) `src/lib/cookies/client.ts` uses `js-cookie` internally. The exported API is unchanged: | Function | Purpose | |----------|---------| | `getClientCookie(name)` | Read a cookie by name | | `setClientCookie(name, value, options?)` | Write a cookie; `options` is `CookieOptions` with `maxAge` in **seconds** | | `deleteClientCookie(name, path?)` | Delete a cookie | | `getColorSchemeCookie()` | Typed helper for the theme cookie | `CookieOptions` type is defined in `src/lib/cookies/constants.ts` — `maxAge` is in seconds (converted to `expires: Date` internally when calling js-cookie). - **Do not** `document.title = title` in the render body of any component — it causes `ReferenceError: document is not defined` during build-time prerendering.