diff --git a/frontend/NOTIFICATIONS_IMPLEMENTATION.md b/frontend/NOTIFICATIONS_IMPLEMENTATION.md new file mode 100644 index 00000000..332236a3 --- /dev/null +++ b/frontend/NOTIFICATIONS_IMPLEMENTATION.md @@ -0,0 +1,592 @@ +# Реализация системы уведомлений на фронтенде + +**Дата:** 2026-02-27 +**Технологии:** Next.js 16.1.6, React 19, TypeScript 5, Sonner +**Статус:** ✅ Завершено + +## Обзор + +Реализована полноценная система уведомлений для фронтенда с поддержкой: +- 📱 **Web Push** - нативные push уведомления (работают даже когда вкладка закрыта) +- 🔔 **Toast уведомления** - всплывающие уведомления в интерфейсе (Sonner) +- 📋 **Notification Center** - центр уведомлений с badge и историей +- ⚙️ **Настройки** - управление подписками на push уведомления + +## Установленные библиотеки + +```json +{ + "sonner": "^1.x" // Toast уведомления для Next.js +} +``` + +**Почему Sonner?** +- ✅ Создан специально для Next.js/React +- ✅ Легковесный (2.5KB gzipped) +- ✅ Красивый дизайн из коробки +- ✅ Поддержка promise/loading состояний +- ✅ Используется в shadcn/ui + +## Структура файлов + +``` +frontend/ +├── public/ +│ └── sw.js # Service Worker для Web Push +├── src/ +│ ├── components/ +│ │ ├── NotificationCenter.tsx # UI компонент центра уведомлений +│ │ ├── Providers.tsx # Добавлен Toaster от Sonner +│ │ └── AppShell.tsx # Интегрирован NotificationCenter в header +│ ├── hooks/ +│ │ ├── useWebPush.ts # React хук для Web Push +│ │ └── useApi.ts # Уже был (useNotifications) +│ └── lib/ +│ ├── push.ts # Утилиты для Web Push +│ └── api.ts # Добавлены методы push подписок +``` + +## Компоненты и функции + +### 1. Service Worker (`public/sw.js`) + +**Назначение:** Обрабатывает входящие push события от сервера даже когда вкладка закрыта. + +**Функции:** +- `push` event - получение и показ нативных уведомлений +- `notificationclick` event - открытие страницы при клике +- `notificationclose` event - отправка статистики (опционально) + +**Формат данных от сервера:** +```javascript +{ + "title": "Новая заявка", + "body": "Заявка #123 требует вашего внимания", + "icon": "/logo.png", + "tag": "notification-123", + "data": { + "url": "/requests/123", + "notification_id": 123, + "category": "requests" + }, + "requireInteraction": false +} +``` + +**Регистрация:** Автоматическая при вызове `useWebPush()`. + +### 2. Web Push хук (`hooks/useWebPush.ts`) + +**Назначение:** React хук для управления Web Push подписками. + +**Использование:** +```tsx +import { useWebPush } from '@/hooks/useWebPush'; + +function MyComponent() { + const { + isSupported, // Поддерживается ли Web Push + isSubscribed, // Есть ли активная подписка + permission, // 'default' | 'granted' | 'denied' + subscribe, // Подписаться + unsubscribe, // Отписаться + isLoading // Загрузка + } = useWebPush(); + + return ( + + ); +} +``` + +**API методы:** +- `subscribe()` - запрашивает разрешение и создает подписку +- `unsubscribe()` - удаляет подписку +- `requestPermission()` - явно запрашивает разрешение + +**Внутренняя логика:** +1. Проверяет поддержку браузером (`isPushSupported()`) +2. Получает VAPID ключ с сервера +3. Регистрирует Service Worker +4. Создает push подписку (`PushManager.subscribe()`) +5. Отправляет данные подписки на сервер + +### 3. NotificationCenter (`components/NotificationCenter.tsx`) + +**Назначение:** UI компонент центра уведомлений с иконкой bell. + +**Возможности:** +- 🔔 Badge с количеством непрочитанных +- 📋 Список последних 20 уведомлений +- ✅ Отметка как прочитано +- ⚙️ Настройки push подписок +- 📱 Респонсивный дизайн (desktop + mobile) + +**Структура:** +```tsx + + + +
{/* Заголовок + кнопки */}
+ {/* Включить/выключить push */} + {/* Список уведомлений */} + +
+
+``` + +**Интеграция:** +```tsx +import { NotificationCenter } from '@/components/NotificationCenter'; + +
+ +
+``` + +### 4. Toast уведомления (Sonner) + +**Назначение:** Всплывающие уведомления в интерфейсе. + +**Провайдер:** Уже добавлен в `Providers.tsx`: +```tsx +import { Toaster } from 'sonner'; + + +``` + +**Использование:** +```tsx +import { toast } from 'sonner'; + +// Простое уведомление +toast('Изменения сохранены'); + +// С типом +toast.success('Подписка активирована'); +toast.error('Не удалось сохранить'); +toast.warning('Осталось 5 минут'); +toast.info('Новое обновление доступно'); + +// С действиями +toast('Файл загружен', { + action: { + label: 'Открыть', + onClick: () => window.open('/files/123') + } +}); + +// Promise (автоматический loading → success/error) +toast.promise( + apiClient.saveData(), + { + loading: 'Сохранение...', + success: 'Сохранено!', + error: 'Ошибка сохранения' + } +); +``` + +**Преимущества:** +- Автоматическое управление очередью +- Swipe to dismiss (свайп для закрытия) +- Поддержка action buttons +- Красивые анимации + +### 5. API методы (`lib/api.ts`) + +**Добавлены методы:** + +```typescript +// Получить VAPID ключ для подписки +async getVapidPublicKey(): Promise<{ vapid_public_key: string }> + +// Создать push подписку +async subscribePush(data: { + endpoint: string; + keys: { p256dh: string; auth: string }; + device_name?: string; +}): Promise<{ status: string; message: string; created: boolean }> + +// Удалить push подписку +async unsubscribePush(endpoint?: string): Promise<{ status: string }> + +// Получить список подписок +async getPushSubscriptions(): Promise<{ subscriptions: any[] }> +``` + +**Уже существующие:** +```typescript +// Получить список уведомлений +async getNotifications(): Promise + +// Отметить уведомление как прочитанное +async markNotificationAsRead(id: number): Promise + +// Отметить все как прочитанные +async markAllNotificationsAsRead(): Promise +``` + +## Workflow (Как это работает) + +### 1. Инициализация при загрузке страницы + +``` +User opens page + ↓ +useWebPush hook initialized + ↓ +Check browser support (Service Worker + Push API + Notification API) + ↓ +✅ Supported → Continue + ↓ +Fetch VAPID public key from server + ↓ +Register Service Worker (/sw.js) + ↓ +Check current subscription (PushManager.getSubscription()) + ↓ +isSubscribed = (subscription !== null) +``` + +### 2. Подписка на уведомления (User action) + +``` +User clicks "Включить уведомления" + ↓ +Check permission: + - 'default' → Request permission (Notification.requestPermission()) + - 'denied' → Show error toast + - 'granted' → Continue + ↓ +Create push subscription (PushManager.subscribe()) + - userVisibleOnly: true + - applicationServerKey: VAPID key + ↓ +Serialize subscription (endpoint + keys.p256dh + keys.auth) + ↓ +Send to backend (POST /api/v1/notifications/push/subscribe/) + ↓ +Backend saves to WebPushDevice table + ↓ +✅ Show success toast +``` + +### 3. Получение push уведомления + +``` +Backend sends push notification (django-push-notifications) + ↓ +Browser receives push event (даже если вкладка закрыта) + ↓ +Service Worker intercepts 'push' event + ↓ +Parse payload (JSON.parse(event.data.text())) + ↓ +Show native notification (registration.showNotification()) + ↓ +User clicks notification + ↓ +Service Worker 'notificationclick' event + ↓ +Focus or open window with notification URL +``` + +### 4. Обновление списка уведомлений + +``` +NotificationCenter component mounts + ↓ +useNotifications() hook called + ↓ +Fetch notifications from API (GET /api/v1/notifications/) + ↓ +Display in dropdown with unread badge + ↓ +User clicks notification + ↓ +Mark as read (POST /api/v1/notifications/{id}/mark_read/) + ↓ +Navigate to action_url +``` + +## Интеграция в существующие компоненты + +### AppShell.tsx + +**Изменения:** +- Импортирован `NotificationCenter` +- Заменены статичные кнопки Bell на `` +- Добавлено в desktop версию (header) +- Добавлено в mobile версию (нижняя панель) + +**До:** +```tsx + +``` + +**После:** +```tsx + +``` + +### Providers.tsx + +**Изменения:** +- Импортирован `Toaster` из Sonner +- Добавлен `` в провайдеры + +```tsx +import { Toaster } from 'sonner'; + + +``` + +## Тестирование + +### Тестовый сценарий 1: Подписка на уведомления + +1. Открыть приложение в браузере +2. Кликнуть на иконку Bell в header +3. Кликнуть на "Настройки" (⚙️) +4. Кликнуть "Включить" в разделе Push уведомления +5. Браузер покажет запрос разрешения → Разрешить +6. Toast: "Подписка на уведомления активирована" +7. В настройках должно быть "Включены" и кнопка "Отключить" + +### Тестовый сценарий 2: Получение push уведомления + +**Из Django shell:** +```python +from notifications.services import NotificationService +from notifications.models import Notification +from django.contrib.auth import get_user_model + +User = get_user_model() +user = User.objects.get(username='testuser') +notification = Notification.objects.filter(recipient=user).first() + +# Отправить push +count = NotificationService.send_web_push_notification(notification) +print(f"Отправлено: {count}") +``` + +**Проверки:** +1. Нативное уведомление должно появиться (даже если вкладка закрыта) +2. При клике должна открыться нужная страница +3. В NotificationCenter должно появиться уведомление +4. Badge должен увеличиться + +### Тестовый сценарий 3: Toast уведомления + +**В любом компоненте:** +```tsx +import { toast } from 'sonner'; + + +``` + +**Проверки:** +1. Toast появляется в правом верхнем углу +2. Автоматически исчезает через 4 секунды +3. Можно закрыть свайпом вправо +4. При наведении пауза на таймере + +### Тестовый сценарий 4: NotificationCenter UI + +1. Открыть NotificationCenter +2. Должен показаться список уведомлений +3. Непрочитанные - с синим фоном и точкой +4. При клике на уведомление: + - Отмечается как прочитанное + - Badge уменьшается + - Происходит навигация (если есть URL) +5. "Прочитать все" - все уведомления становятся прочитанными + +## Браузерная совместимость + +### Web Push API + +| Браузер | Desktop | Mobile | Примечания | +|---------|---------|--------|------------| +| Chrome | ✅ 50+ | ✅ 50+ | Полная поддержка | +| Firefox | ✅ 44+ | ✅ 48+ | Полная поддержка | +| Safari | ✅ 16+ | ❌ | iOS не поддерживает | +| Edge | ✅ 79+ | ✅ | На базе Chromium | +| Opera | ✅ 37+ | ✅ | На базе Chromium | + +**Важно:** iOS Safari НЕ поддерживает Web Push (даже в iOS 16+). +- Для iOS нужно использовать нативное приложение + APNS + +### Fallback стратегия + +```tsx +const { isSupported, isSubscribed } = useWebPush(); + +if (!isSupported) { + // Показываем альтернативу + return
Push уведомления не поддерживаются вашим браузером
; +} +``` + +## Production checklist + +- [ ] **HTTPS обязателен** - Service Worker требует HTTPS +- [ ] **VAPID ключи** - проверить что есть в production settings +- [ ] **Service Worker scope** - проверить что sw.js доступен в корне +- [ ] **Тестирование на реальных устройствах** + - Desktop Chrome/Firefox + - Android Chrome + - Desktop Safari (если поддержка добавится) +- [ ] **Мониторинг ошибок** - логировать ошибки Service Worker +- [ ] **Rate limiting** - защита от спама push уведомлений + +## Конфигурация + +### Environment variables (Backend) + +```bash +# В .env или settings.py +VAPID_PUBLIC_KEY=BMTitZy9r4ygYJBgGdaZuCkb7rwR7iHLJv... +VAPID_PRIVATE_KEY= +VAPID_ADMIN_EMAIL=admin@example.com +``` + +### Next.js конфигурация + +**`next.config.ts`** - добавить headers для Service Worker (если нужно): +```typescript +async headers() { + return [ + { + source: '/sw.js', + headers: [ + { + key: 'Service-Worker-Allowed', + value: '/' + } + ] + } + ]; +} +``` + +## Troubleshooting + +### Проблема: Service Worker не регистрируется + +**Возможные причины:** +- Приложение запущено на HTTP (нужен HTTPS или localhost) +- sw.js недоступен (404) +- Браузер не поддерживает Service Worker + +**Решение:** +```bash +# Проверить доступность sw.js +curl http://localhost:3000/sw.js + +# В консоли браузера +navigator.serviceWorker.getRegistrations().then(console.log) +``` + +### Проблема: Push подписка не создается + +**Возможные причины:** +- Разрешение denied +- VAPID ключ неверный +- PushManager не поддерживается + +**Решение:** +```javascript +// В консоли браузера +console.log('Permission:', Notification.permission); +console.log('PushManager supported:', 'PushManager' in window); + +// Проверить VAPID ключ +fetch('/api/v1/notifications/push/vapid-key/') + .then(r => r.json()) + .then(console.log); +``` + +### Проблема: Уведомления не приходят + +**Возможные причины:** +- Подписка истекла (endpoint недействителен) +- Backend не может отправить (VAPID ошибка) +- Service Worker не активен + +**Решение:** +```python +# Django shell - проверить активные устройства +from push_notifications.models import WebPushDevice +devices = WebPushDevice.objects.filter(user=user, active=True) +print(devices.count()) + +# Отправить тестовое уведомление +devices.send_message('{"title": "Test", "body": "Hello"}') +``` + +## Дальнейшие улучшения + +### Функциональность + +- [ ] **Фильтры уведомлений** - по категориям (заявки, сообщения, календарь) +- [ ] **Группировка** - по дате или типу +- [ ] **Действия в уведомлении** - прямо из push (approve/reject) +- [ ] **Звук** - настраиваемый звук для разных типов +- [ ] **Badge на иконке приложения** - счетчик непрочитанных + +### UX/UI + +- [ ] **Skeleton loading** - вместо "Загрузка..." +- [ ] **Infinite scroll** - для списка уведомлений +- [ ] **Поиск** - по уведомлениям +- [ ] **Настройки по типам** - включить/выключить для каждого типа +- [ ] **Темная тема** - поддержка dark mode + +### Производительность + +- [ ] **WebSocket real-time** - мгновенное обновление списка +- [ ] **Service Worker cache** - кеширование данных уведомлений +- [ ] **Prefetch** - предзагрузка страниц из action_url + +### Аналитика + +- [ ] **Отслеживание открытий** - сколько push уведомлений открыто +- [ ] **Conversion tracking** - какие уведомления ведут к действиям +- [ ] **A/B тестирование** - разные тексты/форматы + +## Заключение + +✅ **Система уведомлений полностью реализована:** + +**Backend:** +- django-push-notifications 3.1.0 +- WebPushDevice модель +- VAPID ключи настроены +- API endpoints готовы + +**Frontend:** +- Service Worker для Web Push +- NotificationCenter UI компонент +- useWebPush React хук +- Sonner для toast уведомлений +- Интеграция в AppShell + +**Результат:** +- Нативные push уведомления (работают даже когда вкладка закрыта) +- Красивый UI с badge и историей +- Настройки подписок +- Респонсивный дизайн +- TypeScript типизация + +**Готово к использованию!** 🎉 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3c5690a5..8f475e40 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,7 +15,8 @@ "next": "16.1.6", "react": "19.2.3", "react-big-calendar": "^1.19.4", - "react-dom": "19.2.3" + "react-dom": "19.2.3", + "sonner": "^2.0.7" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -5821,6 +5822,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index c9c94c86..b7b27ab5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,7 +17,8 @@ "next": "16.1.6", "react": "19.2.3", "react-big-calendar": "^1.19.4", - "react-dom": "19.2.3" + "react-dom": "19.2.3", + "sonner": "^2.0.7" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 00000000..ec07d267 --- /dev/null +++ b/frontend/public/sw.js @@ -0,0 +1,166 @@ +/** + * 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); diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 568853b1..acde6d60 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -8,6 +8,7 @@ import { apiClient } from "@/lib/api"; import { useUser } from "@/contexts/UserContext"; import { CalendarModal } from "@/components/CalendarModal"; import CalendarParticipantsModal from "@/components/CalendarParticipantsModal"; +import { NotificationCenter } from "@/components/NotificationCenter"; import { EventModal } from "@/components/EventModal"; import { ViewDayEventsModal } from "@/components/ViewDayEventsModal"; import { ViewEventDetailsModal } from "@/components/ViewEventDetailsModal"; @@ -129,9 +130,7 @@ function Header({ onOpenLeftNav, onOpenCalendar }: HeaderProps) {
- +
- +
diff --git a/frontend/src/components/NotificationCenter.tsx b/frontend/src/components/NotificationCenter.tsx new file mode 100644 index 00000000..635fd9d1 --- /dev/null +++ b/frontend/src/components/NotificationCenter.tsx @@ -0,0 +1,195 @@ +'use client'; + +import { useEffect, useState, useRef } from 'react'; +import { Bell, Settings, X } from 'lucide-react'; +import { useNotifications } from '@/hooks/useApi'; +import { useWebPush } from '@/hooks/useWebPush'; +import { formatDistanceToNow } from 'date-fns'; +import { ru } from 'date-fns/locale'; + +export function NotificationCenter() { + const [isOpen, setIsOpen] = useState(false); + const [showSettings, setShowSettings] = useState(false); + const dropdownRef = useRef(null); + + const { notifications, unreadCount, markAsRead, markAllAsRead, loading } = useNotifications(); + const { isSupported, isSubscribed, subscribe, unsubscribe, permission, isLoading: pushLoading } = useWebPush(); + + // Закрытие при клике вне компонента + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + setShowSettings(false); + } + } + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const handleNotificationClick = async (notification: any) => { + if (!notification.is_read) { + await markAsRead(notification.id); + } + + // Навигация если есть action_url + if (notification.action_url) { + window.location.href = notification.action_url; + } + }; + + const handleTogglePush = async () => { + if (isSubscribed) { + await unsubscribe(); + } else { + await subscribe(); + } + }; + + return ( +
+ {/* Кнопка уведомлений */} + + + {/* Dropdown */} + {isOpen && ( +
+ {/* Заголовок */} +
+

Уведомления

+
+ {unreadCount > 0 && ( + + )} + + +
+
+ + {/* Настройки Push */} + {showSettings && ( +
+

Push уведомления

+ {!isSupported ? ( +

+ Push уведомления не поддерживаются вашим браузером +

+ ) : permission === 'denied' ? ( +

+ Вы запретили уведомления. Измените настройки браузера. +

+ ) : ( +
+ + {isSubscribed ? 'Включены' : 'Отключены'} + + +
+ )} +
+ )} + + {/* Список уведомлений */} +
+ {loading ? ( +
+ Загрузка... +
+ ) : notifications.length === 0 ? ( +
+ +

Нет уведомлений

+
+ ) : ( +
    + {notifications.slice(0, 20).map((notification) => ( +
  • handleNotificationClick(notification)} + className={`p-4 border-b hover:bg-gray-50 cursor-pointer transition-colors ${ + !notification.is_read ? 'bg-blue-50' : '' + }`} + > +
    + {!notification.is_read && ( +
    + )} +
    +

    + {notification.title} +

    +

    + {notification.short_message || notification.message} +

    +

    + {formatDistanceToNow(new Date(notification.created_at), { + addSuffix: true, + locale: ru, + })} +

    +
    +
    +
  • + ))} +
+ )} +
+ + {/* Футер */} + {notifications.length > 0 && ( + + )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/Providers.tsx b/frontend/src/components/Providers.tsx index 2321eb3e..87d4c3fa 100644 --- a/frontend/src/components/Providers.tsx +++ b/frontend/src/components/Providers.tsx @@ -3,11 +3,13 @@ import { UserProvider } from "@/contexts/UserContext"; import { CalendarProvider } from "@/contexts/CalendarContext"; import { ReactNode } from "react"; +import { Toaster } from "sonner"; export function Providers({ children }: { children: ReactNode }) { return ( + {children} diff --git a/frontend/src/hooks/useWebPush.ts b/frontend/src/hooks/useWebPush.ts new file mode 100644 index 00000000..e97fcf20 --- /dev/null +++ b/frontend/src/hooks/useWebPush.ts @@ -0,0 +1,187 @@ +/** + * React хук для управления Web Push уведомлениями + */ + +import { useState, useEffect, useCallback } from 'react'; +import { + isPushSupported, + getNotificationPermission, + requestNotificationPermission, + registerServiceWorker, + subscribeToPush, + unsubscribeFromPush, + getCurrentSubscription, + serializeSubscription, +} from '@/lib/push'; +import { apiClient } from '@/lib/api'; +import { toast } from 'sonner'; + +interface UseWebPushReturn { + isSupported: boolean; + isSubscribed: boolean; + isLoading: boolean; + permission: NotificationPermission; + subscribe: () => Promise; + unsubscribe: () => Promise; + requestPermission: () => Promise; +} + +/** + * Хук для управления Web Push подписками + */ +export function useWebPush(): UseWebPushReturn { + const [isSupported] = useState(() => isPushSupported()); + const [isSubscribed, setIsSubscribed] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [permission, setPermission] = useState('default'); + const [swRegistration, setSwRegistration] = useState(null); + const [vapidPublicKey, setVapidPublicKey] = useState(null); + + // Инициализация: проверка поддержки и текущего статуса + useEffect(() => { + if (!isSupported) { + setIsLoading(false); + return; + } + + async function init() { + try { + // Получаем разрешение + const perm = getNotificationPermission(); + setPermission(perm); + + // Получаем VAPID ключ + const response = await apiClient.getVapidPublicKey(); + setVapidPublicKey(response.vapid_public_key); + + // Регистрируем Service Worker + const registration = await registerServiceWorker(); + setSwRegistration(registration); + + // Проверяем текущую подписку + const subscription = await getCurrentSubscription(registration); + setIsSubscribed(subscription !== null); + + console.log('[useWebPush] Инициализация завершена, подписка:', subscription !== null); + } catch (error) { + console.error('[useWebPush] Ошибка инициализации:', error); + toast.error('Не удалось инициализировать уведомления'); + } finally { + setIsLoading(false); + } + } + + init(); + }, [isSupported]); + + // Запрос разрешения на уведомления + const requestPermission = useCallback(async (): Promise => { + const perm = await requestNotificationPermission(); + setPermission(perm); + return perm; + }, []); + + // Подписаться на push уведомления + const subscribe = useCallback(async (): Promise => { + if (!isSupported || !swRegistration || !vapidPublicKey) { + toast.error('Web Push не поддерживается'); + return false; + } + + setIsLoading(true); + + try { + // Запрашиваем разрешение если нужно + if (permission !== 'granted') { + const perm = await requestPermission(); + if (perm !== 'granted') { + toast.error('Разрешение на уведомления отклонено'); + return false; + } + } + + // Создаем подписку + const subscription = await subscribeToPush(swRegistration, vapidPublicKey); + const subscriptionData = serializeSubscription(subscription); + + // Отправляем на сервер + await apiClient.subscribePush({ + ...subscriptionData, + device_name: getBrowserInfo(), + }); + + setIsSubscribed(true); + toast.success('Подписка на уведомления активирована'); + return true; + } catch (error) { + console.error('[useWebPush] Ошибка подписки:', error); + toast.error('Не удалось подписаться на уведомления'); + return false; + } finally { + setIsLoading(false); + } + }, [isSupported, swRegistration, vapidPublicKey, permission, requestPermission]); + + // Отписаться от push уведомлений + const unsubscribe = useCallback(async (): Promise => { + if (!isSupported || !swRegistration) { + return false; + } + + setIsLoading(true); + + try { + // Удаляем локальную подписку + const success = await unsubscribeFromPush(swRegistration); + + if (success) { + // Удаляем на сервере + await apiClient.unsubscribePush(); + + setIsSubscribed(false); + toast.success('Подписка отменена'); + return true; + } + + return false; + } catch (error) { + console.error('[useWebPush] Ошибка отписки:', error); + toast.error('Не удалось отписаться от уведомлений'); + return false; + } finally { + setIsLoading(false); + } + }, [isSupported, swRegistration]); + + return { + isSupported, + isSubscribed, + isLoading, + permission, + subscribe, + unsubscribe, + requestPermission, + }; +} + +/** + * Получает информацию о браузере для device_name + */ +function getBrowserInfo(): string { + if (typeof window === 'undefined') return 'Unknown'; + + const ua = navigator.userAgent; + let browser = 'Unknown'; + + if (ua.indexOf('Firefox') > -1) { + browser = 'Firefox'; + } else if (ua.indexOf('Edge') > -1) { + browser = 'Edge'; + } else if (ua.indexOf('Chrome') > -1) { + browser = 'Chrome'; + } else if (ua.indexOf('Safari') > -1) { + browser = 'Safari'; + } + + return browser; +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 080244e1..5de1fece 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -533,6 +533,33 @@ class ApiClient { }); } + // Web Push подписки + async getVapidPublicKey(): Promise<{ vapid_public_key: string }> { + return this.request('/api/v1/notifications/push/vapid-key/'); + } + + async subscribePush(data: { + endpoint: string; + keys: { p256dh: string; auth: string }; + device_name?: string; + }): Promise<{ status: string; message: string; created: boolean }> { + return this.request('/api/v1/notifications/push/subscribe/', { + method: 'POST', + body: JSON.stringify(data), + }); + } + + async unsubscribePush(endpoint?: string): Promise<{ status: string; message: string }> { + return this.request('/api/v1/notifications/push/unsubscribe/', { + method: 'DELETE', + body: JSON.stringify({ endpoint }), + }); + } + + async getPushSubscriptions(): Promise<{ subscriptions: any[] }> { + return this.request('/api/v1/notifications/push/subscriptions/'); + } + // Календарь (django-scheduler API) async getCalendarEvents(params?: { start?: string; end?: string; calendar?: number }): Promise { const queryParams = new URLSearchParams(); diff --git a/frontend/src/lib/push.ts b/frontend/src/lib/push.ts new file mode 100644 index 00000000..e8b15083 --- /dev/null +++ b/frontend/src/lib/push.ts @@ -0,0 +1,153 @@ +/** + * Web Push утилиты для управления подписками + */ + +/** + * Конвертирует base64 строку в Uint8Array для applicationServerKey + */ +export function urlBase64ToUint8Array(base64String: string): Uint8Array { + const padding = '='.repeat((4 - base64String.length % 4) % 4); + const base64 = (base64String + padding) + .replace(/-/g, '+') + .replace(/_/g, '/'); + + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + + return outputArray; +} + +/** + * Проверяет поддержку Web Push браузером + */ +export function isPushSupported(): boolean { + if (typeof window === 'undefined') return false; + + if (!('serviceWorker' in navigator)) { + console.warn('[PushNotifications] Service Worker не поддерживается'); + return false; + } + if (!('PushManager' in window)) { + console.warn('[PushNotifications] Push API не поддерживается'); + return false; + } + if (!('Notification' in window)) { + console.warn('[PushNotifications] Notification API не поддерживается'); + return false; + } + + return true; +} + +/** + * Получает текущее разрешение на уведомления + */ +export function getNotificationPermission(): NotificationPermission { + if (typeof window === 'undefined' || !('Notification' in window)) { + return 'default'; + } + return Notification.permission; +} + +/** + * Запрашивает разрешение на уведомления + */ +export async function requestNotificationPermission(): Promise { + if (typeof window === 'undefined' || !('Notification' in window)) { + return 'default'; + } + + return await Notification.requestPermission(); +} + +/** + * Регистрирует Service Worker + */ +export async function registerServiceWorker(): Promise { + if (!('serviceWorker' in navigator)) { + throw new Error('Service Worker не поддерживается'); + } + + const registration = await navigator.serviceWorker.register('/sw.js', { + scope: '/' + }); + + console.log('[PushNotifications] Service Worker зарегистрирован:', registration.scope); + + // Ждем активации + if (registration.installing) { + await new Promise((resolve) => { + registration.installing!.addEventListener('statechange', (e) => { + if ((e.target as ServiceWorker).state === 'activated') { + resolve(); + } + }); + }); + } + + return registration; +} + +/** + * Получает или создает push подписку + */ +export async function subscribeToPush( + registration: ServiceWorkerRegistration, + vapidPublicKey: string +): Promise { + const applicationServerKey = urlBase64ToUint8Array(vapidPublicKey); + + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: applicationServerKey as BufferSource + }); + + console.log('[PushNotifications] Push подписка создана'); + return subscription; +} + +/** + * Отписывается от push уведомлений + */ +export async function unsubscribeFromPush( + registration: ServiceWorkerRegistration +): Promise { + const subscription = await registration.pushManager.getSubscription(); + + if (!subscription) { + return false; + } + + const success = await subscription.unsubscribe(); + console.log('[PushNotifications] Push подписка удалена'); + return success; +} + +/** + * Получает текущую push подписку + */ +export async function getCurrentSubscription( + registration: ServiceWorkerRegistration +): Promise { + return await registration.pushManager.getSubscription(); +} + +/** + * Конвертирует PushSubscription в формат для отправки на сервер + */ +export function serializeSubscription(subscription: PushSubscription) { + const key = subscription.getKey('p256dh'); + const token = subscription.getKey('auth'); + + return { + endpoint: subscription.endpoint, + keys: { + p256dh: key ? btoa(String.fromCharCode(...new Uint8Array(key))) : '', + auth: token ? btoa(String.fromCharCode(...new Uint8Array(token))) : '' + } + }; +}