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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user