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:
parent
051c9ae887
commit
f7ac5102de
592
frontend/NOTIFICATIONS_IMPLEMENTATION.md
Normal file
592
frontend/NOTIFICATIONS_IMPLEMENTATION.md
Normal 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 типизация
|
||||
|
||||
**Готово к использованию!** 🎉
|
||||
13
frontend/package-lock.json
generated
13
frontend/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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
166
frontend/public/sw.js
Normal 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);
|
||||
@ -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" />
|
||||
|
||||
195
frontend/src/components/NotificationCenter.tsx
Normal file
195
frontend/src/components/NotificationCenter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
187
frontend/src/hooks/useWebPush.ts
Normal file
187
frontend/src/hooks/useWebPush.ts
Normal 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;
|
||||
}
|
||||
@ -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
153
frontend/src/lib/push.ts
Normal 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))) : ''
|
||||
}
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user