Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BAUeCFpDkRD6tU5wGsc1C
179 lines
6.2 KiB
TypeScript
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);
|
|
}
|