12 KiB
Plano — i18n (infra + extração PT-PT / EN)
ESTADO: IMPLEMENTADO E VERIFICADO (2026-05-30). Ambas as apps bilingues PT/EN. Verificado: paridade de chaves 85/85 (admin) + 52/52 (operator), 274 mensagens ICU OK,
tsclimpo,test:e2e3/3 +test:e2e:auth4/4. Guia de manutenção e de adicionar línguas emdocs/i18n.md.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 viatoLocaleString('pt-PT')fixo (report-view.tsx). <html lang>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)
- 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). - SEM i18n routing (modo "without i18n routing" do next-intl). O locale não vai no URL (
/en/...). Vem de um cookieNEXT_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
<Link href>existente.
- 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). - Mensagens por-app, não um package partilhado. Cada app tem
messages/pt.json+messages/en.jsoncom namespaces internos. Só ~9 strings se repetem entre apps (estados, turnos, "Entrar") — não justifica um@repo/i18npartilhado e a complexidade de monorepo que traz. (Extrair umcommonpartilhado fica para quando houver um 3º consumidor.) - Datas/números/tempos relativos passam a usar o formatter do next-intl (
useFormatter/getFormatter), nãotoLocaleString('pt-PT'). O locale do formatter vem do contexto → datas ficam localizadas automaticamente. - 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 # <html lang={locale}>, 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, estadosstatus.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
export const LOCALES = ['pt', 'en'] as const;
export type Locale = (typeof LOCALES)[number];
export const DEFAULT_LOCALE: Locale = 'pt';
export const LOCALE_LABELS: Record<Locale, string> = { 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)
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
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
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 (
<html lang={locale}>
<body>
<NextIntlClientProvider locale={locale} messages={messages}>
<Providers>{children}</Providers>
</NextIntlClientProvider>
</body>
</html>
);
}
Nota: o
NextIntlClientProviderenvolve osProvidersexistentes (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'})eformat.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)
'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; <html lang> 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 --noEmitlimpo.
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 <html lang="en"> → dinâmico; seletor de idioma na home (ou no picker).
AC:
- operator-pwa 100% PT (verbatim) + EN.
pnpm test:e2ehappy-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
withNextIntlcomwithPWAno operator. Mitigação:withPWA(withNextIntl(nextConfig)); testar que o SW ainda gera e que a app arranca. - Risco — Server vs Client.
useTranslationssó em client components;getTranslationsem 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).