// Push notification service for browser push API import { config, configUtils } from '@/lib/config' export interface PushSubscriptionData { endpoint: string keys: { p256dh: string auth: string } } export interface NotificationPayload { title: string body: string icon?: string badge?: string data?: Record tag?: string requireInteraction?: boolean actions?: Array<{ action: string title: string icon?: string }> } export class PushNotificationService { private static instance: PushNotificationService static getInstance(): PushNotificationService { if (!this.instance) { this.instance = new PushNotificationService() } return this.instance } // Check if push notifications are supported isSupported(): boolean { return 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window } // Check if push notifications are configured isConfigured(): boolean { return configUtils.hasPushConfig() } // Get current notification permission getPermission(): NotificationPermission { return Notification.permission } // Request notification permission async requestPermission(): Promise { if (!this.isSupported()) { throw new Error('Push notifications are not supported') } const permission = await Notification.requestPermission() console.log('Notification permission:', permission) return permission } // Convert Uint8Array to base64 URL-safe string private 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 } // Subscribe to push notifications async subscribe(): Promise { if (!this.isSupported()) { throw new Error('Push notifications are not supported') } if (!this.isConfigured()) { throw new Error('Push notifications are not configured. Missing VAPID key.') } if (this.getPermission() !== 'granted') { const permission = await this.requestPermission() if (permission !== 'granted') { throw new Error('Push notification permission denied') } } try { const registration = await navigator.serviceWorker.ready // Check if already subscribed const existingSubscription = await registration.pushManager.getSubscription() if (existingSubscription) { console.log('Using existing push subscription') return this.subscriptionToData(existingSubscription) } // Create new subscription console.log('Creating new push subscription with VAPID key:', config.push.vapidPublicKey.substring(0, 20) + '...') const applicationServerKey = this.urlBase64ToUint8Array(config.push.vapidPublicKey) console.log('VAPID key converted to Uint8Array, length:', applicationServerKey.length) const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey }) const subscriptionData = this.subscriptionToData(subscription) console.log('Push subscription created successfully:', { endpoint: subscriptionData.endpoint, hasKeys: !!subscriptionData.keys.p256dh && !!subscriptionData.keys.auth }) return subscriptionData } catch (error) { console.error('Failed to subscribe to push notifications:', error) // Provide more specific error information if (error instanceof DOMException) { if (error.name === 'InvalidStateError') { console.error('VAPID key format issue detected. Please check your VAPID public key.') } else if (error.name === 'NotAllowedError') { console.error('Push notifications blocked by user or browser policy.') } else if (error.name === 'NotSupportedError') { console.error('Push notifications not supported by this browser.') } } throw error } } // Unsubscribe from push notifications async unsubscribe(): Promise { try { const registration = await navigator.serviceWorker.ready const subscription = await registration.pushManager.getSubscription() if (subscription) { await subscription.unsubscribe() console.log('Push subscription removed') } } catch (error) { console.error('Failed to unsubscribe from push notifications:', error) throw error } } // Get current subscription async getSubscription(): Promise { try { const registration = await navigator.serviceWorker.ready const subscription = await registration.pushManager.getSubscription() if (subscription) { return this.subscriptionToData(subscription) } return null } catch (error) { console.error('Failed to get push subscription:', error) return null } } // Convert PushSubscription to our data format private subscriptionToData(subscription: PushSubscription): PushSubscriptionData { const keys = subscription.getKey('p256dh') const auth = subscription.getKey('auth') if (!keys || !auth) { throw new Error('Failed to get subscription keys') } return { endpoint: subscription.endpoint, keys: { p256dh: btoa(String.fromCharCode(...new Uint8Array(keys))), auth: btoa(String.fromCharCode(...new Uint8Array(auth))) } } } // Show a local notification (for testing) async showLocalNotification(payload: NotificationPayload): Promise { if (!this.isSupported()) { throw new Error('Notifications are not supported') } if (this.getPermission() !== 'granted') { await this.requestPermission() } if (this.getPermission() !== 'granted') { throw new Error('Notification permission denied') } // Send message to service worker to show notification const registration = await navigator.serviceWorker.ready if (registration.active) { registration.active.postMessage({ type: 'SHOW_NOTIFICATION', payload }) } else { // Fallback: show browser notification directly new Notification(payload.title, { body: payload.body, icon: payload.icon || '/pwa-192x192.png', badge: payload.badge || '/pwa-192x192.png', tag: payload.tag, requireInteraction: payload.requireInteraction, data: payload.data }) } } // Check if user is subscribed to push notifications async isSubscribed(): Promise { const subscription = await this.getSubscription() return subscription !== null } } // Export singleton instance export const pushService = PushNotificationService.getInstance()