264 lines
9.0 KiB
TypeScript
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,
|
|
};
|
|
}
|