Stop_smoking_website_ver2/src/hooks/useNotifications.ts

198 lines
6.3 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');
}
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,
};
}