216 lines
13 KiB
Markdown
216 lines
13 KiB
Markdown
# 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.
|