Passo 12 completo. Build limpo, AC server-side totalmente verificado. O que foi implementado: lib/queue/ — camada de persistência offline: db.ts — Dexie 4 com tabelas pending e deadLetters broadcast.ts — BroadcastChannel helper (mai-call-sync) para comunicar entre tabs sync.ts — loop de sync com retry/backoff: signPhotoUpload → PUT MinIO → create; 409 = sucesso; 4xx = dead-letter; erros de rede = paragem + retry na próxima volta SyncProvider — React Context que: Arranca sync ao reconectar (online event + visibilitychange) Polling de 10s como fallback Regista Background Sync API quando disponível Expõe pendingCount / deadLetterCount via useSyncState() Formulário (/maintenance/new) — refatorado: ao submeter, escreve em IndexedDB e navega imediatamente para /sent sem esperar pelo servidor. O SyncProvider processa a fila em background. Feedback visual: SyncChip na home: "Tudo sincronizado" / "N pedidos por enviar" / erro dead-letter /maintenance/sent: mostra "Em fila" (Clock) ou "Enviado" (CheckCircle2) reactivamente via BroadcastChannel Workbox (@ducanh2912/next-pwa) — app shell precaching ativo, para que o app carregue mesmo sem rede depois da primeira visita.
77 lines
2.3 KiB
TypeScript
77 lines
2.3 KiB
TypeScript
'use client';
|
|
|
|
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
type ReactNode,
|
|
} from 'react';
|
|
import { subscribeBroadcast } 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 [state, setState] = useState<SyncState>({ pendingCount: 0, deadLetterCount: 0 });
|
|
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 () => refreshCounts());
|
|
|
|
const onOnline = () => sync();
|
|
const onVisible = () => {
|
|
if (document.visibilityState === 'visible' && navigator.onLine) sync();
|
|
};
|
|
|
|
window.addEventListener('online', onOnline);
|
|
document.addEventListener('visibilitychange', onVisible);
|
|
|
|
// Poll every 10s as a fallback
|
|
timerRef.current = setInterval(() => {
|
|
if (navigator.onLine) sync();
|
|
}, 10_000);
|
|
|
|
// Register Background Sync if SW + API available
|
|
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 */});
|
|
}
|
|
|
|
// Initial sync attempt
|
|
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}</SyncCtx.Provider>;
|
|
}
|