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:
Pedro Gomes 2026-05-30 16:46:07 +01:00
parent 2093f12d0a
commit 35e7027881
41 changed files with 1549 additions and 259 deletions

View File

@ -22,14 +22,13 @@ AUTH_SECRET="dev-secret-do-not-use-in-production-please-change-me"
# must consciously opt in by editing their .env. See README "Auth" section. # must consciously opt in by editing their .env. See README "Auth" section.
AUTH_DEV_AUTOLOGIN="false" AUTH_DEV_AUTOLOGIN="false"
# Base URL Auth.js uses to build callback/redirect URLs. It MUST match the host # Base URL Auth.js uses to build callback/redirect URLs. Must match the host of
# of the app being served. This shared .env can hold only ONE value, set here to # the app being served. This shared .env holds ONE value — the operator-pwa
# the operator-pwa (:3000). The admin-web (:3001) therefore needs its OWN # (:3000). The admin-web (:3001) gets its own from apps/admin-web/.env.admin,
# AUTH_URL whenever autologin is OFF (real-login dev, or production) — otherwise # which its `dev` script loads with precedence over this file — so admin login
# Auth.js redirects admin users to :3000 and the admin login breaks. # works with autologin OFF without any extra step.
# - Local with autologin ON: this value is harmless (middleware never redirects). # - Production: each app still gets its own AUTH_URL from the deploy env
# - E2E real-login: e2e/playwright.auth.config.ts passes AUTH_URL=:3001 to admin. # (a value already in the process wins over .env.admin).
# - Production: give EACH app its own AUTH_URL (per-app env), not this shared file.
NEXT_PUBLIC_APP_URL="http://localhost:3000" NEXT_PUBLIC_APP_URL="http://localhost:3000"
AUTH_URL="http://localhost:3000" AUTH_URL="http://localhost:3000"

View File

@ -232,6 +232,28 @@ you want end-to-end confidence before a demo:
--- ---
## Languages (i18n)
Both apps support **Portuguese (PT, default) and English (EN)**. The language is
stored in a `NEXT_LOCALE` cookie and selected via the **PT | EN** switcher in the
app header.
### Changing language
Click the **PT | EN** pill in the header of either app. The preference is saved
in a cookie (1-year expiry) — no account change required.
### Adding a new language
1. Create `apps/<app>/messages/<locale>.json` (copy `en.json` as a starting point).
2. Add the locale to `LOCALES` in `apps/<app>/i18n/locales.ts`.
3. That's it — the switcher picks it up automatically.
See **[docs/i18n.md](docs/i18n.md)** for the full guide, including the
key-parity and ICU validation scripts to run before shipping a new language.
---
## Common commands ## Common commands
| Command | What it does | | Command | What it does |
@ -299,12 +321,12 @@ by changing the endpoint env var.
**`Tenant not found`** — the seed was wiped. Run `pnpm db:seed`. **`Tenant not found`** — the seed was wiped. Run `pnpm db:seed`.
**Admin login redirects to `:3000` / the operator picker** — the shared `.env` **Admin login redirects to `:3000` / the operator picker** — the admin-web needs
sets `AUTH_URL=http://localhost:3000`, which is correct for the operator-pwa but its own `AUTH_URL=http://localhost:3001` (the shared `.env` points at the operator
wrong for the admin-web (:3001). With `AUTH_DEV_AUTOLOGIN=true` it never bites on :3000). This is handled automatically: the admin `dev` script loads
(the middleware doesn't redirect). With autologin OFF, give the admin-web its own `apps/admin-web/.env.admin` with precedence. If you deleted or edited that file
`AUTH_URL=http://localhost:3001` (the E2E auth config does this). In production, and hit this, restore `AUTH_URL="http://localhost:3001"` there. In production,
each app must have its own `AUTH_URL`. each app gets its own `AUTH_URL` from the deploy environment.
**`DATABASE_URL not found`** — `.env` is missing or Docker Postgres is not **`DATABASE_URL not found`** — `.env` is missing or Docker Postgres is not
running. Run `docker compose up -d` then retry. running. Run `docker compose up -d` then retry.

View File

@ -0,0 +1,9 @@
# Per-app AUTH_URL for admin-web (port 3001).
#
# The shared root .env sets AUTH_URL=http://localhost:3000 (correct for the
# operator-pwa). Auth.js needs each app to know its OWN base URL, otherwise the
# admin redirects unauthenticated users to :3000 and login breaks when autologin
# is off. This file gives admin-web its own value; the dev/start scripts load it
# BEFORE the root .env, and dotenv never overrides an already-set variable, so
# this wins. Not a secret — safe to commit.
AUTH_URL="http://localhost:3001"

View File

@ -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>
);
}

View File

@ -1,17 +1,27 @@
import { NextIntlClientProvider } from 'next-intl';
import { getLocale, getMessages, getTranslations } from 'next-intl/server';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { Providers } from './providers'; import { Providers } from './providers';
import './globals.css'; import './globals.css';
export const metadata: Metadata = { export async function generateMetadata(): Promise<Metadata> {
title: 'FieldOps — Manutenção', const t = await getTranslations('metadata');
description: 'Backoffice de manutenção industrial.', return {
title: t('title'),
description: t('description'),
}; };
}
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const locale = await getLocale();
const messages = await getMessages();
export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="pt"> <html lang={locale}>
<body className="min-h-screen bg-background font-sans antialiased"> <body className="min-h-screen bg-background font-sans antialiased">
<NextIntlClientProvider locale={locale} messages={messages}>
<Providers>{children}</Providers> <Providers>{children}</Providers>
</NextIntlClientProvider>
</body> </body>
</html> </html>
); );

View File

