Formas de pagamento: - Endpoint GET /catalog/payment-methods lendo vw_formas_pagamento filtrado por ativa=1 e integrar_sar=1 - FormaPagamento schema/type no shared api-interface - Hook useFormasPagamento (staleTime 1h) substituindo lista hardcoded Offline (FR-4.2 / NFR-2.1–2.4): - IndexedDB queue: lib/offline/idb.ts + order-queue.ts sem deps externos - NewOrderPage detecta !navigator.onLine → enqueueOrder() → toast + reset - useOfflineSync: auto-sync ao reconectar (POST orders + PATCH transmit) - usePendingOrders: fila reativa via CustomEvents - AppShell: banner offline + useOfflineSync() global - OrdersPage: seção de pedidos pendentes com retry/descartar - sw.js: network-first para API GETs cacheáveis + stale-while-revalidate para assets + app shell navigate fallback Docs: - architecture.md: documento de decisões de arquitetura do SAR MVP Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
124 lines
3.7 KiB
JavaScript
124 lines
3.7 KiB
JavaScript
// Service Worker SAR
|
|
// C4/NFR-2: cache de API para uso offline (network-first, fallback to cache)
|
|
// C6: Web Push
|
|
// App shell: stale-while-revalidate para assets estáticos
|
|
|
|
const API_CACHE = 'sar-api-v2';
|
|
const SHELL_CACHE = 'sar-shell-v2';
|
|
|
|
// Paths de API que valem ser cacheados para offline
|
|
// Auth e mutations (POST/PATCH) nunca são interceptados
|
|
const CACHEABLE_API = [
|
|
'/api/v1/clients',
|
|
'/api/v1/catalog',
|
|
'/api/v1/orders',
|
|
'/api/v1/dashboard',
|
|
'/api/v1/auth/me',
|
|
];
|
|
|
|
// ── Fetch ──────────────────────────────────────────────────────────────────────
|
|
|
|
self.addEventListener('fetch', (event) => {
|
|
const { request } = event;
|
|
if (request.method !== 'GET') return;
|
|
|
|
const url = new URL(request.url);
|
|
|
|
if (url.pathname.startsWith('/api/v1/')) {
|
|
const cacheable = CACHEABLE_API.some((p) => url.pathname.startsWith(p));
|
|
if (cacheable) {
|
|
event.respondWith(networkFirst(request, API_CACHE));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (request.mode === 'navigate') {
|
|
// App shell HTML — network first, cache fallback
|
|
event.respondWith(shellNavigate(request));
|
|
return;
|
|
}
|
|
|
|
// Assets estáticos (JS/CSS/fontes/imagens) — stale-while-revalidate
|
|
if (/\.(js|css|woff2?|png|svg|ico)$/.test(url.pathname)) {
|
|
event.respondWith(staleWhileRevalidate(request, SHELL_CACHE));
|
|
}
|
|
});
|
|
|
|
// Network-first: tenta rede, cai no cache se offline
|
|
async function networkFirst(request, cacheName) {
|
|
const cache = await caches.open(cacheName);
|
|
try {
|
|
const response = await fetch(request);
|
|
if (response.ok) {
|
|
cache.put(request, response.clone());
|
|
}
|
|
return response;
|
|
} catch {
|
|
const cached = await cache.match(request);
|
|
if (cached) return cached;
|
|
return offlineResponse();
|
|
}
|
|
}
|
|
|
|
// Navigate: network first; fallback para a raiz cacheada
|
|
async function shellNavigate(request) {
|
|
const cache = await caches.open(SHELL_CACHE);
|
|
try {
|
|
const response = await fetch(request);
|
|
if (response.ok) {
|
|
cache.put(request, response.clone());
|
|
// Sempre armazena a raiz como fallback universal
|
|
cache.put(new Request('/'), response.clone());
|
|
}
|
|
return response;
|
|
} catch {
|
|
const cached = (await cache.match(request)) ?? (await cache.match('/'));
|
|
if (cached) return cached;
|
|
return offlineResponse();
|
|
}
|
|
}
|
|
|
|
// Stale-while-revalidate: responde do cache, atualiza em background
|
|
async function staleWhileRevalidate(request, cacheName) {
|
|
const cache = await caches.open(cacheName);
|
|
const cached = await cache.match(request);
|
|
const networkFetch = fetch(request).then((response) => {
|
|
if (response.ok) cache.put(request, response.clone());
|
|
return response;
|
|
});
|
|
return cached ?? networkFetch;
|
|
}
|
|
|
|
function offlineResponse() {
|
|
return new Response(
|
|
JSON.stringify({
|
|
type: 'sar:offline',
|
|
title: 'Sem conexão',
|
|
status: 503,
|
|
}),
|
|
{ status: 503, headers: { 'Content-Type': 'application/json' } },
|
|
);
|
|
}
|
|
|
|
// ── Push (C6) ─────────────────────────────────────────────────────────────────
|
|
|
|
self.addEventListener('push', (event) => {
|
|
const data = event.data?.json() ?? {};
|
|
event.waitUntil(
|
|
self.registration.showNotification(data.title ?? 'SAR', {
|
|
body: data.body ?? '',
|
|
icon: '/sar-icon.png',
|
|
badge: '/sar-icon.png',
|
|
data: data.url ? { url: data.url } : undefined,
|
|
}),
|
|
);
|
|
});
|
|
|
|
self.addEventListener('notificationclick', (event) => {
|
|
event.notification.close();
|
|
const url = event.notification.data?.url;
|
|
if (url) {
|
|
event.waitUntil(clients.openWindow(url));
|
|
}
|
|
});
|