'use client'; import { useEffect, useState, useCallback } from 'react'; import { ReminderSettings } from '@/lib/storage'; const LAST_NOTIFICATION_KEY = 'quittraq_last_notification_date'; export type NotificationPermission = 'default' | 'granted' | 'denied'; // Helper to convert VAPID key function urlBase64ToUint8Array(base64String: string) { 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; } export function useNotifications(reminderSettings: ReminderSettings) { const [permission, setPermission] = useState('default'); const [isSupported, setIsSupported] = useState(false); const [swRegistration, setSwRegistration] = useState(null); // Register Service Worker useEffect(() => { if (typeof window !== 'undefined' && 'serviceWorker' in navigator && 'PushManager' in window) { setIsSupported(true); setPermission(Notification.permission as NotificationPermission); navigator.serviceWorker.register('/sw.js') .then(registration => { console.log('Service Worker registered:', registration); setSwRegistration(registration); }) .catch(error => { console.error('Service Worker registration failed:', error); }); } }, []); const subscribeToPush = useCallback(async () => { try { const registration = await navigator.serviceWorker.ready; if (!registration) { throw new Error('Service Worker not ready'); } let publicVapidKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY; // If missing from build-time env, fetch it from runtime API if (!publicVapidKey) { console.log('VAPID key missing from build-time env, fetching from runtime API...'); const configRes = await fetch('/api/notifications/config'); if (configRes.ok) { const config = await configRes.json() as { publicKey?: string }; publicVapidKey = config.publicKey || ''; } } if (!publicVapidKey) { throw new Error('Missing VAPID key (neither in build env nor runtime API). Please ensure NEXT_PUBLIC_VAPID_PUBLIC_KEY is set in Cloudflare secrets.'); } // 1. Ensure we have a subscription let subscription = await registration.pushManager.getSubscription(); if (!subscription) { subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(publicVapidKey) }); console.log('New push notification subscribed'); } else { console.log('Existing push subscription found, syncing with server...'); } // 2. Refresh it on the backend (ALWAYS) const res = await fetch('/api/notifications/subscribe', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ subscription }), }); if (!res.ok) { const data = await res.json() as { error?: string }; throw new Error(data.error || 'Failed to sync subscription to server'); } // Success console.log('Subscription synced with server'); } catch (error) { console.error('Failed to subscribe:', error); throw error; // Propagate to caller } }, []); // Request notification permission const requestPermission = useCallback(async () => { if (!isSupported) return 'denied'; try { const result = await Notification.requestPermission(); setPermission(result as NotificationPermission); if (result === 'granted') { // Verify we can subscribe and save to backend await subscribeToPush(); } return result; } catch (error) { console.error('Error requesting notification permission:', error); alert('Error enabling notifications: ' + (error instanceof Error ? error.message : 'Unknown error')); return 'denied'; } }, [isSupported, subscribeToPush]); // Send a notification const sendNotification = useCallback( (title: string, options?: NotificationOptions) => { if (!isSupported || permission !== 'granted') return; try { const notification = new Notification(title, { icon: '/icon-192.png', badge: '/icon-192.png', ...options, }); notification.onclick = () => { window.focus(); notification.close(); }; return notification; } catch (error) { console.error('Error sending notification:', error); } }, [isSupported, permission] ); // Play a "bubble pop" sound using Web Audio API const playNotificationSound = useCallback(() => { try { const AudioContext = window.AudioContext || (window as any).webkitAudioContext; if (!AudioContext) return; const ctx = new AudioContext(); const oscillator = ctx.createOscillator(); const gainNode = ctx.createGain(); oscillator.connect(gainNode); gainNode.connect(ctx.destination); // Bubble 'pop' effect: sine wave with rapid frequency drop oscillator.type = 'sine'; oscillator.frequency.setValueAtTime(800, ctx.currentTime); oscillator.frequency.exponentialRampToValueAtTime(100, ctx.currentTime + 0.1); // Envelope: quick attack, quick decay gainNode.gain.setValueAtTime(0, ctx.currentTime); gainNode.gain.linearRampToValueAtTime(0.3, ctx.currentTime + 0.01); gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.1); oscillator.start(); oscillator.stop(ctx.currentTime + 0.15); } catch (e) { console.error('Error playing notification sound:', e); } }, []); // Track previous settings to detect changes const [prevSettings, setPrevSettings] = useState(reminderSettings); // If settings change, we record it useEffect(() => { if (JSON.stringify(prevSettings) !== JSON.stringify(reminderSettings)) { setPrevSettings(reminderSettings); } }, [reminderSettings, prevSettings]); return { isSupported, permission, requestPermission, sendNotification, }; }