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:
@@ -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
42
src/lib/push.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user