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
This commit is contained in:
igor_nadein 2026-02-27 19:39:13 +03:00
parent 051c9ae887
commit f7ac5102de
10 changed files with 1339 additions and 12 deletions

View File

@ -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 (
<button
onClick={subscribe}
disabled={!isSupported || isLoading}
>
{isSubscribed ? 'Отключить' : 'Включить'} уведомления
</button>
);
}
```
**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
<NotificationCenter>
<button> {/* Bell иконка с badge */} </button>
<dropdown>
<header> {/* Заголовок + кнопки */} </header>
<settings> {/* Включить/выключить push */} </settings>
<list> {/* Список уведомлений */} </list>
<footer> {/* Ссылка на полный список */} </footer>
</dropdown>
</NotificationCenter>
```
**Интеграция:**
```tsx
import { NotificationCenter } from '@/components/NotificationCenter';
<header>
<NotificationCenter />
</header>
```
### 4. Toast уведомления (Sonner)
**Назначение:** Всплывающие уведомления в интерфейсе.
**Провайдер:** Уже добавлен в `Providers.tsx`:
```tsx
import { Toaster } from 'sonner';
<Toaster position="top-right" richColors />
```
**Использование:**
```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<any>
// Отметить уведомление как прочитанное
async markNotificationAsRead(id: number): Promise<void>
// Отметить все как прочитанные
async markAllNotificationsAsRead(): Promise<void>
```
## 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 на `<NotificationCenter />`
- Добавлено в desktop версию (header)
- Добавлено в mobile версию (нижняя панель)
**До:**
```tsx
<button className="...">
<Bell size={18} />
</button>
```
**После:**
```tsx
<NotificationCenter />
```
### Providers.tsx
**Изменения:**
- Импортирован `Toaster` из Sonner
- Добавлен `<Toaster />` в провайдеры
```tsx
import { Toaster } from 'sonner';
<Toaster position="top-right" richColors />
```
## Тестирование
### Тестовый сценарий 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';
<button onClick={() => toast.success('Тестовое уведомление')}>
Показать toast
</button>
```
**Проверки:**
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 <div>Push уведомления не поддерживаются вашим браузером</div>;
}
```
## 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=<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 типизация
**Готово к использованию!** 🎉

View File

@ -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",

View File

@ -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",

166
frontend/public/sw.js Normal file
View File

@ -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);

View File