@ -2,10 +2,13 @@
import { useState, type FormEvent } from 'react'; import { useState, type FormEvent } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { signIn } from 'next-auth/react'; import { signIn } from 'next-auth/react';
export function LoginForm() { export function LoginForm() {
const router = useRouter(); const router = useRouter();
const t = useTranslations('auth');
const tc = useTranslations('common');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -20,13 +23,13 @@ export function LoginForm() {
try { try {
const result = await signIn('credentials', { email, password, redirect: false }); const result = await signIn('credentials', { email, password, redirect: false });
if (result?.error) { if (result?.error) {
setError('Email ou password incorretos. Tente novamente.'); setError(t('invalidCredentials'));
} else { } else {
router.push('/maintenance'); router.push('/maintenance');
router.refresh(); router.refresh();
} }
} catch { } catch {
setError('Erro inesperado. Tente novamente.'); setError(t('unexpectedError'));
} finally { } finally {
setBusy(false); setBusy(false);
} }
@ -36,7 +39,7 @@ export function LoginForm() {
<form onSubmit={handleSubmit} className="flex flex-col gap-4"> <form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label htmlFor="email" className="text-sm font-medium"> <label htmlFor="email" className="text-sm font-medium">
Email {t('emailLabel')}
</label> </label>
<input <input
id="email" id="email"
@ -46,13 +49,13 @@ export function LoginForm() {
autoComplete="email" autoComplete="email"
disabled={busy} disabled={busy}
className="rounded-lg border border-border bg-background px-3 py-2.5 text-sm outline-none focus:ring-2 focus:ring-primary disabled:opacity-50" className="rounded-lg border border-border bg-background px-3 py-2.5 text-sm outline-none focus:ring-2 focus:ring-primary disabled:opacity-50"
placeholder="admin@demo.local" placeholder={t('emailPlaceholder')}
/> />
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label htmlFor="password" className="text-sm font-medium"> <label htmlFor="password" className="text-sm font-medium">
Password {t('passwordLabel')}
</label> </label>
<input <input
id="password" id="password"
@ -72,7 +75,7 @@ export function LoginForm() {
disabled={busy} disabled={busy}
className="mt-2 w-full rounded-xl bg-primary py-3 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90 active:scale-[0.98] disabled:opacity-50" className="mt-2 w-full rounded-xl bg-primary py-3 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90 active:scale-[0.98] disabled:opacity-50"
> >
{busy ? 'A entrar…' : 'Entrar'} {busy ? tc('entering') : tc('enter')}
</button> </button>
</form> </form>
); );

View File

@ -1,13 +1,13 @@
import { getTranslations } from 'next-intl/server';
import { LoginForm } from './login-form'; import { LoginForm } from './login-form';
export default function LoginPage() { export default async function LoginPage() {
const t = await getTranslations('auth');
return ( return (
<main className="mx-auto flex min-h-screen max-w-sm flex-col justify-center gap-8 p-6"> <main className="mx-auto flex min-h-screen max-w-sm flex-col justify-center gap-8 p-6">
<header className="text-center"> <header className="text-center">
<h1 className="text-2xl font-bold tracking-tight">FieldOps</h1> <h1 className="text-2xl font-bold tracking-tight">{t('title')}</h1>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">{t('subtitle')}</p>
Acesso à consola de manutenção
</p>
</header> </header>
<LoginForm /> <LoginForm />
</main> </main>

View File

@ -3,22 +3,26 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { CheckCircle2, Clock, Loader2, Wrench, BarChart2 } from 'lucide-react'; import { CheckCircle2, Clock, Loader2, Wrench, BarChart2 } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { trpc } from '@/lib/trpc/client'; import { trpc } from '@/lib/trpc/client';
import type { RouterOutputs } from '@/lib/trpc/server'; import type { RouterOutputs } from '@/lib/trpc/server';
import { LanguageSwitcher } from '../language-switcher';
type Status = 'OPEN' | 'CLAIMED' | 'RESOLVED'; type Status = 'OPEN' | 'CLAIMED' | 'RESOLVED';
type QueueItem = RouterOutputs['maintenanceRequest']['queue']['items'][number]; type QueueItem = RouterOutputs['maintenanceRequest']['queue']['items'][number];
// ── Helpers ──────────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────────
function timeAgo(date: Date | string): string { type TFn = (key: string, values?: Record<string, string | number>) => string;
function timeAgo(date: Date | string, t: TFn): string {
const diffMs = Date.now() - new Date(date).getTime(); const diffMs = Date.now() - new Date(date).getTime();
const mins = Math.floor(diffMs / 60_000); const mins = Math.floor(diffMs / 60_000);
if (mins < 1) return 'agora'; if (mins < 1) return t('timeAgo.now');
if (mins < 60) return `${mins}m`; if (mins < 60) return t('timeAgo.minutesAgo', { mins });
const hours = Math.floor(mins / 60); const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h`; if (hours < 24) return t('timeAgo.hoursAgo', { hours });
return `${Math.floor(hours / 24)}d`; return t('timeAgo.daysAgo', { days: Math.floor(hours / 24) });
} }
function playBeep() { function playBeep() {
@ -39,12 +43,6 @@ function playBeep() {
} }
} }
const STATUS_LABEL: Record<Status, string> = {
OPEN: 'Aberto',
CLAIMED: 'Em curso',
RESOLVED: 'Resolvido',
};
const STATUS_CLASS: Record<Status, string> = { const STATUS_CLASS: Record<Status, string> = {
OPEN: 'bg-orange-100 text-orange-700', OPEN: 'bg-orange-100 text-orange-700',
CLAIMED: 'bg-blue-100 text-blue-700', CLAIMED: 'bg-blue-100 text-blue-700',
@ -53,7 +51,7 @@ const STATUS_CLASS: Record<Status, string> = {
// ── Thumbnail ─────────────────────────────────────────────────────────────── // ── Thumbnail ───────────────────────────────────────────────────────────────
function Thumbnail({ photoKey }: { photoKey: string | null }) { function Thumbnail({ photoKey, alt }: { photoKey: string | null; alt: string }) {
const { data } = trpc.storage.signPhotoDownload.useQuery( const { data } = trpc.storage.signPhotoDownload.useQuery(
{ photoKey: photoKey! }, { photoKey: photoKey! },
{ enabled: !!photoKey, staleTime: 50_000 }, { enabled: !!photoKey, staleTime: 50_000 },
@ -66,7 +64,7 @@ function Thumbnail({ photoKey }: { photoKey: string | null }) {
} }
return ( return (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img src={data.url} alt="Foto" className="h-16 w-16 shrink-0 rounded-lg object-cover" /> <img src={data.url} alt={alt} className="h-16 w-16 shrink-0 rounded-lg object-cover" />
); );
} }
@ -77,17 +75,21 @@ function RequestCard({
onClaim, onClaim,
onResolve, onResolve,
claiming, claiming,
t,
tc,
}: { }: {
item: QueueItem; item: QueueItem;
onClaim: () => void; onClaim: () => void;
onResolve: () => void; onResolve: () => void;
claiming: boolean; claiming: boolean;
t: ReturnType<typeof useTranslations<'maintenance'>>;
tc: ReturnType<typeof useTranslations<'common'>>;
}) { }) {
return ( return (
<div data-testid="request-card" className="flex flex-col gap-3 rounded-xl border border-border bg-card p-4 shadow-sm"> <div data-testid="request-card" className="flex flex-col gap-3 rounded-xl border border-border bg-card p-4 shadow-sm">
{/* Top row: thumbnail + main info */} {/* Top row: thumbnail + main info */}
<div className="flex gap-3"> <div className="flex gap-3">
<Thumbnail photoKey={item.photoKey} /> <Thumbnail photoKey={item.photoKey} alt={t('photo')} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="font-medium"> <p className="font-medium">
{item.workstation.code} {item.workstation.name}{' '} {item.workstation.code} {item.workstation.name}{' '}
@ -95,17 +97,15 @@ function RequestCard({
</p> </p>
<p className="mt-0.5 line-clamp-2 text-sm text-muted-foreground">{item.description}</p> <p className="mt-0.5 line-clamp-2 text-sm text-muted-foreground">{item.description}</p>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
Reportado por {item.reportedBy.email} · {timeAgo(item.createdAt)} {t('reportedBy', { email: item.reportedBy.email, time: timeAgo(item.createdAt, tc) })}
</p> </p>
</div> </div>
</div> </div>
{/* Footer: badge + actions */} {/* Footer: badge + actions */}
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<span <span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${STATUS_CLASS[item.status as Status]}`}>
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${STATUS_CLASS[item.status as Status]}`} {tc(`status.${item.status.toLowerCase() as 'open' | 'claimed' | 'resolved'}`)}
>
{STATUS_LABEL[item.status as Status]}
</span> </span>
{item.status === 'OPEN' && ( {item.status === 'OPEN' && (
@ -115,28 +115,28 @@ function RequestCard({
className="flex items-center gap-1.5 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50" className="flex items-center gap-1.5 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50"
> >
{claiming ? <Loader2 className="h-4 w-4 animate-spin" /> : <Wrench className="h-4 w-4" />} {claiming ? <Loader2 className="h-4 w-4 animate-spin" /> : <Wrench className="h-4 w-4" />}
Aceitar {t('accept')}
</button> </button>
)} )}
{item.status === 'CLAIMED' && ( {item.status === 'CLAIMED' && (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Aceite por {item.claimedBy?.email ?? '?'} · {timeAgo(item.claimedAt!)} {t('claimedBy', { email: item.claimedBy?.email ?? '?', time: timeAgo(item.claimedAt!, tc) })}
</p> </p>
<button <button
onClick={onResolve} onClick={onResolve}
className="flex items-center gap-1.5 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:opacity-90" className="flex items-center gap-1.5 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:opacity-90"
> >
<CheckCircle2 className="h-4 w-4" /> <CheckCircle2 className="h-4 w-4" />
Marcar resolvido {t('markResolved')}
</button> </button>
</div> </div>
)} )}
{item.status === 'RESOLVED' && ( {item.status === 'RESOLVED' && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Resolvido por {item.resolvedBy?.email ?? '?'} · {timeAgo(item.resolvedAt!)} {t('resolvedBy', { email: item.resolvedBy?.email ?? '?', time: timeAgo(item.resolvedAt!, tc) })}
</p> </p>
)} )}
</div> </div>
@ -152,33 +152,34 @@ function ResolveDialog({
note, note,
onNoteChange, onNoteChange,
resolving, resolving,
t,
tc,
}: { }: {
onConfirm: () => void; onConfirm: () => void;
onCancel: () => void; onCancel: () => void;
note: string; note: string;
onNoteChange: (v: string) => void; onNoteChange: (v: string) => void;
resolving: boolean; resolving: boolean;
t: ReturnType<typeof useTranslations<'maintenance'>>;
tc: ReturnType<typeof useTranslations<'common'>>;
}) { }) {
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="w-full max-w-md rounded-2xl bg-card p-6 shadow-xl"> <div className="w-full max-w-md rounded-2xl bg-card p-6 shadow-xl">
<h2 className="mb-4 text-lg font-semibold">Marcar como resolvido</h2> <h2 className="mb-4 text-lg font-semibold">{t('resolveDialogTitle')}</h2>
<label className="mb-1 block text-sm font-medium"> <label className="mb-1 block text-sm font-medium">
Nota de resolução (opcional) {t('resolveNoteLabel')}
</label> </label>
<textarea <textarea
value={note} value={note}
onChange={(e) => onNoteChange(e.target.value)} onChange={(e) => onNoteChange(e.target.value)}
rows={3} rows={3}
placeholder="Descreve o que foi feito…" placeholder={t('resolveNotePlaceholder')}
className="mb-4 w-full resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" className="mb-4 w-full resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/> />
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<button <button onClick={onCancel} className="rounded-lg px-4 py-2 text-sm hover:bg-accent">
onClick={onCancel} {tc('cancel')}
className="rounded-lg px-4 py-2 text-sm hover:bg-accent"
>
Cancelar
</button> </button>
<button <button
onClick={onConfirm} onClick={onConfirm}
@ -186,7 +187,7 @@ function ResolveDialog({
className="flex items-center gap-1.5 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50" className="flex items-center gap-1.5 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50"
> >
{resolving && <Loader2 className="h-4 w-4 animate-spin" />} {resolving && <Loader2 className="h-4 w-4 animate-spin" />}
Confirmar {tc('confirm')}
</button> </button>
</div> </div>
</div> </div>
@ -197,6 +198,9 @@ function ResolveDialog({
// ── Main queue component ──────────────────────────────────────────────────── // ── Main queue component ────────────────────────────────────────────────────
export function MaintenanceQueue() { export function MaintenanceQueue() {
const t = useTranslations('maintenance');
const tc = useTranslations('common');
const [statuses, setStatuses] = useState<Status[]>(['OPEN', 'CLAIMED']); const [statuses, setStatuses] = useState<Status[]>(['OPEN', 'CLAIMED']);
const [area, setArea] = useState(''); const [area, setArea] = useState('');
const [resolveId, setResolveId] = useState<string | null>(null); const [resolveId, setResolveId] = useState<string | null>(null);
@ -217,8 +221,10 @@ export function MaintenanceQueue() {
// Document title badge // Document title badge
useEffect(() => { useEffect(() => {
document.title = document.title =
openCount > 0 ? `(${openCount}) FieldOps — Manutenção` : 'FieldOps — Manutenção'; openCount > 0
}, [openCount]); ? t('documentTitleWithCount', { count: openCount })
: t('documentTitle');
}, [openCount, t]);
// Audio notification for new OPEN requests // Audio notification for new OPEN requests
const prevOpenIds = useRef(new Set<string>()); const prevOpenIds = useRef(new Set<string>());
@ -261,10 +267,10 @@ export function MaintenanceQueue() {
<span className="mr-1.5 inline-flex h-6 w-6 items-center justify-center rounded-full bg-orange-500 text-xs text-white"> <span className="mr-1.5 inline-flex h-6 w-6 items-center justify-center rounded-full bg-orange-500 text-xs text-white">
{openCount} {openCount}
</span> </span>
pedidos abertos {t('openRequestsTitle', { count: openCount })}
</span> </span>
) : ( ) : (
'Fila de manutenção' t('queueTitle')
)} )}
</h1> </h1>
</div> </div>
@ -274,7 +280,7 @@ export function MaintenanceQueue() {
className="flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-xs font-medium text-muted-foreground hover:bg-accent" className="flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-xs font-medium text-muted-foreground hover:bg-accent"
> >
<BarChart2 className="h-3 w-3" /> <BarChart2 className="h-3 w-3" />
Relatório de turno {t('reportLink')}
</Link> </Link>
<button <button
onClick={() => setSoundEnabled((v) => !v)} onClick={() => setSoundEnabled((v) => !v)}
@ -284,14 +290,15 @@ export function MaintenanceQueue() {
: 'bg-muted text-muted-foreground' : 'bg-muted text-muted-foreground'
}`} }`}
> >
{soundEnabled ? '🔔 Som on' : '🔕 Som off'} {soundEnabled ? t('soundOn') : t('soundOff')}
</button> </button>
<LanguageSwitcher />
</div> </div>
</div> </div>
{/* Filters */} {/* Filters */}
<div className="mx-auto mt-2 flex max-w-4xl flex-wrap items-center gap-3"> <div className="mx-auto mt-2 flex max-w-4xl flex-wrap items-center gap-3">
<span className="text-xs text-muted-foreground">Estado:</span> <span className="text-xs text-muted-foreground">{t('filterStatus')}</span>
{(['OPEN', 'CLAIMED', 'RESOLVED'] as Status[]).map((s) => ( {(['OPEN', 'CLAIMED', 'RESOLVED'] as Status[]).map((s) => (
<label key={s} className="flex cursor-pointer items-center gap-1.5 text-sm"> <label key={s} className="flex cursor-pointer items-center gap-1.5 text-sm">
<input <input
@ -300,19 +307,19 @@ export function MaintenanceQueue() {
onChange={() => toggleStatus(s)} onChange={() => toggleStatus(s)}
className="rounded" className="rounded"
/> />
{STATUS_LABEL[s]} {tc(`status.${s.toLowerCase() as 'open' | 'claimed' | 'resolved'}`)}
</label> </label>
))} ))}
{areas.length > 0 && ( {areas.length > 0 && (
<> <>
<span className="text-xs text-muted-foreground">Área:</span> <span className="text-xs text-muted-foreground">{t('filterArea')}</span>
<select <select
value={area} value={area}
onChange={(e) => setArea(e.target.value)} onChange={(e) => setArea(e.target.value)}
className="rounded-lg border border-border bg-card px-2 py-1 text-sm" className="rounded-lg border border-border bg-card px-2 py-1 text-sm"
> >
<option value="">Todas</option> <option value="">{tc('allAreas')}</option>
{areas.map((a) => ( {areas.map((a) => (
<option key={a} value={a}> <option key={a} value={a}>
{a} {a}
@ -324,7 +331,7 @@ export function MaintenanceQueue() {
<div className="ml-auto flex items-center gap-1 text-xs text-muted-foreground"> <div className="ml-auto flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3" /> <Clock className="h-3 w-3" />
Atualiza a cada 5s {t('updatesEvery')}
</div> </div>
</div> </div>
</header> </header>
@ -334,7 +341,7 @@ export function MaintenanceQueue() {
{items.length === 0 ? ( {items.length === 0 ? (
<div className="py-16 text-center text-muted-foreground"> <div className="py-16 text-center text-muted-foreground">
<Wrench className="mx-auto mb-3 h-10 w-10 opacity-30" /> <Wrench className="mx-auto mb-3 h-10 w-10 opacity-30" />
<p>Nenhum pedido com os filtros actuais.</p> <p>{t('emptyQueue')}</p>
</div> </div>
) : ( ) : (
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
@ -342,6 +349,8 @@ export function MaintenanceQueue() {
<RequestCard <RequestCard
key={item.id} key={item.id}
item={item} item={item}
t={t}
tc={tc}
onClaim={() => claimMutation.mutate({ id: item.id })} onClaim={() => claimMutation.mutate({ id: item.id })}
onResolve={() => { onResolve={() => {
setResolveId(item.id); setResolveId(item.id);
@ -362,6 +371,8 @@ export function MaintenanceQueue() {
<ResolveDialog <ResolveDialog
note={resolutionNote} note={resolutionNote}
onNoteChange={setResolutionNote} onNoteChange={setResolutionNote}
t={t}
tc={tc}
onConfirm={() => onConfirm={() =>
resolveMutation.mutate({ resolveMutation.mutate({
id: resolveId, id: resolveId,

View File

@ -1,6 +1,11 @@
import { getTranslations } from 'next-intl/server';
import type { Metadata } from 'next';
import { ReportView } from './report-view'; import { ReportView } from './report-view';
export const metadata = { title: 'FieldOps — Relatório de turno' }; export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('report');
return { title: t('pageTitle') };
}
export default function ReportPage() { export default function ReportPage() {
return <ReportView />; return <ReportView />;

View File

@ -3,40 +3,30 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { ArrowLeft, Printer, AlertCircle } from 'lucide-react'; import { ArrowLeft, Printer, AlertCircle } from 'lucide-react';
import { useTranslations, useFormatter } from 'next-intl';
import { trpc } from '@/lib/trpc/client'; import { trpc } from '@/lib/trpc/client';
import { SHIFTS, shiftWindow, todayWindow, type ShiftKey } from '@/lib/shifts'; import { SHIFTS, shiftWindow, todayWindow, type ShiftKey } from '@/lib/shifts';
// ── Duration helper ───────────────────────────────────────────────────────── // ── Duration helper ─────────────────────────────────────────────────────────
function formatDuration(ms: number | null): string { type TFn = ReturnType<typeof useTranslations<'report'>>;
if (ms === null) return '—';
function formatDuration(ms: number | null, t: TFn): string {
if (ms === null) return t('duration.dash');
const totalMin = Math.round(ms / 60_000); const totalMin = Math.round(ms / 60_000);
if (totalMin < 1) return '< 1 min'; if (totalMin < 1) return t('duration.lessThan1Min');
if (totalMin < 60) return `${totalMin} min`; if (totalMin < 60) return t('duration.minutes', { n: totalMin });
const h = Math.floor(totalMin / 60); const h = Math.floor(totalMin / 60);
const m = totalMin % 60; const m = totalMin % 60;
return m > 0 ? `${h} h ${m} min` : `${h} h`; return m > 0 ? t('duration.hoursMinutes', { h, m }) : t('duration.hours', { h });
} }
function formatDateTime(d: Date | string): string { // ── Status ───────────────────────────────────────────────────────────────────
const dt = new Date(d);
return dt.toLocaleString('pt-PT', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function formatDate(d: Date | string): string { const STATUS_CLASS: Record<'OPEN' | 'CLAIMED', string> = {
return new Date(d).toLocaleDateString('pt-PT', { day: '2-digit', month: '2-digit' }); OPEN: 'bg-orange-100 text-orange-700',
} CLAIMED: 'bg-blue-100 text-blue-700',
};
// ── Window label ─────────────────────────────────────────────────────────────
function windowLabel(from: Date, to: Date): string {
return `${formatDateTime(from)}${formatDateTime(to)}`;
}
// ── Metric card ────────────────────────────────────────────────────────────── // ── Metric card ──────────────────────────────────────────────────────────────
@ -52,16 +42,6 @@ function MetricCard({ label, value, sub }: { label: string; value: string; sub?:
); );
} }
const STATUS_LABEL: Record<'OPEN' | 'CLAIMED', string> = {
OPEN: 'Aberto',
CLAIMED: 'Em curso',
};
const STATUS_CLASS: Record<'OPEN' | 'CLAIMED', string> = {
OPEN: 'bg-orange-100 text-orange-700',
CLAIMED: 'bg-blue-100 text-blue-700',
};
// ── Main component ─────────────────────────────────────────────────────────── // ── Main component ───────────────────────────────────────────────────────────
type WindowState = type WindowState =
@ -87,7 +67,14 @@ function localDateTimeStr(d: Date): string {
return `${localDateStr(d)}T${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; return `${localDateStr(d)}T${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
} }
const DATE_TIME_FMT = { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' } as const;
const DATE_FMT = { day: '2-digit', month: '2-digit' } as const;
export function ReportView() { export function ReportView() {
const t = useTranslations('report');
const tc = useTranslations('common');
const format = useFormatter();
const [windowState, setWindowState] = useState<WindowState>({ type: 'today' }); const [windowState, setWindowState] = useState<WindowState>({ type: 'today' });
const [dayInput, setDayInput] = useState(() => localDateStr(new Date())); const [dayInput, setDayInput] = useState(() => localDateStr(new Date()));
const [customActive, setCustomActive] = useState(false); const [customActive, setCustomActive] = useState(false);
@ -100,7 +87,7 @@ export function ReportView() {
// Stabilise the window so the query key only changes when the user picks a // Stabilise the window so the query key only changes when the user picks a
// new window. Without this, the 'today' mode recomputes `to = new Date()` on // new window. Without this, the 'today' mode recomputes `to = new Date()` on
// every render → new query key → fetch loop. Re-selecting "Hoje" refreshes. // every render → new query key → fetch loop. Re-selecting "Today" refreshes.
const win = useMemo(() => computeWindow(windowState), [windowState]); const win = useMemo(() => computeWindow(windowState), [windowState]);
const { data, isLoading, error } = trpc.maintenanceRequest.report.useQuery( const { data, isLoading, error } = trpc.maintenanceRequest.report.useQuery(
@ -126,8 +113,16 @@ export function ReportView() {
setWindowState({ type: 'custom', from, to }); setWindowState({ type: 'custom', from, to });
} }
const activeShift = const activeShift = windowState.type === 'shift' ? windowState.key : null;
windowState.type === 'shift' ? windowState.key : null;
const range = `${format.dateTime(win.from, DATE_TIME_FMT)}${format.dateTime(win.to, DATE_TIME_FMT)}`;
const windowLabelText =
windowState.type === 'today'
? t('windowLabel.today', { range })
: windowState.type === 'shift'
? t(`windowLabel.${windowState.key}`, { range })
: t('windowLabel.custom', { range });
return ( return (
<div className="min-h-screen bg-background print:bg-white"> <div className="min-h-screen bg-background print:bg-white">
@ -140,25 +135,25 @@ export function ReportView() {
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground" className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
Fila {t('backToQueue')}
</Link> </Link>
<span className="text-muted-foreground">/</span> <span className="text-muted-foreground">/</span>
<h1 className="text-lg font-bold">Relatório de turno</h1> <h1 className="text-lg font-bold">{t('title')}</h1>
</div> </div>
<button <button
onClick={() => window.print()} onClick={() => window.print()}
className="flex items-center gap-1.5 rounded-lg bg-muted px-3 py-1.5 text-sm font-medium hover:bg-accent" className="flex items-center gap-1.5 rounded-lg bg-muted px-3 py-1.5 text-sm font-medium hover:bg-accent"
> >
<Printer className="h-4 w-4" /> <Printer className="h-4 w-4" />
Imprimir {t('print')}
</button> </button>
</div> </div>
</header> </header>
{/* ── Print header (only in print) ── */} {/* ── Print header (only in print) ── */}
<div className="hidden print:block px-8 pt-6 pb-2"> <div className="hidden print:block px-8 pt-6 pb-2">
<p className="text-lg font-bold">FieldOps Relatório de manutenção</p> <p className="text-lg font-bold">{t('printHeader')}</p>
<p className="text-sm text-gray-600">{windowLabel(win.from, win.to)}</p> <p className="text-sm text-gray-600">{range}</p>
</div> </div>
{/* ── Window selector (hidden in print) ── */} {/* ── Window selector (hidden in print) ── */}
@ -174,7 +169,7 @@ export function ReportView() {
: 'bg-card border border-border hover:bg-accent' : 'bg-card border border-border hover:bg-accent'
}`} }`}
> >
Hoje {t('today')}
</button> </button>
{(Object.keys(SHIFTS) as ShiftKey[]).map((key) => ( {(Object.keys(SHIFTS) as ShiftKey[]).map((key) => (
<button <button
@ -186,7 +181,7 @@ export function ReportView() {
: 'bg-card border border-border hover:bg-accent' : 'bg-card border border-border hover:bg-accent'
}`} }`}
> >
{SHIFTS[key].label} {t(`shiftButton.${key}`)}
</button> </button>
))} ))}
@ -211,7 +206,7 @@ export function ReportView() {
: 'bg-card border border-border hover:bg-accent' : 'bg-card border border-border hover:bg-accent'
}`} }`}
> >
Personalizado {t('custom')}
</button> </button>
</div> </div>
@ -224,7 +219,7 @@ export function ReportView() {
onChange={(e) => setCustomPending((p) => ({ ...p, from: e.target.value }))} onChange={(e) => setCustomPending((p) => ({ ...p, from: e.target.value }))}
className="rounded-lg border border-border bg-card px-2 py-1 text-sm" className="rounded-lg border border-border bg-card px-2 py-1 text-sm"
/> />
<span className="text-sm text-muted-foreground">até</span> <span className="text-sm text-muted-foreground">{t('customUntil')}</span>
<input <input
type="datetime-local" type="datetime-local"
value={customPending.to} value={customPending.to}
@ -235,27 +230,20 @@ export function ReportView() {
onClick={applyCustom} onClick={applyCustom}
className="rounded-lg bg-primary px-3 py-1 text-sm font-medium text-primary-foreground hover:opacity-90" className="rounded-lg bg-primary px-3 py-1 text-sm font-medium text-primary-foreground hover:opacity-90"
> >
Aplicar {t('customApply')}
</button> </button>
</div> </div>
)} )}
{/* Active window label */} {/* Active window label */}
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">{windowLabelText}</p>
{windowState.type === 'shift'
? `Turno d${windowState.key === 'manha' ? 'a Manhã' : windowState.key === 'tarde' ? 'a Tarde' : 'a Noite'}`
: windowState.type === 'today'
? 'Hoje — '
: 'Personalizado — '}
{windowLabel(win.from, win.to)}
</p>
</div> </div>
</div> </div>
{/* ── Body ── */} {/* ── Body ── */}
<main className="mx-auto max-w-4xl px-4 py-6 print:px-8 print:py-4"> <main className="mx-auto max-w-4xl px-4 py-6 print:px-8 print:py-4">
{isLoading && ( {isLoading && (
<p className="py-16 text-center text-muted-foreground">A carregar</p> <p className="py-16 text-center text-muted-foreground">{tc('loading')}</p>
)} )}
{error && ( {error && (
@ -267,7 +255,7 @@ export function ReportView() {
{data && data.totals.created === 0 && ( {data && data.totals.created === 0 && (
<div className="py-16 text-center text-muted-foreground"> <div className="py-16 text-center text-muted-foreground">
<p className="text-lg">Sem pedidos nesta janela.</p> <p className="text-lg">{t('emptyWindow')}</p>
</div> </div>
)} )}
@ -276,41 +264,41 @@ export function ReportView() {
{/* Summary cards */} {/* Summary cards */}
<section> <section>
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground print:text-gray-500"> <h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground print:text-gray-500">
Resumo {t('sections.summary')}
</h2> </h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 print:grid-cols-3"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 print:grid-cols-3">
<MetricCard label="Pedidos" value={String(data.totals.created)} /> <MetricCard label={t('metrics.created')} value={String(data.totals.created)} />
<MetricCard label="Resolvidos" value={String(data.totals.resolved)} /> <MetricCard label={t('metrics.resolved')} value={String(data.totals.resolved)} />
<MetricCard <MetricCard
label="Em aberto" label={t('metrics.open')}
value={String(data.totals.open + data.totals.claimed)} value={String(data.totals.open + data.totals.claimed)}
sub={ sub={
data.totals.open > 0 || data.totals.claimed > 0 data.totals.open > 0 || data.totals.claimed > 0
? `${data.totals.open} aberto · ${data.totals.claimed} em curso` ? t('metrics.openSub', { open: data.totals.open, claimed: data.totals.claimed })
: undefined : undefined
} }
/> />
<MetricCard <MetricCard
label="Resposta média" label={t('metrics.responseAvg')}
value={formatDuration(data.responseMs.avg)} value={formatDuration(data.responseMs.avg, t)}
sub={ sub={
data.responseMs.count > 0 data.responseMs.count > 0
? `sobre ${data.responseMs.count} pedido${data.responseMs.count > 1 ? 's' : ''}` ? t('metrics.requestsSub', { count: data.responseMs.count })
: 'sem dados' : t('metrics.noData')
} }
/> />
<MetricCard <MetricCard
label="Resolução média" label={t('metrics.resolutionAvg')}
value={formatDuration(data.resolutionMs.avg)} value={formatDuration(data.resolutionMs.avg, t)}
sub={ sub={
data.resolutionMs.count > 0 data.resolutionMs.count > 0
? `sobre ${data.resolutionMs.count} pedido${data.resolutionMs.count > 1 ? 's' : ''}` ? t('metrics.requestsSub', { count: data.resolutionMs.count })
: 'sem dados' : t('metrics.noData')
} }
/> />
<MetricCard <MetricCard
label="Pior resposta" label={t('metrics.responseMax')}
value={formatDuration(data.responseMs.max)} value={formatDuration(data.responseMs.max, t)}
/> />
</div> </div>
</section> </section>
@ -319,16 +307,16 @@ export function ReportView() {
{data.byWorkstation.length > 0 && ( {data.byWorkstation.length > 0 && (
<section> <section>
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground print:text-gray-500"> <h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground print:text-gray-500">
Por posto {t('sections.byWorkstation')}
</h2> </h2>
<div className="overflow-hidden rounded-xl border border-border print:border-gray-300"> <div className="overflow-hidden rounded-xl border border-border print:border-gray-300">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-border bg-muted/50 text-left print:bg-gray-50 print:border-gray-300"> <tr className="border-b border-border bg-muted/50 text-left print:bg-gray-50 print:border-gray-300">
<th className="px-4 py-2 font-medium">Código</th> <th className="px-4 py-2 font-medium">{t('table.code')}</th>
<th className="px-4 py-2 font-medium">Nome</th> <th className="px-4 py-2 font-medium">{t('table.name')}</th>
<th className="px-4 py-2 font-medium">Área</th> <th className="px-4 py-2 font-medium">{t('table.area')}</th>
<th className="px-4 py-2 text-right font-medium">Pedidos</th> <th className="px-4 py-2 text-right font-medium">{t('table.requests')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -357,7 +345,7 @@ export function ReportView() {
{data.byArea.length > 1 && ( {data.byArea.length > 1 && (
<section> <section>
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground print:text-gray-500"> <h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground print:text-gray-500">
Por área {t('sections.byArea')}
</h2> </h2>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
{data.byArea.map((a) => ( {data.byArea.map((a) => (
@ -378,10 +366,10 @@ export function ReportView() {
{/* Still open */} {/* Still open */}
<section> <section>
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground print:text-gray-500"> <h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground print:text-gray-500">
Em aberto à hora do relatório {t('sections.stillOpen')}
</h2> </h2>
{data.stillOpen.length === 0 ? ( {data.stillOpen.length === 0 ? (
<p className="text-sm text-green-600">Nada em aberto neste turno. </p> <p className="text-sm text-green-600">{t('allClear')}</p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{data.stillOpen.map((r) => ( {data.stillOpen.map((r) => (
@ -400,13 +388,16 @@ export function ReportView() {
{r.description} {r.description}
</p> </p>
<p className="mt-0.5 text-xs text-muted-foreground print:text-gray-500"> <p className="mt-0.5 text-xs text-muted-foreground print:text-gray-500">
Reportado por {r.reportedByEmail} · {formatDate(r.createdAt)} {t('stillOpenReportedBy', {
email: r.reportedByEmail,
date: format.dateTime(new Date(r.createdAt), DATE_FMT),
})}
</p> </p>
</div> </div>
<span <span
className={`shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium print:border print:bg-transparent ${STATUS_CLASS[r.status]}`} className={`shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium print:border print:bg-transparent ${STATUS_CLASS[r.status]}`}
> >
{STATUS_LABEL[r.status]} {tc(`status.${r.status.toLowerCase() as 'open' | 'claimed'}`)}
</span> </span>
</div> </div>
))} ))}

View File

@ -0,0 +1,8 @@
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);
}

View File

@ -0,0 +1,12 @@
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,
};
});

View File

@ -1,9 +1,9 @@
export type ShiftKey = 'manha' | 'tarde' | 'noite'; export type ShiftKey = 'manha' | 'tarde' | 'noite';
export const SHIFTS: Record<ShiftKey, { label: string; startHour: number; endHour: number }> = { export const SHIFTS: Record<ShiftKey, { startHour: number; endHour: number }> = {
manha: { label: 'Manhã', startHour: 6, endHour: 14 }, manha: { startHour: 6, endHour: 14 },
tarde: { label: 'Tarde', startHour: 14, endHour: 22 }, tarde: { startHour: 14, endHour: 22 },
noite: { label: 'Noite', startHour: 22, endHour: 6 }, noite: { startHour: 22, endHour: 6 },
}; };
/** Given a shift and a day (Date at local midnight), returns [from, to). */ /** Given a shift and a day (Date at local midnight), returns [from, to). */

View File

@ -0,0 +1,113 @@
{
"metadata": {
"title": "FieldOps — Maintenance",
"description": "Industrial maintenance backoffice."
},
"common": {
"enter": "Sign in",
"entering": "Signing in…",
"cancel": "Cancel",
"confirm": "Confirm",
"loading": "Loading…",
"allAreas": "All",
"status": {
"open": "Open",
"claimed": "In progress",
"resolved": "Resolved"
},
"timeAgo": {
"now": "just now",
"minutesAgo": "{mins}m ago",
"hoursAgo": "{hours}h ago",
"daysAgo": "{days}d ago"
}
},
"auth": {
"emailLabel": "Email",
"emailPlaceholder": "admin@demo.local",
"passwordLabel": "Password",
"invalidCredentials": "Incorrect email or password. Please try again.",
"unexpectedError": "Unexpected error. Please try again.",
"title": "FieldOps",
"subtitle": "Maintenance console access"
},
"maintenance": {
"queueTitle": "Maintenance queue",
"openRequestsTitle": "{count} open requests",
"reportLink": "Shift report",
"soundOn": "🔔 Sound on",
"soundOff": "🔕 Sound off",
"filterStatus": "Status:",
"filterArea": "Area:",
"updatesEvery": "Updates every 5s",
"emptyQueue": "No requests match the current filters.",
"photo": "Photo",
"reportedBy": "Reported by {email} · {time}",
"claimedBy": "Accepted by {email} · {time}",
"resolvedBy": "Resolved by {email} · {time}",
"accept": "Accept",
"markResolved": "Mark resolved",
"resolveDialogTitle": "Mark as resolved",
"resolveNoteLabel": "Resolution note (optional)",
"resolveNotePlaceholder": "Describe what was done…",
"documentTitleWithCount": "({count}) FieldOps — Maintenance",
"documentTitle": "FieldOps — Maintenance"
},
"report": {
"pageTitle": "FieldOps — Shift report",
"title": "Shift report",
"print": "Print",
"printHeader": "FieldOps — Maintenance report",
"backToQueue": "Queue",
"today": "Today",
"custom": "Custom",
"customUntil": "to",
"customApply": "Apply",
"loading": "Loading…",
"emptyWindow": "No requests in this window.",
"windowLabel": {
"today": "Today — {range}",
"manha": "Morning Shift — {range}",
"tarde": "Afternoon Shift — {range}",
"noite": "Night Shift — {range}",
"custom": "Custom — {range}"
},
"shiftButton": {
"manha": "Morning",
"tarde": "Afternoon",
"noite": "Night"
},
"sections": {
"summary": "Summary",
"byWorkstation": "By workstation",
"byArea": "By area",
"stillOpen": "Open at report time"
},
"metrics": {
"created": "Requests",
"resolved": "Resolved",
"open": "Open",
"responseAvg": "Avg response",
"resolutionAvg": "Avg resolution",
"responseMax": "Worst response",
"openSub": "{open} open · {claimed} in progress",
"requestsSub": "{count, plural, one {over # request} other {over # requests}}",
"noData": "no data"
},
"table": {
"code": "Code",
"name": "Name",
"area": "Area",
"requests": "Requests"
},
"stillOpenReportedBy": "Reported by {email} · {date}",
"allClear": "Nothing open in this shift. ✓",
"duration": {
"lessThan1Min": "< 1 min",
"minutes": "{n} min",
"hours": "{h} h",
"hoursMinutes": "{h} h {m} min",
"dash": "—"
}
}
}

View File

@ -0,0 +1,113 @@
{
"metadata": {
"title": "FieldOps — Manutenção",
"description": "Backoffice de manutenção industrial."
},
"common": {
"enter": "Entrar",
"entering": "A entrar…",
"cancel": "Cancelar",
"confirm": "Confirmar",
"loading": "A carregar…",
"allAreas": "Todas",
"status": {
"open": "Aberto",
"claimed": "Em curso",
"resolved": "Resolvido"
},
"timeAgo": {
"now": "agora",
"minutesAgo": "há {mins}m",
"hoursAgo": "há {hours}h",
"daysAgo": "há {days}d"
}
},
"auth": {
"emailLabel": "Email",
"emailPlaceholder": "admin@demo.local",
"passwordLabel": "Password",
"invalidCredentials": "Email ou password incorretos. Tente novamente.",
"unexpectedError": "Erro inesperado. Tente novamente.",
"title": "FieldOps",
"subtitle": "Acesso à consola de manutenção"
},
"maintenance": {
"queueTitle": "Fila de manutenção",
"openRequestsTitle": "{count} pedidos abertos",
"reportLink": "Relatório de turno",
"soundOn": "🔔 Som on",
"soundOff": "🔕 Som off",
"filterStatus": "Estado:",
"filterArea": "Área:",
"updatesEvery": "Atualiza a cada 5s",
"emptyQueue": "Nenhum pedido com os filtros actuais.",
"photo": "Foto",
"reportedBy": "Reportado por {email} · {time}",
"claimedBy": "Aceite por {email} · {time}",
"resolvedBy": "Resolvido por {email} · {time}",
"accept": "Aceitar",
"markResolved": "Marcar resolvido",
"resolveDialogTitle": "Marcar como resolvido",
"resolveNoteLabel": "Nota de resolução (opcional)",
"resolveNotePlaceholder": "Descreve o que foi feito…",
"documentTitleWithCount": "({count}) FieldOps — Manutenção",
"documentTitle": "FieldOps — Manutenção"
},
"report": {
"pageTitle": "FieldOps — Relatório de turno",
"title": "Relatório de turno",
"print": "Imprimir",
"printHeader": "FieldOps — Relatório de manutenção",
"backToQueue": "Fila",
"today": "Hoje",
"custom": "Personalizado",
"customUntil": "até",
"customApply": "Aplicar",
"loading": "A carregar…",
"emptyWindow": "Sem pedidos nesta janela.",
"windowLabel": {
"today": "Hoje — {range}",
"manha": "Turno da Manhã — {range}",
"tarde": "Turno da Tarde — {range}",
"noite": "Turno da Noite — {range}",
"custom": "Personalizado — {range}"
},
"shiftButton": {
"manha": "Manhã",
"tarde": "Tarde",
"noite": "Noite"
},
"sections": {
"summary": "Resumo",
"byWorkstation": "Por posto",
"byArea": "Por área",
"stillOpen": "Em aberto à hora do relatório"
},
"metrics": {
"created": "Pedidos",
"resolved": "Resolvidos",
"open": "Em aberto",
"responseAvg": "Resposta média",
"resolutionAvg": "Resolução média",
"responseMax": "Pior resposta",
"openSub": "{open} aberto · {claimed} em curso",
"requestsSub": "{count, plural, one {sobre # pedido} other {sobre # pedidos}}",
"noData": "sem dados"
},
"table": {
"code": "Código",
"name": "Nome",
"area": "Área",
"requests": "Pedidos"
},
"stillOpenReportedBy": "Reportado por {email} · {date}",
"allClear": "Nada em aberto neste turno. ✓",
"duration": {
"lessThan1Min": "< 1 min",
"minutes": "{n} min",
"hours": "{h} h",
"hoursMinutes": "{h} h {m} min",
"dash": "—"
}
}
}

View File

@ -1,6 +1,9 @@
import type { NextConfig } from 'next'; import type { NextConfig } from 'next';
import createNextIntlPlugin from 'next-intl/plugin';
import './env'; // Validate env vars at build time import './env'; // Validate env vars at build time
const withNextIntl = createNextIntlPlugin('./i18n/request.ts');
const config: NextConfig = { const config: NextConfig = {
transpilePackages: ['@repo/db', '@repo/api', '@repo/ui', '@repo/storage'], transpilePackages: ['@repo/db', '@repo/api', '@repo/ui', '@repo/storage'],
reactStrictMode: true, reactStrictMode: true,
@ -14,4 +17,4 @@ const config: NextConfig = {
], ],
}; };
export default config; export default withNextIntl(config);

View File

@ -4,7 +4,7 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "dotenv -e ../../.env -- next dev --port 3001", "dev": "dotenv -e ./.env.admin -e ../../.env -- next dev --port 3001",
"build": "dotenv -e ../../.env -- next build", "build": "dotenv -e ../../.env -- next build",
"start": "dotenv -e ../../.env -- next start --port 3001", "start": "dotenv -e ../../.env -- next start --port 3001",
"lint": "next lint", "lint": "next lint",
@ -24,6 +24,7 @@
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"next": "15.3.9", "next": "15.3.9",
"next-auth": "5.0.0-beta.25", "next-auth": "5.0.0-beta.25",
"next-intl": "^4.13.0",
"pino": "^9.5.0", "pino": "^9.5.0",
"pino-pretty": "^11.3.0", "pino-pretty": "^11.3.0",
"react": "^19.0.0", "react": "^19.0.0",

View File

@ -1,6 +1,7 @@
'use client'; 'use client';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useTranslations } from 'next-intl';
export default function ErrorPage({ export default function ErrorPage({
error, error,
@ -9,16 +10,18 @@ export default function ErrorPage({
error: Error & { digest?: string }; error: Error & { digest?: string };
reset: () => void; reset: () => void;
}) { }) {
const t = useTranslations('errors');
useEffect(() => { useEffect(() => {
console.error(error); console.error(error);
}, [error]); }, [error]);
return ( return (
<main className="flex min-h-screen flex-col items-center justify-center gap-4 p-6 text-center"> <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> <h1 className="text-4xl font-bold">{t('title500')}</h1>
<p className="text-muted-foreground">Ocorreu um erro inesperado.</p> <p className="text-muted-foreground">{t('message500')}</p>
<button onClick={reset} className="text-sm underline underline-offset-4"> <button onClick={reset} className="text-sm underline underline-offset-4">
Tentar novamente {t('retry')}
</button> </button>
</main> </main>
); );

View File

@ -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>
);
}

View File

@ -1,19 +1,24 @@
import { NextIntlClientProvider } from 'next-intl';
import { getLocale, getMessages, getTranslations } from 'next-intl/server';
import type { Metadata, Viewport } from 'next'; import type { Metadata, Viewport } from 'next';
import { Providers } from './providers'; import { Providers } from './providers';
import { SyncProvider } from './sync-provider'; import { SyncProvider } from './sync-provider';
import './globals.css'; import './globals.css';
export const metadata: Metadata = { export async function generateMetadata(): Promise<Metadata> {
title: 'FieldOps — Operator', const t = await getTranslations('metadata');
description: 'Industrial operator console.', return {
title: t('title'),
description: t('description'),
manifest: '/manifest.webmanifest', manifest: '/manifest.webmanifest',
applicationName: 'FieldOps Operator', applicationName: t('appName'),
appleWebApp: { appleWebApp: {
capable: true, capable: true,
title: 'FieldOps Operator', title: t('appName'),
statusBarStyle: 'default', statusBarStyle: 'default',
}, },
}; };
}
export const viewport: Viewport = { export const viewport: Viewport = {
themeColor: '#0f172a', themeColor: '#0f172a',
@ -21,13 +26,18 @@ export const viewport: Viewport = {
initialScale: 1, 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 ( return (
<html lang="en"> <html lang={locale}>
<body className="min-h-screen bg-background font-sans antialiased"> <body className="min-h-screen bg-background font-sans antialiased">
<NextIntlClientProvider locale={locale} messages={messages}>
<Providers> <Providers>
<SyncProvider>{children}</SyncProvider> <SyncProvider>{children}</SyncProvider>
</Providers> </Providers>
</NextIntlClientProvider>
</body> </body>
</html> </html>
); );

View File

@ -4,11 +4,11 @@ import { useState, useRef } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { ArrowLeft, Camera, X } from 'lucide-react'; import { ArrowLeft, Camera, X } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { trpc } from '@/lib/trpc/client'; import { trpc } from '@/lib/trpc/client';
import { db } from '@/lib/queue/db'; import { db } from '@/lib/queue/db';
import { runSync } from '@/lib/queue/sync'; 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> { function compressImage(file: File): Promise<Blob> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const img = new Image(); const img = new Image();
@ -44,6 +44,7 @@ function compressImage(file: File): Promise<Blob> {
} }
export default function NewRequestPage() { export default function NewRequestPage() {
const t = useTranslations('maintenance');
const router = useRouter(); const router = useRouter();
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
@ -56,7 +57,7 @@ export default function NewRequestPage() {
const { data: workstations = [], isLoading: wsLoading } = trpc.workstation.list.useQuery( const { data: workstations = [], isLoading: wsLoading } = trpc.workstation.list.useQuery(
undefined, undefined,
{ staleTime: 60 * 60 * 1000 }, // 1h — serves from cache when offline { staleTime: 60 * 60 * 1000 },
); );
async function handlePhotoChange(e: React.ChangeEvent<HTMLInputElement>) { async function handlePhotoChange(e: React.ChangeEvent<HTMLInputElement>) {
@ -68,7 +69,7 @@ export default function NewRequestPage() {
setPhotoBlob(compressed); setPhotoBlob(compressed);
setPhotoPreview(URL.createObjectURL(compressed)); setPhotoPreview(URL.createObjectURL(compressed));
} catch { } 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 { try {
const clientRequestId = crypto.randomUUID(); 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({ await db.pending.add({
clientRequestId, clientRequestId,
workstationId, workstationId,
@ -100,12 +99,11 @@ export default function NewRequestPage() {
retries: 0, retries: 0,
}); });
// Attempt immediate sync if online (fire-and-forget)
if (navigator.onLine) runSync().catch(() => {}); if (navigator.onLine) runSync().catch(() => {});
router.push(`/maintenance/sent?cid=${clientRequestId}`); router.push(`/maintenance/sent?cid=${clientRequestId}`);
} catch (err) { } 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); setSubmitting(false);
} }
} }
@ -119,14 +117,14 @@ export default function NewRequestPage() {
<Link href="/" className="rounded-md p-1 hover:bg-accent"> <Link href="/" className="rounded-md p-1 hover:bg-accent">
<ArrowLeft className="h-5 w-5" /> <ArrowLeft className="h-5 w-5" />
</Link> </Link>
<h1 className="text-base font-semibold">Novo pedido de manutenção</h1> <h1 className="text-base font-semibold">{t('newTitle')}</h1>
</header> </header>
<form onSubmit={handleSubmit} className="flex flex-1 flex-col gap-6 p-4"> <form onSubmit={handleSubmit} className="flex flex-1 flex-col gap-6 p-4">
{/* Posto */} {/* Workstation */}
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label htmlFor="workstation" className="text-sm font-medium"> <label htmlFor="workstation" className="text-sm font-medium">
Posto <span className="text-destructive">*</span> {t('workstationLabel')} <span className="text-destructive">{t('workstationRequired')}</span>
</label> </label>
<select <select
id="workstation" 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" className="w-full rounded-lg border border-border bg-card px-3 py-2.5 text-sm disabled:opacity-50"
> >
<option value=""> <option value="">
{wsLoading ? 'A carregar postos…' : 'Seleciona um posto…'} {wsLoading ? t('workstationLoading') : t('workstationPlaceholder')}
</option> </option>
{workstations.map((ws) => ( {workstations.map((ws) => (
<option key={ws.id} value={ws.id}> <option key={ws.id} value={ws.id}>
@ -147,13 +145,13 @@ export default function NewRequestPage() {
</select> </select>
</div> </div>
{/* Foto */} {/* Photo */}
<div className="flex flex-col gap-1.5"> <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 ? ( {photoPreview ? (
<div className="relative"> <div className="relative">
{/* eslint-disable-next-line @next/next/no-img-element */} {/* 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 <button
type="button" type="button"
onClick={removePhoto} 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" 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" /> <Camera className="h-5 w-5" />
Tirar / escolher foto {t('photoButton')}
</button> </button>
)} )}
<input <input
@ -182,10 +180,10 @@ export default function NewRequestPage() {
/> />
</div> </div>
{/* Descrição */} {/* Description */}
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label htmlFor="description" className="flex items-center justify-between text-sm font-medium"> <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'}`}> <span className={`text-xs ${descLen > 1000 ? 'text-destructive' : 'text-muted-foreground'}`}>
{descLen}/1000 {descLen}/1000
</span> </span>
@ -198,7 +196,7 @@ export default function NewRequestPage() {
minLength={3} minLength={3}
maxLength={1000} maxLength={1000}
rows={4} 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" 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> </div>
@ -213,7 +211,7 @@ export default function NewRequestPage() {
disabled={!canSubmit} 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" 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> </button>
</div> </div>
</form> </form>

View File

@ -3,11 +3,13 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { CheckCircle2, Clock } from 'lucide-react'; import { CheckCircle2, Clock } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { db } from '@/lib/queue/db'; import { db } from '@/lib/queue/db';
import { subscribeBroadcast } from '@/lib/queue/broadcast'; import { subscribeBroadcast } from '@/lib/queue/broadcast';
export function SentStatus({ cid }: { cid: string }) { 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(() => { useEffect(() => {
async function check() { async function check() {
@ -35,7 +37,7 @@ export function SentStatus({ cid }: { cid: string }) {
<CheckCircle2 className="h-16 w-16 text-green-500" /> <CheckCircle2 className="h-16 w-16 text-green-500" />
)} )}
<h1 className="text-2xl font-bold"> <h1 className="text-2xl font-bold">
{pending ? 'Pedido em fila' : 'Pedido enviado'} {pending ? t('pendingTitle') : t('sentTitle')}
</h1> </h1>
{cid && ( {cid && (
<p className="font-mono text-xs text-muted-foreground" data-testid="request-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>
)} )}
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{pending {pending ? t('pendingMessage') : t('sentMessage')}
? 'Será enviado assim que a ligação for restabelecida.'
: 'A equipa de manutenção foi notificada e irá tratar do problema.'}
</p> </p>
</div> </div>
<Link <Link
href="/" href="/"
className="rounded-xl bg-primary px-8 py-3 font-semibold text-primary-foreground hover:opacity-90" className="rounded-xl bg-primary px-8 py-3 font-semibold text-primary-foreground hover:opacity-90"
> >
Voltar ao início {t('backHome')}
</Link> </Link>
</main> </main>
); );

View File

@ -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 ( return (
<main className="flex min-h-screen flex-col items-center justify-center gap-4 p-6 text-center"> <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> <h1 className="text-4xl font-bold">{t('title404')}</h1>
<p className="text-muted-foreground">Página não encontrada.</p> <p className="text-muted-foreground">{t('message404')}</p>
<a href="/" className="text-sm underline underline-offset-4"> <a href="/" className="text-sm underline underline-offset-4">
Voltar ao início {t('backHome')}
</a> </a>
</main> </main>
); );

View File

@ -1,15 +1,17 @@
import Link from 'next/link'; import Link from 'next/link';
import { Wrench } from 'lucide-react'; import { Wrench } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { resolveUser } from '@/lib/auth'; import { resolveUser } from '@/lib/auth';
import { api } from '@/lib/trpc/server'; import { api } from '@/lib/trpc/server';
import { SignOutButton } from './sign-out-button'; import { SignOutButton } from './sign-out-button';
import { StatusBadge } from './status-badge'; import { StatusBadge } from './status-badge';
import { SyncChip } from './sync-chip'; import { SyncChip } from './sync-chip';
import { LanguageSwitcher } from './language-switcher';
export default async function HomePage() { export default async function HomePage() {
const t = await getTranslations('home');
const user = await resolveUser(); const user = await resolveUser();
// myRecent is a protectedProcedure — fails gracefully when there is no session.
type RecentItem = Awaited<ReturnType<typeof api.maintenanceRequest.myRecent>>[number]; type RecentItem = Awaited<ReturnType<typeof api.maintenanceRequest.myRecent>>[number];
let recent: RecentItem[] = []; let recent: RecentItem[] = [];
try { try {
@ -23,12 +25,15 @@ export default async function HomePage() {
{/* ── Header ── */} {/* ── Header ── */}
<header className="flex items-center justify-between border-b border-border bg-card px-4 py-3"> <header className="flex items-center justify-between border-b border-border bg-card px-4 py-3">
<div> <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"> <p className="text-sm font-medium" data-testid="current-user">
{user?.email ?? '—'} {user?.email ?? '—'}
</p> </p>
</div> </div>
<div className="flex items-center gap-2">
<LanguageSwitcher />
<SignOutButton /> <SignOutButton />
</div>
</header> </header>
<div className="flex flex-1 flex-col gap-6 p-4"> <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]" 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" /> <Wrench className="h-6 w-6" />
Pedir manutenção {t('requestMaintenance')}
</Link> </Link>
{/* ── Recent requests ── */} {/* ── Recent requests ── */}
<section> <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 ? ( {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"> <ul className="flex flex-col gap-2">
{recent.map((req) => ( {recent.map((req) => (

View File

@ -2,6 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { signIn } from 'next-auth/react'; import { signIn } from 'next-auth/react';
import { ArrowLeft, Delete } from 'lucide-react'; import { ArrowLeft, Delete } from 'lucide-react';
@ -10,8 +11,6 @@ interface Operator {
email: string; email: string;
} }
// ── State types ──────────────────────────────────────────────────────────────
type PickerState = type PickerState =
| { step: 'list' } | { step: 'list' }
| { step: 'pin'; operator: Operator }; | { step: 'pin'; operator: Operator };
@ -19,20 +18,18 @@ type PickerState =
const PIN_MIN = 4; const PIN_MIN = 4;
const PIN_MAX = 6; const PIN_MAX = 6;
// ── Sub-components ───────────────────────────────────────────────────────────
function OperatorList({ function OperatorList({
operators, operators,
onSelect, onSelect,
t,
}: { }: {
operators: Operator[]; operators: Operator[];
onSelect: (op: Operator) => void; onSelect: (op: Operator) => void;
t: ReturnType<typeof useTranslations<'auth'>>;
}) { }) {
if (operators.length === 0) { if (operators.length === 0) {
return ( return (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">{t('noOperators')}</p>
Nenhum operador encontrado. Execute <code>pnpm db:seed</code>.
</p>
); );
} }
return ( return (
@ -53,9 +50,13 @@ function OperatorList({
function PinPad({ function PinPad({
operator, operator,
onBack, onBack,
t,
tc,
}: { }: {
operator: Operator; operator: Operator;
onBack: () => void; onBack: () => void;
t: ReturnType<typeof useTranslations<'auth'>>;
tc: ReturnType<typeof useTranslations<'common'>>;
}) { }) {
const router = useRouter(); const router = useRouter();
const [digits, setDigits] = useState(''); const [digits, setDigits] = useState('');
@ -85,14 +86,14 @@ function PinPad({
}); });
if (result?.error) { if (result?.error) {
setDigits(''); setDigits('');
setError('PIN incorreto ou conta bloqueada. Tente novamente.'); setError(t('invalidPin'));
} else { } else {
router.push('/'); router.push('/');
router.refresh(); router.refresh();
} }
} catch { } catch {
setDigits(''); setDigits('');
setError('Erro inesperado. Tente novamente.'); setError(t('unexpectedError'));
} finally { } finally {
setBusy(false); setBusy(false);
} }
@ -108,12 +109,12 @@ function PinPad({
onClick={onBack} onClick={onBack}
disabled={busy} disabled={busy}
className="rounded-lg p-2 hover:bg-accent disabled:opacity-50" className="rounded-lg p-2 hover:bg-accent disabled:opacity-50"
aria-label="Voltar" aria-label={t('back')}
> >
<ArrowLeft className="h-5 w-5" /> <ArrowLeft className="h-5 w-5" />
</button> </button>
<div> <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> <p className="text-sm font-medium">{operator.email}</p>
</div> </div>
</div> </div>
@ -150,7 +151,7 @@ function PinPad({
onClick={erase} onClick={erase}
disabled={busy || digits.length === 0} 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" 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" /> <Delete className="h-5 w-5" />
</button> </button>
@ -175,15 +176,15 @@ function PinPad({
disabled={digits.length < PIN_MIN || busy} 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" 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> </button>
</div> </div>
); );
} }
// ── Main component ───────────────────────────────────────────────────────────
export function OperatorPicker({ operators }: { operators: Operator[] }) { export function OperatorPicker({ operators }: { operators: Operator[] }) {
const t = useTranslations('auth');
const tc = useTranslations('common');
const [state, setState] = useState<PickerState>({ step: 'list' }); const [state, setState] = useState<PickerState>({ step: 'list' });
if (state.step === 'pin') { if (state.step === 'pin') {
@ -191,6 +192,8 @@ export function OperatorPicker({ operators }: { operators: Operator[] }) {
<PinPad <PinPad
operator={state.operator} operator={state.operator}
onBack={() => setState({ step: 'list' })} onBack={() => setState({ step: 'list' })}
t={t}
tc={tc}
/> />
); );
} }
@ -199,6 +202,7 @@ export function OperatorPicker({ operators }: { operators: Operator[] }) {
<OperatorList <OperatorList
operators={operators} operators={operators}
onSelect={(op) => setState({ step: 'pin', operator: op })} onSelect={(op) => setState({ step: 'pin', operator: op })}
t={t}
/> />
); );
} }

View File

@ -1,4 +1,5 @@
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { getTranslations } from 'next-intl/server';
import { prisma } from '@repo/db'; import { prisma } from '@repo/db';
import { resolveUser } from '@/lib/auth'; import { resolveUser } from '@/lib/auth';
import { OperatorPicker } from './operator-picker'; 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 login step. prisma is used directly (bypassing the tRPC auth layer) so
// the page works even when AUTH_DEV_AUTOLOGIN=false. // the page works even when AUTH_DEV_AUTOLOGIN=false.
export default async function SelectOperatorPage() { export default async function SelectOperatorPage() {
const t = await getTranslations('auth');
const user = await resolveUser(); const user = await resolveUser();
if (user) redirect('/'); if (user) redirect('/');
@ -19,8 +21,8 @@ export default async function SelectOperatorPage() {
return ( return (
<main className="mx-auto flex min-h-screen max-w-sm flex-col justify-center gap-8 p-6"> <main className="mx-auto flex min-h-screen max-w-sm flex-col justify-center gap-8 p-6">
<header className="text-center"> <header className="text-center">
<h1 className="text-2xl font-bold tracking-tight">Quem és tu?</h1> <h1 className="text-2xl font-bold tracking-tight">{t('pickerTitle')}</h1>
<p className="mt-1 text-sm text-muted-foreground">Escolhe o teu perfil para continuar.</p> <p className="mt-1 text-sm text-muted-foreground">{t('pickerSubtitle')}</p>
</header> </header>
<OperatorPicker operators={operators} /> <OperatorPicker operators={operators} />
</main> </main>

View File

@ -1,14 +1,16 @@
'use client'; 'use client';
import { useTranslations } from 'next-intl';
import { signOut } from 'next-auth/react'; import { signOut } from 'next-auth/react';
export function SignOutButton() { export function SignOutButton() {
const t = useTranslations('auth');
return ( return (
<button <button
onClick={() => signOut({ callbackUrl: '/select-operator' })} onClick={() => signOut({ callbackUrl: '/select-operator' })}
className="text-xs text-muted-foreground underline-offset-2 hover:underline" className="text-xs text-muted-foreground underline-offset-2 hover:underline"
> >
Trocar {t('switchOperator')}
</button> </button>
); );
} }

View File

@ -1,14 +1,20 @@
const CONFIG = { 'use client';
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' }, import { useTranslations } from 'next-intl';
RESOLVED: { label: 'Resolvido',className: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' },
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; } as const;
export function StatusBadge({ status }: { status: keyof typeof CONFIG }) { type Status = keyof typeof STATUS_CLASS;
const { label, className } = CONFIG[status];
export function StatusBadge({ status }: { status: Status }) {
const t = useTranslations('common');
return ( return (
<span className={`shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium ${className}`}> <span className={`shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium ${STATUS_CLASS[status]}`}>
{label} {t(`status.${status.toLowerCase() as 'open' | 'claimed' | 'resolved'}`)}
</span> </span>
); );
} }

View File

@ -1,14 +1,16 @@
'use client'; 'use client';
import { useTranslations } from 'next-intl';
import { useSyncState } from './sync-provider'; import { useSyncState } from './sync-provider';
export function SyncChip() { export function SyncChip() {
const t = useTranslations('sync');
const { pendingCount, deadLetterCount } = useSyncState(); const { pendingCount, deadLetterCount } = useSyncState();
if (deadLetterCount > 0) { if (deadLetterCount > 0) {
return ( return (
<div className="rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive"> <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> </div>
); );
} }
@ -17,7 +19,7 @@ export function SyncChip() {
return ( return (
<div className="flex items-center gap-2 rounded-lg bg-orange-50 px-3 py-2 text-xs text-orange-700"> <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" /> <span className="h-2 w-2 rounded-full bg-orange-400" />
{pendingCount} pedido{pendingCount > 1 ? 's' : ''} por enviar {t('pending', { count: pendingCount })}
</div> </div>
); );
} }
@ -25,7 +27,7 @@ export function SyncChip() {
return ( return (
<div className="flex items-center gap-2 rounded-lg bg-green-50 px-3 py-2 text-xs text-green-700"> <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" /> <span className="h-2 w-2 rounded-full bg-green-500" />
Tudo sincronizado {t('synced')}
</div> </div>
); );
} }

View File

@ -9,6 +9,7 @@ import {
useState, useState,
type ReactNode, type ReactNode,
} from 'react'; } from 'react';
import { useTranslations } from 'next-intl';
import { subscribeBroadcast, type SyncMessage } from '@/lib/queue/broadcast'; import { subscribeBroadcast, type SyncMessage } from '@/lib/queue/broadcast';
import { runSync } from '@/lib/queue/sync'; import { runSync } from '@/lib/queue/sync';
import { db } from '@/lib/queue/db'; import { db } from '@/lib/queue/db';
@ -22,6 +23,7 @@ const SyncCtx = createContext<SyncState>({ pendingCount: 0, deadLetterCount: 0 }
export const useSyncState = () => useContext(SyncCtx); export const useSyncState = () => useContext(SyncCtx);
export function SyncProvider({ children }: { children: ReactNode }) { export function SyncProvider({ children }: { children: ReactNode }) {
const t = useTranslations('sync');
const [state, setState] = useState<SyncState>({ pendingCount: 0, deadLetterCount: 0 }); const [state, setState] = useState<SyncState>({ pendingCount: 0, deadLetterCount: 0 });
const [failedIds, setFailedIds] = useState<string[]>([]); const [failedIds, setFailedIds] = useState<string[]>([]);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null); const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
@ -85,11 +87,11 @@ export function SyncProvider({ children }: { children: ReactNode }) {
key={id} key={id}
className="flex items-center justify-between rounded-lg bg-destructive px-4 py-3 text-sm text-destructive-foreground shadow-lg" 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 <button
onClick={() => setFailedIds((prev) => prev.filter((x) => x !== id))} onClick={() => setFailedIds((prev) => prev.filter((x) => x !== id))}
className="ml-4 shrink-0 opacity-80 hover:opacity-100" className="ml-4 shrink-0 opacity-80 hover:opacity-100"
aria-label="Fechar" aria-label={t('close')}
> >
</button> </button>

View File

@ -0,0 +1,8 @@
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);
}

View File

@ -0,0 +1,12 @@
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,
};
});

View File

@ -0,0 +1,70 @@
{
"metadata": {
"title": "FieldOps — Operator",
"description": "Industrial operator console.",
"appName": "FieldOps Operator"
},
"common": {
"enter": "Sign in",
"entering": "Signing in…",
"status": {
"open": "Open",
"claimed": "In progress",
"resolved": "Resolved"
}
},
"errors": {
"title500": "500",
"message500": "An unexpected error occurred.",
"retry": "Try again",
"title404": "404",
"message404": "Page not found.",
"backHome": "Back to home"
},
"auth": {
"pickerTitle": "Who are you?",
"pickerSubtitle": "Choose your profile to continue.",
"noOperators": "No operators found. Run pnpm db:seed.",
"back": "Back",
"operatorSelected": "Selected operator",
"invalidPin": "Incorrect PIN or account locked. Please try again.",
"unexpectedError": "Unexpected error. Please try again.",
"deleteDigit": "Delete",
"switchOperator": "Switch"
},
"home": {
"operator": "Operator",
"myRequests": "My requests",
"requestMaintenance": "Request maintenance",
"noRequests": "No requests yet."
},
"sync": {
"deadLetters": "{count, plural, one {# request failed — contact your supervisor.} other {# requests failed — contact your supervisor.}}",
"pending": "{count, plural, one {# request pending} other {# requests pending}}",
"synced": "All synced",
"requestFailed": "Request {id}… failed — contact your supervisor.",
"close": "Close"
},
"maintenance": {
"newTitle": "New maintenance request",
"workstationLabel": "Workstation",
"workstationRequired": "*",
"workstationLoading": "Loading workstations…",
"workstationPlaceholder": "Select a workstation…",
"photoLabel": "Photo (optional)",
"photoPreview": "Preview",
"photoButton": "Take / choose photo",
"descriptionLabel": "Description",
"descriptionRequired": "*",
"descriptionPlaceholder": "Describe the problem…",
"photoError": "Could not process the photo. Please try again.",
"saveError": "Error saving request. Please try again.",
"submit": "Submit request",
"submitting": "Saving…",
"sentTitle": "Request submitted",
"pendingTitle": "Request queued",
"sentMessage": "The maintenance team has been notified and will handle the issue.",
"pendingMessage": "Will be sent as soon as the connection is restored.",
"backHome": "Back to home"
}
}

View File

@ -0,0 +1,70 @@
{
"metadata": {
"title": "FieldOps — Operador",
"description": "Consola de operador industrial.",
"appName": "FieldOps Operador"
},
"common": {
"enter": "Entrar",
"entering": "A entrar…",
"status": {
"open": "Aberto",
"claimed": "Em curso",
"resolved": "Resolvido"
}
},
"errors": {
"title500": "500",
"message500": "Ocorreu um erro inesperado.",
"retry": "Tentar novamente",
"title404": "404",
"message404": "Página não encontrada.",
"backHome": "Voltar ao início"
},
"auth": {
"pickerTitle": "Quem és tu?",
"pickerSubtitle": "Escolhe o teu perfil para continuar.",
"noOperators": "Nenhum operador encontrado. Execute pnpm db:seed.",
"back": "Voltar",
"operatorSelected": "Operador selecionado",
"invalidPin": "PIN incorreto ou conta bloqueada. Tente novamente.",
"unexpectedError": "Erro inesperado. Tente novamente.",
"deleteDigit": "Apagar",
"switchOperator": "Trocar"
},
"home": {
"operator": "Operador",
"myRequests": "Os meus pedidos",
"requestMaintenance": "Pedir manutenção",
"noRequests": "Nenhum pedido ainda."
},
"sync": {
"deadLetters": "{count, plural, one {# pedido com erro — contacta o supervisor.} other {# pedidos com erro — contacta o supervisor.}}",
"pending": "{count, plural, one {# pedido por enviar} other {# pedidos por enviar}}",
"synced": "Tudo sincronizado",
"requestFailed": "Pedido {id}… falhou — contacta o supervisor.",
"close": "Fechar"
},
"maintenance": {
"newTitle": "Novo pedido de manutenção",
"workstationLabel": "Posto",
"workstationRequired": "*",
"workstationLoading": "A carregar postos…",
"workstationPlaceholder": "Seleciona um posto…",
"photoLabel": "Foto (opcional)",
"photoPreview": "Pré-visualização",
"photoButton": "Tirar / escolher foto",
"descriptionLabel": "Descrição",
"descriptionRequired": "*",
"descriptionPlaceholder": "Descreve o problema…",
"photoError": "Não foi possível processar a foto. Tenta de novo.",
"saveError": "Erro ao guardar pedido. Tenta de novo.",
"submit": "Enviar pedido",
"submitting": "A guardar…",
"sentTitle": "Pedido enviado",
"pendingTitle": "Pedido em fila",
"sentMessage": "A equipa de manutenção foi notificada e irá tratar do problema.",
"pendingMessage": "Será enviado assim que a ligação for restabelecida.",
"backHome": "Voltar ao início"
}
}

View File

@ -1,7 +1,10 @@
import type { NextConfig } from 'next'; import type { NextConfig } from 'next';
import withPWAInit from '@ducanh2912/next-pwa'; import withPWAInit from '@ducanh2912/next-pwa';
import createNextIntlPlugin from 'next-intl/plugin';
import './env'; import './env';
const withNextIntl = createNextIntlPlugin('./i18n/request.ts');
const withPWA = withPWAInit({ const withPWA = withPWAInit({
dest: 'public', dest: 'public',
cacheOnFrontEndNav: true, cacheOnFrontEndNav: true,
@ -29,4 +32,4 @@ const nextConfig: NextConfig = {
], ],
}; };
export default withPWA(nextConfig); export default withPWA(withNextIntl(nextConfig));

View File

@ -17,15 +17,16 @@
"@repo/db": "workspace:*", "@repo/db": "workspace:*",
"@repo/domain": "workspace:*", "@repo/domain": "workspace:*",
"@repo/ui": "workspace:*", "@repo/ui": "workspace:*",
"dexie": "^4.0.10",
"@t3-oss/env-nextjs": "^0.11.1", "@t3-oss/env-nextjs": "^0.11.1",
"@tanstack/react-query": "^5.62.10", "@tanstack/react-query": "^5.62.10",
"@trpc/client": "^11.0.0", "@trpc/client": "^11.0.0",
"@trpc/react-query": "^11.0.0", "@trpc/react-query": "^11.0.0",
"@trpc/server": "^11.0.0", "@trpc/server": "^11.0.0",
"dexie": "^4.0.10",
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"next": "15.3.9", "next": "15.3.9",
"next-auth": "5.0.0-beta.25", "next-auth": "5.0.0-beta.25",
"next-intl": "^4.13.0",
"pino": "^9.5.0", "pino": "^9.5.0",
"pino-pretty": "^11.3.0", "pino-pretty": "^11.3.0",
"react": "^19.0.0", "react": "^19.0.0",

138
docs/i18n.md Normal file
View File

@ -0,0 +1,138 @@
# Internationalization (i18n)
FieldOps ships in **Portuguese (PT, default)** and **English (EN)**. Both apps
(`operator-pwa` and `admin-web`) are fully translated. This doc explains how it
works and **how to add a new language**.
## How it works
- Library: [`next-intl`](https://next-intl.dev) (Next.js 15 App Router).
- **No locale in the URL.** This is an internal app (no SEO), so we use
next-intl's "without i18n routing" mode. The locale comes from a
**`NEXT_LOCALE` cookie**, not a `/en/...` path prefix. This keeps every route
unchanged and avoids any clash with the auth middleware.
- **Default is `pt`.** A missing or invalid cookie falls back to Portuguese.
- Dates, numbers and relative times are formatted by next-intl's `useFormatter`,
so they follow the active locale automatically (no hardcoded `pt-PT`).
- Translations live **per app** (not in a shared package) — the two apps share
only a handful of strings, not enough to justify a shared package yet.
## File structure (same in each app)
```
apps/<app>/
i18n/
locales.ts # LOCALES, DEFAULT_LOCALE, LOCALE_LABELS, isLocale()
request.ts # reads the NEXT_LOCALE cookie, loads the messages file
messages/
pt.json # Portuguese (default)
en.json # English
app/
layout.tsx # <html lang={locale}>, NextIntlClientProvider
language-switcher.tsx # the PT | EN pill
next.config.ts # wrapped with withNextIntl(...)
```
Messages are organised into namespaces (`common`, `auth`, `maintenance`,
`report`, `home`, `sync`, `errors`, `metadata`) — see the JSON files.
## Changing language (end user)
Click the **PT | EN** pill in the app header. The choice is saved in the
`NEXT_LOCALE` cookie (1-year expiry); server components re-render via
`router.refresh()`. No account or DB change involved.
> The switcher currently appears on the **authenticated** screens (operator home,
> maintenance queue), not on the login/picker pages. A first-time user sees the
> initial login in PT; once they switch, the cookie persists across logins.
## Adding a new language
Example: adding **French (`fr`)** to the admin-web. Repeat for operator-pwa.
1. **Create the messages file.** Copy `apps/admin-web/messages/en.json` to
`apps/admin-web/messages/fr.json` and translate every value. **Keep every key
identical** — only translate the values. Leave ICU placeholders
(`{email}`, `{count}`) and plural structure (`{count, plural, one {…} other {…}}`)
intact.
2. **Register the locale** in `apps/admin-web/i18n/locales.ts`:
```ts
export const LOCALES = ['pt', 'en', 'fr'] as const;
export const LOCALE_LABELS: Record<Locale, string> = { pt: 'PT', en: 'EN', fr: 'FR' };
```
3. **Repeat steps 12 for `operator-pwa`** (copy its `en.json`, add `fr` to its
`locales.ts`).
4. **Verify** (see below). The **FR** button then appears in the switcher
automatically — no other code changes needed.
> Translating the *values* needs someone who speaks the language. Everything
> else is mechanical.
## Keeping translations healthy
Two failure modes are NOT caught by `tsc` or the E2E tests (the E2E only run in
the default PT), so check them whenever you touch translations:
**1. Key parity** — every locale file must have exactly the same keys, or a
missing key renders as raw text (or throws) when a user switches to it:
```sh
node -e '
const fs=require("fs");
function flat(o,p=""){let k=[];for(const key in o){const np=p?p+"."+key:key;if(typeof o[key]==="object"&&o[key]!==null)k=k.concat(flat(o[key],np));else k.push(np);}return k;}
for(const app of ["admin-web","operator-pwa"]){
const files=fs.readdirSync(`apps/${app}/messages`).filter(f=>f.endsWith(".json"));
const sets=files.map(f=>[f,new Set(flat(JSON.parse(fs.readFileSync(`apps/${app}/messages/${f}`))))]);
const base=sets[0][1];
for(const [f,s] of sets){
const missing=[...base].filter(k=>!s.has(k));
const extra=[...s].filter(k=>!base.has(k));
console.log(`[${app}] ${f}: ${s.size} keys`, missing.length||extra.length?`MISSING ${missing} EXTRA ${extra}`:"OK");
}
}'
```
**2. ICU syntax** — a malformed plural (e.g. a missing brace) only throws when
that exact message renders, which the PT-only E2E may never hit:
```sh
node -e '
const fs=require("fs"),path=require("path");
const dir=fs.readdirSync("node_modules/.pnpm").find(d=>d.startsWith("intl-messageformat@"));
const {IntlMessageFormat}=require(path.resolve("node_modules/.pnpm",dir,"node_modules/intl-messageformat"));
function flat(o,p=""){let r={};for(const k in o){const np=p?p+"."+k:k;if(typeof o[k]==="object"&&o[k]!==null)Object.assign(r,flat(o[k],np));else r[np]=o[k];}return r;}
let errors=0;
for(const app of ["admin-web","operator-pwa"]){
for(const f of fs.readdirSync(`apps/${app}/messages`).filter(f=>f.endsWith(".json"))){
const loc=f.replace(".json","");
const msgs=flat(JSON.parse(fs.readFileSync(`apps/${app}/messages/${f}`)));
for(const [key,val] of Object.entries(msgs)){
try{new IntlMessageFormat(val,loc);}catch(e){console.log(`ICU ERROR [${app}/${loc}] ${key}: ${e.message}`);errors++;}
}
}
}
console.log(errors?`${errors} ERRORS`:"ALL ICU OK");'
```
Both should report OK before shipping a new language.
## What is intentionally NOT translated
- **Dynamic content** — workstation names, areas, request descriptions. These
are customer data, kept in whatever language the user typed.
- **Backend tRPC error messages** — almost never surface to the user (the UI has
its own messages).
- The `Credentials` provider labels in `lib/auth.ts` (internal to Auth.js; the
custom login UI is what users actually see).
## Future improvements (not done yet)
- Persist the locale per tenant/user in the DB (currently cookie-only).
- Show the language switcher on the login/picker pages too.
- A small E2E that switches to EN and asserts a translated string (today the
EN path is covered only by the parity + ICU checks above).
- next-intl strict message-key typing (autocomplete + compile-time error on
unknown keys).

175
docs/plans/i18n-pt-en.md Normal file
View File

@ -0,0 +1,175 @@
# 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).

View File

@ -57,9 +57,10 @@ export default defineConfig({
timeout: 120_000, timeout: 120_000,
stdout: 'pipe', stdout: 'pipe',
stderr: 'pipe', stderr: 'pipe',
// AUTH_URL must point to the admin server — .env has it at 3000 (operator) // AUTH_URL is no longer overridden here — the admin-web `dev` script loads
// which causes Auth.js to redirect unauthenticated users to localhost:3000. // apps/admin-web/.env.admin (AUTH_URL=:3001) with precedence over the root
env: { AUTH_DEV_AUTOLOGIN: 'false', AUTH_URL: ADMIN_URL }, // .env, so the app knows its own base URL. See apps/admin-web/.env.admin.
env: { AUTH_DEV_AUTOLOGIN: 'false' },
}, },
], ],
}); });

416
pnpm-lock.yaml generated
View File

@ -70,7 +70,10 @@ importers:
version: 15.3.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) version: 15.3.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
next-auth: next-auth:
specifier: 5.0.0-beta.25 specifier: 5.0.0-beta.25
version: 5.0.0-beta.25(next@15.3.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6) version: 5.0.0-beta.25(next@15.3.9(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)
next-intl:
specifier: ^4.13.0
version: 4.13.0(next@15.3.9(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
pino: pino:
specifier: ^9.5.0 specifier: ^9.5.0
version: 9.14.0 version: 9.14.0
@ -164,7 +167,10 @@ importers:
version: 15.3.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) version: 15.3.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
next-auth: next-auth:
specifier: 5.0.0-beta.25 specifier: 5.0.0-beta.25
version: 5.0.0-beta.25(next@15.3.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6) version: 5.0.0-beta.25(next@15.3.9(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)
next-intl:
specifier: ^4.13.0
version: 4.13.0(next@15.3.9(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
pino: pino:
specifier: ^9.5.0 specifier: ^9.5.0
version: 9.14.0 version: 9.14.0
@ -1273,6 +1279,18 @@ packages:
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@formatjs/fast-memoize@3.1.5':
resolution: {integrity: sha512-KLi3fan6WnCHmigd9pmEEN8Hid0v4wiFBW576M/d07KMWYecf1CvyMI3n34vCmHT4AoVqG2n702kiHbXjzZX2A==}
'@formatjs/icu-messageformat-parser@3.5.10':
resolution: {integrity: sha512-XeJihYLy1lCe19xfK1KWKG/betBOK2rB0luL8lSkjfvJj0zP+LTJvkC+RKd0jsFI8mWxN71LrarHSrEXE8xxOQ==}
'@formatjs/icu-skeleton-parser@2.1.9':
resolution: {integrity: sha512-rsxswgHMfU1zUgB2byc08fesf83wLGjFnzLCEtuf00mx2doiqc6pYrf67raI37XqdRcGUviQepk2UKGqpng74Q==}
'@formatjs/intl-localematcher@0.8.9':
resolution: {integrity: sha512-GmB0F/gYh4Hdl4rLWjgDsgT+x4pB54fkJeRh8kAZ4XFzKeCK8dGs+SBJWXO42QZtOUni+IDWKNuCw6wiL4lTvw==}
'@humanfs/core@0.19.2': '@humanfs/core@0.19.2':
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
engines: {node: '>=18.18.0'} engines: {node: '>=18.18.0'}
@ -1541,6 +1559,94 @@ packages:
'@panva/hkdf@1.2.1': '@panva/hkdf@1.2.1':
resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==}
'@parcel/watcher-android-arm64@2.5.6':
resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [android]
'@parcel/watcher-darwin-arm64@2.5.6':
resolution: {integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [darwin]
'@parcel/watcher-darwin-x64@2.5.6':
resolution: {integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [darwin]
'@parcel/watcher-freebsd-x64@2.5.6':
resolution: {integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [freebsd]
'@parcel/watcher-linux-arm-glibc@2.5.6':
resolution: {integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm-musl@2.5.6':
resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-arm64-glibc@2.5.6':
resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm64-musl@2.5.6':
resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-x64-glibc@2.5.6':
resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-x64-musl@2.5.6':
resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
'@parcel/watcher-win32-arm64@2.5.6':
resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [win32]
'@parcel/watcher-win32-ia32@2.5.6':
resolution: {integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==}
engines: {node: '>= 10.0.0'}
cpu: [ia32]
os: [win32]
'@parcel/watcher-win32-x64@2.5.6':
resolution: {integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [win32]
'@parcel/watcher@2.5.6':
resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==}
engines: {node: '>= 10.0.0'}
'@pinojs/redact@0.4.0': '@pinojs/redact@0.4.0':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
@ -1628,6 +1734,9 @@ packages:
rollup: rollup:
optional: true optional: true
'@schummar/icu-type-parser@1.21.5':
resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==}
'@smithy/core@3.24.3': '@smithy/core@3.24.3':
resolution: {integrity: sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==} resolution: {integrity: sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
@ -1670,12 +1779,102 @@ packages:
'@surma/rollup-plugin-off-main-thread@2.2.3': '@surma/rollup-plugin-off-main-thread@2.2.3':
resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==} resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==}
'@swc/core-darwin-arm64@1.15.40':
resolution: {integrity: sha512-PaYyclfmQ++77D8ityYvmmVzHv9aG8ROwt2GfG6/ccloy4Hgf80qtOnzb9VYvPsUT7Ty1uhuDRhv3XYpf62qhQ==}
engines: {node: '>=10'}
cpu: [arm64]
os: [darwin]
'@swc/core-darwin-x64@1.15.40':
resolution: {integrity: sha512-HbbPzvfLBUXjIB1Ezks+//lNUjmLjfyd63XSwprJgrZaXYdm70kohXPJUWdqKZozolFxbPaO+xtBaiUp6BoueA==}
engines: {node: '>=10'}
cpu: [x64]
os: [darwin]
'@swc/core-linux-arm-gnueabihf@1.15.40':
resolution: {integrity: sha512-SlRZsCjOCPR2LvFs0Ri/Xrx/5o5TCt8vl4gW6mX1hEZOG0a625RxzRHpHdAQNGykmAN/7IeaFAJG+QnNmxlHcA==}
engines: {node: '>=10'}
cpu: [arm]
os: [linux]
'@swc/core-linux-arm64-gnu@1.15.40':
resolution: {integrity: sha512-Q8byxJt2fh8CR3EUX6snBpy47AoBVm+In/+Z3rjDHMjC38ZvR9/gtUUNCT0tfrn4EdVsO8/QPi59nxrxvqxvBQ==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@swc/core-linux-arm64-musl@1.15.40':
resolution: {integrity: sha512-4z0MgHU+7M0pZDqBN1El7mFXDI1SBwinfcUkAyA4v8QrhOIUOZltySt2aStQLZGrdXVXM4Y4ylfiTC04ED+MoQ==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@swc/core-linux-ppc64-gnu@1.15.40':
resolution: {integrity: sha512-fLI4iUgeSZu0eRWUXwe6YzPFx9gHbFiPkl8Rp3mJfP8OpNR3nTQCGPvHdDh9xniW7mVvgMY4ni7A4VzqI1KrpA==}
engines: {node: '>=10'}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@swc/core-linux-s390x-gnu@1.15.40':
resolution: {integrity: sha512-YqeKMAb7d4nQSGMJQ454IlaCENpzcDqhvBE9+CPfdnYpnUXxd+BSrB6Xk0YjW8UyoEhUj4p6quATCxbsp6J3jg==}
engines: {node: '>=10'}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@swc/core-linux-x64-gnu@1.15.40':
resolution: {integrity: sha512-7HOuS1iGcme/j/TuL1TfmmLGiMQrjv/GmjyZeydl00FKPtpGXEldwqfI56xgd1YzrzoB2svWjxbGGyQ0TEASxg==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@swc/core-linux-x64-musl@1.15.40':
resolution: {integrity: sha512-h4kZYHc7dpc9P9u4brRJaS8Pl7tPVHAeiLSzw7T5RfIJgAoSdaCMKzI/2Uay9gFhaw8uyCDl0L5q37r0EpAfIA==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@swc/core-win32-arm64-msvc@1.15.40':
resolution: {integrity: sha512-+mQgKZXSj6mV38Zh05QaxSjUDmGP/R2JWlXZTDLSPkDzHU6p3GxN9eeSf5dfyDVU86946fmCvSzyl/ucImx8+A==}
engines: {node: '>=10'}
cpu: [arm64]
os: [win32]
'@swc/core-win32-ia32-msvc@1.15.40':
resolution: {integrity: sha512-yvwdPLGd25mcj/mNatjNQ0lZujtQD6psH3v9PNmMb+fSzjbNG8KIDxjFWrcV+fsFVLOkyOmdJsFmX7NAFjVyPw==}
engines: {node: '>=10'}
cpu: [ia32]
os: [win32]
'@swc/core-win32-x64-msvc@1.15.40':
resolution: {integrity: sha512-OXtKsLU1bVtInzzDEAY2sYiF/rl4tvAnLLLpuMp3HzAOQZ5A+i69AKDhA1YLQTaMAqO3vzyYNVAYVRMPtSYD4w==}
engines: {node: '>=10'}
cpu: [x64]
os: [win32]
'@swc/core@1.15.40':
resolution: {integrity: sha512-2kwzJikRvgtNAG7MwVZY2vEzZjTxKIq5jXOihuSV/8U+Hej8Va22t65aKnJZs3P+NwojZvR8Mf8kyM7O+V8sQg==}
engines: {node: '>=10'}
peerDependencies:
'@swc/helpers': '>=0.5.17'
peerDependenciesMeta:
'@swc/helpers':
optional: true
'@swc/counter@0.1.3': '@swc/counter@0.1.3':
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
'@swc/helpers@0.5.15': '@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
'@swc/types@0.1.26':
resolution: {integrity: sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==}
'@t3-oss/env-core@0.11.1': '@t3-oss/env-core@0.11.1':
resolution: {integrity: sha512-MaxOwEoG1ntCFoKJsS7nqwgcxLW1SJw238AJwfJeaz3P/8GtkxXZsPPolsz1AdYvUTbe3XvqZ/VCdfjt+3zmKw==} resolution: {integrity: sha512-MaxOwEoG1ntCFoKJsS7nqwgcxLW1SJw238AJwfJeaz3P/8GtkxXZsPPolsz1AdYvUTbe3XvqZ/VCdfjt+3zmKw==}
peerDependencies: peerDependencies:
@ -2600,6 +2799,9 @@ packages:
help-me@5.0.0: help-me@5.0.0:
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
icu-minify@4.13.0:
resolution: {integrity: sha512-SIFMeUHZJjzS5RvIGvybKvWoHjDm9cGVEs2EpJ8PmywOdJLWyblPm7TdPLLoUtkJtwQD7iGhl2WMptZ+N0on+w==}
idb@7.1.1: idb@7.1.1:
resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==} resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==}
@ -2633,6 +2835,9 @@ packages:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
intl-messageformat@11.2.7:
resolution: {integrity: sha512-+q6Ktg119nULZEpZ8YTuGOst9MyEzFtjD63FTGBlN1mLz0Z/MOUYDIvnpVKwq17eezIEh+cfJIebfJoCetpiNw==}
is-array-buffer@3.0.5: is-array-buffer@3.0.5:
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -2926,6 +3131,10 @@ packages:
natural-compare@1.4.0: natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
negotiator@1.0.0:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'}
neo-async@2.6.2: neo-async@2.6.2:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
@ -2945,6 +3154,19 @@ packages:
nodemailer: nodemailer:
optional: true optional: true
next-intl-swc-plugin-extractor@4.13.0:
resolution: {integrity: sha512-6S/fJI0KXvLCL8nhBo9P8eGaJPzmwJBTCzX0NaUIj0VyU8U89d//T+vjMLdNIXl5MlLaYH7B9MbAjb8Mvu+tqQ==}
next-intl@4.13.0:
resolution: {integrity: sha512-OvNq2v5XLx4EkQOsAhVE9g+6zdb83XHusADCXXtIW4LILYnjEVaeINdr1lkVWKSjzwNUiMSlH5N4K0OQTRiv6A==}
peerDependencies:
next: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
next@15.3.9: next@15.3.9:
resolution: {integrity: sha512-bat50ogkh2esjfkbqmVocL5QunR9RGCSO2oQKFjKeDcEylIgw3JY6CMfGnzoVfXJ9SDLHI546sHmsk90D2ivwQ==} resolution: {integrity: sha512-bat50ogkh2esjfkbqmVocL5QunR9RGCSO2oQKFjKeDcEylIgw3JY6CMfGnzoVfXJ9SDLHI546sHmsk90D2ivwQ==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
@ -2966,6 +3188,9 @@ packages:
sass: sass:
optional: true optional: true
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
node-fetch-native@1.6.7: node-fetch-native@1.6.7:
resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
@ -3112,6 +3337,9 @@ packages:
engines: {node: '>=18'} engines: {node: '>=18'}
hasBin: true hasBin: true
po-parser@2.1.1:
resolution: {integrity: sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ==}
possible-typed-array-names@1.1.0: possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -3770,6 +3998,11 @@ packages:
uri-js@4.4.1: uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
use-intl@4.13.0:
resolution: {integrity: sha512-fAFDrWaASxlhXOipcOyb5VDD+YONqj6+8O8EcG/J7RBoOUF3A8YahRWLN+mBxYMrlMQB8N6Voqk5X+YC+HSL0A==}
peerDependencies:
react: ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0
util-deprecate@1.0.2: util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
@ -5090,6 +5323,18 @@ snapshots:
'@eslint/core': 0.17.0 '@eslint/core': 0.17.0
levn: 0.4.1 levn: 0.4.1
'@formatjs/fast-memoize@3.1.5': {}
'@formatjs/icu-messageformat-parser@3.5.10':
dependencies:
'@formatjs/icu-skeleton-parser': 2.1.9
'@formatjs/icu-skeleton-parser@2.1.9': {}
'@formatjs/intl-localematcher@0.8.9':
dependencies:
'@formatjs/fast-memoize': 3.1.5
'@humanfs/core@0.19.2': '@humanfs/core@0.19.2':
dependencies: dependencies:
'@humanfs/types': 0.15.0 '@humanfs/types': 0.15.0
@ -5273,6 +5518,66 @@ snapshots:
'@panva/hkdf@1.2.1': {} '@panva/hkdf@1.2.1': {}
'@parcel/watcher-android-arm64@2.5.6':
optional: true
'@parcel/watcher-darwin-arm64@2.5.6':
optional: true
'@parcel/watcher-darwin-x64@2.5.6':
optional: true
'@parcel/watcher-freebsd-x64@2.5.6':
optional: true
'@parcel/watcher-linux-arm-glibc@2.5.6':
optional: true
'@parcel/watcher-linux-arm-musl@2.5.6':
optional: true
'@parcel/watcher-linux-arm64-glibc@2.5.6':
optional: true
'@parcel/watcher-linux-arm64-musl@2.5.6':
optional: true
'@parcel/watcher-linux-x64-glibc@2.5.6':
optional: true
'@parcel/watcher-linux-x64-musl@2.5.6':
optional: true
'@parcel/watcher-win32-arm64@2.5.6':
optional: true
'@parcel/watcher-win32-ia32@2.5.6':
optional: true
'@parcel/watcher-win32-x64@2.5.6':
optional: true
'@parcel/watcher@2.5.6':
dependencies:
detect-libc: 2.1.2
is-glob: 4.0.3
node-addon-api: 7.1.1
picomatch: 4.0.4
optionalDependencies:
'@parcel/watcher-android-arm64': 2.5.6
'@parcel/watcher-darwin-arm64': 2.5.6
'@parcel/watcher-darwin-x64': 2.5.6
'@parcel/watcher-freebsd-x64': 2.5.6
'@parcel/watcher-linux-arm-glibc': 2.5.6
'@parcel/watcher-linux-arm-musl': 2.5.6
'@parcel/watcher-linux-arm64-glibc': 2.5.6
'@parcel/watcher-linux-arm64-musl': 2.5.6
'@parcel/watcher-linux-x64-glibc': 2.5.6
'@parcel/watcher-linux-x64-musl': 2.5.6
'@parcel/watcher-win32-arm64': 2.5.6
'@parcel/watcher-win32-ia32': 2.5.6
'@parcel/watcher-win32-x64': 2.5.6
'@pinojs/redact@0.4.0': {} '@pinojs/redact@0.4.0': {}
'@playwright/test@1.60.0': '@playwright/test@1.60.0':
@ -5362,6 +5667,8 @@ snapshots:
optionalDependencies: optionalDependencies:
rollup: 2.80.0 rollup: 2.80.0
'@schummar/icu-type-parser@1.21.5': {}
'@smithy/core@3.24.3': '@smithy/core@3.24.3':
dependencies: dependencies:
'@aws-crypto/crc32': 5.2.0 '@aws-crypto/crc32': 5.2.0
@ -5419,12 +5726,70 @@ snapshots:
magic-string: 0.25.9 magic-string: 0.25.9
string.prototype.matchall: 4.0.12 string.prototype.matchall: 4.0.12
'@swc/core-darwin-arm64@1.15.40':
optional: true
'@swc/core-darwin-x64@1.15.40':
optional: true
'@swc/core-linux-arm-gnueabihf@1.15.40':
optional: true
'@swc/core-linux-arm64-gnu@1.15.40':
optional: true
'@swc/core-linux-arm64-musl@1.15.40':
optional: true
'@swc/core-linux-ppc64-gnu@1.15.40':
optional: true
'@swc/core-linux-s390x-gnu@1.15.40':
optional: true
'@swc/core-linux-x64-gnu@1.15.40':
optional: true
'@swc/core-linux-x64-musl@1.15.40':
optional: true
'@swc/core-win32-arm64-msvc@1.15.40':
optional: true
'@swc/core-win32-ia32-msvc@1.15.40':
optional: true
'@swc/core-win32-x64-msvc@1.15.40':
optional: true
'@swc/core@1.15.40':
dependencies:
'@swc/counter': 0.1.3
'@swc/types': 0.1.26
optionalDependencies:
'@swc/core-darwin-arm64': 1.15.40
'@swc/core-darwin-x64': 1.15.40
'@swc/core-linux-arm-gnueabihf': 1.15.40
'@swc/core-linux-arm64-gnu': 1.15.40
'@swc/core-linux-arm64-musl': 1.15.40
'@swc/core-linux-ppc64-gnu': 1.15.40
'@swc/core-linux-s390x-gnu': 1.15.40
'@swc/core-linux-x64-gnu': 1.15.40
'@swc/core-linux-x64-musl': 1.15.40
'@swc/core-win32-arm64-msvc': 1.15.40
'@swc/core-win32-ia32-msvc': 1.15.40
'@swc/core-win32-x64-msvc': 1.15.40
'@swc/counter@0.1.3': {} '@swc/counter@0.1.3': {}
'@swc/helpers@0.5.15': '@swc/helpers@0.5.15':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
'@swc/types@0.1.26':
dependencies:
'@swc/counter': 0.1.3
'@t3-oss/env-core@0.11.1(typescript@5.9.3)(zod@3.25.76)': '@t3-oss/env-core@0.11.1(typescript@5.9.3)(zod@3.25.76)':
dependencies: dependencies:
zod: 3.25.76 zod: 3.25.76
@ -6010,8 +6375,7 @@ snapshots:
destr@2.0.5: {} destr@2.0.5: {}
detect-libc@2.1.2: detect-libc@2.1.2: {}
optional: true
dexie@4.4.2: {} dexie@4.4.2: {}
@ -6481,6 +6845,10 @@ snapshots:
help-me@5.0.0: {} help-me@5.0.0: {}
icu-minify@4.13.0:
dependencies:
'@formatjs/icu-messageformat-parser': 3.5.10
idb@7.1.1: {} idb@7.1.1: {}
ieee754@1.2.1: {} ieee754@1.2.1: {}
@ -6509,6 +6877,11 @@ snapshots:
hasown: 2.0.3 hasown: 2.0.3
side-channel: 1.1.0 side-channel: 1.1.0
intl-messageformat@11.2.7:
dependencies:
'@formatjs/fast-memoize': 3.1.5
'@formatjs/icu-messageformat-parser': 3.5.10
is-array-buffer@3.0.5: is-array-buffer@3.0.5:
dependencies: dependencies:
call-bind: 1.0.9 call-bind: 1.0.9
@ -6765,14 +7138,35 @@ snapshots:
natural-compare@1.4.0: {} natural-compare@1.4.0: {}
negotiator@1.0.0: {}
neo-async@2.6.2: {} neo-async@2.6.2: {}
next-auth@5.0.0-beta.25(next@15.3.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6): next-auth@5.0.0-beta.25(next@15.3.9(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6):
dependencies: dependencies:
'@auth/core': 0.37.2 '@auth/core': 0.37.2
next: 15.3.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) next: 15.3.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react: 19.2.6 react: 19.2.6
next-intl-swc-plugin-extractor@4.13.0: {}
next-intl@4.13.0(next@15.3.9(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(typescript@5.9.3):
dependencies:
'@formatjs/intl-localematcher': 0.8.9
'@parcel/watcher': 2.5.6
'@swc/core': 1.15.40
icu-minify: 4.13.0
negotiator: 1.0.0
next: 15.3.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
next-intl-swc-plugin-extractor: 4.13.0
po-parser: 2.1.1
react: 19.2.6
use-intl: 4.13.0(react@19.2.6)
optionalDependencies:
typescript: 5.9.3
transitivePeerDependencies:
- '@swc/helpers'
next@15.3.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): next@15.3.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies: dependencies:
'@next/env': 15.3.9 '@next/env': 15.3.9
@ -6799,6 +7193,8 @@ snapshots:
- '@babel/core' - '@babel/core'
- babel-plugin-macros - babel-plugin-macros
node-addon-api@7.1.1: {}
node-fetch-native@1.6.7: {} node-fetch-native@1.6.7: {}
node-releases@2.0.44: {} node-releases@2.0.44: {}
@ -6947,6 +7343,8 @@ snapshots:
optionalDependencies: optionalDependencies:
fsevents: 2.3.2 fsevents: 2.3.2
po-parser@2.1.1: {}
possible-typed-array-names@1.1.0: {} possible-typed-array-names@1.1.0: {}
postcss-import@15.1.0(postcss@8.5.14): postcss-import@15.1.0(postcss@8.5.14):
@ -7593,6 +7991,14 @@ snapshots:
dependencies: dependencies:
punycode: 2.3.1 punycode: 2.3.1
use-intl@4.13.0(react@19.2.6):
dependencies:
'@formatjs/fast-memoize': 3.1.5
'@schummar/icu-type-parser': 1.21.5
icu-minify: 4.13.0
intl-messageformat: 11.2.7
react: 19.2.6
util-deprecate@1.0.2: {} util-deprecate@1.0.2: {}
watchpack@2.5.1: watchpack@2.5.1:

View File

@ -10,8 +10,10 @@ packages:
# - esbuild: prebuilt native binary; used transitively by tsx. # - esbuild: prebuilt native binary; used transitively by tsx.
# - sharp: prebuilt native binary; used by Next.js image optimization. # - sharp: prebuilt native binary; used by Next.js image optimization.
allowBuilds: allowBuilds:
'@parcel/watcher': true
'@prisma/client': true '@prisma/client': true
'@prisma/engines': true '@prisma/engines': true
'@swc/core': true
esbuild: true esbuild: true
prisma: true prisma: true
sharp: true sharp: true