Frontend:
- Установлен Sonner для toast уведомлений
- Создан Service Worker (public/sw.js) для Web Push
- Реализован хук useWebPush для управления подписками
- Добавлены утилиты Web Push (lib/push.ts)
- Создан компонент NotificationCenter с UI
- Интегрирован в AppShell (header desktop + mobile)
- Добавлен Toaster в Providers
API методы:
- getVapidPublicKey() - получение VAPID ключа
- subscribePush() - создание подписки
- unsubscribePush() - удаление подписки
- getPushSubscriptions() - список подписок
Возможности:
- � Web Push (работают даже когда вкладка закрыта)
- � NotificationCenter с badge и историей
- � Toast уведомления (Sonner)
- ⚙️ Настройки push подписок
- � Респонсивный дизайн
Документация: frontend/NOTIFICATIONS_IMPLEMENTATION.md
167 lines
5.8 KiB
JavaScript
167 lines
5.8 KiB
JavaScript
/**
|
||
* Service Worker для обработки Web Push уведомлений
|
||
*
|
||
* Этот файл должен находиться в /public/ для Next.js.
|
||
* Service Worker перехватывает push-события от браузера и показывает
|
||
* нативные уведомления даже когда страница закрыта.
|
||
*/
|
||
|
||
const SW_VERSION = '2.0.0';
|
||
|
||
// Настройки по умолчанию для уведомлений
|
||
const DEFAULT_NOTIFICATION_OPTIONS = {
|
||
icon: '/logo.png',
|
||
badge: '/logo.png',
|
||
vibrate: [100, 50, 100],
|
||
requireInteraction: false,
|
||
renotify: true,
|
||
};
|
||
|
||
/**
|
||
* Событие установки Service Worker
|
||
*/
|
||
self.addEventListener('install', (event) => {
|
||
console.log('[SW] Service Worker installing, version:', SW_VERSION);
|
||
// Немедленно активировать новую версию
|
||
self.skipWaiting();
|
||
});
|
||
|
||
/**
|
||
* Событие активации Service Worker
|
||
*/
|
||
self.addEventListener('activate', (event) => {
|
||
console.log('[SW] Service Worker activated, version:', SW_VERSION);
|
||
// Захватить все клиенты без перезагрузки
|
||
event.waitUntil(self.clients.claim());
|
||
});
|
||
|
||
/**
|
||
* Обработка входящих 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 || 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 || {}
|
||
};
|
||
|
||
// Дополнительные опции
|
||
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 = event.notification.data?.url || '/';
|
||
|
||
// Открываем или фокусируемся на странице
|
||
event.waitUntil(
|
||
clients.matchAll({ type: 'window', includeUncontrolled: true })
|
||
.then((windowClients) => {
|
||
// Ищем уже открытую вкладку с нашим сайтом
|
||
for (let client of windowClients) {
|
||
if (client.url.includes(self.location.origin) && 'focus' in client) {
|
||
return client.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
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
// Если открытой вкладки нет, открываем новую
|
||
if (clients.openWindow) {
|
||
return clients.openWindow(urlToOpen);
|
||
}
|
||
})
|
||
);
|
||
});
|
||
|
||
/**
|
||
* Обработка закрытия уведомления
|
||
*/
|
||
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);
|