// src/components/NotificationToastHandler.tsx /** * Global notification toast handler * Listens for WebSocket notifications and displays them as toasts * Should be rendered once at the app root level */ import { useCallback, useEffect } from 'react'; import { useWebSocket } from '../hooks/useWebSocket'; import { useEventBus } from '../hooks/useEventBus'; import toast from 'react-hot-toast'; import type { DealNotificationData, SystemMessageData } from '../types/websocket'; import { formatCurrency } from '../utils/formatUtils'; interface NotificationToastHandlerProps { /** * Whether to enable toast notifications * @default true */ enabled?: boolean; /** * Whether to play a sound when notifications arrive * @default false */ playSound?: boolean; /** * Custom sound URL (if playSound is true) */ soundUrl?: string; } export function NotificationToastHandler({ enabled = true, playSound = false, soundUrl = '/notification-sound.mp3', }: NotificationToastHandlerProps) { // Connect to WebSocket const { isConnected, error } = useWebSocket({ autoConnect: true, onConnect: () => { if (enabled) { toast.success('Connected to live notifications', { duration: 2000, icon: 'đŸŸĸ', }); } }, onDisconnect: () => { if (enabled && error) { toast.error('Disconnected from live notifications', { duration: 3000, icon: '🔴', }); } }, }); // Play notification sound const playNotificationSound = useCallback(() => { if (!playSound) return; try { const audio = new Audio(soundUrl); audio.volume = 0.3; audio.play().catch((error) => { console.warn('Failed to play notification sound:', error); }); } catch (error) { console.warn('Failed to play notification sound:', error); } }, [playSound, soundUrl]); // Handle deal notifications const handleDealNotification = useCallback( (data?: DealNotificationData) => { if (!enabled || !data) return; playNotificationSound(); const dealsCount = data.deals.length; const firstDeal = data.deals[0]; // Show toast with deal information toast.success(
{dealsCount === 1 ? 'New Deal Found!' : `${dealsCount} New Deals Found!`}
{dealsCount === 1 && firstDeal && (
{firstDeal.item_name} for {formatCurrency(firstDeal.best_price_in_cents)} at{' '} {firstDeal.store_name}
)} {dealsCount > 1 && (
Check your deals page to see all offers
)}
, { duration: 5000, icon: '🎉', position: 'top-right', }, ); }, [enabled, playNotificationSound], ); // Handle system messages const handleSystemMessage = useCallback( (data?: SystemMessageData) => { if (!enabled || !data) return; const toastOptions = { duration: data.severity === 'error' ? 6000 : 4000, position: 'top-center' as const, }; switch (data.severity) { case 'error': toast.error(data.message, { ...toastOptions, icon: '❌' }); break; case 'warning': toast(data.message, { ...toastOptions, icon: 'âš ī¸' }); break; case 'info': default: toast(data.message, { ...toastOptions, icon: 'â„šī¸' }); break; } }, [enabled], ); // Handle errors const handleError = useCallback( (data?: { message: string; code?: string }) => { if (!enabled || !data) return; toast.error(`Error: ${data.message}`, { duration: 5000, icon: '🚨', }); }, [enabled], ); // Subscribe to event bus useEventBus('notification:deal', handleDealNotification); useEventBus('notification:system', handleSystemMessage); useEventBus('notification:error', handleError); // Show connection error if persistent useEffect(() => { if (error && !isConnected) { // Only show after a delay to avoid showing on initial connection const timer = setTimeout(() => { if (error && !isConnected && enabled) { toast.error('Unable to connect to live notifications. Some features may be limited.', { duration: 5000, icon: 'âš ī¸', }); } }, 5000); return () => clearTimeout(timer); } }, [error, isConnected, enabled]); // This component doesn't render anything - it just handles side effects return null; }