localgreenchain/components/realtime/NotificationToast.tsx
Claude 7098335ce7
Add real-time updates system with Socket.io
Implement Agent 6: Real-Time Updates feature for LocalGreenChain:

- Add Socket.io server with room-based subscriptions
- Create client-side hooks (useSocket, useLiveFeed, usePlantUpdates)
- Add SocketProvider context for application-wide state
- Implement UI components:
  - ConnectionStatus: Shows WebSocket connection state
  - LiveFeed: Real-time event feed display
  - NotificationToast: Toast notifications with auto-dismiss
  - LiveChart: Real-time data visualization
- Add event type definitions and formatting utilities
- Create socket API endpoint for WebSocket initialization
- Add socket stats endpoint for monitoring
- Extend tailwind with fadeIn/slideIn animations

Integrates with existing EventStream SSE system for fallback.
2025-11-23 03:51:51 +00:00

325 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Notification Toast Component
*
* Displays real-time notifications as toast messages.
*/
import React, { useEffect, useState, useCallback } from 'react';
import classNames from 'classnames';
import { useSocketContext } from '../../lib/realtime/SocketContext';
import type { RealtimeNotification } from '../../lib/realtime/types';
interface NotificationToastProps {
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left';
maxVisible?: number;
autoHideDuration?: number;
className?: string;
}
/**
* Get position classes
*/
function getPositionClasses(position: NotificationToastProps['position']): string {
switch (position) {
case 'top-left':
return 'top-4 left-4';
case 'bottom-right':
return 'bottom-4 right-4';
case 'bottom-left':
return 'bottom-4 left-4';
case 'top-right':
default:
return 'top-4 right-4';
}
}
/**
* Get notification type styles
*/
function getTypeStyles(type: RealtimeNotification['type']): {
bg: string;
border: string;
icon: string;
iconColor: string;
} {
switch (type) {
case 'success':
return {
bg: 'bg-green-50',
border: 'border-green-200',
icon: '✓',
iconColor: 'text-green-600',
};
case 'warning':
return {
bg: 'bg-yellow-50',
border: 'border-yellow-200',
icon: '⚠',
iconColor: 'text-yellow-600',
};
case 'error':
return {
bg: 'bg-red-50',
border: 'border-red-200',
icon: '✕',
iconColor: 'text-red-600',
};
case 'info':
default:
return {
bg: 'bg-blue-50',
border: 'border-blue-200',
icon: '',
iconColor: 'text-blue-600',
};
}
}
/**
* Single toast notification
*/
function Toast({
notification,
onDismiss,
autoHideDuration,
}: {
notification: RealtimeNotification;
onDismiss: (id: string) => void;
autoHideDuration: number;
}) {
const [isVisible, setIsVisible] = useState(false);
const [isLeaving, setIsLeaving] = useState(false);
const styles = getTypeStyles(notification.type);
// Animate in
useEffect(() => {
const timer = setTimeout(() => setIsVisible(true), 10);
return () => clearTimeout(timer);
}, []);
// Auto hide
useEffect(() => {
if (autoHideDuration <= 0) return;
const timer = setTimeout(() => {
handleDismiss();
}, autoHideDuration);
return () => clearTimeout(timer);
}, [autoHideDuration]);
const handleDismiss = useCallback(() => {
setIsLeaving(true);
setTimeout(() => {
onDismiss(notification.id);
}, 300);
}, [notification.id, onDismiss]);
return (
<div
className={classNames(
'max-w-sm w-full p-4 rounded-lg border shadow-lg transition-all duration-300',
styles.bg,
styles.border,
{
'opacity-0 translate-x-4': !isVisible || isLeaving,
'opacity-100 translate-x-0': isVisible && !isLeaving,
}
)}
role="alert"
>
<div className="flex items-start gap-3">
{/* Icon */}
<span className={classNames('text-xl font-bold flex-shrink-0', styles.iconColor)}>
{styles.icon}
</span>
{/* Content */}
<div className="flex-1 min-w-0">
<h4 className="font-medium text-gray-900 text-sm">
{notification.title}
</h4>
<p className="text-sm text-gray-600 mt-1">
{notification.message}
</p>
</div>
{/* Close button */}
<button
onClick={handleDismiss}
className="flex-shrink-0 text-gray-400 hover:text-gray-600 transition-colors"
aria-label="Dismiss notification"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</button>
</div>
</div>
);
}
/**
* Notification Toast container
*/
export function NotificationToast({
position = 'top-right',
maxVisible = 5,
autoHideDuration = 5000,
className,
}: NotificationToastProps) {
const { notifications, dismissNotification } = useSocketContext();
// Only show non-read, non-dismissed notifications
const visibleNotifications = notifications
.filter((n) => !n.read && !n.dismissed)
.slice(0, maxVisible);
if (visibleNotifications.length === 0) {
return null;
}
return (
<div
className={classNames(
'fixed z-50 flex flex-col gap-2',
getPositionClasses(position),
className
)}
>
{visibleNotifications.map((notification) => (
<Toast
key={notification.id}
notification={notification}
onDismiss={dismissNotification}
autoHideDuration={autoHideDuration}
/>
))}
</div>
);
}
/**
* Notification bell with badge
*/
export function NotificationBell({
onClick,
className,
}: {
onClick?: () => void;
className?: string;
}) {
const { unreadCount } = useSocketContext();
return (
<button
onClick={onClick}
className={classNames(
'relative p-2 text-gray-600 hover:text-gray-900 transition-colors',
className
)}
aria-label={`Notifications${unreadCount > 0 ? ` (${unreadCount} unread)` : ''}`}
>
{/* Bell icon */}
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
{/* Badge */}
{unreadCount > 0 && (
<span className="absolute top-0 right-0 inline-flex items-center justify-center px-1.5 py-0.5 text-xs font-bold leading-none text-white transform translate-x-1/2 -translate-y-1/2 bg-red-500 rounded-full">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
);
}
/**
* Notification list dropdown
*/
export function NotificationList({
className,
onClose,
}: {
className?: string;
onClose?: () => void;
}) {
const { notifications, markNotificationRead, markAllRead } = useSocketContext();
return (
<div
className={classNames(
'w-80 max-h-96 bg-white rounded-lg shadow-lg border border-gray-200 overflow-hidden',
className
)}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 bg-gray-50">
<h3 className="font-semibold text-gray-900">Notifications</h3>
{notifications.length > 0 && (
<button
onClick={markAllRead}
className="text-sm text-blue-600 hover:text-blue-800"
>
Mark all read
</button>
)}
</div>
{/* List */}
<div className="overflow-y-auto max-h-72">
{notifications.length === 0 ? (
<div className="py-8 text-center text-gray-500">
<div className="text-3xl mb-2">🔔</div>
<p className="text-sm">No notifications</p>
</div>
) : (
notifications.map((notification) => (
<div
key={notification.id}
className={classNames(
'px-4 py-3 border-b border-gray-100 hover:bg-gray-50 cursor-pointer transition-colors',
{ 'bg-blue-50': !notification.read }
)}
onClick={() => markNotificationRead(notification.id)}
>
<div className="flex items-start gap-2">
<span className="text-lg">
{getTypeStyles(notification.type).icon}
</span>
<div className="flex-1 min-w-0">
<p className={classNames('text-sm', { 'font-medium': !notification.read })}>
{notification.title}
</p>
<p className="text-xs text-gray-500 truncate">
{notification.message}
</p>
<p className="text-xs text-gray-400 mt-1">
{new Date(notification.timestamp).toLocaleTimeString()}
</p>
</div>
</div>
</div>
))
)}
</div>
</div>
);
}
export default NotificationToast;