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:
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export default function ErrorPage({
|
||||
error,
|
||||
@@ -9,16 +10,18 @@ export default function ErrorPage({
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const t = useTranslations('errors');
|
||||
|
||||
useEffect(() => {
|
||||
console.error(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center gap-4 p-6 text-center">
|
||||
<h1 className="text-4xl font-bold">500</h1>
|
||||
<p className="text-muted-foreground">Ocorreu um erro inesperado.</p>
|
||||
<h1 className="text-4xl font-bold">{t('title500')}</h1>
|
||||
<p className="text-muted-foreground">{t('message500')}</p>
|
||||
<button onClick={reset} className="text-sm underline underline-offset-4">
|
||||
Tentar novamente
|
||||
{t('retry')}
|
||||
</button>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useLocale } from 'next-intl';
|
||||
import { LOCALES, LOCALE_LABELS, type Locale } from '@/i18n/locales';
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const router = useRouter();
|
||||
const current = useLocale() as Locale;
|
||||
|
||||
function switchTo(locale: Locale) {
|
||||
document.cookie = `NEXT_LOCALE=${locale}; path=/; max-age=31536000; SameSite=Lax`;
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 rounded-full border border-border bg-muted p-0.5">
|
||||
{LOCALES.map((l) => (
|
||||
<button
|
||||
key={l}
|
||||
onClick={() => switchTo(l)}
|
||||
className={`rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors ${
|
||||
l === current
|
||||
? 'bg-card text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{LOCALE_LABELS[l]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,24 @@
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getLocale, getMessages, getTranslations } from 'next-intl/server';
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { Providers } from './providers';
|
||||
import { SyncProvider } from './sync-provider';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'FieldOps — Operator',
|
||||
description: 'Industrial operator console.',
|
||||
manifest: '/manifest.webmanifest',
|
||||
applicationName: 'FieldOps Operator',
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
title: 'FieldOps Operator',
|
||||
statusBarStyle: 'default',
|
||||
},
|
||||
};
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations('metadata');
|
||||
return {
|
||||
title: t('title'),
|
||||
description: t('description'),
|
||||
manifest: '/manifest.webmanifest',
|
||||
applicationName: t('appName'),
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
title: t('appName'),
|
||||
statusBarStyle: 'default',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: '#0f172a',
|
||||
@@ -21,13 +26,18 @@ export const viewport: Viewport = {
|
||||
initialScale: 1,
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const locale = await getLocale();
|
||||
const messages = await getMessages();
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang={locale}>
|
||||
<body className="min-h-screen bg-background font-sans antialiased">
|
||||
<Providers>
|
||||
<SyncProvider>{children}</SyncProvider>
|
||||
</Providers>
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
<Providers>
|
||||
<SyncProvider>{children}</SyncProvider>
|
||||
</Providers>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { CheckCircle2, Clock } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { db } from '@/lib/queue/db';
|
||||
import { subscribeBroadcast } from '@/lib/queue/broadcast';
|
||||
|
||||
export function SentStatus({ cid }: { cid: string }) {
|
||||
const [inQueue, setInQueue] = useState<boolean | null>(null); // null = loading
|
||||
const t = useTranslations('maintenance');
|
||||
const [inQueue, setInQueue] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function check() {
|
||||
@@ -35,7 +37,7 @@ export function SentStatus({ cid }: { cid: string }) {
|
||||
<CheckCircle2 className="h-16 w-16 text-green-500" />
|
||||
)}
|
||||
<h1 className="text-2xl font-bold">
|
||||
{pending ? 'Pedido em fila' : 'Pedido enviado'}
|
||||
{pending ? t('pendingTitle') : t('sentTitle')}
|
||||
</h1>
|
||||
{cid && (
|
||||
<p className="font-mono text-xs text-muted-foreground" data-testid="request-cid">
|
||||
@@ -43,16 +45,14 @@ export function SentStatus({ cid }: { cid: string }) {
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{pending
|
||||
? 'Será enviado assim que a ligação for restabelecida.'
|
||||
: 'A equipa de manutenção foi notificada e irá tratar do problema.'}
|
||||
{pending ? t('pendingMessage') : t('sentMessage')}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded-xl bg-primary px-8 py-3 font-semibold text-primary-foreground hover:opacity-90"
|
||||
>
|
||||
Voltar ao início
|
||||
{t('backHome')}
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
export default function NotFound() {
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
export default async function NotFound() {
|
||||
const t = await getTranslations('errors');
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center gap-4 p-6 text-center">
|
||||
<h1 className="text-4xl font-bold">404</h1>
|
||||
<p className="text-muted-foreground">Página não encontrada.</p>
|
||||
<h1 className="text-4xl font-bold">{t('title404')}</h1>
|
||||
<p className="text-muted-foreground">{t('message404')}</p>
|
||||
<a href="/" className="text-sm underline underline-offset-4">
|
||||
Voltar ao início
|
||||
{t('backHome')}
|
||||
</a>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import Link from 'next/link';
|
||||
import { Wrench } from 'lucide-react';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { resolveUser } from '@/lib/auth';
|
||||
import { api } from '@/lib/trpc/server';
|
||||
import { SignOutButton } from './sign-out-button';
|
||||
import { StatusBadge } from './status-badge';
|
||||
import { SyncChip } from './sync-chip';
|
||||
import { LanguageSwitcher } from './language-switcher';
|
||||
|
||||
export default async function HomePage() {
|
||||
const t = await getTranslations('home');
|
||||
const user = await resolveUser();
|
||||
|
||||
// myRecent is a protectedProcedure — fails gracefully when there is no session.
|
||||
type RecentItem = Awaited<ReturnType<typeof api.maintenanceRequest.myRecent>>[number];
|
||||
let recent: RecentItem[] = [];
|
||||
try {
|
||||
@@ -23,12 +25,15 @@ export default async function HomePage() {
|
||||
{/* ── Header ── */}
|
||||
<header className="flex items-center justify-between border-b border-border bg-card px-4 py-3">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Operador</p>
|
||||
<p className="text-xs text-muted-foreground">{t('operator')}</p>
|
||||
<p className="text-sm font-medium" data-testid="current-user">
|
||||
{user?.email ?? '—'}
|
||||
</p>
|
||||
</div>
|
||||
<SignOutButton />
|
||||
<div className="flex items-center gap-2">
|
||||
<LanguageSwitcher />
|
||||
<SignOutButton />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-6 p-4">
|
||||
@@ -42,15 +47,15 @@ export default async function HomePage() {
|
||||
className="flex items-center justify-center gap-3 rounded-2xl bg-primary px-6 py-10 text-lg font-semibold text-primary-foreground shadow-sm transition-opacity hover:opacity-90 active:scale-[0.98]"
|
||||
>
|
||||
<Wrench className="h-6 w-6" />
|
||||
Pedir manutenção
|
||||
{t('requestMaintenance')}
|
||||
</Link>
|
||||
|
||||
{/* ── Recent requests ── */}
|
||||
<section>
|
||||
<h2 className="mb-3 text-sm font-medium text-muted-foreground">Os meus pedidos</h2>
|
||||
<h2 className="mb-3 text-sm font-medium text-muted-foreground">{t('myRequests')}</h2>
|
||||
|
||||
{recent.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Nenhum pedido ainda.</p>
|
||||
<p className="text-sm text-muted-foreground">{t('noRequests')}</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{recent.map((req) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { signOut } from 'next-auth/react';
|
||||
|
||||
export function SignOutButton() {
|
||||
const t = useTranslations('auth');
|
||||
return (
|
||||
<button
|
||||
onClick={() => signOut({ callbackUrl: '/select-operator' })}
|
||||
className="text-xs text-muted-foreground underline-offset-2 hover:underline"
|
||||
>
|
||||
Trocar
|
||||
{t('switchOperator')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
const CONFIG = {
|
||||
OPEN: { label: 'Aberto', className: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' },
|
||||
CLAIMED: { label: 'Em curso', className: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' },
|
||||
RESOLVED: { label: 'Resolvido',className: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' },
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
const STATUS_CLASS = {
|
||||
OPEN: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400',
|
||||
CLAIMED: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
RESOLVED: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
|
||||
} as const;
|
||||
|
||||
export function StatusBadge({ status }: { status: keyof typeof CONFIG }) {
|
||||
const { label, className } = CONFIG[status];
|
||||
type Status = keyof typeof STATUS_CLASS;
|
||||
|
||||
export function StatusBadge({ status }: { status: Status }) {
|
||||
const t = useTranslations('common');
|
||||
return (
|
||||
<span className={`shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium ${className}`}>
|
||||
{label}
|
||||
<span className={`shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium ${STATUS_CLASS[status]}`}>
|
||||
{t(`status.${status.toLowerCase() as 'open' | 'claimed' | 'resolved'}`)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useSyncState } from './sync-provider';
|
||||
|
||||
export function SyncChip() {
|
||||
const t = useTranslations('sync');
|
||||
const { pendingCount, deadLetterCount } = useSyncState();
|
||||
|
||||
if (deadLetterCount > 0) {
|
||||
return (
|
||||
<div className="rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{deadLetterCount} pedido{deadLetterCount > 1 ? 's' : ''} com erro — contacta o supervisor.
|
||||
{t('deadLetters', { count: deadLetterCount })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +19,7 @@ export function SyncChip() {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-orange-50 px-3 py-2 text-xs text-orange-700">
|
||||
<span className="h-2 w-2 rounded-full bg-orange-400" />
|
||||
{pendingCount} pedido{pendingCount > 1 ? 's' : ''} por enviar
|
||||
{t('pending', { count: pendingCount })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +27,7 @@ export function SyncChip() {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-green-50 px-3 py-2 text-xs text-green-700">
|
||||
<span className="h-2 w-2 rounded-full bg-green-500" />
|
||||
Tudo sincronizado
|
||||
{t('synced')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { subscribeBroadcast, type SyncMessage } from '@/lib/queue/broadcast';
|
||||
import { runSync } from '@/lib/queue/sync';
|
||||
import { db } from '@/lib/queue/db';
|
||||
@@ -22,6 +23,7 @@ const SyncCtx = createContext<SyncState>({ pendingCount: 0, deadLetterCount: 0 }
|
||||
export const useSyncState = () => useContext(SyncCtx);
|
||||
|
||||
export function SyncProvider({ children }: { children: ReactNode }) {
|
||||
const t = useTranslations('sync');
|
||||
const [state, setState] = useState<SyncState>({ pendingCount: 0, deadLetterCount: 0 });
|
||||
const [failedIds, setFailedIds] = useState<string[]>([]);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
@@ -85,11 +87,11 @@ export function SyncProvider({ children }: { children: ReactNode }) {
|
||||
key={id}
|
||||
className="flex items-center justify-between rounded-lg bg-destructive px-4 py-3 text-sm text-destructive-foreground shadow-lg"
|
||||
>
|
||||
<span>Pedido {id.slice(0, 8)}… falhou — contacta o supervisor.</span>
|
||||
<span>{t('requestFailed', { id: id.slice(0, 8) })}</span>
|
||||
<button
|
||||
onClick={() => setFailedIds((prev) => prev.filter((x) => x !== id))}
|
||||
className="ml-4 shrink-0 opacity-80 hover:opacity-100"
|
||||
aria-label="Fechar"
|
||||
aria-label={t('close')}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user