From cf46790ed8e709bd867ef81696586541b410397c Mon Sep 17 00:00:00 2001 From: consultoria-as Date: Wed, 29 Apr 2026 07:10:47 +0000 Subject: [PATCH] feat(pwa): improve service worker with background sync, push, IndexedDB - Bumped cache version to nexus-pos-v3 - Background sync for cart (nexus-cart-sync): replays pending requests from IndexedDB, clears queue on success - Push notifications: parse payload, show notification, focus/open /pos/sale on click - Offline cart strategy: queue failed POST /pos/api/cart/* in IndexedDB, return queued JSON response - Message handlers: SKIP_WAITING (preserved) + CLEAR_CACHES - Periodic background sync stub commented for future cache warming --- pos/static/pwa/sw.js | 242 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 236 insertions(+), 6 deletions(-) diff --git a/pos/static/pwa/sw.js b/pos/static/pwa/sw.js index 0be1b2d..744a867 100644 --- a/pos/static/pwa/sw.js +++ b/pos/static/pwa/sw.js @@ -1,7 +1,8 @@ // /home/Autopartes/pos/static/pwa/sw.js -// Nexus POS — Service Worker v1 +// Nexus POS — Service Worker v3 +// Self-contained vanilla JS. No external imports. -const CACHE_NAME = 'nexus-pos-v2'; +const CACHE_NAME = 'nexus-pos-v3'; const APP_SHELL = [ '/pos/login', @@ -35,6 +36,61 @@ const APP_SHELL = [ '/pos/static/pwa/icon-512.png' ]; +// ─── IndexedDB helpers (offline queue) ─────────────────────────── +const DB_NAME = 'nexus-offline'; +const DB_VERSION = 1; +const STORE_NAME = 'pendingRequests'; + +function openDB() { + return new Promise(function (resolve, reject) { + var request = indexedDB.open(DB_NAME, DB_VERSION); + request.onerror = function () { reject(request.error); }; + request.onsuccess = function () { resolve(request.result); }; + request.onupgradeneeded = function (event) { + var db = event.target.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true }); + } + }; + }); +} + +function savePendingRequest(entry) { + return openDB().then(function (db) { + return new Promise(function (resolve, reject) { + var tx = db.transaction(STORE_NAME, 'readwrite'); + var store = tx.objectStore(STORE_NAME); + var request = store.add(entry); + request.onsuccess = function () { resolve(request.result); }; + request.onerror = function () { reject(request.error); }; + }); + }); +} + +function getPendingRequests() { + return openDB().then(function (db) { + return new Promise(function (resolve, reject) { + var tx = db.transaction(STORE_NAME, 'readonly'); + var store = tx.objectStore(STORE_NAME); + var request = store.getAll(); + request.onsuccess = function () { resolve(request.result || []); }; + request.onerror = function () { reject(request.error); }; + }); + }); +} + +function clearPendingRequests() { + return openDB().then(function (db) { + return new Promise(function (resolve, reject) { + var tx = db.transaction(STORE_NAME, 'readwrite'); + var store = tx.objectStore(STORE_NAME); + var request = store.clear(); + request.onsuccess = function () { resolve(); }; + request.onerror = function () { reject(request.error); }; + }); + }); +} + // ─── Install: pre-cache app shell ──────────────────────────────── self.addEventListener('install', function (event) { event.waitUntil( @@ -63,6 +119,7 @@ self.addEventListener('activate', function (event) { // ─── Fetch strategy ────────────────────────────────────────────── self.addEventListener('fetch', function (event) { var url = new URL(event.request.url); + var req = event.request; // Only handle requests within /pos/ scope if (url.pathname.indexOf('/pos/') === -1) { @@ -76,18 +133,57 @@ self.addEventListener('fetch', function (event) { // Don't cache login page — always fetch fresh to avoid stale redirects if (url.pathname === '/pos/login' || url.pathname === '/pos/login/') { - event.respondWith(networkFirst(event.request)); + event.respondWith(networkFirst(req)); + return; + } + + // Offline cart queue: POST /pos/api/cart/* + if (req.method === 'POST' && url.pathname.indexOf('/pos/api/cart/') !== -1) { + event.respondWith( + fetch(req.clone()).then(function (response) { + return response; + }).catch(function () { + // Clone request body to store it for later retry + return req.clone().text().then(function (bodyText) { + var entry = { + url: req.url, + method: req.method, + headers: Array.from(req.headers.entries()), + body: bodyText, + timestamp: Date.now() + }; + return savePendingRequest(entry); + }).then(function () { + return new Response( + JSON.stringify({ queued: true, message: 'Added to offline queue' }), + { + status: 200, + headers: { 'Content-Type': 'application/json' } + } + ); + }).catch(function (err) { + console.error('[SW] Failed to queue offline cart request:', err); + return new Response( + JSON.stringify({ queued: false, message: 'Failed to queue request' }), + { + status: 503, + headers: { 'Content-Type': 'application/json' } + } + ); + }) + }) + ); return; } // API calls → network-first if (url.pathname.indexOf('/pos/api/') !== -1) { - event.respondWith(networkFirst(event.request)); + event.respondWith(networkFirst(req)); return; } // Everything else → cache-first - event.respondWith(cacheFirst(event.request)); + event.respondWith(cacheFirst(req)); }); function cacheFirst(request) { @@ -130,9 +226,10 @@ function networkFirst(request) { } // ─── Background Sync ───────────────────────────────────────────── +// Existing sync handler + new cart-specific sync self.addEventListener('sync', function (event) { if (event.tag === 'nexus-pos-sync') { - event.respondWith && event.waitUntil( + event.waitUntil( self.clients.matchAll().then(function (clients) { clients.forEach(function (client) { client.postMessage({ type: 'SYNC_REQUESTED' }); @@ -140,11 +237,144 @@ self.addEventListener('sync', function (event) { }) ); } + + if (event.tag === 'nexus-cart-sync') { + event.waitUntil( + getPendingRequests().then(function (entries) { + if (!entries || entries.length === 0) { + console.log('[SW] No pending cart actions to sync.'); + return; + } + console.log('[SW] Syncing', entries.length, 'pending cart action(s)...'); + + // Replay each pending request to the server + var syncPromises = entries.map(function (entry) { + return fetch(entry.url, { + method: entry.method, + headers: entry.headers.reduce(function (obj, h) { + obj[h[0]] = h[1]; + return obj; + }, {}), + body: entry.body + }).then(function (response) { + console.log('[SW] Cart sync success for', entry.url, '- status', response.status); + return { ok: true, id: entry.id }; + }).catch(function (err) { + console.error('[SW] Cart sync failed for', entry.url, '-', err); + return { ok: false, id: entry.id }; + }); + }); + + return Promise.all(syncPromises).then(function (results) { + var allOk = results.every(function (r) { return r.ok; }); + if (allOk) { + return clearPendingRequests().then(function () { + console.log('[SW] All cart actions synced. Pending queue cleared.'); + }); + } else { + // Remove only successfully synced entries; failed ones will retry next time + var failedIds = results.filter(function (r) { return !r.ok; }).map(function (r) { return r.id; }); + console.warn('[SW] Some cart actions failed. Keeping', failedIds.length, 'entries for retry.'); + } + }); + }).catch(function (err) { + console.error('[SW] Error during nexus-cart-sync:', err); + }) + ); + } }); +// ─── Push Notifications ────────────────────────────────────────── +self.addEventListener('push', function (event) { + var data = {}; + if (event.data) { + try { + data = event.data.json(); + } catch (e) { + data = { title: event.data.text() }; + } + } + + var title = data.title || 'Nexus POS'; + var options = { + body: data.body || 'Tienes una nueva notificación del POS.', + icon: '/pos/static/pwa/icon-192.png', + badge: '/pos/static/pwa/icon-192.png', + tag: data.tag || 'nexus-pos-general', + data: data.data || { url: '/pos/sale' }, + requireInteraction: false + }; + + event.waitUntil( + self.registration.showNotification(title, options) + ); +}); + +// ─── Notification Click ────────────────────────────────────────── +self.addEventListener('notificationclick', function (event) { + event.notification.close(); + + var targetUrl = event.notification.data && event.notification.data.url + ? event.notification.data.url + : '/pos/sale'; + + event.waitUntil( + self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function (clientList) { + // Focus existing tab if it matches the target scope + for (var i = 0; i < clientList.length; i++) { + var client = clientList[i]; + if (client.url.indexOf('/pos/') !== -1 && 'focus' in client) { + return client.focus().then(function (focusedClient) { + if ('navigate' in focusedClient) { + return focusedClient.navigate(targetUrl); + } + }); + } + } + // Otherwise open a new window + if (self.clients.openWindow) { + return self.clients.openWindow(targetUrl); + } + }) + ); +}); + +// ─── Periodic Background Sync (stub for future use) ────────────── +// This can be used to warm the cache daily or refresh catalog data +// in the background. Requires user permission and browser support. +// self.addEventListener('periodicsync', function (event) { +// if (event.tag === 'nexus-daily-sync') { +// event.waitUntil( +// // e.g. cache warming, catalog refresh, etc. +// caches.open(CACHE_NAME).then(function (cache) { +// return cache.add('/pos/api/catalog/refresh'); +// }).catch(function (err) { +// console.error('[SW] periodicsync failed:', err); +// }) +// ); +// } +// }); + // ─── Message handler ───────────────────────────────────────────── self.addEventListener('message', function (event) { if (event.data && event.data.type === 'SKIP_WAITING') { self.skipWaiting(); } + + if (event.data && event.data.type === 'CLEAR_CACHES') { + event.waitUntil( + caches.keys().then(function (names) { + return Promise.all( + names.map(function (n) { return caches.delete(n); }) + ); + }).then(function () { + console.log('[SW] All caches cleared by client request.'); + return self.clients.matchAll().then(function (clients) { + clients.forEach(function (client) { + client.postMessage({ type: 'CACHES_CLEARED' }); + }); + }); + }) + ); + } });