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

View File

@@ -897,6 +897,15 @@ export const api = {
req<{ ok: boolean }>('DELETE', `/api/hotels/${slug}/payment-gateways/${id}`),
},
// ── Push notifications ────────────────────────────────────────────────────
push: {
getVapidKey: () => req<{ publicKey: string | null }>('GET', '/api/push/vapid-key'),
subscribe: (sub: { endpoint: string; p256dh: string; auth: string }) =>
req<{ ok: boolean }>('POST', '/api/push/subscribe', sub),
unsubscribe: (endpoint: string) =>
req<{ ok: boolean }>('DELETE', '/api/push/unsubscribe', { endpoint }),
},
// Public widget API (no auth)
widget: {
getConfig: (slug: string) =>

42
src/lib/push.ts Normal file
View File

@@ -0,0 +1,42 @@
function urlBase64ToUint8Array(base64String: string): ArrayBuffer {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
const rawData = window.atob(base64)
const arr = new Uint8Array([...rawData].map(char => char.charCodeAt(0)))
return arr.buffer as ArrayBuffer
}
export async function registerSW(): Promise<ServiceWorkerRegistration | null> {
if (!('serviceWorker' in navigator)) return null
try {
const reg = await navigator.serviceWorker.register('/sw.js', { scope: '/' })
return reg
} catch { return null }
}
export async function subscribeToPush(vapidPublicKey: string): Promise<PushSubscription | null> {
if (!('PushManager' in window)) return null
try {
const reg = await navigator.serviceWorker.ready
const existing = await reg.pushManager.getSubscription()
if (existing) return existing
return await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
})
} catch { return null }
}
export async function unsubscribeFromPush(): Promise<boolean> {
if (!('serviceWorker' in navigator)) return false
try {
const reg = await navigator.serviceWorker.ready
const sub = await reg.pushManager.getSubscription()
if (sub) return sub.unsubscribe()
return false
} catch { return false }
}
export function isPushSupported(): boolean {
return 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window
}