veza/apps/web/src/components/notifications/NotificationMenu.tsx
senke 6974c12a25 aesthetic-improvements: align spacing to 8px grid (Action 11.2.1.3)
- Created automated script (scripts/align-8px-grid.py) to align all spacing to 8px grid
- Replaced non-8px-aligned spacing: gap-3/p-3/m-3 (12px) → gap-4/p-4/m-4 (16px), gap-5/p-5/m-5 (20px) → gap-6/p-6/m-6 (24px), gap-10/p-10/m-10 (40px) → gap-12/p-12/m-12 (48px), gap-20/p-20/m-20 (80px) → gap-24/p-24/m-24 (96px)
- Preserved: 4px values (gap-1, p-1, m-1) as they may be intentional fine-tuning, responsive breakpoints (sm:, md:, lg:), test files, documentation
- Modified files across all components to ensure consistent 8px grid alignment
- Action 11.2.1.3: Align all elements to 8px grid - COMPLETE
2026-01-16 11:50:46 +01:00

339 lines
11 KiB
TypeScript

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<HTMLDivElement>(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<NotificationsResponse>(['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<NotificationsResponse>(['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 (
<div className="relative" ref={menuRef}>
{/* Bouton de notifications */}
<Button
variant="ghost"
size="icon"
className="relative"
onClick={() => setIsOpen(!isOpen)}
aria-label="Notifications"
aria-expanded={isOpen}
aria-haspopup="true"
>
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<span
className="absolute -top-1 -right-1 h-5 w-5 bg-destructive rounded-full text-xs text-destructive-foreground flex items-center justify-center font-semibold"
aria-label={`${unreadCount} notifications non lues`}
>
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
</Button>
{/* Menu dropdown */}
{isOpen && (
<div className="absolute right-0 mt-2 w-80 bg-background border rounded-lg shadow-lg z-50 max-h-[500px] flex flex-col">
{/* En-tête */}
<div className="p-4 border-b flex items-center justify-between">
<h3 className="font-semibold text-sm">Notifications</h3>
<div className="flex items-center space-x-2">
{unreadCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={handleMarkAllAsRead}
className="h-7 text-xs"
disabled={markAllAsReadMutation.isPending}
>
{markAllAsReadMutation.isPending ? (
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
) : (
<CheckCheck className="h-3 w-3 mr-1" />
)}
Tout marquer comme lu
</Button>
)}
</div>
</div>
{/* Liste des notifications */}
<div className="overflow-y-auto flex-1">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : notifications.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
<Bell className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p className="text-sm">Aucune notification</p>
</div>
) : (
<div className="divide-y">
{notifications.map((notification) => (
<div
key={notification.id}
className={cn(
'p-4 hover:bg-accent transition-colors cursor-pointer',
!notification.read && 'bg-accent/50',
)}
onClick={() => handleNotificationClick(notification)}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2 mb-1">
{!notification.read && (
<span className="h-2 w-2 bg-primary rounded-full flex-shrink-0 mt-1.5" />
)}
<p
className={cn(
'text-sm font-medium',
!notification.read && 'font-semibold',
)}
>
{notification.title}
</p>
</div>
{notification.content && (
<p className="text-sm text-muted-foreground mb-2 line-clamp-2">
{notification.content}
</p>
)}
<p className="text-xs text-muted-foreground">
{formatDistanceToNow(
new Date(notification.created_at),
{
addSuffix: true,
locale: fr,
},
)}
</p>
</div>
<div className="flex items-center space-x-1 ml-2 shrink-0">
{!notification.read && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={(e) => {
e.stopPropagation();
handleMarkAsRead(notification.id);
}}
aria-label="Marquer comme lu"
disabled={markAsReadMutation.isPending}
>
{markAsReadMutation.isPending ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Check className="h-3 w-3" />
)}
</Button>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Footer with link to all notifications */}
{notifications.length > 0 && (
<div className="p-4 border-t">
<Button
variant="ghost"
size="sm"
className="w-full"
onClick={() => {
navigate('/notifications');
setIsOpen(false);
}}
>
Voir toutes les notifications
</Button>
</div>
)}
</div>
)}
</div>
);
}