# Plano — i18n (infra + extração PT-PT / EN) > Autor: Opus 4.8 (sessão de design, 2026-05-30). Destinado a implementação pelo Sonnet. > Pré-requisitos: MAI CALL v0.1 + Auth v0.2 + v0.3 + verificação E2E, todos implementados. Estado verificado contra o repo. > **Motivo:** Pedro quer a app pronta para vários idiomas antes de empilhar mais módulos (o custo de extrair strings cresce com cada módulo). Começar com **PT-PT (default) + EN**. Tradução real só destas duas; a infra fica pronta para adicionar línguas com um ficheiro `.json`. ## Objetivo numa frase Toda a UI das duas apps passa de **texto fixo em português** para **chaves de tradução** (`t('...')`), com ficheiros `pt.json` + `en.json` por app, e um seletor de idioma — sem mexer nas rotas nem partir os testes E2E. ## Estado atual (medido) - **148 strings** hardcoded em **23 ficheiros** (59 em operator-pwa, 89 em admin-web). Inventário completo confirmado por levantamento. - Inclui strings **dinâmicas** (`Reportado por {email}`, `há {n}m`, `{count} pedidos`) e **datas** via `toLocaleString('pt-PT')` fixo (report-view.tsx). - `` está **inconsistente**: operator-pwa diz `"en"` (errado, app é PT), admin-web diz `"pt"`. Ambos passam a dinâmicos. ## Decisões fixadas (não revisitar sem motivo forte) 1. **Biblioteca: `next-intl`** (padrão de facto para Next 15 App Router, suporta Server e Client Components, formatação de datas/números/plurais por locale via ICU). 2. **SEM i18n routing** (modo "without i18n routing" do next-intl). O locale **não** vai no URL (`/en/...`). Vem de um **cookie `NEXT_LOCALE`**. Justificação crítica: - É uma app **interna** (sem SEO) — URLs por locale não trazem valor. - **Não precisa do middleware do next-intl** → **não colide com o middleware de auth** que já existe em ambas as apps. Este é o ponto que torna a integração barata. - Não muda nenhuma rota nem `` existente. 3. **Default = `pt`.** Cookie ausente ou inválido → `pt`. Isto é deliberado e **protege os testes E2E**: os specs procuram texto PT exato; com PT como default, passam sem alteração — **desde que as traduções PT sejam verbatim das strings atuais** (copiar exatamente, incluindo reticências `…`, acentos, e maiúsculas). 4. **Mensagens por-app**, não um package partilhado. Cada app tem `messages/pt.json` + `messages/en.json` com namespaces internos. Só ~9 strings se repetem entre apps (estados, turnos, "Entrar") — não justifica um `@repo/i18n` partilhado e a complexidade de monorepo que traz. (Extrair um `common` partilhado fica para quando houver um 3º consumidor.) 5. **Datas/números/tempos relativos** passam a usar o **formatter do next-intl** (`useFormatter`/`getFormatter`), não `toLocaleString('pt-PT')`. O locale do formatter vem do contexto → datas ficam localizadas automaticamente. 6. **Persistência do locale: só cookie (+ seletor) nesta fase.** Guardar a preferência por-tenant ou por-utilizador na BD fica para depois (corte consciente — ver §Cortes). O cookie não fecha essa porta. ## Estrutura de ficheiros (por app, exemplo admin-web) ``` apps/admin-web/ i18n/ request.ts # getRequestConfig — lê o cookie, carrega as mensagens locales.ts # LOCALES = ['pt','en'] as const, DEFAULT_LOCALE='pt', labels messages/ pt.json en.json app/ layout.tsx # , NextIntlClientProvider (via Providers) language-switcher.tsx # client component: PT | EN → set cookie + router.refresh() next.config.ts # withNextIntl(...) composto com o config existente ``` Namespaces sugeridos (chaves dentro do JSON), para manter organizado: - `common` — botões/labels repetidos: `enter`, `cancel`, `confirm`, `back`, `loading`, `close`, estados `status.open/claimed/resolved`. - `auth` — login/picker. - `home` — home do operador. - `maintenance` — fila + criar pedido. - `report` — relatório de turno. - `errors` — 404/500/genéricos. ## Abordagem técnica (exemplos — implementar exatamente neste padrão) ### `i18n/locales.ts` ```ts export const LOCALES = ['pt', 'en'] as const; export type Locale = (typeof LOCALES)[number]; export const DEFAULT_LOCALE: Locale = 'pt'; export const LOCALE_LABELS: Record = { pt: 'PT', en: 'EN' }; export function isLocale(v: string | undefined): v is Locale { return !!v && (LOCALES as readonly string[]).includes(v); } ``` ### `i18n/request.ts` (next-intl, sem routing) ```ts import { getRequestConfig } from 'next-intl/server'; import { cookies } from 'next/headers'; import { DEFAULT_LOCALE, isLocale } from './locales'; export default getRequestConfig(async () => { const cookie = (await cookies()).get('NEXT_LOCALE')?.value; const locale = isLocale(cookie) ? cookie : DEFAULT_LOCALE; return { locale, messages: (await import(`../messages/${locale}.json`)).default, }; }); ``` ### `next.config.ts` ```ts import createNextIntlPlugin from 'next-intl/plugin'; const withNextIntl = createNextIntlPlugin('./i18n/request.ts'); // admin-web: export default withNextIntl(nextConfig); // operator-pwa: export default withPWA(withNextIntl(nextConfig)); // compor com o PWA ``` ### `app/layout.tsx` ```tsx import { NextIntlClientProvider } from 'next-intl'; import { getLocale, getMessages } from 'next-intl/server'; export default async function RootLayout({ children }) { const locale = await getLocale(); const messages = await getMessages(); return ( {children} ); } ``` > Nota: o `NextIntlClientProvider` envolve os `Providers` existentes (client). As mensagens são passadas do server → client uma vez. ### Uso nos componentes - **Client component:** `const t = useTranslations('report'); ... t('print')`. - **Server component:** `const t = await getTranslations('home');`. - **Datas/tempos:** `const format = useFormatter(); format.dateTime(d, {day:'2-digit', month:'2-digit', hour:'2-digit', minute:'2-digit'})` e `format.relativeTime(date)` para os "há 5m". Remove o `'pt-PT'` fixo. - **Plurais** (`{count} pedido(s)`): ICU no JSON → `"pendingCount": "{count, plural, one {# pedido por enviar} other {# pedidos por enviar}}"` (pt) / `one {# request pending} other {# requests pending}` (en). ### `language-switcher.tsx` (client) ```tsx 'use client'; // dois botões PT | EN. onClick: // document.cookie = `NEXT_LOCALE=${l}; path=/; max-age=31536000`; // router.refresh(); // re-render server components com o novo locale ``` Colocar: na home do operador (`app/page.tsx` header) e no header da fila (`maintenance-queue.tsx`). ## `lib/shifts.ts` — caso especial Os labels `Manhã/Tarde/Noite` estão numa constante de lógica, não num componente. **Não traduzir no shifts.ts.** Em vez disso, manter as `ShiftKey` (`manha/tarde/noite`) e traduzir no componente: `t('report.shift.' + key)`. O `shifts.ts` perde o campo `label` (ou mantém-no só como fallback técnico não usado na UI). ## Passos de implementação (ordenados) ### Passo 1 — admin-web completo (infra + extração + seletor) **Faz:** toda a infra (§Estrutura + §Abordagem) na admin-web; extrai as **89 strings** para `messages/pt.json` (verbatim) + traduz para `messages/en.json`; datas do report via formatter; `` dinâmico; seletor de idioma no header da fila; trata os plurais e o `shifts.ts`. **AC:** - App arranca; com cookie ausente está 100% em PT, **idêntica ao atual** (texto verbatim). - Seletor → EN traduz toda a UI da admin-web; volta a PT sem recarregar manualmente. - `pnpm test:e2e` (a parte da admin: report + queue) **continua verde sem alterar os specs** (porque PT é default e verbatim). - `cd apps/admin-web && npx tsc --noEmit` limpo. ### Passo 2 — operator-pwa completo **Faz:** mesma infra (atenção: **compor `withNextIntl` com `withPWA`** no next.config); extrai as **59 strings**; trata `timeAgo`/`sync-chip`/`status-badge` (plurais ICU); corrige `` → dinâmico; seletor de idioma na home (ou no picker). **AC:** - operator-pwa 100% PT (verbatim) + EN. - `pnpm test:e2e` happy-path **verde sem alterar specs**; `pnpm test:e2e:auth` (login operador usa "Entrar", "PIN incorreto…") **verde** — confirmar que essas strings PT estão verbatim. - typecheck limpo. O Service Worker (PWA) continua a funcionar (o config compõe, não substitui). ### Passo 3 — Limpeza, cortes e verificação final **Faz:** documentar os cortes (abaixo); README ganha uma secção curta "Idiomas" (como mudar, como adicionar uma língua = novo `.json`); correr a bateria toda. **AC:** `pnpm typecheck` (sem novos erros além do pré-existente em @repo/storage), `pnpm test:e2e` (3/3) e `pnpm test:e2e:auth` (4/4) verdes. README documenta adicionar um idioma. ## Cortes propositados — o que NÃO entra | Cortado | Porquê | Quando volta | |---|---|---| | Persistir locale por-tenant/utilizador na BD | Cookie + seletor chega para PT/EN; não fecha a porta | quando um cliente pedir locale fixo por fábrica | | Traduzir mensagens de erro do tRPC (backend) | Quase nunca chegam ao utilizador (a UI tem mensagens próprias); 1 só está em PT | se passarem a ser mostradas ao utilizador | | Strings do `Credentials` provider em `lib/auth.ts` (`name`/`label`) | São do fluxo interno do Auth.js; usamos UI própria (picker/form), nunca se veem | se usarmos a UI default do Auth.js | | `pages/_error.tsx` (Pages Router legacy) | Fallback que praticamente não dispara no App Router | se virar relevante | | Conteúdo dinâmico (nomes de postos, áreas, descrições) | São dados do cliente, ficam na língua dele — não é tradução | nunca (por design) | | Línguas além de PT/EN, RTL | Fora de âmbito agora | quando houver cliente que peça | ## Sequência crítica e riscos - **Fazer a admin-web inteira primeiro (Passo 1)** valida a abordagem numa app sem PWA antes de a replicar. Só depois o operator-pwa (Passo 2), que tem o wrapper do PWA a compor. - **Risco principal — partir os E2E.** Os specs procuram texto PT exato. Mitigação tripla: (a) PT é o default; (b) as traduções PT são **verbatim** (copiar, não reescrever); (c) correr ambos os E2E no fim de cada passo. - **Risco — compor `withNextIntl` com `withPWA`** no operator. Mitigação: `withPWA(withNextIntl(nextConfig))`; testar que o SW ainda gera e que a app arranca. - **Risco — Server vs Client.** `useTranslations` só em client components; `getTranslations` em server. O inventário marca quais são quais; em caso de dúvida, o componente que já usa `'use client'` usa o hook. - Nada disto toca em lógica de negócio, API, ou base de dados → o risco é de UI/config, apanhável pelos E2E. ## Anexo — onde estão as 148 strings (do inventário) **operator-pwa (59):** layout.tsx(3), page.tsx(4), error.tsx(3), not-found.tsx(3), select-operator/page.tsx(2), select-operator/operator-picker.tsx(8), sign-out-button.tsx(1), status-badge.tsx(3), sync-chip.tsx(3), maintenance/new/page.tsx(14), maintenance/sent/sent-status.tsx(5), sync-provider.tsx(2), lib/auth.ts(3 — corte), pages/_error.tsx(2 — corte). **admin-web (89):** layout.tsx(2), login/page.tsx(2), login/login-form.tsx(7), maintenance/maintenance-queue.tsx(31), maintenance/report/report-view.tsx(35), maintenance/report/page.tsx(1), lib/shifts.ts(3 — via componente), lib/auth.ts(3 — corte), pages/_error.tsx(2 — corte).