All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 18m47s
178 lines
4.8 KiB
TypeScript
178 lines
4.8 KiB
TypeScript
// 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(
|
||
<div className="flex flex-col gap-1">
|
||
<div className="font-semibold">
|
||
{dealsCount === 1 ? 'New Deal Found!' : `${dealsCount} New Deals Found!`}
|
||
</div>
|
||
{dealsCount === 1 && firstDeal && (
|
||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||
{firstDeal.item_name} for {formatCurrency(firstDeal.best_price_in_cents)} at{' '}
|
||
{firstDeal.store_name}
|
||
</div>
|
||
)}
|
||
{dealsCount > 1 && (
|
||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||
Check your deals page to see all offers
|
||
</div>
|
||
)}
|
||
</div>,
|
||
{
|
||
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;
|
||
}
|