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.
176 lines
11 KiB
Markdown
176 lines
11 KiB
Markdown
# 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).
|
|
- `<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)
|
|
|
|
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 `<Link href>` 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 # <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`, 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<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)
|
|
```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 (
|
|
<html lang={locale}>
|
|
<body>
|
|
<NextIntlClientProvider locale={locale} messages={messages}>
|
|
<Providers>{children}</Providers>
|
|
</NextIntlClientProvider>
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|
|
```
|
|
> 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; `<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 --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 `<html lang="en">` → 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).
|