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>
BIN
public/icons/icon-128.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
public/icons/icon-144.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
public/icons/icon-152.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
public/icons/icon-180.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
public/icons/icon-192.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
public/icons/icon-384.png
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
BIN
public/icons/icon-512.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/icons/icon-72.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
public/icons/icon-96.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
20
public/manifest.json
Normal 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
@@ -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)
|
||||
})
|
||||
)
|
||||
})
|
||||