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:
@@ -4,11 +4,11 @@ import { useState, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, Camera, X } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { trpc } from '@/lib/trpc/client';
|
||||
import { db } from '@/lib/queue/db';
|
||||
import { runSync } from '@/lib/queue/sync';
|
||||
|
||||
// Resize to max 1600px on longest side and compress to JPEG q=0.8.
|
||||
function compressImage(file: File): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
@@ -44,6 +44,7 @@ function compressImage(file: File): Promise<Blob> {
|
||||
}
|
||||
|
||||
export default function NewRequestPage() {
|
||||
const t = useTranslations('maintenance');
|
||||
const router = useRouter();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -56,7 +57,7 @@ export default function NewRequestPage() {
|
||||
|
||||
const { data: workstations = [], isLoading: wsLoading } = trpc.workstation.list.useQuery(
|
||||
undefined,
|
||||
{ staleTime: 60 * 60 * 1000 }, // 1h — serves from cache when offline
|
||||
{ staleTime: 60 * 60 * 1000 },
|
||||
);
|
||||
|
||||
async function handlePhotoChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
@@ -68,7 +69,7 @@ export default function NewRequestPage() {
|
||||
setPhotoBlob(compressed);
|
||||
setPhotoPreview(URL.createObjectURL(compressed));
|
||||
} catch {
|
||||
setError('Não foi possível processar a foto. Tenta de novo.');
|
||||
setError(t('photoError'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,8 +90,6 @@ export default function NewRequestPage() {
|
||||
try {
|
||||
const clientRequestId = crypto.randomUUID();
|
||||
|
||||
// Enqueue in IndexedDB immediately — returns control to the user
|
||||
// regardless of network state. The SyncProvider will drain the queue.
|
||||
await db.pending.add({
|
||||
clientRequestId,
|
||||
workstationId,
|
||||
@@ -100,12 +99,11 @@ export default function NewRequestPage() {
|
||||
retries: 0,
|
||||
});
|
||||
|
||||
// Attempt immediate sync if online (fire-and-forget)
|
||||
if (navigator.onLine) runSync().catch(() => {});
|
||||
|
||||
router.push(`/maintenance/sent?cid=${clientRequestId}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Erro ao guardar pedido. Tenta de novo.');
|
||||
setError(err instanceof Error ? err.message : t('saveError'));
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
@@ -119,14 +117,14 @@ export default function NewRequestPage() {
|
||||
<Link href="/" className="rounded-md p-1 hover:bg-accent">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<h1 className="text-base font-semibold">Novo pedido de manutenção</h1>
|
||||
<h1 className="text-base font-semibold">{t('newTitle')}</h1>
|
||||
</header>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-1 flex-col gap-6 p-4">
|
||||
{/* Posto */}
|
||||
{/* Workstation */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="workstation" className="text-sm font-medium">
|
||||
Posto <span className="text-destructive">*</span>
|
||||
{t('workstationLabel')} <span className="text-destructive">{t('workstationRequired')}</span>
|
||||
</label>
|
||||
<select
|
||||
id="workstation"
|
||||
@@ -137,7 +135,7 @@ export default function NewRequestPage() {
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-2.5 text-sm disabled:opacity-50"
|
||||
>
|
||||
<option value="">
|
||||
{wsLoading ? 'A carregar postos…' : 'Seleciona um posto…'}
|
||||
{wsLoading ? t('workstationLoading') : t('workstationPlaceholder')}
|
||||
</option>
|
||||
{workstations.map((ws) => (
|
||||
<option key={ws.id} value={ws.id}>
|
||||
@@ -147,13 +145,13 @@ export default function NewRequestPage() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Foto */}
|
||||
{/* Photo */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">Foto (opcional)</span>
|
||||
<span className="text-sm font-medium">{t('photoLabel')}</span>
|
||||
{photoPreview ? (
|
||||
<div className="relative">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={photoPreview} alt="Pré-visualização" className="h-48 w-full rounded-lg object-cover" />
|
||||
<img src={photoPreview} alt={t('photoPreview')} className="h-48 w-full rounded-lg object-cover" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={removePhoto}
|
||||
@@ -169,7 +167,7 @@ export default function NewRequestPage() {
|
||||
className="flex h-24 w-full items-center justify-center gap-2 rounded-lg border-2 border-dashed border-border text-sm text-muted-foreground hover:bg-accent"
|
||||
>
|
||||
<Camera className="h-5 w-5" />
|
||||
Tirar / escolher foto
|
||||
{t('photoButton')}
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
@@ -182,10 +180,10 @@ export default function NewRequestPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Descrição */}
|
||||
{/* Description */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="description" className="flex items-center justify-between text-sm font-medium">
|
||||
<span>Descrição <span className="text-destructive">*</span></span>
|
||||
<span>{t('descriptionLabel')} <span className="text-destructive">{t('descriptionRequired')}</span></span>
|
||||
<span className={`text-xs ${descLen > 1000 ? 'text-destructive' : 'text-muted-foreground'}`}>
|
||||
{descLen}/1000
|
||||
</span>
|
||||
@@ -198,7 +196,7 @@ export default function NewRequestPage() {
|
||||
minLength={3}
|
||||
maxLength={1000}
|
||||
rows={4}
|
||||
placeholder="Descreve o problema…"
|
||||
placeholder={t('descriptionPlaceholder')}
|
||||
className="w-full resize-none rounded-lg border border-border bg-card px-3 py-2.5 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
@@ -213,7 +211,7 @@ export default function NewRequestPage() {
|
||||
disabled={!canSubmit}
|
||||
className="w-full rounded-xl bg-primary px-6 py-4 text-base font-semibold text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||
>
|
||||
{submitting ? 'A guardar…' : 'Enviar pedido'}
|
||||
{submitting ? t('submitting') : t('submit')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user