igor_nadein f7ac5102de feat: реализована система уведомлений на фронтенде
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
2026-02-27 19:39:13 +03:00

167 lines
5.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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);