xpeditis2.0/apps/frontend/app/[locale]/dashboard/notifications/page.tsx
David abe3bfe9ae feat(ui): refonte du menu et de la page de notifications
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BAUeCFpDkRD6tU5wGsc1C
2026-09-07 21:40:56 +02:00

179 lines
6.2 KiB
TypeScript

'use client';
import { useMemo, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Bell, CheckCheck } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { PageHeader } from '@/components/ui/PageHeader';
import { EmptyState, ErrorState, Skeleton } from '@/components/ui/states';
import { NotificationRow } from '@/components/notifications/notification-row';
import { useNotificationLabels } from '@/components/notifications/use-notification-labels';
import {
notificationKind,
type NotificationKind,
} from '@/components/notifications/notification-kind';
import { useNotifications } from '@/hooks/useNotifications';
import { cn } from '@/lib/utils';
import type { NotificationResponse } from '@/types/api';
/** Familles proposées au filtre, dans l'ordre où elles comptent au quotidien. */
const FILTERS = ['all', 'unread', 'booking', 'account', 'system'] as const;
type Filter = (typeof FILTERS)[number];
const PAGE_SIZE = 50;
/**
* Toutes les notifications.
*
* Cette page existait déjà mais n'était liée de nulle part : « Voir tout »
* ouvrait un panneau latéral qui rejouait le menu. Elle est désormais la seule
* destination, et partage ses lignes avec la cloche — une notification se
* présente pareil où qu'on la lise.
*/
export default function NotificationsPage() {
const t = useTranslations('components.notifications');
const [filter, setFilter] = useState<Filter>('all');
const {
notifications,
unreadCount,
isLoading,
isError,
refetch,
markAsRead,
markAllAsRead,
isMarkingAll,
} = useNotifications({ limit: PAGE_SIZE });
const labels = useNotificationLabels();
const visible = useMemo(() => {
if (filter === 'all') return notifications;
if (filter === 'unread') return notifications.filter(n => !n.read);
return notifications.filter(n => matchesKind(n, filter));
}, [notifications, filter]);
const countFor = (value: Filter) => {
if (value === 'all') return notifications.length;
if (value === 'unread') return unreadCount;
return notifications.filter(n => matchesKind(n, value)).length;
};
return (
<>
<PageHeader
title={t('pageTitle')}
description={t('pageDescription')}
actions={
unreadCount > 0 && (
<Button
variant="outline"
size="sm"
onClick={() => markAllAsRead()}
loading={isMarkingAll}
>
<CheckCheck aria-hidden="true" />
{t('markAllRead')}
</Button>
)
}
tabs={
<div className="flex flex-wrap gap-2" role="tablist" aria-label={t('filterLabel')}>
{FILTERS.map(value => {
const active = filter === value;
const count = countFor(value);
return (
<button
key={value}
type="button"
role="tab"
aria-selected={active}
onClick={() => setFilter(value)}
className={cn(
'inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-body-sm transition-colors duration-150 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
active
? 'border-brand-navy bg-brand-navy text-white'
: 'border-border bg-white text-neutral-700 hover:bg-neutral-50'
)}
>
{t(`filters.${value}`)}
{count > 0 && (
<span
className={cn(
'tabular-nums text-body-xs',
active ? 'text-white/70' : 'text-neutral-400'
)}
>
{count}
</span>
)}
</button>
);
})}
</div>
}
/>
<div className="overflow-hidden rounded-lg border border-border bg-white">
{isLoading ? (
<div className="space-y-4 p-5" aria-hidden="true">
{Array.from({ length: 5 }).map((_, index) => (
<div key={index} className="flex gap-3">
<Skeleton className="size-9 shrink-0 rounded-md" />
<div className="flex-1 space-y-2">
<Skeleton className="h-3 w-1/3" />
<Skeleton className="h-3 w-3/4" />
</div>
</div>
))}
</div>
) : isError ? (
<ErrorState
title={t('errorTitle')}
description={t('errorBody')}
retryLabel={t('retry')}
onRetry={() => refetch()}
/>
) : visible.length === 0 ? (
<EmptyState
icon={Bell}
title={filter === 'all' ? t('emptyTitle') : t('emptyFilterTitle')}
description={filter === 'all' ? t('emptyBody') : t('emptyFilterBody')}
action={
filter !== 'all' && (
<Button variant="outline" size="sm" onClick={() => setFilter('all')}>
{t('filters.all')}
</Button>
)
}
/>
) : (
<ul className="divide-y divide-border">
{visible.map(notification => (
<li key={notification.id}>
<NotificationRow
notification={notification}
labels={labels}
density="comfortable"
onOpen={n => !n.read && markAsRead(n.id)}
/>
</li>
))}
</ul>
)}
</div>
</>
);
}
/** `document` et `rate` se rangent avec les réservations : c'est le même dossier. */
function matchesKind(notification: NotificationResponse, filter: Filter): boolean {
const kind = notificationKind(notification.type);
const family: Record<Exclude<Filter, 'all' | 'unread'>, NotificationKind[]> = {
booking: ['booking', 'document', 'rate'],
account: ['account'],
system: ['system'],
};
return family[filter as Exclude<Filter, 'all' | 'unread'>].includes(kind);
}