Vitron-Front/public/sw.js
Arda Samadi fef53fd003 feat(notifications): web push opt-in + unread nav badges
Surfaces three events without a full notification center:
- service worker (public/sw.js) gains push + notificationclick handlers
  (RTL, focuses an existing tab or opens the target URL).
- usePushNotifications hook: permission + VAPID subscribe/unsubscribe
  against the backend push endpoints.
- opt-in UI both ways: a one-time dismissible post-login banner
  (PushPermissionBanner, rendered in MyLayout) and a permanent toggle
  (PushSettingToggle) on buyer profile settings and seller store settings.
- useBadgeCounts polls /api/notif/v1/badges/; small red badges on the
  bottom-nav profile icon (chat + order updates), the profile sidebar
  (پیام‌ها / سفارش‌ها) and the seller dashboard sidebar. Visiting orders
  clears its badge.

Degrades gracefully: if the backend endpoints are absent the counts fall
back to zero and nothing throws.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 14:02:56 +03:30

197 lines
5.2 KiB
JavaScript

const STATIC_CACHE_NAME = "vitrown-static-v2";
const DYNAMIC_CACHE_NAME = "vitrown-dynamic-v2";
const STATIC_ASSETS = [
"/",
"/resources/manifest.webmanifest",
"/favicon.ico",
"/icon-192x192.png",
"/icon-512x512.png",
"/apple-touch-icon.png",
];
// Install event - cache static assets
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(STATIC_CACHE_NAME).then((cache) => {
return cache.addAll(STATIC_ASSETS);
})
);
self.skipWaiting();
});
// Activate event - clean up old caches
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((cacheName) => {
return (
cacheName.startsWith("vitrown-") &&
cacheName !== STATIC_CACHE_NAME &&
cacheName !== DYNAMIC_CACHE_NAME
);
})
.map((cacheName) => caches.delete(cacheName))
);
})
);
self.clients.claim();
});
// Fetch event - network first, cache fallback strategy
self.addEventListener("fetch", (event) => {
const { request } = event;
const url = new URL(request.url);
// Skip non-GET requests
if (request.method !== "GET") {
return;
}
// Skip non-HTTP(S) requests (chrome-extension://, etc.)
if (!url.protocol.startsWith("http")) {
return;
}
// Skip WebSocket, SSE, and API requests
if (
url.pathname.startsWith("/api/") ||
url.protocol === "ws:" ||
url.protocol === "wss:" ||
request.headers.get("accept")?.includes("text/event-stream")
) {
return;
}
// For navigation requests, try network first
if (request.mode === "navigate") {
event.respondWith(
fetch(request)
.then((response) => {
const responseToCache = response.clone();
caches.open(DYNAMIC_CACHE_NAME).then((cache) => {
cache.put(request, responseToCache);
});
return response;
})
.catch(() => {
return caches.match(request);
})
);
return;
}
// For static assets (CSS, JS, images), use cache first
if (
request.destination === "style" ||
request.destination === "script" ||
request.destination === "image" ||
request.destination === "font"
) {
event.respondWith(
caches.match(request).then((cachedResponse) => {
if (cachedResponse) {
// Update cache in background
fetch(request).then((response) => {
if (response.status === 200) {
const responseToCache = response.clone();
caches.open(STATIC_CACHE_NAME).then((cache) => {
cache.put(request, responseToCache);
});
}
});
return cachedResponse;
}
return fetch(request).then((response) => {
if (response.status === 200) {
const responseToCache = response.clone();
caches.open(STATIC_CACHE_NAME).then((cache) => {
cache.put(request, responseToCache);
});
}
return response;
});
})
);
return;
}
// For other requests, network first
event.respondWith(
fetch(request)
.then((response) => {
if (response.status === 200) {
const responseToCache = response.clone();
caches.open(DYNAMIC_CACHE_NAME).then((cache) => {
cache.put(request, responseToCache);
});
}
return response;
})
.catch(() => {
return caches.match(request);
})
);
});
// Listen for messages from the client
self.addEventListener("message", (event) => {
if (event.data && event.data.type === "SKIP_WAITING") {
self.skipWaiting();
}
});
// ---- Web Push ----
// Show a notification when a push arrives. Payload is JSON:
// { title, body, url, tag, notification_id }
self.addEventListener("push", (event) => {
let data = {};
try {
data = event.data ? event.data.json() : {};
} catch (e) {
data = { title: "ویترون", body: event.data ? event.data.text() : "" };
}
const title = data.title || "ویترون";
const options = {
body: data.body || "",
icon: "/icon-192x192.png",
badge: "/icon-96x96.png",
dir: "rtl",
lang: "fa",
tag: data.tag || "vitrown",
renotify: true,
data: { url: data.url || "/", notificationId: data.notification_id },
};
event.waitUntil(self.registration.showNotification(title, options));
});
// Focus an existing tab (or open a new one) on the target URL when clicked.
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const targetUrl = (event.notification.data && event.notification.data.url) || "/";
event.waitUntil(
clients
.matchAll({ type: "window", includeUncontrolled: true })
.then((clientList) => {
for (const client of clientList) {
// If a tab is already open, navigate it and focus.
if ("focus" in client) {
client.focus();
if ("navigate" in client) {
client.navigate(targetUrl).catch(() => {});
}
return;
}
}
if (clients.openWindow) {
return clients.openWindow(targetUrl);
}
})
);
});