297 lines
9.6 KiB
TypeScript
297 lines
9.6 KiB
TypeScript
import React from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { AlertTriangle, Bell, CheckCircle2, Clock, Plus } from 'lucide-react';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuTrigger,
|
|
DropdownMenuSeparator,
|
|
} from '@/components/ui/dropdown-menu';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
|
|
import { getDashboardSummary, getEvents, type DashboardSummary, type TenantEvent } from '../api';
|
|
import { ADMIN_EVENT_CREATE_PATH, ADMIN_EVENT_VIEW_PATH, ADMIN_EVENTS_PATH } from '../constants';
|
|
import { isAuthError } from '../auth/tokens';
|
|
|
|
export type NotificationTone = 'info' | 'warning' | 'success';
|
|
|
|
interface TenantNotification {
|
|
id: string;
|
|
title: string;
|
|
description?: string;
|
|
tone: NotificationTone;
|
|
action?: {
|
|
label: string;
|
|
onSelect: () => void;
|
|
};
|
|
}
|
|
|
|
export function NotificationCenter() {
|
|
const navigate = useNavigate();
|
|
const { t } = useTranslation('management');
|
|
const [open, setOpen] = React.useState(false);
|
|
const [loading, setLoading] = React.useState(true);
|
|
const [notifications, setNotifications] = React.useState<TenantNotification[]>([]);
|
|
const [dismissed, setDismissed] = React.useState<Set<string>>(new Set());
|
|
|
|
const visibleNotifications = React.useMemo(
|
|
() => notifications.filter((notification) => !dismissed.has(notification.id)),
|
|
[notifications, dismissed]
|
|
);
|
|
|
|
const unreadCount = visibleNotifications.length;
|
|
|
|
const refresh = React.useCallback(async () => {
|
|
setLoading(true);
|
|
|
|
try {
|
|
const [events, summary] = await Promise.all([
|
|
getEvents().catch(() => [] as TenantEvent[]),
|
|
getDashboardSummary().catch(() => null as DashboardSummary | null),
|
|
]);
|
|
|
|
setNotifications(buildNotifications({
|
|
events,
|
|
summary,
|
|
navigate,
|
|
t,
|
|
}));
|
|
} catch (error) {
|
|
if (!isAuthError(error)) {
|
|
console.error('[NotificationCenter] Failed to load data', error);
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [navigate, t]);
|
|
|
|
React.useEffect(() => {
|
|
refresh();
|
|
}, [refresh]);
|
|
|
|
const handleDismiss = React.useCallback((id: string) => {
|
|
setDismissed((prev) => {
|
|
const next = new Set(prev);
|
|
next.add(id);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const iconForTone: Record<NotificationTone, React.ReactNode> = React.useMemo(
|
|
() => ({
|
|
info: <Clock className="h-4 w-4 text-slate-400" />,
|
|
warning: <AlertTriangle className="h-4 w-4 text-amber-500" />,
|
|
success: <CheckCircle2 className="h-4 w-4 text-emerald-500" />,
|
|
}),
|
|
[]
|
|
);
|
|
|
|
return (
|
|
<DropdownMenu open={open} onOpenChange={(next) => {
|
|
setOpen(next);
|
|
if (next) {
|
|
refresh();
|
|
}
|
|
}}>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="relative rounded-full border border-transparent text-slate-600 hover:text-rose-600 dark:text-slate-200"
|
|
aria-label={t('notifications.trigger')}
|
|
>
|
|
<Bell className="h-5 w-5" />
|
|
{unreadCount > 0 ? (
|
|
<Badge className="absolute -right-1 -top-1 rounded-full bg-rose-600 px-1.5 text-[10px] font-semibold text-white">
|
|
{unreadCount}
|
|
</Badge>
|
|
) : null}
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-80 space-y-1 p-0">
|
|
<DropdownMenuLabel className="flex items-center justify-between py-2">
|
|
<span>{t('notifications.title')}</span>
|
|
{!loading && unreadCount === 0 ? (
|
|
<Badge variant="outline">{t('notifications.empty')}</Badge>
|
|
) : null}
|
|
</DropdownMenuLabel>
|
|
<DropdownMenuSeparator />
|
|
{loading ? (
|
|
<div className="space-y-2 p-3">
|
|
<Skeleton className="h-12 w-full rounded-xl" />
|
|
<Skeleton className="h-12 w-full rounded-xl" />
|
|
</div>
|
|
) : (
|
|
<div className="max-h-80 space-y-1 overflow-y-auto p-1">
|
|
{visibleNotifications.length === 0 ? (
|
|
<p className="px-3 py-4 text-sm text-slate-500">
|
|
{t('notifications.empty.message')}
|
|
</p>
|
|
) : (
|
|
visibleNotifications.map((item) => (
|
|
<DropdownMenuItem key={item.id} className="flex flex-col gap-1 py-3" onSelect={(event) => event.preventDefault()}>
|
|
<div className="flex items-start gap-3">
|
|
<span className="mt-0.5">{iconForTone[item.tone]}</span>
|
|
<div className="flex-1 space-y-1">
|
|
<p className="text-sm font-semibold text-slate-900 dark:text-white">{item.title}</p>
|
|
{item.description ? (
|
|
<p className="text-xs text-slate-600 dark:text-slate-300">{item.description}</p>
|
|
) : null}
|
|
<div className="flex flex-wrap gap-2 pt-1">
|
|
{item.action ? (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-7 rounded-full px-3 text-xs"
|
|
onClick={() => {
|
|
item.action?.onSelect();
|
|
setOpen(false);
|
|
}}
|
|
>
|
|
{item.action.label}
|
|
</Button>
|
|
) : null}
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="h-7 rounded-full px-3 text-xs text-slate-500 hover:text-rose-600"
|
|
onClick={() => handleDismiss(item.id)}
|
|
>
|
|
{t('notifications.action.dismiss')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</DropdownMenuItem>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
className="flex items-center gap-2 text-xs"
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
setDismissed(new Set());
|
|
refresh();
|
|
}}
|
|
>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
{t('notifications.action.refresh')}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
}
|
|
|
|
function buildNotifications({
|
|
events,
|
|
summary,
|
|
navigate,
|
|
t,
|
|
}: {
|
|
events: TenantEvent[];
|
|
summary: DashboardSummary | null;
|
|
navigate: ReturnType<typeof useNavigate>;
|
|
t: (key: string, options?: Record<string, unknown>) => string;
|
|
}): TenantNotification[] {
|
|
const items: TenantNotification[] = [];
|
|
const primary = events[0] ?? null;
|
|
const now = Date.now();
|
|
|
|
if (events.length === 0) {
|
|
items.push({
|
|
id: 'no-events',
|
|
title: t('notifications.noEvents.title'),
|
|
description: t('notifications.noEvents.description'),
|
|
tone: 'warning',
|
|
action: {
|
|
label: t('notifications.noEvents.cta'),
|
|
onSelect: () => navigate(ADMIN_EVENT_CREATE_PATH),
|
|
},
|
|
});
|
|
|
|
return items;
|
|
}
|
|
|
|
events.forEach((event) => {
|
|
if (event.status !== 'published') {
|
|
items.push({
|
|
id: `draft-${event.id}`,
|
|
title: t('notifications.draftEvent.title'),
|
|
description: t('notifications.draftEvent.description'),
|
|
tone: 'info',
|
|
action: event.slug
|
|
? {
|
|
label: t('notifications.draftEvent.cta'),
|
|
onSelect: () => navigate(ADMIN_EVENT_VIEW_PATH(event.slug!)),
|
|
}
|
|
: undefined,
|
|
});
|
|
}
|
|
|
|
const eventDate = event.event_date ? new Date(event.event_date).getTime() : null;
|
|
if (eventDate && eventDate > now) {
|
|
const days = Math.round((eventDate - now) / (1000 * 60 * 60 * 24));
|
|
if (days <= 7) {
|
|
items.push({
|
|
id: `upcoming-${event.id}`,
|
|
title: t('notifications.upcomingEvent.title'),
|
|
description: days === 0
|
|
? t('notifications.upcomingEvent.description_today')
|
|
: t('notifications.upcomingEvent.description_days', { count: days }),
|
|
tone: 'info',
|
|
action: event.slug
|
|
? {
|
|
label: t('notifications.upcomingEvent.cta'),
|
|
onSelect: () => navigate(ADMIN_EVENT_VIEW_PATH(event.slug!)),
|
|
}
|
|
: undefined,
|
|
});
|
|
}
|
|
}
|
|
|
|
const pendingUploads = Number(event.pending_photo_count ?? 0);
|
|
if (pendingUploads > 0) {
|
|
items.push({
|
|
id: `pending-uploads-${event.id}`,
|
|
title: t('notifications.pendingUploads.title'),
|
|
description: t('notifications.pendingUploads.description', { count: pendingUploads }),
|
|
tone: 'warning',
|
|
action: event.slug
|
|
? {
|
|
label: t('notifications.pendingUploads.cta'),
|
|
onSelect: () => navigate(`${ADMIN_EVENT_VIEW_PATH(event.slug!)}#photos`),
|
|
}
|
|
: undefined,
|
|
});
|
|
}
|
|
});
|
|
|
|
if ((summary?.new_photos ?? 0) > 0) {
|
|
items.push({
|
|
id: 'summary-new-photos',
|
|
title: t('notifications.newPhotos.title'),
|
|
description: t('notifications.newPhotos.description', { count: summary?.new_photos ?? 0 }),
|
|
tone: 'success',
|
|
action: primary?.slug
|
|
? {
|
|
label: t('notifications.newPhotos.cta'),
|
|
onSelect: () => navigate(ADMIN_EVENT_VIEW_PATH(primary.slug!)),
|
|
}
|
|
: {
|
|
label: t('notifications.newPhotos.ctaFallback'),
|
|
onSelect: () => navigate(ADMIN_EVENTS_PATH),
|
|
},
|
|
});
|
|
}
|
|
|
|
return items;
|
|
}
|