# Client i18n and Persian copy next-intl v4 mechanics, the namespace map, and the binding Persian style guide — the last of which is enforced by `npm run lint:copy`. > Last verified: 2026-07-30 against commit `d3ec723`. --- ## 1. The rule **No hard-coded user-facing strings.** Every user-visible string — label, placeholder, `aria-label`, button text, error message — is a key in **both** `messages/en.json` and `messages/fa.json`, and the two files stay in sync. The one sanctioned exception is `app/global-error.tsx`, which replaces the root layout on a root-level crash and therefore renders its own `` and cannot use next-intl. Keep it minimal and bilingual. Locales: **`fa` (default, RTL)** and `en`. `/en` is explicitly accessed; a bare `/` normalizes to the default locale. | Context | API | | --- | --- | | Client component | `const t = useTranslations('nav'); t('home')` | | Server component | `const t = await getTranslations('nav'); t('home')` | | Structured (array/object) values | `t.raw('terms_sections')` | | Rich text with tags | `t.rich('consent_line', { terms: …, privacy: … })` | Top-level keys are namespaces. Adding a translation means adding the key to both files — never one. --- ## 2. The namespace map MVP namespaces are complete. Add a key to an existing namespace where it fits; seed a new namespace only with a genuinely new surface, and seed it in both files at once. | Namespace | Owns | | --- | --- | | `common` | Shared words — loading, retry, `currency_toman`, the brand wordmark | | `nav` | The actor shells' tab labels — every shell builds its nav from here | | `shell` | Actor-shell titles | | `auth` | Phone-OTP login, the role router, `RoleGuard` states, select-role, the login-hero trust bullets, the consent line | | `legal` | `/terms` and `/privacy`. **The one namespace with structured JSON values** — `terms_sections`/`privacy_sections` are arrays of `{title, body}` read via `t.raw` | | `welcome` | The public landing. Its `category_*` labels are **marketing copy, deliberately distinct from the live `catalog` category names** | | `onboarding` | The A3→A4 wizard, plus the shared enum labels (relation / condition / gender codes → labels) | | `home` | The family home — greeting, search entry, category grid, nudges | | `profile` | Customer profile and emergency contact | | `patients` | The care-circle list and CRUD | | `records` | The care-record viewer and the nurse visit-note panel. Reuses `onboarding`/`patients` enum labels — never re-keyed | | `geo` · `address` · `coverage` | The cascading region select · the customer address book · the nurse coverage editor | | `catalog` | **Shared** catalog vocabulary — the five `price_unit` labels, count nouns, the estimated-total label. Read by `PriceDisplay` on both sides | | `services` | The nurse services surface and the variant builder | | `nurseProfile` | The nurse profile bootstrap and the public-profile preview | | `activation` | The shared `ActivationChecklist` rows and its collapsed live state | | `bank` | Nurse payout bank settings and the three ownership states | | `verification` | The nurse trust flow — per-step and per-status labels keyed off the code, the honesty-sensitive manual-vs-auto copy, the journey group labels | | `search` | Discovery C1/C2/C3 — filters, the same-gender facet, all four result states, card and profile labels | | `booking` | The booking-request flow **and** post-payment engagement — `bstatus_*`, `sstatus_*`, EVV banners, care-instruction labels, `money_*`, the bookings list | | `payment` | Checkout and invoice — the breakdown rows, the **verbatim escrow copy** (`escrow_notice`), the card-flow states, the confirmation and invoice screens, `pstatus_*`, مودیان states | | `refunds` | Cancellation and refund status — policy tiers keyed off `cancellation_policy_code`, the refund-vs-fee breakdown, `step_*`/`rstatus_*`, per-channel ETA copy | | `bnpl` | Installment checkout D1–D5 — the ownership-truth copy, provider names keyed off `provider_{code}`, the plan/eligibility/schedule labels, the wallet due list | | `payouts` | Nurse earnings and payout history — the balance header incl. the negative "owed back" state, the four buckets, `estate_*`/`pstatus_*`/`bstatus_*`, the cadence explainer | | `reviews` | The review form, tag labels keyed off the code, moderation-status labels, the aggregate count | | `tickets` | The messaging surface — inbox, thread, composer, author-role labels, and both emergency surfaces | | `notifications` | The notification center and bell. Row `title`/`body` are **server-rendered copy, not keys** | | `admin` | Every backoffice console, the Persian legal terms (پروانه تأسیس / مسئول فنی / نماد اعتماد الکترونیکی), and the enum-label prefixes | | `partner` | The partner-centre portal — a separate authz scope | ### Enum labels **A label is keyed off the stable code, never derived from the wire value.** `status_pending_moderation`, `pstatus_failed`, `tag_punctual` — the code is the key suffix, and the vocabulary of codes is a client constant, not something read off a response. Shared enum labels are **reused** across namespaces, never re-keyed. This is what lets the server rename a display string without a client deploy, and lets the client show a Persian label for a code it has never seen without falling back to raw English. --- ## 3. Numbers and interpolation Persian digits (۰۱۲۳۴۵۶۷۸۹) everywhere on `/fa` — both hard-coded literals (`"۲۴ ساعت"`) and interpolated numbers. | Case | Do | | --- | --- | | A number inside an ICU message | Use the `number` sub-format — `{count, number}` — or a plain `#` inside a `plural` block. next-intl formats both through the active locale, so `fa` gets Persian digits automatically | | A raw number built into a string in code | Route it through `formatNumber` (`@/utils`). **Never template a raw JS number into Persian text** | | A date | `formatShamsiDate` / `formatShamsiDateTime` (`@/utils`) — UTC ISO in, Persian calendar out | | Money | `` or the `@/utils` money helpers. Never a float, never a raw digit run | ### Policy numbers are never hard-coded into a string Legally or financially sensitive numbers that the admin config panel can change — the dispute-window hours, the cancellation lead-time hours, the refund ETA day range — **never** go into a message string. The message key takes a parameter (`{hours}`, `{minDays}`/`{maxDays}`) and the call site interpolates from `src/constants/policy.ts`, which single-sources them. A config edit must never again silently make the UI copy lie. (These are real server config with no public read yet; the constants file is the interim single source.) --- ## 4. Persian style — binding for `fa.json` `npm run lint:copy` (`client/scripts/check-copy.mjs`, part of `npm run check`) greps every leaf string in `fa.json` for the banned variants marked **linted** below, on every run. A regression fails the gate immediately rather than needing to be re-discovered by a human. `en.json` is hand-written and reviewed for idiom, not linted. ### 4.1 Brand name — **linted** **«بالین‌یار» — ZWNJ (`‌`) between بالین and یار, always.** Never a plain space («بالین یار»). The brand name appears in money and trust copy — login, escrow, refunds — as often as anywhere else, and that is the worst place to be inconsistent. ### 4.2 تأیید — hamza, always — **linted** Write **تأیید** and its derived forms — **تأییدشده**, **تأییدیه**, **تأیید کردن** — every time. Never تایید/تاییدشده/تاییدیه. This is the single most frequent word in a verification product: one spelling, no exceptions, in every namespace. ### 4.3 جستجو — one form — **linted** Standard form: **جستجو** (no ZWNJ, one word). Not «جست‌وجو», not «جست و جو». Applies to the noun and any compound (`در جستجو`, `نتایج جستجو`). ### 4.4 ZWNJ (نیم‌فاصله) Use ZWNJ — never a plain space, never nothing — in: - **می‌ + verb stem** — می‌شود، می‌کند، می‌پردازید، می‌ماند. Never میشود or می شود. - **Plural ها** — keep the ZWNJ before ها when the base ends in a consonant that would otherwise misread (`شب‌ها`, not `شبها`); a word already ending in a vowel or silent-h takes it too (`بچه‌ها`). - **Compound past-participle adjectives** — تأییدشده، لغوشده، ردشده، منتشرشده، پرداخت‌شده. One ZWNJ-joined word: not two spaced words («تایید شده»), not fused with no separator. - The brand name (§4.1). ### 4.5 Two other linted rules - **The archaic passive می‌گردد is banned** — use می‌شود. (The check anchors on a leading space so the entirely legitimate «برمی‌گردد», which fuses «بر» directly on, is never flagged.) - **«بازی » is banned** — it catches an indefinite «ی» misattached to the wrong word. ### 4.6 Punctuation and quotes - Persian prose uses **«…» guillemets** for quoted terms and labels. Prefer Persian «،» / «؛» inside new multi-clause translated sentences; most existing short labels use plain Latin `,`/`;` — **don't retrofit those**, just don't add more. - English uses **straight** apostrophes (`don't`, `couldn't`) throughout — never curly (`’`). Don't reintroduce curly quotes when editing English copy. ### 4.7 Domain glossary | Term | Means | Never | | --- | --- | --- | | **بیمار** | the care recipient | «مددجو» — it appeared once and was dropped for the 99%-majority form | | **پرستار** | the caregiver | «مراقب» as a noun for the person. «مراقب» survives only as an adjective/role qualifier — "جنسیت مراقب" = the caregiver's gender | | **رزرو** | a confirmed, **paid** booking | calling a `booking_request` «رزرو» before it converts | | **درخواست رزرو** | a pre-payment request | conflating it with رزرو | | **ویزیت** | one scheduled visit/session within a booking | — | | **شبا** | IBAN | «شماره شبا» for the field label, «شبا» alone elsewhere | The رزرو / درخواست رزرو split mirrors the code's `bookings` vs `bookingRequests` and the server's `Bookings` vs `Booking` areas. It is a money boundary, not a synonym. ### 4.8 Shell naming — one metaphor per audience class - **End-user shells** (family, nurse — the apps people book or work through day to day) → **«اپلیکیشن»**: «اپلیکیشن خانواده», «اپلیکیشن پرستار». - **Back-office shells** (admin, partner-centre) → **«کنسول»**: «کنسول مدیریت», «کنسول همکار». - Never «نما» (view) or «پرتال» (portal) for a whole shell name. (`booking.evv_nurse_view` "نمای پرستار" is a different thing — a chip labelling *whose perspective* a shared screen is rendered from, not a shell name. It correctly keeps «نما» in that narrower sense.) ### 4.9 Verification pipeline vs. the identity step **«تأیید صلاحیت»** names the whole 7-step nurse trust pipeline — the nav entry, the hub title, its start/progress/approved states, the admin queue. **«احراز هویت»** stays the name of the *one* KYC step inside it (national ID + civil registry + liveness selfie), on both the nurse side (`verification.step_identity_kyc`) and the admin side (`admin.step_identity_kyc`). A nurse who passed the KYC step but still saw a pipeline titled «احراز هویت» marked incomplete in the nav read that as a contradiction. They no longer share a name — keep it that way. ### 4.10 Status vocabulary — one nurse-facing form, one admin-facing form For "this step/item was rejected" states that appear on **both** a nurse-facing and an admin-facing screen for the *same underlying concept* (a verification step's outcome): | Audience | Form | Matches its siblings | | --- | --- | --- | | Nurse-facing | **«رد شد»** (`verification.status_failed`) | the declarative sentence register of `status_passed` («تأییدشده») / `status_in_review` («در حال بررسی») | | Admin-facing | **«ردشده»** (`admin.step_failed`, `agg_rejected`, `rstatus_rejected`, `mstatus_rejected`) | the admin namespace's compound-adjective pattern — `step_passed`/`agg_approved`/`center_state_verified` | This does **not** extend to money-failure vocabulary. `payouts.pstatus_failed`, `refunds.rstatus_failed` and `admin.batch_status_failed` all legitimately use «ناموفق»: a transfer *failing* is a different concept from a document being *rejected*, and conflating them would blur a real distinction. ### 4.11 Register Formal شما throughout, with polite imperatives (کنید) for actions and instructions. Never informal تو or bare imperative stems (نکن, برو). --- ## 5. Reference data with two names Server reference data that carries `name_fa`/`name_en` returns **both**, and the client picks by locale. Don't ask the server for a locale-specific name, and don't translate a data row into a message key — categories, provinces and cities are rows an admin can add, not vocabulary.