'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'); } const publicVapidKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY; if (!publicVapidKey) { throw new Error('Missing VAPID key'); } // 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); // Check and send reminder const checkAndSendReminder = useCallback(() => { if (!reminderSettings.enabled || permission !== 'granted') return; const now = new Date(); // 'en-CA' outputs YYYY-MM-DD in local timezone const today = now.toLocaleDateString('en-CA'); if (reminderSettings.frequency === 'hourly') { const LAST_HOURLY_KEY = 'quittraq_last_hourly_notification'; const lastNotifiedHour = localStorage.getItem(LAST_HOURLY_KEY); const currentHourKey = `${today}-${now.getHours()}`; if (lastNotifiedHour === currentHourKey) return; // Already notified this hour // Check active hours const startParts = (reminderSettings.hourlyStart || '09:00').split(':').map(Number); const endParts = (reminderSettings.hourlyEnd || '21:00').split(':').map(Number); const startMinutes = startParts[0] * 60 + startParts[1]; const endMinutes = endParts[0] * 60 + endParts[1]; const currentMinutes = now.getHours() * 60 + now.getMinutes(); if (currentMinutes >= startMinutes && currentMinutes <= endMinutes) { playNotificationSound(); sendNotification('QuitTraq Hourly Check-in', { body: "How are you doing? Log your status to stay on track!", tag: 'hourly-reminder', requireInteraction: true, }); localStorage.setItem(LAST_HOURLY_KEY, currentHourKey); } } else { // Daily logic const lastNotified = localStorage.getItem(LAST_NOTIFICATION_KEY); if (lastNotified === today) return; // Already notified today const [hours, minutes] = reminderSettings.reminderTime.split(':').map(Number); const reminderTime = new Date(); reminderTime.setHours(hours, minutes, 0, 0); // If current time is past the reminder time, send it if (now >= reminderTime) { playNotificationSound(); sendNotification('QuitTraq Reminder', { body: "Time to log your daily usage! Every day counts on your journey.", tag: 'daily-reminder', // Tag ensures we don't stack multiple notifications requireInteraction: true, // Keep it visible until user interacts }); localStorage.setItem(LAST_NOTIFICATION_KEY, today); } } }, [reminderSettings, permission, sendNotification, playNotificationSound]); // If settings change, we might need to reset notification history to allow "update and resend" // This effect MUST be after checkAndSendReminder is defined so we can call it immediately useEffect(() => { if (JSON.stringify(prevSettings) !== JSON.stringify(reminderSettings)) { // Only reset if time changed significantly or if toggled. // We can simply clear the 'last notified' date if the reminderTime changed. if (prevSettings.reminderTime !== reminderSettings.reminderTime && reminderSettings.frequency === 'daily') { localStorage.removeItem(LAST_NOTIFICATION_KEY); // Force immediate check checkAndSendReminder(); } setPrevSettings(reminderSettings); } }, [reminderSettings, prevSettings, checkAndSendReminder]); // Set up interval to check for reminder time useEffect(() => { if (!reminderSettings.enabled || permission !== 'granted') return; // Check immediately checkAndSendReminder(); // Check every minute const interval = setInterval(checkAndSendReminder, 60000); return () => clearInterval(interval); }, [reminderSettings, permission, checkAndSendReminder]); return { isSupported, permission, requestPermission, sendNotification, }; }