13 KiB
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.