# Internationalization (i18n) FieldOps ships in **Portuguese (PT, default)** and **English (EN)**. Both apps (`operator-pwa` and `admin-web`) are fully translated. This doc explains how it works and **how to add a new language**. ## How it works - Library: [`next-intl`](https://next-intl.dev) (Next.js 15 App Router). - **No locale in the URL.** This is an internal app (no SEO), so we use next-intl's "without i18n routing" mode. The locale comes from a **`NEXT_LOCALE` cookie**, not a `/en/...` path prefix. This keeps every route unchanged and avoids any clash with the auth middleware. - **Default is `pt`.** A missing or invalid cookie falls back to Portuguese. - Dates, numbers and relative times are formatted by next-intl's `useFormatter`, so they follow the active locale automatically (no hardcoded `pt-PT`). - Translations live **per app** (not in a shared package) — the two apps share only a handful of strings, not enough to justify a shared package yet. ## File structure (same in each app) ``` apps// i18n/ locales.ts # LOCALES, DEFAULT_LOCALE, LOCALE_LABELS, isLocale() request.ts # reads the NEXT_LOCALE cookie, loads the messages file messages/ pt.json # Portuguese (default) en.json # English app/ layout.tsx # , NextIntlClientProvider language-switcher.tsx # the PT | EN pill next.config.ts # wrapped with withNextIntl(...) ``` Messages are organised into namespaces (`common`, `auth`, `maintenance`, `report`, `home`, `sync`, `errors`, `metadata`) — see the JSON files. ## Changing language (end user) Click the **PT | EN** pill in the app header. The choice is saved in the `NEXT_LOCALE` cookie (1-year expiry); server components re-render via `router.refresh()`. No account or DB change involved. > The switcher currently appears on the **authenticated** screens (operator home, > maintenance queue), not on the login/picker pages. A first-time user sees the > initial login in PT; once they switch, the cookie persists across logins. ## Adding a new language Example: adding **French (`fr`)** to the admin-web. Repeat for operator-pwa. 1. **Create the messages file.** Copy `apps/admin-web/messages/en.json` to `apps/admin-web/messages/fr.json` and translate every value. **Keep every key identical** — only translate the values. Leave ICU placeholders (`{email}`, `{count}`) and plural structure (`{count, plural, one {…} other {…}}`) intact. 2. **Register the locale** in `apps/admin-web/i18n/locales.ts`: ```ts export const LOCALES = ['pt', 'en', 'fr'] as const; export const LOCALE_LABELS: Record = { pt: 'PT', en: 'EN', fr: 'FR' }; ``` 3. **Repeat steps 1–2 for `operator-pwa`** (copy its `en.json`, add `fr` to its `locales.ts`). 4. **Verify** (see below). The **FR** button then appears in the switcher automatically — no other code changes needed. > Translating the *values* needs someone who speaks the language. Everything > else is mechanical. ## Keeping translations healthy Two failure modes are NOT caught by `tsc` or the E2E tests (the E2E only run in the default PT), so check them whenever you touch translations: **1. Key parity** — every locale file must have exactly the same keys, or a missing key renders as raw text (or throws) when a user switches to it: ```sh node -e ' const fs=require("fs"); function flat(o,p=""){let k=[];for(const key in o){const np=p?p+"."+key:key;if(typeof o[key]==="object"&&o[key]!==null)k=k.concat(flat(o[key],np));else k.push(np);}return k;} for(const app of ["admin-web","operator-pwa"]){ const files=fs.readdirSync(`apps/${app}/messages`).filter(f=>f.endsWith(".json")); const sets=files.map(f=>[f,new Set(flat(JSON.parse(fs.readFileSync(`apps/${app}/messages/${f}`))))]); const base=sets[0][1]; for(const [f,s] of sets){ const missing=[...base].filter(k=>!s.has(k)); const extra=[...s].filter(k=>!base.has(k)); console.log(`[${app}] ${f}: ${s.size} keys`, missing.length||extra.length?`MISSING ${missing} EXTRA ${extra}`:"OK"); } }' ``` **2. ICU syntax** — a malformed plural (e.g. a missing brace) only throws when that exact message renders, which the PT-only E2E may never hit: ```sh node -e ' const fs=require("fs"),path=require("path"); const dir=fs.readdirSync("node_modules/.pnpm").find(d=>d.startsWith("intl-messageformat@")); const {IntlMessageFormat}=require(path.resolve("node_modules/.pnpm",dir,"node_modules/intl-messageformat")); function flat(o,p=""){let r={};for(const k in o){const np=p?p+"."+k:k;if(typeof o[k]==="object"&&o[k]!==null)Object.assign(r,flat(o[k],np));else r[np]=o[k];}return r;} let errors=0; for(const app of ["admin-web","operator-pwa"]){ for(const f of fs.readdirSync(`apps/${app}/messages`).filter(f=>f.endsWith(".json"))){ const loc=f.replace(".json",""); const msgs=flat(JSON.parse(fs.readFileSync(`apps/${app}/messages/${f}`))); for(const [key,val] of Object.entries(msgs)){ try{new IntlMessageFormat(val,loc);}catch(e){console.log(`ICU ERROR [${app}/${loc}] ${key}: ${e.message}`);errors++;} } } } console.log(errors?`${errors} ERRORS`:"ALL ICU OK");' ``` Both should report OK before shipping a new language. ## What is intentionally NOT translated - **Dynamic content** — workstation names, areas, request descriptions. These are customer data, kept in whatever language the user typed. - **Backend tRPC error messages** — almost never surface to the user (the UI has its own messages). - The `Credentials` provider labels in `lib/auth.ts` (internal to Auth.js; the custom login UI is what users actually see). ## Future improvements (not done yet) - Persist the locale per tenant/user in the DB (currently cookie-only). - Show the language switcher on the login/picker pages too. - A small E2E that switches to EN and asserts a translated string (today the EN path is covered only by the parity + ICU checks above). - next-intl strict message-key typing (autocomplete + compile-time error on unknown keys).