@ -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) {
</div>
<div className="ml-auto hidden items-center gap-1 sm:gap-2 lg:flex">
<button className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-slate-100">
<Bell size={18} className="text-gray-600" />
</button>
<NotificationCenter />
<div className="ml-1 relative h-10 w-10" id="user-menu-root">
<div
className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-sm font-semibold text-white hover:bg-sky-500 cursor-pointer"
@ -171,13 +170,7 @@ function Header({ onOpenLeftNav, onOpenCalendar }: HeaderProps) {
<div className="pb-3 lg:hidden">
<div className="flex items-center gap-2">
<button
type="button"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-gray-200 bg-gray-50 hover:bg-slate-100"
aria-label="Уведомления"
>
<Bell size={18} className="text-gray-600" />
</button>
<NotificationCenter />
<div className="relative flex-1">
<Search size={16} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />

View File

@ -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<HTMLDivElement>(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 (
<div className="relative" ref={dropdownRef}>
{/* Кнопка уведомлений */}
<button
onClick={() => {
setIsOpen(!isOpen);
setShowSettings(false);
}}
className="relative p-2 rounded-lg hover:bg-gray-100 transition-colors"
aria-label="Уведомления"
>
<Bell className="w-6 h-6" />
{unreadCount > 0 && (
<span className="absolute top-1 right-1 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center font-bold">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
</button>
{/* Dropdown */}
{isOpen && (
<div className="absolute right-0 mt-2 w-96 bg-white rounded-lg shadow-xl border border-gray-200 z-50 max-h-[600px] flex flex-col">
{/* Заголовок */}
<div className="flex items-center justify-between p-4 border-b">
<h3 className="font-semibold text-lg">Уведомления</h3>
<div className="flex items-center gap-2">
{unreadCount > 0 && (
<button
onClick={markAllAsRead}
className="text-sm text-blue-600 hover:text-blue-700"
>
Прочитать все
</button>
)}
<button
onClick={() => setShowSettings(!showSettings)}
className="p-1 hover:bg-gray-100 rounded"
aria-label="Настройки"
>
<Settings className="w-5 h-5" />
</button>
<button
onClick={() => setIsOpen(false)}
className="p-1 hover:bg-gray-100 rounded"
aria-label="Закрыть"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* Настройки Push */}
{showSettings && (
<div className="p-4 bg-gray-50 border-b">
<h4 className="font-medium mb-2">Push уведомления</h4>
{!isSupported ? (
<p className="text-sm text-gray-500">
Push уведомления не поддерживаются вашим браузером
</p>
) : permission === 'denied' ? (
<p className="text-sm text-red-500">
Вы запретили уведомления. Измените настройки браузера.
</p>
) : (
<div className="flex items-center justify-between">
<span className="text-sm">
{isSubscribed ? 'Включены' : 'Отключены'}
</span>
<button
onClick={handleTogglePush}
disabled={pushLoading}
className={`px-4 py-2 rounded text-sm font-medium transition-colors ${
isSubscribed
? 'bg-red-100 text-red-700 hover:bg-red-200'
: 'bg-blue-600 text-white hover:bg-blue-700'
} disabled:opacity-50 disabled:cursor-not-allowed`}
>
{pushLoading ? 'Загрузка...' : isSubscribed ? 'Отключить' : 'Включить'}
</button>
</div>
)}
</div>
)}
{/* Список уведомлений */}
<div className="overflow-y-auto flex-1">
{loading ? (
<div className="p-8 text-center text-gray-500">
Загрузка...
</div>
) : notifications.length === 0 ? (
<div className="p-8 text-center text-gray-500">
<Bell className="w-12 h-12 mx-auto mb-2 opacity-30" />
<p>Нет уведомлений</p>
</div>
) : (
<ul>
{notifications.slice(0, 20).map((notification) => (
<li
key={notification.id}
onClick={() => handleNotificationClick(notification)}
className={`p-4 border-b hover:bg-gray-50 cursor-pointer transition-colors ${
!notification.is_read ? 'bg-blue-50' : ''
}`}
>
<div className="flex items-start gap-3">
{!notification.is_read && (
<div className="w-2 h-2 bg-blue-600 rounded-full mt-2 flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<h4 className="font-medium text-sm mb-1 truncate">
{notification.title}
</h4>
<p className="text-sm text-gray-600 line-clamp-2 mb-1">
{notification.short_message || notification.message}
</p>
<p className="text-xs text-gray-400">
{formatDistanceToNow(new Date(notification.created_at), {
addSuffix: true,
locale: ru,
})}
</p>
</div>
</div>
</li>
))}
</ul>
)}
</div>
{/* Футер */}
{notifications.length > 0 && (
<div className="p-3 border-t bg-gray-50 text-center">
<a
href="/notifications"
className="text-sm text-blue-600 hover:text-blue-700 font-medium"
>
Показать все уведомления
</a>
</div>
)}
</div>
)}
</div>
);
}

View File

@ -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 (
<UserProvider>
<CalendarProvider>
<Toaster position="top-right" richColors />
{children}
</CalendarProvider>
</UserProvider>

View File

@ -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<boolean>;
unsubscribe: () => Promise<boolean>;
requestPermission: () => Promise<NotificationPermission>;
}
/**
* Хук для управления Web Push подписками
*/
export function useWebPush(): UseWebPushReturn {
const [isSupported] = useState(() => isPushSupported());
const [isSubscribed, setIsSubscribed] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [permission, setPermission] = useState<NotificationPermission>('default');
const [swRegistration, setSwRegistration] = useState<ServiceWorkerRegistration | null>(null);
const [vapidPublicKey, setVapidPublicKey] = useState<string | null>(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<NotificationPermission> => {
const perm = await requestNotificationPermission();
setPermission(perm);
return perm;
}, []);
// Подписаться на push уведомления
const subscribe = useCallback(async (): Promise<boolean> => {
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<boolean> => {
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;
}

View File

@ -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<any> {
const queryParams = new URLSearchParams();

153
frontend/src/lib/push.ts Normal file
View File

@ -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<NotificationPermission> {
if (typeof window === 'undefined' || !('Notification' in window)) {
return 'default';
}
return await Notification.requestPermission();
}
/**
* Регистрирует Service Worker
*/
export async function registerServiceWorker(): Promise<ServiceWorkerRegistration> {
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<void>((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<PushSubscription> {
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<boolean> {
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<PushSubscription | null> {
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))) : ''
}
};
}