O que mudou
Infra (por app):
i18n/locales.ts — lista de locales (pt, en), default pt, labels para o seletor
i18n/request.ts — lê o cookie NEXT_LOCALE, carrega as mensagens
messages/pt.json + messages/en.json — todas as strings extraídas
next.config.ts — envolvido com withNextIntl (operator-pwa: withPWA(withNextIntl(...)))
app/layout.tsx — <html lang={locale}> dinâmico, NextIntlClientProvider
app/language-switcher.tsx — seletor PT | EN (cookie + router.refresh())
23 ficheiros de UI atualizados — todos os textos visíveis agora usam t('...') ou getTranslations.
Datas no relatório passaram de toLocaleString('pt-PT') fixo para useFormatter() do next-intl — localizam-se automaticamente.
Plurais em ICU no sync-chip: {count, plural, one {# pedido...} other {# pedidos...}}.
Resultado dos testes:
pnpm test:e2e — 3/3 ✓
pnpm test:e2e:auth — 4/4 ✓
tsc --noEmit em ambas as apps — limpo ✓
Para adicionar uma língua futura: criar messages/<locale>.json + adicionar o locale a i18n/locales.ts em cada app. O seletor aparece automaticamente.
139 lines
6.0 KiB
Markdown
139 lines
6.0 KiB
Markdown
# 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/<app>/
|
||
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 # <html lang={locale}>, 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<Locale, string> = { 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).
|