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.
105 lines
3.2 KiB
TypeScript
105 lines
3.2 KiB
TypeScript
'use client';
|
|
|
|
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useRef,
|
|
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';
|
|
|
|
interface SyncState {
|
|
pendingCount: number;
|
|
deadLetterCount: number;
|
|
}
|
|
|
|
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);
|
|
|
|
const refreshCounts = useCallback(async () => {
|
|
const [p, d] = await Promise.all([db.pending.count(), db.deadLetters.count()]);
|
|
setState({ pendingCount: p, deadLetterCount: d });
|
|
}, []);
|
|
|
|
const sync = useCallback(async () => {
|
|
await runSync();
|
|
await refreshCounts();
|
|
}, [refreshCounts]);
|
|
|
|
useEffect(() => {
|
|
refreshCounts();
|
|
|
|
const unsub = subscribeBroadcast(async (msg: SyncMessage) => {
|
|
await refreshCounts();
|
|
if (msg.type === 'dead-letter') {
|
|
setFailedIds((prev) => [...prev, msg.clientRequestId]);
|
|
}
|
|
});
|
|
|
|
const onOnline = () => sync();
|
|
const onVisible = () => {
|
|
if (document.visibilityState === 'visible' && navigator.onLine) sync();
|
|
};
|
|
|
|
window.addEventListener('online', onOnline);
|
|
document.addEventListener('visibilitychange', onVisible);
|
|
|
|
timerRef.current = setInterval(() => {
|
|
if (navigator.onLine) sync();
|
|
}, 10_000);
|
|
|
|
if ('serviceWorker' in navigator && 'SyncManager' in window) {
|
|
navigator.serviceWorker.ready
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
.then((reg) => (reg as any).sync.register('mai-call-sync'))
|
|
.catch(() => {/* not supported or permission denied */});
|
|
}
|
|
|
|
if (navigator.onLine) sync();
|
|
|
|
return () => {
|
|
unsub();
|
|
window.removeEventListener('online', onOnline);
|
|
document.removeEventListener('visibilitychange', onVisible);
|
|
if (timerRef.current) clearInterval(timerRef.current);
|
|
};
|
|
}, [sync, refreshCounts]);
|
|
|
|
return (
|
|
<SyncCtx.Provider value={state}>
|
|
{children}
|
|
{failedIds.length > 0 && (
|
|
<div className="fixed bottom-4 left-4 right-4 z-50 flex flex-col gap-2">
|
|
{failedIds.map((id) => (
|
|
<div
|
|
key={id}
|
|
className="flex items-center justify-between rounded-lg bg-destructive px-4 py-3 text-sm text-destructive-foreground shadow-lg"
|
|
>
|
|
<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={t('close')}
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</SyncCtx.Provider>
|
|
);
|
|
}
|