/** * Service Worker для обработки Web Push уведомлений * * Этот файл должен находиться в /public/ для Next.js. * Service Worker перехватывает push-события от браузера и показывает * нативные уведомления даже когда страница закрыта. */ const SW_VERSION = '2.1.1'; const STATIC_CACHE = `eusrr-static-${SW_VERSION}`; const PRECACHE_URLS = [ '/manifest.webmanifest', '/favicon-source.svg', '/notification-badge.png', '/logo.png', '/logo.webp', '/icon-192.png', '/icon-512.png', '/icon-512-maskable.png', '/apple-touch-icon.png', ]; // Настройки по умолчанию для уведомлений const DEFAULT_NOTIFICATION_OPTIONS = { icon: '/icon-192.png', badge: '/notification-badge.png', vibrate: [100, 50, 100], requireInteraction: false, renotify: true, }; /** * Событие установки Service Worker */ self.addEventListener('install', (event) => { console.log('[SW] Service Worker installing, version:', SW_VERSION); event.waitUntil( caches .open(STATIC_CACHE) .then((cache) => cache.addAll(PRECACHE_URLS)) .catch((error) => { console.error('[SW] Failed to precache static assets:', error); }) .then(() => self.skipWaiting()) ); }); /** * Событие активации Service Worker */ self.addEventListener('activate', (event) => { console.log('[SW] Service Worker activated, version:', SW_VERSION); event.waitUntil( caches.keys().then((cacheKeys) => Promise.all( cacheKeys.map((cacheKey) => { if (cacheKey !== STATIC_CACHE && cacheKey.startsWith('eusrr-static-')) { return caches.delete(cacheKey); } return Promise.resolve(false); }) ) ).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; } const isApiRequest = url.pathname.startsWith('/api/'); const isStaticAsset = url.pathname.startsWith('/_next/static/') || PRECACHE_URLS.includes(url.pathname) || ['image', 'style', 'script', 'font', 'worker'].includes(request.destination); if (isApiRequest || !isStaticAsset) { return; } event.respondWith( caches.open(STATIC_CACHE).then(async (cache) => { const cachedResponse = await cache.match(request); if (cachedResponse) { return cachedResponse; } const networkResponse = await fetch(request); if (networkResponse.ok) { cache.put(request, networkResponse.clone()).catch(() => undefined); } return networkResponse; }) ); }); /** * Обработка входящих push-уведомлений * * Формат данных с сервера: * { * "title": "Заголовок уведомления", * "body": "Текст уведомления", * "icon": "/logo.png", * "badge": "/logo.png", * "tag": "notification-id", * "data": { * "url": "/path/to/page", * "notification_id": 123, * "category": "requests", * } * } */ self.addEventListener('push', (event) => { console.log('[SW] Push received:', event); let notificationData = { title: 'Новое уведомление', body: '', ...DEFAULT_NOTIFICATION_OPTIONS, data: {} }; if (event.data) { try { const payload = event.data.json(); console.log('[SW] Push payload:', payload); notificationData = { ...notificationData, title: payload.title || payload.head || notificationData.title, body: payload.body || payload.message || '', icon: payload.icon || notificationData.icon, badge: payload.badge || notificationData.badge, tag: payload.tag || `notification-${Date.now()}`, data: { ...(payload.data || {}), url: payload.data?.url || payload.url || payload.action_url || notificationData.data?.url || '/', } }; // Дополнительные опции if (payload.image) { notificationData.image = payload.image; } if (payload.actions) { notificationData.actions = payload.actions; } if (payload.requireInteraction !== undefined) { notificationData.requireInteraction = payload.requireInteraction; } } catch (e) { console.log('[SW] Push data is not JSON, using as text'); notificationData.body = event.data.text(); } } // Показываем уведомление const { title, ...options } = notificationData; event.waitUntil( self.registration.showNotification(title, options) .then(() => { console.log('[SW] Notification shown:', title); }) .catch((error) => { console.error('[SW] Failed to show notification:', error); }) ); }); /** * Обработка клика по уведомлению */ self.addEventListener('notificationclick', (event) => { console.log('[SW] Notification clicked:', event.notification.tag); event.notification.close(); // Получаем URL из данных уведомления const urlToOpen = new URL( event.notification.data?.url || '/', self.location.origin, ).toString(); // Для установленного PWA даём браузеру самому решить, открыть app window // или вкладку. Не фокусируем произвольную already-open same-origin вкладку. event.waitUntil( clients.matchAll({ type: 'window', includeUncontrolled: true }) .then(async (windowClients) => { const exactClient = windowClients.find( (client) => client.url === urlToOpen && 'focus' in client, ); if (exactClient) { return exactClient.focus(); } if (clients.openWindow) { return clients.openWindow(urlToOpen); } const sameOriginClient = windowClients.find( (client) => client.url.startsWith(self.location.origin) && 'focus' in client, ); if (sameOriginClient) { return sameOriginClient.focus().then((client) => { if ('navigate' in client) { return client.navigate(urlToOpen); } return client.postMessage({ type: 'NOTIFICATION_CLICK', url: urlToOpen, notificationId: event.notification.data?.notification_id }); }); } }) ); }); /** * Обработка закрытия уведомления */ self.addEventListener('notificationclose', (event) => { console.log('[SW] Notification closed:', event.notification.tag); // Можно отправить статистику на сервер if (event.notification.data?.notification_id) { // fetch('/api/v1/notifications/stats/closed/', { // method: 'POST', // body: JSON.stringify({ id: event.notification.data.notification_id }) // }); } }); console.log('[SW] Service Worker loaded, version:', SW_VERSION);