-
Notifications
You must be signed in to change notification settings - Fork 42
feat(frontend): internationalization (i18n) support with RTL and 10+ languages #296
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 5 commits
2bc0e51
c74d906
23ec418
a290b42
cdbd31b
808a2c6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,317 @@ | ||||||
| # Internationalization (i18n) and RTL | ||||||
|
|
||||||
| InvoFi ships in 12 languages, three of them right-to-left. This document covers | ||||||
| how the system is wired, how to add a language, how to translate one, and the | ||||||
| rules that keep the right-to-left layouts correct. | ||||||
|
|
||||||
| Implemented for [issue #227](https://github.com/Stellar-VaultLink/invofi/issues/227). | ||||||
|
|
||||||
| --- | ||||||
|
|
||||||
| ## Supported locales | ||||||
|
|
||||||
| | Tag | Language | Direction | Catalogue coverage | | ||||||
| | --- | --- | --- | --- | | ||||||
| | `en` | English | LTR | source of truth — 253 keys | | ||||||
| | `ar` | العربية (Arabic) | **RTL** | complete (253 / 253) | | ||||||
| | `he` | עברית (Hebrew) | **RTL** | application (186 / 253) | | ||||||
| | `fa` | فارسی (Persian) | **RTL** | application (186 / 253) | | ||||||
| | `de` | Deutsch | LTR | application (186 / 253) | | ||||||
| | `es` | Español | LTR | application (186 / 253) | | ||||||
| | `fr` | Français | LTR | application (186 / 253) | | ||||||
| | `ja` | 日本語 | LTR | application (186 / 253) | | ||||||
| | `ko` | 한국어 | LTR | application (186 / 253) | | ||||||
| | `pt` | Português | LTR | application (186 / 253) | | ||||||
| | `tr` | Türkçe | LTR | application (186 / 253) | | ||||||
| | `zh` | 中文 | LTR | application (186 / 253) | | ||||||
|
|
||||||
| "Application" means every screen behind the front door — navigation, dashboard, | ||||||
| marketplace, portfolio, settings, statuses and error pages. The remaining 67 | ||||||
| keys are the `Landing` namespace: long-form marketing and FAQ prose on the | ||||||
| public home page. Those fall back to English, which renders correctly rather | ||||||
| than showing a missing-key placeholder (see [Fallback](#fallback)). Arabic is | ||||||
| translated end to end and is the reference pair used to verify the RTL layout | ||||||
| with no English left in it. | ||||||
|
|
||||||
| Filling in a `Landing` namespace is a good first translation contribution — see | ||||||
| [Contributing a translation](#contributing-a-translation). | ||||||
|
|
||||||
| --- | ||||||
|
|
||||||
| ## Architecture | ||||||
|
|
||||||
| ### No locale in the URL | ||||||
|
|
||||||
| next-intl supports two modes: locale-prefixed routes (`/ar/invoices/…`) and a | ||||||
| locale held outside the URL. **InvoFi uses the second.** The reasoning: | ||||||
|
|
||||||
| - Every application route is behind an auth or wallet gate, so per-locale URLs | ||||||
| buy nothing in search indexing. The only public SEO surface is `/`. | ||||||
| - The Supabase session middleware, `sitemap.ts`, `robots.ts` and every existing | ||||||
| deep link are keyed on unprefixed paths. A `[locale]` segment would fork all | ||||||
| of them, and every link shared before the change would need a redirect. | ||||||
| - Language is a **reader preference**, like the dark-mode toggle — not part of a | ||||||
| document's identity. | ||||||
|
|
||||||
| The locale therefore lives in a cookie, `INVOFI_LOCALE`, readable by the Edge | ||||||
| middleware and by every server component. | ||||||
|
|
||||||
| ### Request flow | ||||||
|
|
||||||
| ``` | ||||||
| Request | ||||||
| │ | ||||||
| ├─ middleware.ts ────────► No INVOFI_LOCALE cookie? | ||||||
| │ negotiate from Accept-Language, set the cookie | ||||||
| │ (an existing cookie is never overwritten) | ||||||
| │ | ||||||
| ├─ i18n/request.ts ─────► getUserLocale() → cookie, else re-negotiate | ||||||
| │ loadMessages(locale) → <locale>.json over en.json | ||||||
| │ | ||||||
| └─ app/layout.tsx ──────► <html lang={locale} dir={dirFor(locale)}> | ||||||
| <NextIntlClientProvider locale messages> | ||||||
| ``` | ||||||
|
|
||||||
| Because the cookie is written in middleware, the **first** HTML response already | ||||||
| carries the right `lang` and `dir`. An Arabic reader never sees a flash of | ||||||
| left-to-right English. | ||||||
|
|
||||||
| ### Files | ||||||
|
|
||||||
| | File | Responsibility | | ||||||
| | --- | --- | | ||||||
| | `src/i18n/config.ts` | The locale registry: tags, RTL set, display names, `Accept-Language` negotiation. Dependency-free so the Edge middleware can import it. | | ||||||
| | `src/i18n/locale.ts` | Server actions that read and write the locale cookie. | | ||||||
| | `src/i18n/messages.ts` | Loads `messages/<locale>.json` and deep-merges it over English. | | ||||||
| | `src/i18n/request.ts` | next-intl request config. | | ||||||
| | `src/middleware.ts` | `Accept-Language` negotiation on first visit. | | ||||||
| | `src/app/layout.tsx` | Stamps `lang`/`dir`, mounts the provider. | | ||||||
| | `src/components/settings/LanguageSwitcher.tsx` | The picker in Settings. | | ||||||
| | `src/lib/intl.ts` | Locale-aware number, currency, date and relative-time formatting. | | ||||||
| | `src/hooks/useFormat.ts` | Binds `src/lib/intl.ts` to the reader's active locale. | | ||||||
| | `messages/*.json` | The catalogues. | | ||||||
|
|
||||||
| ### Precedence | ||||||
|
|
||||||
| 1. An explicit choice in **Settings → Language** (the cookie). | ||||||
| 2. The browser's `Accept-Language` header, q-values honoured, regional tags | ||||||
| folded to their base language (`pt-BR` → `pt`, `zh-Hans-CN` → `zh`). | ||||||
| 3. English. | ||||||
|
|
||||||
| Once a reader has chosen a language, their browser header must never override | ||||||
| it again — the middleware only writes the cookie when none is set. | ||||||
|
|
||||||
| ### Fallback | ||||||
|
|
||||||
| `loadMessages` deep-merges a translation over English, so a partial catalogue | ||||||
| renders translated where it can and English where it cannot. This is what makes | ||||||
| it safe to accept a translation PR that covers 40 keys out of 253: nothing | ||||||
| breaks, and the untranslated remainder is still readable. A locale listed in | ||||||
| `config.ts` with no file at all still renders — in English — rather than | ||||||
| throwing on every request. | ||||||
|
|
||||||
| --- | ||||||
|
|
||||||
| ## Formatting | ||||||
|
|
||||||
| Never build a user-visible number, currency amount or date with string | ||||||
| concatenation. Use `useFormat()`: | ||||||
|
|
||||||
| ```tsx | ||||||
| const format = useFormat(); | ||||||
|
|
||||||
| format.currency(offer.amount, offer.currency); // 10.000 XLM (de) · 10,000 XLM (en) | ||||||
| format.percent(offer.interest_rate); // 5.00% (en) · %5,00 (tr) | ||||||
| format.date(invoice.due_date); // May 18, 2033 (en) · 2033/05/18 (ja) | ||||||
| format.relativeDays(3); // in 3 days (en) · بعد ٣ أيام (ar) | ||||||
| ``` | ||||||
|
|
||||||
| The differences these cover are not cosmetic: | ||||||
|
|
||||||
| - **Grouping and decimals.** `1,234.50` in English is `1.234,50` in German. A | ||||||
| hardcoded `en-US` format does not read as ugly in Germany — it reads as a | ||||||
| different number. | ||||||
| - **Currency placement.** `$10,000` in English, `10 000 $` in French. In RTL | ||||||
| locales the symbol moves to the other side of the number. Only | ||||||
| `Intl.NumberFormat` with `style: 'currency'` gets this right. | ||||||
| - **Date field order.** `Aug 24, 2026` · `24 août 2026` · `2026年8月24日`. | ||||||
| - **Plurals.** English has two forms; Arabic has six (`zero`, `one`, `two`, | ||||||
| `few`, `many`, `other`); Japanese, Korean and Chinese have one. `{n} days` is | ||||||
| wrong in most of the languages we ship. | ||||||
|
|
||||||
| `XLM` has no ISO 4217 code, so it is formatted as a locale-grouped decimal with | ||||||
| the ticker appended after a non-breaking space. `USDC` maps to `USD` and goes | ||||||
| through the currency formatter. | ||||||
|
|
||||||
| ### Plurals in the catalogue | ||||||
|
|
||||||
| Put the plural *inside* the message, never in the component: | ||||||
|
|
||||||
| ```jsonc | ||||||
| // messages/en.json | ||||||
| "accruing": "Accruing across {count, plural, =0 {no active positions} one {# active position} other {# active positions}}" | ||||||
| ``` | ||||||
|
|
||||||
| ```jsonc | ||||||
| // messages/ar.json — Arabic supplies all six of its forms | ||||||
| "accruing": "يتراكم لحظيًا عبر {count, plural, zero {لا مراكز نشطة} one {مركز نشط واحد} two {مركزين نشطين} few {# مراكز نشطة} many {# مركزًا نشطًا} other {# مركز نشط}}" | ||||||
| ``` | ||||||
|
|
||||||
| ```jsonc | ||||||
| // messages/ja.json — Japanese has a single form | ||||||
| "accruing": "{count, plural, =0 {進行中のポジションはありません} other {# 件の進行中ポジション}}でリアルタイムに積み上がっています" | ||||||
| ``` | ||||||
|
|
||||||
| A component that writes `{count} position{count !== 1 ? 's' : ''}` cannot be | ||||||
| translated correctly into any of these, which is why the catalogue owns the | ||||||
| wording. | ||||||
|
|
||||||
| --- | ||||||
|
|
||||||
| ## RTL | ||||||
|
|
||||||
| `dir="rtl"` on `<html>` flips text direction. It does **not** flip a layout that | ||||||
| positions things with physical left/right. Two rules keep RTL correct. | ||||||
|
|
||||||
| ### 1. Use CSS logical properties | ||||||
|
|
||||||
| | Never | Always | | ||||||
| | --- | --- | | ||||||
| | `ml-2` / `mr-2` | `ms-2` / `me-2` | | ||||||
| | `pl-4` / `pr-4` | `ps-4` / `pe-4` | | ||||||
| | `left-3` / `right-3` | `start-3` / `end-3` | | ||||||
| | `border-l` / `border-r` | `border-s` / `border-e` | | ||||||
| | `rounded-l-md` / `rounded-r-md` | `rounded-s-md` / `rounded-e-md` | | ||||||
| | `text-left` / `text-right` | `text-start` / `text-end` | | ||||||
| | `space-x-4` | `gap-4` | | ||||||
|
|
||||||
| `space-x-*` deserves its own note: it applies a physical `margin-left` to | ||||||
| sibling elements and does **not** mirror under `dir="rtl"`. Use `gap` on the | ||||||
| flex container instead. | ||||||
|
|
||||||
| The one legitimate use of physical values is symmetric centring | ||||||
| (`left-1/2` paired with `-translate-x-1/2`), which is direction-independent — | ||||||
| converting only half of that pair breaks it. | ||||||
|
|
||||||
| ### 2. Mirror directional glyphs and motion | ||||||
|
|
||||||
| Arrows and chevrons mean *forward* and *back*, not *right* and *left*: | ||||||
|
|
||||||
| ```tsx | ||||||
| <ArrowRight className="ms-2 h-4 w-4 rtl:rotate-180" /> | ||||||
| ``` | ||||||
|
|
||||||
| Anything that slides in from an edge needs the same treatment — the mobile | ||||||
| drawer is anchored with `end-0`, so it must slide out towards the physical edge | ||||||
| it sits on: | ||||||
|
|
||||||
| ```tsx | ||||||
| drawerOpen ? "translate-x-0" : "translate-x-full rtl:-translate-x-full" | ||||||
| ``` | ||||||
|
|
||||||
| ### 3. Pin identifiers to LTR | ||||||
|
|
||||||
| Stellar addresses, contract IDs and URLs are base32/ASCII identifiers. Inside an | ||||||
| RTL paragraph the browser will reorder them. Pin them: | ||||||
|
|
||||||
| ```tsx | ||||||
| <span dir="ltr" className="font-mono">{contractId}</span> | ||||||
| ``` | ||||||
|
|
||||||
| ### Checking your work | ||||||
|
|
||||||
| Set the language to العربية in Settings, then: | ||||||
|
|
||||||
| - Does the navigation start on the right? | ||||||
| - Do icons sit on the correct side of their labels? | ||||||
| - Do chevrons and arrows point the other way? | ||||||
| - Are contract IDs and addresses still readable left to right? | ||||||
| - Does the mobile drawer slide in from the right edge? | ||||||
|
|
||||||
| --- | ||||||
|
|
||||||
| ## Adding a language | ||||||
|
|
||||||
| Three steps, no other code changes: | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Change “Three steps” to “Four steps”. The section lists four numbered steps in Lines 237-241. Update the count so contributors receive consistent instructions. Proposed documentation fix-Three steps, no other code changes:
+Four steps, no other code changes:📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||
|
|
||||||
| 1. Add the tag to `locales` in `src/i18n/config.ts`, in alphabetical order | ||||||
| after `en`. | ||||||
| 2. Add its `native` and `english` display names to `localeNames`. | ||||||
| 3. If it is right-to-left, add the tag to `rtlLocales`. | ||||||
| 4. Create `messages/<tag>.json` — see below. | ||||||
|
|
||||||
| `src/i18n/config.test.ts` fails if a locale is missing display names, and | ||||||
| `src/i18n/messages.test.ts` fails if it has no catalogue file. | ||||||
|
|
||||||
| --- | ||||||
|
|
||||||
| ## Contributing a translation | ||||||
|
|
||||||
| You do not need to run the app to contribute a translation. You need one file. | ||||||
|
|
||||||
| 1. **Pick a locale.** Either a language in the table above showing partial | ||||||
| coverage, or a new one (add it to `config.ts` as above). | ||||||
|
|
||||||
| 2. **Copy the shape from `messages/en.json`.** Keys are nested by namespace | ||||||
| (`Navbar`, `Dashboard`, `Portfolio`, …). Keep the key names *exactly* as | ||||||
| they are in English — the key is the address, only the value is translated. | ||||||
|
|
||||||
| 3. **Translate values only. Keep every `{placeholder}`.** | ||||||
|
|
||||||
| ```jsonc | ||||||
| // en | ||||||
| "description": "Invoice {id} will be marked as Cancelled. This cannot be undone." | ||||||
| // de — {id} survives, word order changes freely | ||||||
| "description": "Rechnung {id} wird als storniert markiert. Das lässt sich nicht rückgängig machen." | ||||||
| ``` | ||||||
|
|
||||||
| A message with `<link>…</link>` wraps a link. Keep the tag around whatever | ||||||
| text should be clickable in your language. | ||||||
|
|
||||||
| 4. **Use your language's real plural forms** in `{count, plural, …}` blocks. | ||||||
| The [CLDR plural rules](https://cldr.unicode.org/index/cldr-spec/plural-rules) | ||||||
| list which categories your language needs. Do not copy English's | ||||||
| `one`/`other` if your language has more or fewer. | ||||||
|
|
||||||
| 5. **Leave out what you cannot translate.** A partial file is fine and merges | ||||||
| over English. Do not ship a machine-translated placeholder you would not put | ||||||
| your name to. | ||||||
|
|
||||||
| 6. **Do not translate:** contract IDs, Stellar addresses, `XLM`/`USDC` tickers, | ||||||
| `RPC`/`Horizon`, CSV column headers (they are machine-readable), or the | ||||||
| product name *InvoFi*. | ||||||
|
|
||||||
| 7. **Verify before opening the PR:** | ||||||
|
|
||||||
| ```bash | ||||||
| cd invofi/apps/frontend | ||||||
| npm test -- src/i18n/messages.test.ts | ||||||
| ``` | ||||||
|
|
||||||
| This checks that your file introduces no unknown keys, keeps every | ||||||
| placeholder its English source uses, and parses as valid ICU for your | ||||||
| locale's plural rules. | ||||||
|
|
||||||
| 8. **See it in the app** (optional): | ||||||
|
|
||||||
| ```bash | ||||||
| npm run dev | ||||||
| ``` | ||||||
|
|
||||||
| Then Settings → Language → your language. | ||||||
|
|
||||||
| Open the PR with only `messages/<tag>.json` changed (plus `config.ts` if the | ||||||
| language is new). Translation PRs are reviewed for correctness of meaning, not | ||||||
| for style. | ||||||
|
|
||||||
| --- | ||||||
|
|
||||||
| ## Tests | ||||||
|
|
||||||
| | Suite | What it covers | | ||||||
| | --- | --- | | ||||||
| | `src/i18n/config.test.ts` | Locale registry, RTL direction resolution, `Accept-Language` negotiation (q-values, regional fallback, `q=0`, unsupported languages). | | ||||||
| | `src/i18n/messages.test.ts` | Every locale has a file; no unknown keys; placeholders preserved; ICU structurally valid; English fallback for untranslated keys; Arabic is complete. | | ||||||
| | `src/i18n/icu.test.ts` | The catalogue reader itself: argument extraction that is not fooled by plural branch text, and rejection of unbalanced braces, bodiless branches, and plurals with no `other` catch-all. | | ||||||
| | `src/lib/intl.test.ts` | Locale-correct grouping, currency placement, date field order, plural-aware relative time, i128 stroop precision. | | ||||||
| | `e2e/i18n.spec.ts` | `Accept-Language` → RTL page; unsupported language → English; regional tag → base language; switching language in Settings; an explicit choice beating the browser header; locale-formatted dates on a real page. | | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Add a language to the architecture diagram fence.
markdownlint-cli2reports MD040 for the unlabeled fenced block at Line 61. Mark it astextto preserve the ASCII diagram and satisfy the lint rule.Proposed documentation fix
🧰 Tools
🪛 markdownlint-cli2 (0.23.2)
[warning] 61-61: Fenced code blocks should have a language specified
(MD040, fenced-code-language)
🤖 Prompt for AI Agents
Source: Linters/SAST tools