Files
NextChair/public/sw.js
T
Stefan Lazic 31c48296d9
CI / ci (push) Canceled after 0s
In App Notifications
Policies section (business-details-form.tsx)

Late Arrival Policy, Booking Terms and Privacy Policy URL are gone. The section now has Cancellation Policy plus the Terms & Privacy name and email dropdowns.
Their database columns stay, so nothing is lost. Saving no longer touches them.

Email and push only

One switch, IN_APP_NOTIFICATIONS_VISIBLE in in-app.ts, is set to false and controls everything below.

Bell icon: gone for customers, staff and owners, including the unread badge.
Notification pages: /notifications, /staff/notifications and /owner/notifications now return 404.

Settings: Settings → Notifications shows only Email and Push toggles.
Testing: I checked all of this in a local test app as the owner.
2026-10-17 11:34:30 +01:00

170 lines
6.5 KiB
JavaScript

// 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`), plus an app-icon
// badge number counting the ones still unread (see updateAppBadge).
// - 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();
});
/**
* App-icon badge = how many of this app's push notifications are still
* sitting in the notification shelf. Counting what the OS is actually
* showing (instead of a stored counter) means nothing needs persisting —
* a service worker can be stopped at any moment, losing in-memory state.
* The page clears the badge and the shelf when the app is opened (see
* service-worker-manager.tsx).
*
* Needs the Badging API: installed PWAs on Android/desktop Chrome and
* Home Screen web apps on iOS 16.4+. Everywhere else this is a no-op.
*/
async function updateAppBadge() {
if (!("setAppBadge" in self.navigator)) return;
try {
const count = (await self.registration.getNotifications()).length;
if (count > 0) await self.navigator.setAppBadge(count);
else await self.navigator.clearAppBadge();
} catch {
// Badging is cosmetic — never let it break showing the notification.
}
}
/** True when the app is open and in front of the person — a badge on the icon is pointless (and stale the moment they leave) then. */
async function appIsInForeground() {
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
return windows.some((client) => client.visibilityState === "visible");
}
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)
.then(async () => {
if (!(await appIsInForeground())) await updateAppBadge();
}),
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = event.notification.data?.url || "/";
event.waitUntil(
Promise.all([
updateAppBadge(),
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);
}),
]),
);
});