- 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
339 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|