feat: PWA push notifications — service worker, VAPID, subscription management

- Add web-push VAPID backend with push_subscriptions table (migration 078)
- Backend push module with sendPushToUser/sendPushToRoom helpers
- Push routes: GET vapid-key, POST subscribe, DELETE unsubscribe
- Trigger push on new chat messages in direct/group rooms
- PWA manifest, service worker, and icons (72–512px)
- Frontend push lib: SW registration, subscribe/unsubscribe helpers
- Push API in api.ts, SW registered in main.tsx
- usePushSubscription hook for auto-subscribe on permission grant
- PushPermissionRow component in ChatWidget settings view

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-15 12:14:03 +03:00
parent 756a71bf2f
commit 1641b14f29
27 changed files with 1056 additions and 7 deletions

BIN
public/icons/icon-128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
public/icons/icon-144.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
public/icons/icon-152.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

BIN
public/icons/icon-180.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

BIN
public/icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

BIN
public/icons/icon-384.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

BIN
public/icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
public/icons/icon-72.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
public/icons/icon-96.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

20
public/manifest.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "HotelSync PMS",
"short_name": "HotelSync",
"description": "Система управления отелем HotelSync",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#4F46E5",
"orientation": "portrait-primary",
"icons": [
{ "src": "/icons/icon-72.png", "sizes": "72x72", "type": "image/png" },
{ "src": "/icons/icon-96.png", "sizes": "96x96", "type": "image/png" },
{ "src": "/icons/icon-128.png", "sizes": "128x128", "type": "image/png" },
{ "src": "/icons/icon-144.png", "sizes": "144x144", "type": "image/png" },
{ "src": "/icons/icon-152.png", "sizes": "152x152", "type": "image/png" },
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
{ "src": "/icons/icon-384.png", "sizes": "384x384", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
]
}

35
public/sw.js Normal file
View File

@@ -0,0 +1,35 @@
const CACHE_NAME = 'hotelsync-v1'
self.addEventListener('install', () => self.skipWaiting())
self.addEventListener('activate', event => event.waitUntil(self.clients.claim()))
self.addEventListener('push', event => {
if (!event.data) return
let data
try { data = event.data.json() } catch { data = { title: 'HotelSync', body: event.data.text() } }
const title = data.title ?? 'HotelSync'
const options = {
body: data.body ?? '',
icon: data.icon ?? '/icons/icon-192.png',
badge: '/icons/icon-72.png',
tag: data.tag,
renotify: !!data.tag,
data: { url: data.url ?? '/' },
vibrate: [200, 100, 200],
}
event.waitUntil(self.registration.showNotification(title, options))
})
self.addEventListener('notificationclick', event => {
event.notification.close()
const url = event.notification.data?.url ?? '/'
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(clients => {
const existing = clients.find(c => c.url.includes(self.location.origin))
if (existing) { existing.focus(); existing.navigate(url) }
else self.clients.openWindow(url)
})
)
})