# 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 `<html>` 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 | `<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.
