Stop_smoking_website_ver2/src/hooks/useNotifications.ts

264 lines
9.0 KiB
TypeScript

'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<NotificationPermission>('default');
const [isSupported, setIsSupported] = useState(false);
const [swRegistration, setSwRegistration] = useState<ServiceWorkerRegistration | null>(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,
};
}