localization support

O que mudou
Infra (por app):

i18n/locales.ts — lista de locales (pt, en), default pt, labels para o seletor
i18n/request.ts — lê o cookie NEXT_LOCALE, carrega as mensagens
messages/pt.json + messages/en.json — todas as strings extraídas
next.config.ts — envolvido com withNextIntl (operator-pwa: withPWA(withNextIntl(...)))
app/layout.tsx — <html lang={locale}> dinâmico, NextIntlClientProvider
app/language-switcher.tsx — seletor PT | EN (cookie + router.refresh())
23 ficheiros de UI atualizados — todos os textos visíveis agora usam t('...') ou getTranslations.

Datas no relatório passaram de toLocaleString('pt-PT') fixo para useFormatter() do next-intl — localizam-se automaticamente.

Plurais em ICU no sync-chip: {count, plural, one {# pedido...} other {# pedidos...}}.

Resultado dos testes:

pnpm test:e2e — 3/3 ✓
pnpm test:e2e:auth — 4/4 ✓
tsc --noEmit em ambas as apps — limpo ✓
Para adicionar uma língua futura: criar messages/<locale>.json + adicionar o locale a i18n/locales.ts em cada app. O seletor aparece automaticamente.
This commit is contained in:
2026-05-30 16:46:07 +01:00
parent 2093f12d0a
commit 35e7027881
41 changed files with 1549 additions and 259 deletions
+6 -3
View File
@@ -1,6 +1,7 @@
'use client';
import { useEffect } from 'react';
import { useTranslations } from 'next-intl';
export default function ErrorPage({
error,
@@ -9,16 +10,18 @@ export default function ErrorPage({
error: Error & { digest?: string };
reset: () => void;
}) {
const t = useTranslations('errors');
useEffect(() => {
console.error(error);
}, [error]);
return (
<main className="flex min-h-screen flex-col items-center justify-center gap-4 p-6 text-center">
<h1 className="text-4xl font-bold">500</h1>
<p className="text-muted-foreground">Ocorreu um erro inesperado.</p>
<h1 className="text-4xl font-bold">{t('title500')}</h1>
<p className="text-muted-foreground">{t('message500')}</p>
<button onClick={reset} className="text-sm underline underline-offset-4">
Tentar novamente
{t('retry')}
</button>
</main>
);
@@ -0,0 +1,33 @@
'use client';
import { useRouter } from 'next/navigation';
import { useLocale } from 'next-intl';
import { LOCALES, LOCALE_LABELS, type Locale } from '@/i18n/locales';
export function LanguageSwitcher() {
const router = useRouter();
const current = useLocale() as Locale;
function switchTo(locale: Locale) {
document.cookie = `NEXT_LOCALE=${locale}; path=/; max-age=31536000; SameSite=Lax`;
router.refresh();
}
return (
<div className="flex items-center gap-1 rounded-full border border-border bg-muted p-0.5">
{LOCALES.map((l) => (
<button
key={l}
onClick={() => switchTo(l)}
className={`rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors ${
l === current
? 'bg-card text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{LOCALE_LABELS[l]}
</button>
))}
</div>
);
}
+26 -16
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 { Providers } from './providers';
import { SyncProvider } from './sync-provider';
import './globals.css';
export const metadata: Metadata = {
title: 'FieldOps — Operator',
description: 'Industrial operator console.',
manifest: '/manifest.webmanifest',
applicationName: 'FieldOps Operator',
appleWebApp: {
capable: true,
title: 'FieldOps Operator',
statusBarStyle: 'default',
},
};
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('metadata');
return {
title: t('title'),
description: t('description'),
manifest: '/manifest.webmanifest',
applicationName: t('appName'),
appleWebApp: {
capable: true,
title: t('appName'),
statusBarStyle: 'default',
},
};
}
export const viewport: Viewport = {
themeColor: '#0f172a',
@@ -21,13 +26,18 @@ export const viewport: Viewport = {
initialScale: 1,
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const locale = await getLocale();
const messages = await getMessages();
return (
<html lang="en">
<html lang={locale}>
<body className="min-h-screen bg-background font-sans antialiased">
<Providers>
<SyncProvider>{children}</SyncProvider>
</Providers>
<NextIntlClientProvider locale={locale} messages={messages}>
<Providers>
<SyncProvider>{children}</SyncProvider>
</Providers>
</NextIntlClientProvider>
</body>
</html>
);
+17 -19
View File
@@ -4,11 +4,11 @@ import { useState, useRef } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { ArrowLeft, Camera, X } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { trpc } from '@/lib/trpc/client';
import { db } from '@/lib/queue/db';
import { runSync } from '@/lib/queue/sync';
// Resize to max 1600px on longest side and compress to JPEG q=0.8.
function compressImage(file: File): Promise<Blob> {
return new Promise((resolve, reject) => {
const img = new Image();
@@ -44,6 +44,7 @@ function compressImage(file: File): Promise<Blob> {
}
export default function NewRequestPage() {
const t = useTranslations('maintenance');
const router = useRouter();
const fileRef = useRef<HTMLInputElement>(null);
@@ -56,7 +57,7 @@ export default function NewRequestPage() {
const { data: workstations = [], isLoading: wsLoading } = trpc.workstation.list.useQuery(
undefined,
{ staleTime: 60 * 60 * 1000 }, // 1h — serves from cache when offline
{ staleTime: 60 * 60 * 1000 },
);
async function handlePhotoChange(e: React.ChangeEvent<HTMLInputElement>) {
@@ -68,7 +69,7 @@ export default function NewRequestPage() {
setPhotoBlob(compressed);
setPhotoPreview(URL.createObjectURL(compressed));
} catch {
setError('Não foi possível processar a foto. Tenta de novo.');
setError(t('photoError'));
}
}
@@ -89,8 +90,6 @@ export default function NewRequestPage() {
try {
const clientRequestId = crypto.randomUUID();
// Enqueue in IndexedDB immediately — returns control to the user
// regardless of network state. The SyncProvider will drain the queue.
await db.pending.add({
clientRequestId,
workstationId,
@@ -100,12 +99,11 @@ export default function NewRequestPage() {
retries: 0,
});
// Attempt immediate sync if online (fire-and-forget)
if (navigator.onLine) runSync().catch(() => {});
router.push(`/maintenance/sent?cid=${clientRequestId}`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Erro ao guardar pedido. Tenta de novo.');
setError(err instanceof Error ? err.message : t('saveError'));
setSubmitting(false);
}
}
@@ -119,14 +117,14 @@ export default function NewRequestPage() {
<Link href="/" className="rounded-md p-1 hover:bg-accent">
<ArrowLeft className="h-5 w-5" />
</Link>
<h1 className="text-base font-semibold">Novo pedido de manutenção</h1>
<h1 className="text-base font-semibold">{t('newTitle')}</h1>
</header>
<form onSubmit={handleSubmit} className="flex flex-1 flex-col gap-6 p-4">
{/* Posto */}
{/* Workstation */}
<div className="flex flex-col gap-1.5">
<label htmlFor="workstation" className="text-sm font-medium">
Posto <span className="text-destructive">*</span>
{t('workstationLabel')} <span className="text-destructive">{t('workstationRequired')}</span>
</label>
<select
id="workstation"
@@ -137,7 +135,7 @@ export default function NewRequestPage() {
className="w-full rounded-lg border border-border bg-card px-3 py-2.5 text-sm disabled:opacity-50"
>
<option value="">
{wsLoading ? 'A carregar postos…' : 'Seleciona um posto…'}
{wsLoading ? t('workstationLoading') : t('workstationPlaceholder')}
</option>
{workstations.map((ws) => (
<option key={ws.id} value={ws.id}>
@@ -147,13 +145,13 @@ export default function NewRequestPage() {
</select>
</div>
{/* Foto */}
{/* Photo */}
<div className="flex flex-col gap-1.5">
<span className="text-sm font-medium">Foto (opcional)</span>
<span className="text-sm font-medium">{t('photoLabel')}</span>
{photoPreview ? (
<div className="relative">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={photoPreview} alt="Pré-visualização" className="h-48 w-full rounded-lg object-cover" />
<img src={photoPreview} alt={t('photoPreview')} className="h-48 w-full rounded-lg object-cover" />
<button
type="button"
onClick={removePhoto}
@@ -169,7 +167,7 @@ export default function NewRequestPage() {
className="flex h-24 w-full items-center justify-center gap-2 rounded-lg border-2 border-dashed border-border text-sm text-muted-foreground hover:bg-accent"
>
<Camera className="h-5 w-5" />
Tirar / escolher foto
{t('photoButton')}
</button>
)}
<input
@@ -182,10 +180,10 @@ export default function NewRequestPage() {
/>
</div>
{/* Descrição */}
{/* Description */}
<div className="flex flex-col gap-1.5">
<label htmlFor="description" className="flex items-center justify-between text-sm font-medium">
<span>Descrição <span className="text-destructive">*</span></span>
<span>{t('descriptionLabel')} <span className="text-destructive">{t('descriptionRequired')}</span></span>
<span className={`text-xs ${descLen > 1000 ? 'text-destructive' : 'text-muted-foreground'}`}>
{descLen}/1000
</span>
@@ -198,7 +196,7 @@ export default function NewRequestPage() {
minLength={3}
maxLength={1000}
rows={4}
placeholder="Descreve o problema…"
placeholder={t('descriptionPlaceholder')}
className="w-full resize-none rounded-lg border border-border bg-card px-3 py-2.5 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
@@ -213,7 +211,7 @@ export default function NewRequestPage() {
disabled={!canSubmit}
className="w-full rounded-xl bg-primary px-6 py-4 text-base font-semibold text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-40"
>
{submitting ? 'A guardar…' : 'Enviar pedido'}
{submitting ? t('submitting') : t('submit')}
</button>
</div>
</form>
@@ -3,11 +3,13 @@
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { CheckCircle2, Clock } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { db } from '@/lib/queue/db';
import { subscribeBroadcast } from '@/lib/queue/broadcast';
export function SentStatus({ cid }: { cid: string }) {
const [inQueue, setInQueue] = useState<boolean | null>(null); // null = loading
const t = useTranslations('maintenance');
const [inQueue, setInQueue] = useState<boolean | null>(null);
useEffect(() => {
async function check() {
@@ -35,7 +37,7 @@ export function SentStatus({ cid }: { cid: string }) {
<CheckCircle2 className="h-16 w-16 text-green-500" />
)}
<h1 className="text-2xl font-bold">
{pending ? 'Pedido em fila' : 'Pedido enviado'}
{pending ? t('pendingTitle') : t('sentTitle')}
</h1>
{cid && (
<p className="font-mono text-xs text-muted-foreground" data-testid="request-cid">
@@ -43,16 +45,14 @@ export function SentStatus({ cid }: { cid: string }) {
</p>
)}
<p className="text-sm text-muted-foreground">
{pending
? 'Será enviado assim que a ligação for restabelecida.'
: 'A equipa de manutenção foi notificada e irá tratar do problema.'}
{pending ? t('pendingMessage') : t('sentMessage')}
</p>
</div>
<Link
href="/"
className="rounded-xl bg-primary px-8 py-3 font-semibold text-primary-foreground hover:opacity-90"
>
Voltar ao início
{t('backHome')}
</Link>
</main>
);
+8 -4
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 (
<main className="flex min-h-screen flex-col items-center justify-center gap-4 p-6 text-center">
<h1 className="text-4xl font-bold">404</h1>
<p className="text-muted-foreground">Página não encontrada.</p>
<h1 className="text-4xl font-bold">{t('title404')}</h1>
<p className="text-muted-foreground">{t('message404')}</p>
<a href="/" className="text-sm underline underline-offset-4">
Voltar ao início
{t('backHome')}
</a>
</main>
);
+11 -6
View File
@@ -1,15 +1,17 @@
import Link from 'next/link';
import { Wrench } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { resolveUser } from '@/lib/auth';
import { api } from '@/lib/trpc/server';
import { SignOutButton } from './sign-out-button';
import { StatusBadge } from './status-badge';
import { SyncChip } from './sync-chip';
import { LanguageSwitcher } from './language-switcher';
export default async function HomePage() {
const t = await getTranslations('home');
const user = await resolveUser();
// myRecent is a protectedProcedure — fails gracefully when there is no session.
type RecentItem = Awaited<ReturnType<typeof api.maintenanceRequest.myRecent>>[number];
let recent: RecentItem[] = [];
try {
@@ -23,12 +25,15 @@ export default async function HomePage() {
{/* ── Header ── */}
<header className="flex items-center justify-between border-b border-border bg-card px-4 py-3">
<div>
<p className="text-xs text-muted-foreground">Operador</p>
<p className="text-xs text-muted-foreground">{t('operator')}</p>
<p className="text-sm font-medium" data-testid="current-user">
{user?.email ?? '—'}
</p>
</div>
<SignOutButton />
<div className="flex items-center gap-2">
<LanguageSwitcher />
<SignOutButton />
</div>
</header>
<div className="flex flex-1 flex-col gap-6 p-4">
@@ -42,15 +47,15 @@ export default async function HomePage() {
className="flex items-center justify-center gap-3 rounded-2xl bg-primary px-6 py-10 text-lg font-semibold text-primary-foreground shadow-sm transition-opacity hover:opacity-90 active:scale-[0.98]"
>
<Wrench className="h-6 w-6" />
Pedir manutenção
{t('requestMaintenance')}
</Link>
{/* ── Recent requests ── */}
<section>
<h2 className="mb-3 text-sm font-medium text-muted-foreground">Os meus pedidos</h2>
<h2 className="mb-3 text-sm font-medium text-muted-foreground">{t('myRequests')}</h2>
{recent.length === 0 ? (
<p className="text-sm text-muted-foreground">Nenhum pedido ainda.</p>
<p className="text-sm text-muted-foreground">{t('noRequests')}</p>
) : (
<ul className="flex flex-col gap-2">
{recent.map((req) => (
@@ -2,6 +2,7 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { signIn } from 'next-auth/react';
import { ArrowLeft, Delete } from 'lucide-react';
@@ -10,8 +11,6 @@ interface Operator {
email: string;
}
// ── State types ──────────────────────────────────────────────────────────────
type PickerState =
| { step: 'list' }
| { step: 'pin'; operator: Operator };
@@ -19,20 +18,18 @@ type PickerState =
const PIN_MIN = 4;
const PIN_MAX = 6;
// ── Sub-components ───────────────────────────────────────────────────────────
function OperatorList({
operators,
onSelect,
t,
}: {
operators: Operator[];
onSelect: (op: Operator) => void;
t: ReturnType<typeof useTranslations<'auth'>>;
}) {
if (operators.length === 0) {
return (
<p className="text-sm text-muted-foreground">
Nenhum operador encontrado. Execute <code>pnpm db:seed</code>.
</p>
<p className="text-sm text-muted-foreground">{t('noOperators')}</p>
);
}
return (
@@ -53,9 +50,13 @@ function OperatorList({
function PinPad({
operator,
onBack,
t,
tc,
}: {
operator: Operator;
onBack: () => void;
t: ReturnType<typeof useTranslations<'auth'>>;
tc: ReturnType<typeof useTranslations<'common'>>;
}) {
const router = useRouter();
const [digits, setDigits] = useState('');
@@ -85,14 +86,14 @@ function PinPad({
});
if (result?.error) {
setDigits('');
setError('PIN incorreto ou conta bloqueada. Tente novamente.');
setError(t('invalidPin'));
} else {
router.push('/');
router.refresh();
}
} catch {
setDigits('');
setError('Erro inesperado. Tente novamente.');
setError(t('unexpectedError'));
} finally {
setBusy(false);
}
@@ -108,12 +109,12 @@ function PinPad({
onClick={onBack}
disabled={busy}
className="rounded-lg p-2 hover:bg-accent disabled:opacity-50"
aria-label="Voltar"
aria-label={t('back')}
>
<ArrowLeft className="h-5 w-5" />
</button>
<div>
<p className="text-xs text-muted-foreground">Operador selecionado</p>
<p className="text-xs text-muted-foreground">{t('operatorSelected')}</p>
<p className="text-sm font-medium">{operator.email}</p>
</div>
</div>
@@ -150,7 +151,7 @@ function PinPad({
onClick={erase}
disabled={busy || digits.length === 0}
className="flex items-center justify-center rounded-2xl border border-border bg-card py-5 text-lg font-medium transition-colors hover:bg-accent active:scale-[0.97] disabled:opacity-40"
aria-label="Apagar"
aria-label={t('deleteDigit')}
>
<Delete className="h-5 w-5" />
</button>
@@ -175,15 +176,15 @@ function PinPad({
disabled={digits.length < PIN_MIN || busy}
className="w-full rounded-xl bg-primary py-4 text-base font-semibold text-primary-foreground transition-opacity hover:opacity-90 active:scale-[0.98] disabled:opacity-40"
>
{busy ? 'A entrar…' : 'Entrar'}
{busy ? tc('entering') : tc('enter')}
</button>
</div>
);
}
// ── Main component ───────────────────────────────────────────────────────────
export function OperatorPicker({ operators }: { operators: Operator[] }) {
const t = useTranslations('auth');
const tc = useTranslations('common');
const [state, setState] = useState<PickerState>({ step: 'list' });
if (state.step === 'pin') {
@@ -191,6 +192,8 @@ export function OperatorPicker({ operators }: { operators: Operator[] }) {
<PinPad
operator={state.operator}
onBack={() => setState({ step: 'list' })}
t={t}
tc={tc}
/>
);
}
@@ -199,6 +202,7 @@ export function OperatorPicker({ operators }: { operators: Operator[] }) {
<OperatorList
operators={operators}
onSelect={(op) => setState({ step: 'pin', operator: op })}
t={t}
/>
);
}
@@ -1,4 +1,5 @@
import { redirect } from 'next/navigation';
import { getTranslations } from 'next-intl/server';
import { prisma } from '@repo/db';
import { resolveUser } from '@/lib/auth';
import { OperatorPicker } from './operator-picker';
@@ -7,6 +8,7 @@ import { OperatorPicker } from './operator-picker';
// the login step. prisma is used directly (bypassing the tRPC auth layer) so
// the page works even when AUTH_DEV_AUTOLOGIN=false.
export default async function SelectOperatorPage() {
const t = await getTranslations('auth');
const user = await resolveUser();
if (user) redirect('/');
@@ -19,8 +21,8 @@ export default async function SelectOperatorPage() {
return (
<main className="mx-auto flex min-h-screen max-w-sm flex-col justify-center gap-8 p-6">
<header className="text-center">
<h1 className="text-2xl font-bold tracking-tight">Quem és tu?</h1>
<p className="mt-1 text-sm text-muted-foreground">Escolhe o teu perfil para continuar.</p>
<h1 className="text-2xl font-bold tracking-tight">{t('pickerTitle')}</h1>
<p className="mt-1 text-sm text-muted-foreground">{t('pickerSubtitle')}</p>
</header>
<OperatorPicker operators={operators} />
</main>
+3 -1
View File
@@ -1,14 +1,16 @@
'use client';
import { useTranslations } from 'next-intl';
import { signOut } from 'next-auth/react';
export function SignOutButton() {
const t = useTranslations('auth');
return (
<button
onClick={() => signOut({ callbackUrl: '/select-operator' })}
className="text-xs text-muted-foreground underline-offset-2 hover:underline"
>
Trocar
{t('switchOperator')}
</button>
);
}
+14 -8
View File
@@ -1,14 +1,20 @@
const CONFIG = {
OPEN: { label: 'Aberto', className: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' },
CLAIMED: { label: 'Em curso', className: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' },
RESOLVED: { label: 'Resolvido',className: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' },
'use client';
import { useTranslations } from 'next-intl';
const STATUS_CLASS = {
OPEN: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400',
CLAIMED: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
RESOLVED: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
} as const;
export function StatusBadge({ status }: { status: keyof typeof CONFIG }) {
const { label, className } = CONFIG[status];
type Status = keyof typeof STATUS_CLASS;
export function StatusBadge({ status }: { status: Status }) {
const t = useTranslations('common');
return (
<span className={`shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium ${className}`}>
{label}
<span className={`shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium ${STATUS_CLASS[status]}`}>
{t(`status.${status.toLowerCase() as 'open' | 'claimed' | 'resolved'}`)}
</span>
);
}
+5 -3
View File
@@ -1,14 +1,16 @@
'use client';
import { useTranslations } from 'next-intl';
import { useSyncState } from './sync-provider';
export function SyncChip() {
const t = useTranslations('sync');
const { pendingCount, deadLetterCount } = useSyncState();
if (deadLetterCount > 0) {
return (
<div className="rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
{deadLetterCount} pedido{deadLetterCount > 1 ? 's' : ''} com erro contacta o supervisor.
{t('deadLetters', { count: deadLetterCount })}
</div>
);
}
@@ -17,7 +19,7 @@ export function SyncChip() {
return (
<div className="flex items-center gap-2 rounded-lg bg-orange-50 px-3 py-2 text-xs text-orange-700">
<span className="h-2 w-2 rounded-full bg-orange-400" />
{pendingCount} pedido{pendingCount > 1 ? 's' : ''} por enviar
{t('pending', { count: pendingCount })}
</div>
);
}
@@ -25,7 +27,7 @@ export function SyncChip() {
return (
<div className="flex items-center gap-2 rounded-lg bg-green-50 px-3 py-2 text-xs text-green-700">
<span className="h-2 w-2 rounded-full bg-green-500" />
Tudo sincronizado
{t('synced')}
</div>
);
}
+4 -2
View File
@@ -9,6 +9,7 @@ import {
useState,
type ReactNode,
} from 'react';
import { useTranslations } from 'next-intl';
import { subscribeBroadcast, type SyncMessage } from '@/lib/queue/broadcast';
import { runSync } from '@/lib/queue/sync';
import { db } from '@/lib/queue/db';
@@ -22,6 +23,7 @@ const SyncCtx = createContext<SyncState>({ pendingCount: 0, deadLetterCount: 0 }
export const useSyncState = () => useContext(SyncCtx);
export function SyncProvider({ children }: { children: ReactNode }) {
const t = useTranslations('sync');
const [state, setState] = useState<SyncState>({ pendingCount: 0, deadLetterCount: 0 });
const [failedIds, setFailedIds] = useState<string[]>([]);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -85,11 +87,11 @@ export function SyncProvider({ children }: { children: ReactNode }) {
key={id}
className="flex items-center justify-between rounded-lg bg-destructive px-4 py-3 text-sm text-destructive-foreground shadow-lg"
>
<span>Pedido {id.slice(0, 8)} falhou contacta o supervisor.</span>
<span>{t('requestFailed', { id: id.slice(0, 8) })}</span>
<button
onClick={() => setFailedIds((prev) => prev.filter((x) => x !== id))}
className="ml-4 shrink-0 opacity-80 hover:opacity-100"
aria-label="Fechar"
aria-label={t('close')}
>
</button>