50 KiB
Balinyaar Client — Claude Code Guidelines
The web frontend of Balinyaar, a trust-first home-nursing marketplace in Iran. This file is the
engineering contract for everything under client/: providers, routing, data fetching, theming,
i18n, cookies, and the rules every change must follow.
- Repo-wide context and the backend → root CLAUDE.md.
- Product/domain rules (what to build) →
product/— read the relevant doc before designing a feature; don't infer business rules from code. - Visual/design work (brand palette, tokens, component look-and-feel) → the frontend-designer skill. It is the design contract and defers to this file for engineering rules. Don't restate this file there.
Stack
- Next.js 16 — App Router, Turbopack, React Server Components. Not a static export — the app
relies on server components, middleware, and server-side cookies. (
next.config.mjsonly wires the next-intl plugin +reactStrictMode.) - React 19 + TypeScript (
strict). - MUI v9 (
@mui/material) for components and theming; Emotion underneath (RTL viastylis-plugin-rtl). - next-intl v4 for i18n — locales
fa(default, RTL) anden. - TanStack Query v5 for server state; a small AuthContext (React context + reducer,
src/context/auth/, seeded with server-read auth state) for auth/session state. - notistack for toasts; js-cookie (wrapped) for client cookies.
- Jest + Testing Library for unit tests.
- Quality gates: tsc, ESLint 9 (flat config), Prettier.
Commands
| Task | Command |
|---|---|
| Dev server | npm run dev |
| Production build | npm run build |
| Type-check | npm run type |
| Lint | npm run lint |
| Lint + autofix | npm run lint:fix |
| Type + lint (the gate) | npm run check |
| Format (Prettier) | npm run format |
| Test (watch) | npm test |
| Test (CI, once) | npm run test:ci |
Always run npm run check before declaring work done. Run npm run test:ci as well when you
touch a component that has a co-located *.test.tsx.
Quality gates: lint & type (how they work)
Both gates are plain CLI tools. There is no next lint — it was removed in Next 16; calling it
silently does nothing.
npm run type→tsc --noEmit. Config intsconfig.json:stricton,noEmit,@/*→src/*.npm run lint→eslint .driven by flat config ineslint.config.mjs. That config spreadseslint-config-next(core-web-vitals + typescript + react + react-hooks + jsx-a11y + import) and applieseslint-config-prettierlast so ESLint never fights Prettier on formatting.npm run checkruns type then lint. Keep it green.
Rules for this project:
- This project is flat-config only. Do not add
.eslintrc*files — put any rule changes ineslint.config.mjs. - ESLint owns correctness, Prettier owns formatting. Don't add stylistic ESLint rules.
- No unused variables or imports.
@typescript-eslint/no-unused-varsis raised from eslint-config-next's defaultwarntoerror(ineslint.config.mjs), so dead code failsnpm run check. Delete unused code rather than disabling the rule; prefix a deliberately-unused binding with_(e.g._event,catch (_err)) to opt out. - Prefer fixing code over silencing the linter. When a disable is genuinely correct — e.g. a
deliberate browser-only read after mount that trips
react-hooks/set-state-in-effect— use a scoped// eslint-disable-next-line <rule>with a one-line reason, never a file-wide disable. - Pin to ESLint 9. ESLint 10 currently crashes with this Next 16 toolchain
(
scopeManager.addGlobals is not a function).import/no-cycleis also disabled — its TS resolver has an interface mismatch here (see the note ineslint.config.mjs).
Golden rules (the short list)
A change is "done" only if it respects all of these — each has a full section below.
- Never add a layout above
[locale].src/app/[locale]/layout.tsxis the root layout (it renders<html>/<body>). A layout above it freezeslang/dir/messages on the default locale. - Respect the server/client boundary. Never import
next/headers,next-intl/server, or@/lib/cookies/serverfrom a client component; never import@/lib/cookies/clientfrom an RSC. - No hard-coded UI strings. Every user-visible string is a key in both
messages/en.jsonandmessages/fa.json. - Fetch only through
clientFetch/serverFetch(@/lib/api) — never rawfetch(). Domain calls live insrc/services/{domain}/apis/. - Cookies only through the cookie manager (
@/lib/cookies/*) — neverdocument.cookie,js-cookie,localStorage, orsessionStoragefor app/auth state. - Colors come from
tokens.css(var(--…)), never hard-coded insx. Use the pre-builtAPP_THEME_LTR/APP_THEME_RTL; never callcreateTheme()in a component. - MUI v9 API only. Use
sx={{ mb: 4 }}, notmb={4}as a direct prop. No MUI-v5/v6-only props (useFlexGap,flexWraponStack,storageWindow,InitColorSchemeScript, …). - Shared components get a co-located
*.test.tsx. (A component imported from >1 place.) - Magic strings become named constants (
src/constants/or a co-locatedconstants.ts). npm run checkis green and translations stay in sync before you finish.- No dead code; comment the why, not the what. Unused vars/imports are lint errors — remove them. Don't add comments that restate the code; comment only a non-obvious decision, constraint, or trade-off. See Comments & dead code below.
Project Structure
This section is the canonical description of the client's architecture. When a change adds, removes,
or renames a route group, provider, or top-level src/ folder, update this tree in the same change
(root CLAUDE.md working agreement #7).
client/
├── messages/ # Translation files (add keys to BOTH files)
│ ├── en.json
│ └── fa.json
├── middleware.ts # next-intl routing middleware (locale detection + redirect)
├── next.config.mjs # createNextIntlPlugin wires i18n into Next.js
└── src/
├── app/
│ ├── globals.css
│ ├── fonts/ # Local font files (woff2) — Mikhak for fa
│ └── [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/page.tsx # /search — DEFERRED→f6 stub (PlaceholderScreen; Home search bar + category tiles land here carrying q/category_id)
│ │ │ ├── onboarding/page.tsx # /onboarding — A3→A4 wizard (relation → first patient)
│ │ │ ├── bookings/page.tsx # /bookings
│ │ │ ├── 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)
│ ├── 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
│ ├── 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 <html> and <body>. This is intentional and load-bearing (see below); do not re-introduce a layout above the [locale] segment.
Root / locale layout (src/app/[locale]/layout.tsx) is an RSC that owns the document shell, all i18n, and theme context. It:
- Sources the locale from the URL param (
params.locale), validated againstrouting.locales(falls back todefaultLocale). No header reads. - Renders
<html lang dir>(dirfromgetDirection(locale)) plusdata-mui-color-schemefromgetThemeMode(). - Loads the Mikhak font and attaches its CSS-variable class to
<html>only forfa(see Fonts). - Calls
setRequestLocale(locale)so server components deeper in the tree can callgetLocale()/getTranslations()reliably. - Calls
getMessages({ locale })with the locale passed explicitly sogetRequestConfigreceives it viaPromise.resolve(locale)(not through the React.cache read), avoiding any cache-ordering race. - Wraps children with
NextIntlClientProvider,AuthProvider(seeded with server-read auth state), andThemeProvider. - Exports
generateStaticParamsso Next.js can enumerate locale routes at build time.
WHY <html> MUST live in [locale]/layout.tsx and not a layout above it: a layout above the [locale] segment is shared between /fa and /en. Next.js statically caches it at build time with defaultLocale ('fa') and never re-renders it on a client-side locale switch (the segment doesn't change). Its lang/dir/messages therefore freeze on 'fa'/'rtl' for every route, including /en. The [locale] layout is the lowest boundary keyed on the locale param, so it is the only place where <html lang dir> reliably tracks the active locale.
Route-group layouts ((private-routes)/layout.tsx, (public-routes)/layout.tsx) are 'use client' — they only wrap a layout component and need no server capabilities.
Never import from next/headers, next-intl/server, or @/lib/cookies/server in a client component. The build will fail.
i18n (next-intl v4)
Adding translations:
- Add the key to
messages/en.jsonANDmessages/fa.json. Both files must always be in sync. - Top-level keys are namespaces:
"nav","common", etc.
Using translations in client components:
import { useTranslations } from 'next-intl';
function MyComponent() {
const t = useTranslations('nav'); // namespace
return <span>{t('home')}</span>; // key
}
Using translations in Server Components:
import { getTranslations } from 'next-intl/server';
async function MyServerComponent() {
const t = await getTranslations('nav');
return <span>{t('home')}</span>;
}
Established namespaces and where they're used:
'nav'— the actor shells (CustomerLayout/NurseLayout/AdminLayout) build their nav from here'common'—DarkModeButton.tsx(dark/light labels), shared words (loading, retry, currency_toman, …)'shell'— actor-shell titles + the not-yet-built placeholder body'patients'— the E1 patient list/CRUD (list, card, add/edit dialog, archive)'onboarding'— the A3→A4 wizard + the shared enum labels (relation/condition/gender codes → labels)'home'— the A5 family home (greeting + avatar, search bar, category grid, record/profile nudges)'profile'— the customer profile + emergency contact'nurseProfile'— the nurse B7 profile bootstrap (photo/bio/years + unverified placeholder)'bank'— the nurse payout bank settings (IBAN form + the three ownership states)'geo'— the shared cascading province→city→district dropdowns (CascadingRegionSelect: level labels, "whole city", cascade hints)'address'— the customer address book + add/edit form (title/street, map-pin helper, set-primary, empty/delete states) + the profile-hub link'coverage'— the nurse coverage-area editor (whole-city/specific-district scope, chips, duplicate + "won't appear in search" warnings)'catalog'— shared catalog vocabulary: the fiveprice_unitlabels + count nouns + the estimated-total label (read byPriceDisplay; f6 reuses it customer-side)'services'— the f4 nurse Services & prices surface (offerings list, the variant builder steps/fields/validation, the duplicate-listing warning, deactivate confirm)'search'— the f4 deferred/searchplaceholder (title + "arrives next phase" + query/category echo); f6 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_taglinefor 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.tsin a client component; never importclient.tsin an RSC. COOKIE_NAMES.COLOR_SCHEME = 'color-scheme'— the single source of truth for the theme cookie name. Do not redeclare it anywhere.
Constants
Rule: every magic string or configurable value must be a named constant — never inline.
A value is "magic" if its meaning isn't obvious from the literal alone: cookie names, event names, localStorage keys, route paths, query-param names, numeric timeouts, API endpoint slugs.
Where to define:
- Cookie names / options:
src/lib/cookies/constants.ts - Feature-scope constants: co-locate in a
constants.tsnext to that feature's files - App-wide constants (used across multiple features):
src/constants/— one file per concern (routes.ts,events.ts, etc.)
Rules:
- Import the constant; never copy-paste the string value.
- When renaming, update the constant definition — the rest of the codebase follows automatically.
Theme System
How it works (end-to-end, no-flash)
- Request arrives →
getThemeMode()reads'color-scheme'cookie → returns{ colorScheme, defaultMode } - Root layout sets
data-mui-color-scheme={colorScheme}on<html>server-side <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'slocalStoragewrites for key'mode'to our cookie; reads returnnullso MUI always trusts thedefaultModeprop
- Reads the same cookie, sets
<MuiThemeProvider defaultMode={defaultMode}>mounts — uses the server-derived mode, not localStorageColorSchemeCookieSyncin ThemeProvider writes the cookie viauseColorScheme().colorSchemeon 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:
setMode('dark')is calledStorage.prototype.setItemintercept fires → writes'color-scheme'='dark'cookie synchronously- MUI sets
data-mui-color-scheme="dark"on<html> - CSS variables resolve → browser repaints. No React re-render above the button.
Use colorScheme (not mode) for the isDark check — mode can be 'system' even when dark is active.
Direction (RTL / LTR)
Derived from locale via getDirection(locale) in src/theme/direction.ts:
- RTL locales:
fa,ar,he,ur - All others:
ltr
ThemeProvider accepts a dir prop and selects the matching pre-built theme (APP_THEME_RTL for RTL). The RTL Emotion cache uses stylis-plugin-rtl to mirror all generated CSS.
src/app/[locale]/layout.tsx sets dir={dir} on <html> and passes dir to ThemeProvider. Because that layout is keyed on the [locale] URL param, changing locale re-renders it with a fresh dir — on both hard and soft navigation, no client-side state. Do not move the <html dir> render to a layout above [locale]; such a layout is shared across locales, gets statically cached with the default locale, and dir freezes on 'rtl' for /en.
Default locale is fa (RTL). The middleware redirects bare / to /fa/. English is explicitly accessed at /en/.
Fonts
Fonts are loaded per locale — the Persian face is never shipped to English pages:
| Locale | Font | CSS variable | Source | Loaded when |
|---|---|---|---|---|
fa (RTL) |
Mikhak | --font-mikhak |
next/font/local — woff2 files in src/app/fonts/ |
only on fa routes |
en (LTR) |
Space Grotesk | --font-space-grotesk |
(not currently wired — falls back to the system stack) | — |
Typography exports:
TYPOGRAPHY_LTR— Space Grotesk headings, system font body (used byAPP_THEME_LTR)TYPOGRAPHY_RTL— Mikhak for all text including body (used byAPP_THEME_RTL, ensures full Persian glyph coverage)TYPOGRAPHY— alias forTYPOGRAPHY_LTR(deprecated, prefer the explicit exports)
Rules:
- Mikhak is declared with
preload: false, and its.variableclass is attached to<html>only whenlocale === 'fa'. Both are required: anext/fontloader called in the root layout would otherwise preload on every route (including/en), andpreload: falseensures the woff2 only downloads when Persian text actually renders. - Font files live in
src/app/fonts/(notpublic/). next/font/local resolves paths relative to the calling file (src/app/[locale]/layout.tsx) at build time. - 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 vialocalFont/localFont-equivalent insrc/app/[locale]/layout.tsx, attach its.variableclass conditionally on the matching locale, and updateBRAND_FONT_VARIABLE_*constants intypography.ts.
Unit Testing
Rule: every shared component must have a co-located test file.
A component is "shared" if it is imported from more than one place (page, layout, or other component).
Coverage baseline for shared components:
- It renders without crashing.
- Every documented prop produces the correct HTML attribute or CSS class.
- User interactions (click, change) call the expected callbacks.
Test location: src/components/ComponentName/ComponentName.test.tsx next to the component.
Test wrapper: wrap with <ThemeProvider> if the component uses MUI theming.
Do NOT mock MUI components — test against the rendered DOM.
Enforcement: before removing or renaming a shared component, check whether src/**/*.test.{ts,tsx} files import it. If so, update or delete those tests too.
Comments & dead code
- No dead code. Unused variables, imports, parameters, and private members are lint errors
(
@typescript-eslint/no-unused-vars, raised toerror— see Quality gates). Delete them; don't comment them out and don't silence the rule. Prefix a deliberately-unused binding with_to opt out. - Comment the why, never the what. Code should read for itself — a comment that restates what
the code already says is noise. Don't write
// set the access tokenabovesetClientCookie(...), or JSDoc that just echoes a function's name. - Do add a tight comment when a decision is genuinely non-obvious from the code: a workaround for a
framework quirk, a business rule, an ordering or security constraint, a deliberate deviation. Explain
why it is this way. The comments in
src/app/[locale]/layout.tsx(why<html>lives in the[locale]layout) andsrc/lib/auth/token.ts(why the JWTexpcheck is UX-only, never a security boundary) are the model to follow. - Prefer a clearer name or a small helper over a comment whenever that removes the need for it.
Anti-patterns (do not do these)
- Do not read
localStorageordocument.cookiein render functions — useuseEffector server-sidecookies()fromnext/headers. - Do not call
createTheme()inside a component or hook — useAPP_THEME_LTR/APP_THEME_RTL. - Do not use
storageWindow={null}onMuiThemeProvider— it is silently ignored in MUI v9. - Do not use
InitColorSchemeScriptfrom MUI — useColorSchemeScriptfrom@/theme. - Do not set
colorSchemeSelector: 'data'— use'data-mui-color-scheme'. - Do not check
mode === 'dark'for "is dark active" — usecolorScheme === 'dark'. - Do not hard-code UI strings — add translation keys to both
messages/en.jsonandmessages/fa.json. - Do not add a
src/app/layout.tsxor any layout above the[locale]segment. Such a layout is shared across locales, gets statically cached at build time withdefaultLocale('fa'), and never re-renders on a locale switch — so<html lang/dir>, messages, providers, and fonts placed there freeze on 'fa'/'rtl' for/en.src/app/[locale]/layout.tsxis the root layout (it renders<html>/<body>) precisely because it is the lowest boundary keyed on the locale param. - Do not call
getMessages()without passing{ locale }explicitly —getMessages({ locale })passes the locale directly togetRequestConfigviaPromise.resolve(locale), bypassing potential React.cache ordering issues. - Do not remove
setRequestLocale(locale)fromsrc/app/[locale]/layout.tsx— without it,getLocale()called by deeper server components always returnsdefaultLocale. - Do not add
notFound()tosrc/app/[locale]/layout.tsx— unknown locale URLs are handled by middleware (redirect to defaultLocale); a hard 404 here breaks fallback behavior. - Do not import
TYPOGRAPHY— useTYPOGRAPHY_LTRorTYPOGRAPHY_RTLexplicitly. - Do not load fonts inside components or pages — all next/font declarations belong in
src/app/[locale]/layout.tsx, with the.variableclass attached conditionally per locale (Mikhak only forfa). - Do not import
@/lib/cookies/serverin client components or@/lib/cookies/clientin RSCs. - Do not call
fetch()directly in components or services — useserverFetch(RSC/Server Actions) orclientFetch(hooks/Client Components) from@/lib/api. - Do not create a top-level barrel at
src/services/index.ts— imports should make the domain origin clear (e.g.import { useLogin } from '@/services/auth', notimport { useLogin } from '@/services'). - Each domain does have an
index.tsthat re-exports its hooks (e.g.src/services/auth/index.ts). Do not exporttypes,keys, orapis/*from this barrel — only hooks. - Do not mix
clientFetchandserverFetchin the same file — keepclientApi.tsandserverApi.tsseparate; Next.js enforces the environment boundary at build time. - Do not toast inside hooks for 401/403/5xx — those are already toasted by
clientFetch. Only toast inonErrorfor domain-specific 4xx messages. - Do not call
js-cookie(Cookies.*) directly — use the central client cookie manager (@/lib/cookies/client). - Do not read or write
document.cookiedirectly — use the central client cookie manager. - Do not store auth tokens in
sessionStorageorlocalStorage— use cookies via@/lib/cookies/client. - Do not pass
flexWraporuseFlexGapas direct props to MUIStack— these are not valid Stack props in MUI v9 and cause a TypeScript overload error. Usesx={{ flexWrap: 'wrap' }}instead.useFlexGapwas a MUI v5 opt-in and does not exist in v9. - Do not use mui old api which cause errors
API Fetch Services
Central fetch primitives live in src/lib/api/:
| File | Use from | Purpose |
|---|---|---|
client.ts |
hooks, client components | clientFetch<T> — throws ApiError on error |
server.ts |
RSCs, Server Actions only | serverFetch<T> — throws ApiError on error |
errors.ts |
anywhere | ApiError class (status, message, code) |
Error contract — clientFetch:
- 401 — toast "session expired", clear cookies, redirect to login (no throw; page navigates away)
- 403 — toast "forbidden", throw
ApiError - 5xx — toast "server error", throw
ApiError - Other 4xx — throw
ApiError, no toast; the calling hook owns the user-facing message - Network failure — toast "network error", throw
ApiError
Error contract — serverFetch:
- All errors throw
ApiError(no toast — server can't fire browser events) - RSC callers decide whether to
notFound(),redirect(), or let the error propagate to an error boundary
Domain API calls live in src/services/{domain}/apis/clientApi.ts (or serverApi.ts). Never call raw fetch() directly.
The services/{domain} reference pattern (copy auth / patients)
Every domain follows the same shape: types.ts (wire types + the domain's Api interface), keys.ts
(hierarchical React Query key factory), apis/ (implementations + a selecting index.ts), hooks/
(one hook per file), and a barrel index.ts that re-exports hooks only (never types/keys/apis).
- Caching is deliberate: set a
staleTimeon reads so revisiting a screen doesn't refetch; mutations invalidate the affected list key (queryClient.invalidateQueries) orsetQueryData— never leave the cache stale. Seeservices/patients/hooks/*. - Reference data is cached for the whole session: rarely-changing lookups (the geo province→city→district
hierarchy) use an Infinite
staleTime+ a shared, hierarchical key factory (geographyKeys) so each level is fetched once and served from cache across every consumer (the address form, the coverage editor, and later search) — never refetched on a dropdown open. Contrast with mutable lists (addresses, coverage areas) which invalidate on every mutation. Seeservices/geography/*. Reuse this pattern for future reference data; do not reinvent per-consumer fetching.services/catalog(f4) is the second long-lived cached reference domain: admin-seeded categories + a category's option groups/values use the same InfinitestaleTime/gcTime(CATALOG_REFERENCE_*) so the Home grid and every builder step read them from cache; the nurse's own variant list is the mutable side — mutations invalidatecatalogKeys.myVariantsLists(). - Mock behind a seam: when the backend endpoint isn't live, implement the domain's
Apiinterface twice — a realclientApi.tsand an in-memorymockApi.ts— and select inapis/index.tsby a config flag (USE_{DOMAIN}_MOCK). Hooks import the selectedapi; the swap is one line. Record every mock indev/shared-working-context/reports/mocks-registry.md. - The wire envelope: the server wraps responses in
ApiEnvelope<T>({ isSuccess, statusCode, message, requestId, data }, camelCase — seelib/api/types.ts).clientFetchreturns the raw body, so a realclientApireads the payload viaunwrap(). Types are derived fromdev/contracts/+dev/contracts/openapi/swagger.v1.json, mirroring the wire exactly. - Money & dates: format via
@/utils—formatIrrToToman/formatIrr/parseIrr(IRR strings, integer-safe BigInt) andformatShamsiDate/formatShamsiDateTime(UTC ISO → Persian calendar). Money is never a float.
Auth Cookies & session state
| Cookie | Constant | TTL | Set by |
|---|---|---|---|
access_token |
COOKIE_NAMES.ACCESS_TOKEN |
15 min | persistAuthTokens (src/lib/auth/session.ts) — via useVerifyOtp, useRefresh, useSelectRole, and the fetch-layer silent refresh |
refresh_token |
COOKIE_NAMES.REFRESH_TOKEN |
7 days | same as above |
The credential is phone-OTP — there is no username/password anywhere; email is never a login key.
The login flow lives in src/components/auth/ (LoginFlow → PhoneStep/OtpStep) at /login, over the
services/auth domain (requestOtp/verifyOtp/refresh/logout/getMe/selectRole).
Role router: after a successful verify, RoleRouter (src/components/auth/) reads /me and navigates —
customer→family app, nurse→nurse app, empty roles→/select-role, admin→admin console — showing the branded
splash while /me loads so the wrong shell never flashes. The routing decision is the pure
resolveRoleDestination(me, intendedRole) in src/services/auth/routing.ts (unit-tested). The middleware
still owns the auth gate; the router only decides which app.
Session state lives in AuthContext (src/context/auth/), now carrying SessionUser { id?, phone, roles: AppRole[] }. The root layout resolves the session on the server with getServerAuthState()
(src/lib/auth/server.ts) — which reads the access_token cookie and checks the JWT exp via the shared
isTokenAlive (src/lib/auth/token.ts) — and passes it to <AuthProvider initialState={…}>, so the first
render already knows whether the user is authenticated. Roles are not derivable from the opaque JWE token
server-side, so the server seeds isAuthenticated only; useSessionRoleSync() (mounted in the
private-routes layout) hydrates currentUser.roles from /me — the single source the shells read via
useActorRole(). invalidateQueries(authKeys.me()) runs on login; removeQueries(authKeys.all) on logout.
Lifecycle:
- Written by
persistAuthTokensafter verify/refresh/select-role, which also dispatchLOG_INto keepAuthContextin sync without a reload. - Deleted by
useLogout()(src/services/auth/hooks/useLogout.ts) — the single logout path: revoke the server session, clear both cookies,LOG_OUT, drop the/mecache, redirect — and byclientFetchwhen a 401 can't be recovered by a refresh. - Read on the server by
serverFetch/getServerAuthStateviagetServerCookie. - Read on the client by
clientFetchviagetClientCookie(to attachAuthorization: Bearer).
Silent refresh: clientFetch attempts one single-flight attemptTokenRefresh (src/lib/api/refresh.ts)
on a 401 and retries the request once; a failed refresh (unknown/expired/reused token → the server revokes
the session) clears tokens and redirects to /login. The refresh/OTP endpoints are excluded from this retry.
Middleware (middleware.ts) gates private routes with the same isTokenAlive helper before render.
Security posture — current limits and best-practice follow-ups. The flow above is the intended client design, but some hardening needs server coordination — don't silently "fix" it client-only:
- Tokens are non-httpOnly cookies (JS-readable) so
clientFetchcan attach the bearer header — this trades XSS-hardening for the bearer pattern. Real hardening (httpOnly cookies set by the server + a same-origin proxy) spans the server. - The middleware check is UX-only, not a security boundary: it decodes the JWT and checks
expbut does not verify the signature. The API is the only authority; never gate real authorization on the middleware orisTokenAlive. - Role gating is coarse: 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. Therefresh_tokencookie TTL (7d) is shorter than the server session default (30d) — a follow-up can align the cookiemaxAgetorefreshExpiresAt.
Toast Notifications (notistack)
<SnackbarProvider> wraps all children inside ThemeProvider in src/app/[locale]/layout.tsx.
In React components/hooks — use notistack directly:
import { useSnackbar } from 'notistack'
const { enqueueSnackbar } = useSnackbar()
enqueueSnackbar('Saved!', { variant: 'success' })
Outside React (plain functions, fetch services) — use the event bridge:
import { dispatchToast } from '@/lib/toast'
dispatchToast('Something went wrong', 'error')
dispatchToast fires a window CustomEvent (app:toast). ToastBridge (a zero-UI 'use client' component inside SnackbarProvider) listens and calls enqueueSnackbar.
ToastBridge is already rendered in [locale]/layout.tsx — do not add another instance.
Toast colors follow the theme. NotistackProvider maps every notistack variant to a styled(MaterialDesignContent) whose backgroundColor/color come from the --bal-{success,error,warning,info} (+ *-contrast) tokens in tokens.css. Because those tokens are defined on <html>, they cascade into notistack's Portal and switch with the color scheme automatically. Never hard-code a toast color — adjust the tokens instead.
Direction is inherited, not passed. notistack's Portal mounts under <body>, so it inherits dir from <html dir> (set per-locale in the root layout). Do not pass a dir prop to SnackbarProvider — it is not a valid prop (TS error) and is unnecessary:
<NotistackProvider>{children}</NotistackProvider>
Route Constants
Named path constants live in src/constants/routes.ts:
ROUTES.LOGIN = '/login'
ROUTES.HOME = '/'
PUBLIC_PATHS = [ROUTES.LOGIN, ...] // paths that bypass middleware auth check
Import from the barrel: import { ROUTES, PUBLIC_PATHS } from '@/constants'.
To add a new public route, append it to PUBLIC_PATHS — the middleware picks it up automatically.
Client Cookie Manager (js-cookie)
src/lib/cookies/client.ts uses js-cookie internally. The exported API is unchanged:
| Function | Purpose |
|---|---|
getClientCookie(name) |
Read a cookie by name |
setClientCookie(name, value, options?) |
Write a cookie; options is CookieOptions with maxAge in seconds |
deleteClientCookie(name, path?) |
Delete a cookie |
getColorSchemeCookie() |
Typed helper for the theme cookie |
CookieOptions type is defined in src/lib/cookies/constants.ts — maxAge is in seconds (converted to expires: Date internally when calling js-cookie).
- Do not
document.title = titlein the render body of any component — it causesReferenceError: document is not definedduring build-time prerendering.