Files
baya-monorepo/archive/docs/rules/client/i18n.md
T
2026-08-02 20:01:31 +03:30

13 KiB
Raw Blame History

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 valuesterms_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 D1D5 — 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.