localization support

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.
This commit is contained in:
2026-05-30 16:46:07 +01:00
parent 2093f12d0a
commit 35e7027881
41 changed files with 1549 additions and 259 deletions
@@ -2,6 +2,7 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { signIn } from 'next-auth/react';
import { ArrowLeft, Delete } from 'lucide-react';
@@ -10,8 +11,6 @@ interface Operator {
email: string;
}
// ── State types ──────────────────────────────────────────────────────────────
type PickerState =
| { step: 'list' }
| { step: 'pin'; operator: Operator };
@@ -19,20 +18,18 @@ type PickerState =
const PIN_MIN = 4;
const PIN_MAX = 6;
// ── Sub-components ───────────────────────────────────────────────────────────
function OperatorList({
operators,
onSelect,
t,
}: {
operators: Operator[];
onSelect: (op: Operator) => void;
t: ReturnType<typeof useTranslations<'auth'>>;
}) {
if (operators.length === 0) {
return (
<p className="text-sm text-muted-foreground">
Nenhum operador encontrado. Execute <code>pnpm db:seed</code>.
</p>
<p className="text-sm text-muted-foreground">{t('noOperators')}</p>
);
}
return (
@@ -53,9 +50,13 @@ function OperatorList({
function PinPad({
operator,
onBack,
t,
tc,
}: {
operator: Operator;
onBack: () => void;
t: ReturnType<typeof useTranslations<'auth'>>;
tc: ReturnType<typeof useTranslations<'common'>>;
}) {
const router = useRouter();
const [digits, setDigits] = useState('');
@@ -85,14 +86,14 @@ function PinPad({
});
if (result?.error) {
setDigits('');
setError('PIN incorreto ou conta bloqueada. Tente novamente.');
setError(t('invalidPin'));
} else {
router.push('/');
router.refresh();
}
} catch {
setDigits('');
setError('Erro inesperado. Tente novamente.');
setError(t('unexpectedError'));
} finally {
setBusy(false);
}
@@ -108,12 +109,12 @@ function PinPad({
onClick={onBack}
disabled={busy}
className="rounded-lg p-2 hover:bg-accent disabled:opacity-50"
aria-label="Voltar"
aria-label={t('back')}
>
<ArrowLeft className="h-5 w-5" />
</button>
<div>
<p className="text-xs text-muted-foreground">Operador selecionado</p>
<p className="text-xs text-muted-foreground">{t('operatorSelected')}</p>
<p className="text-sm font-medium">{operator.email}</p>
</div>
</div>
@@ -150,7 +151,7 @@ function PinPad({
onClick={erase}
disabled={busy || digits.length === 0}
className="flex items-center justify-center rounded-2xl border border-border bg-card py-5 text-lg font-medium transition-colors hover:bg-accent active:scale-[0.97] disabled:opacity-40"
aria-label="Apagar"
aria-label={t('deleteDigit')}
>
<Delete className="h-5 w-5" />
</button>
@@ -175,15 +176,15 @@ function PinPad({
disabled={digits.length < PIN_MIN || busy}
className="w-full rounded-xl bg-primary py-4 text-base font-semibold text-primary-foreground transition-opacity hover:opacity-90 active:scale-[0.98] disabled:opacity-40"
>
{busy ? 'A entrar…' : 'Entrar'}
{busy ? tc('entering') : tc('enter')}
</button>
</div>
);
}
// ── Main component ───────────────────────────────────────────────────────────
export function OperatorPicker({ operators }: { operators: Operator[] }) {
const t = useTranslations('auth');
const tc = useTranslations('common');
const [state, setState] = useState<PickerState>({ step: 'list' });
if (state.step === 'pin') {
@@ -191,6 +192,8 @@ export function OperatorPicker({ operators }: { operators: Operator[] }) {
<PinPad
operator={state.operator}
onBack={() => setState({ step: 'list' })}
t={t}
tc={tc}
/>
);
}
@@ -199,6 +202,7 @@ export function OperatorPicker({ operators }: { operators: Operator[] }) {
<OperatorList
operators={operators}
onSelect={(op) => setState({ step: 'pin', operator: op })}
t={t}
/>
);
}
@@ -1,4 +1,5 @@
import { redirect } from 'next/navigation';
import { getTranslations } from 'next-intl/server';
import { prisma } from '@repo/db';
import { resolveUser } from '@/lib/auth';
import { OperatorPicker } from './operator-picker';
@@ -7,6 +8,7 @@ import { OperatorPicker } from './operator-picker';
// the login step. prisma is used directly (bypassing the tRPC auth layer) so
// the page works even when AUTH_DEV_AUTOLOGIN=false.
export default async function SelectOperatorPage() {
const t = await getTranslations('auth');
const user = await resolveUser();
if (user) redirect('/');
@@ -19,8 +21,8 @@ export default async function SelectOperatorPage() {
return (
<main className="mx-auto flex min-h-screen max-w-sm flex-col justify-center gap-8 p-6">
<header className="text-center">
<h1 className="text-2xl font-bold tracking-tight">Quem és tu?</h1>
<p className="mt-1 text-sm text-muted-foreground">Escolhe o teu perfil para continuar.</p>
<h1 className="text-2xl font-bold tracking-tight">{t('pickerTitle')}</h1>
<p className="mt-1 text-sm text-muted-foreground">{t('pickerSubtitle')}</p>
</header>
<OperatorPicker operators={operators} />
</main>