import { useState, useRef, useEffect } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; import { Button } from '@/components/ui/button'; import { Bell, Check, CheckCheck, Loader2 } from 'lucide-react'; import { cn } from '@/lib/utils'; import { getNotifications, markNotificationAsRead, markAllNotificationsAsRead, type Notification, type NotificationsResponse, } from '@/features/notifications/services/notificationService'; import { useToast } from '@/hooks/useToast'; import { formatDistanceToNow } from 'date-fns'; import { fr } from 'date-fns/locale'; import type { BaseComponentProps } from '../types'; /** * FE-COMP-014: Notification center component with real-time updates * FE-TYPE-013: Fully typed component props */ const POLL_INTERVAL = 30000; // 30 seconds const MAX_NOTIFICATIONS = 50; /** * Props for NotificationMenu component */ export interface NotificationMenuProps extends BaseComponentProps { // No additional props needed - uses global stores } export function NotificationMenu({ className: _className, }: NotificationMenuProps = {}) { const [isOpen, setIsOpen] = useState(false); const menuRef = useRef(null); const navigate = useNavigate(); const queryClient = useQueryClient(); const { success: showSuccess, error: showError } = useToast(); // Fetch notifications with real-time polling const { data: notificationsData, isLoading, refetch, } = useQuery({ queryKey: ['notifications', 'menu'], queryFn: () => getNotifications({ limit: MAX_NOTIFICATIONS }), refetchInterval: POLL_INTERVAL, // Poll every 30 seconds staleTime: 10000, // Consider data stale after 10 seconds }); const notifications = notificationsData?.notifications || []; const unreadCount = notifications.filter((n) => !n.read).length; // Mark as read mutation // Action 4.4.1.5: Add optimistic update const markAsReadMutation = useMutation({ mutationFn: markNotificationAsRead, onMutate: async (notificationId: string) => { // Cancel outgoing refetches await queryClient.cancelQueries({ queryKey: ['notifications'] }); // Snapshot previous value const previousNotifications = queryClient.getQueryData< NotificationsResponse >(['notifications']); // Optimistically mark notification as read if (previousNotifications) { queryClient.setQueryData(['notifications'], { ...previousNotifications, notifications: previousNotifications.notifications.map((n) => n.id === notificationId ? { ...n, read: true } : n, ), unreadCount: Math.max( (previousNotifications.unreadCount || 1) - 1, 0, ), }); } return { previousNotifications }; }, onError: (_error, _notificationId, context) => { // Rollback on error if (context?.previousNotifications) { queryClient.setQueryData( ['notifications'], context.previousNotifications, ); } showError('Erreur lors du marquage'); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['notifications'] }); }, }); // Mark all as read mutation // Action 4.4.1.5: Add optimistic update const markAllAsReadMutation = useMutation({ mutationFn: markAllNotificationsAsRead, onMutate: async () => { // Cancel outgoing refetches await queryClient.cancelQueries({ queryKey: ['notifications'] }); // Snapshot previous value const previousNotifications = queryClient.getQueryData< NotificationsResponse >(['notifications']); // Optimistically mark all notifications as read if (previousNotifications) { queryClient.setQueryData(['notifications'], { ...previousNotifications, notifications: previousNotifications.notifications.map((n) => ({ ...n, read: true, })), unreadCount: 0, }); } return { previousNotifications }; }, onError: (_error, _variables, context) => { // Rollback on error if (context?.previousNotifications) { queryClient.setQueryData( ['notifications'], context.previousNotifications, ); } showError('Erreur lors du marquage'); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['notifications'] }); showSuccess('Toutes les notifications ont été marquées comme lues'); }, }); // Fermer le menu si on clique en dehors useEffect(() => { function handleClickOutside(event: MouseEvent) { if (menuRef.current && !menuRef.current.contains(event.target as Node)) { setIsOpen(false); } } if (isOpen) { document.addEventListener('mousedown', handleClickOutside); } return () => { document.removeEventListener('mousedown', handleClickOutside); }; }, [isOpen]); const handleMarkAsRead = (id: string) => { markAsReadMutation.mutate(id); }; const handleMarkAllAsRead = () => { markAllAsReadMutation.mutate(); }; const handleNotificationClick = (notification: Notification) => { // Mark as read if unread if (!notification.read) { handleMarkAsRead(notification.id); } // Navigate to link if available if (notification.link) { navigate(notification.link); setIsOpen(false); } }; // Refresh notifications when menu opens useEffect(() => { if (isOpen) { refetch(); } }, [isOpen, refetch]); return (
{/* Bouton de notifications */} {/* Menu dropdown */} {isOpen && (
{/* En-tête */}

Notifications

{unreadCount > 0 && ( )}
{/* Liste des notifications */}
{isLoading ? (
) : notifications.length === 0 ? (

Aucune notification

) : (
{notifications.map((notification) => (
handleNotificationClick(notification)} >
{!notification.read && ( )}

{notification.title}

{notification.content && (

{notification.content}

)}

{formatDistanceToNow( new Date(notification.created_at), { addSuffix: true, locale: fr, }, )}

{!notification.read && ( )}
))}
)}
{/* Footer with link to all notifications */} {notifications.length > 0 && (
)}
)}
); }