// Service worker — internal filename/comment only, never shown to a user. // // Scope, deliberately: this app is almost entirely dynamic, signed-in, // per-request server-rendered content (appointments, calendars, loyalty // balances) — caching that would risk serving stale or, worse, another // user's data. So this file does NOT try to make the booking flow work // offline. What it does do: // - Push notifications (`push`/`notificationclick` — unchanged from the // original Phase 7 push-notifications work). // - Precache + serve a small static app shell (offline fallback page, // manifest, icons) so a lost connection shows a real "you're offline" // screen instead of the browser's own dinosaur/error page. // - An update-available handshake: a new SW version installs and waits // rather than taking over immediately, so the running page can prompt // the user before switching (see update-available-banner.tsx). // // Every GET request that isn't the app shell or a navigation falls // through untouched (`fetch` handler doesn't call respondWith), and every // non-GET request (Server Actions, API mutations) is never intercepted at // all — this worker must never sit between the app and a write. // v2: /manifest.webmanifest was previously cache-first here alongside the // truly static icon fallbacks — but it's dynamic, per-business content // (name/icons/colors, editable from "App Icon & Home Screen" settings), // not a static build asset. Cache-first meant a phone that had ever // loaded it once would keep serving that stale response indefinitely, // regardless of any later branding change or server-side fix — bumping // CACHE_VERSION forces every existing install to drop its old cache // (including any previously-cached manifest response) on next activate. const CACHE_VERSION = "v2"; const CACHE_NAME = `nextchair-shell-${CACHE_VERSION}`; const OFFLINE_URL = "/offline"; const APP_SHELL = [OFFLINE_URL, "/icons/icon-192.png", "/icons/icon-512.png"]; self.addEventListener("install", (event) => { event.waitUntil( caches .open(CACHE_NAME) .then((cache) => cache.addAll(APP_SHELL)) // Deliberately no self.skipWaiting() here — an already-open tab keeps // running its current version until the user acts on the // update-available prompt (see the `message` handler below). .catch(() => { // A failed precache (e.g. first install with no network at all) // shouldn't block installation — push notifications should still // work even if the offline shell couldn't be cached yet. }), ); }); self.addEventListener("activate", (event) => { event.waitUntil( caches .keys() .then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))) .then(() => self.clients.claim()), ); }); self.addEventListener("fetch", (event) => { const { request } = event; if (request.method !== "GET") return; const url = new URL(request.url); if (url.origin !== self.location.origin) return; // Page navigations: network-first (this app is dynamic — a cached page // would be stale or session-mismatched), falling back to the offline // shell only when the network is actually unreachable. if (request.mode === "navigate") { event.respondWith( fetch(request).catch(() => caches.match(OFFLINE_URL).then((cached) => cached ?? Response.error())), ); return; } // The static app-shell assets themselves: cache-first, since these are // versioned by CACHE_NAME and only change on a new SW deploy. if (APP_SHELL.some((path) => url.pathname === path)) { event.respondWith( caches.match(request).then((cached) => cached ?? fetch(request)), ); return; } // Everything else (Next static build assets, API routes, images) — // untouched, normal network fetch. }); self.addEventListener("message", (event) => { if (event.data?.type === "SKIP_WAITING") self.skipWaiting(); }); self.addEventListener("push", (event) => { if (!event.data) return; let payload; try { payload = event.data.json(); } catch { return; } // The server always sets a real, business-branded title (see // src/services/notifications/reminder-worker.ts) — this is only a // defensive fallback for a malformed payload, so it stays neutral // rather than naming the internal product. const title = payload.title || "New notification"; const options = { body: payload.body || "", data: { url: payload.url || "/" }, icon: "/icons/icon-192.png", badge: "/icons/icon-192.png", }; event.waitUntil(self.registration.showNotification(title, options)); }); self.addEventListener("notificationclick", (event) => { event.notification.close(); const url = event.notification.data?.url || "/"; event.waitUntil( self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientList) => { for (const client of clientList) { if (client.url.includes(url) && "focus" in client) return client.focus(); } if (self.clients.openWindow) return self.clients.openWindow(url); }), ); });