307 lines
11 KiB
TypeScript
307 lines
11 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('dashboard');
|
||
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', { defaultValue: 'Benachrichtigungen' })}
|
||
>
|
||
<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', { defaultValue: 'Notifications' })}</span>
|
||
{!loading && unreadCount === 0 ? (
|
||
<Badge variant="outline">{t('notifications.empty', { defaultValue: 'Aktuell ruhig' })}</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', { defaultValue: 'Alles erledigt – wir melden uns bei Neuigkeiten.' })}
|
||
</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', { defaultValue: 'Ausblenden' })}
|
||
</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', { defaultValue: 'Neue Hinweise laden' })}
|
||
</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', { defaultValue: 'Legen wir los' }),
|
||
description: t('notifications.noEvents.description', {
|
||
defaultValue: 'Erstelle dein erstes Event, um Uploads, Aufgaben und Einladungen freizuschalten.',
|
||
}),
|
||
tone: 'warning',
|
||
action: {
|
||
label: t('notifications.noEvents.cta', { defaultValue: 'Event erstellen' }),
|
||
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', { defaultValue: 'Event noch als Entwurf' }),
|
||
description: t('notifications.draftEvent.description', {
|
||
defaultValue: 'Veröffentliche das Event, um Einladungen und Galerie freizugeben.',
|
||
}),
|
||
tone: 'info',
|
||
action: event.slug
|
||
? {
|
||
label: t('notifications.draftEvent.cta', { defaultValue: 'Event öffnen' }),
|
||
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', { defaultValue: 'Event startet bald' }),
|
||
description: t('notifications.upcomingEvent.description', {
|
||
defaultValue: days === 0
|
||
? 'Heute findet ein Event statt – checke Uploads und Tasks.'
|
||
: `Noch ${days} Tage – bereite Einladungen und Aufgaben vor.`,
|
||
}),
|
||
tone: 'info',
|
||
action: event.slug
|
||
? {
|
||
label: t('notifications.upcomingEvent.cta', { defaultValue: 'Zum Event' }),
|
||
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', { defaultValue: 'Uploads warten auf Freigabe' }),
|
||
description: t('notifications.pendingUploads.description', {
|
||
defaultValue: `${pendingUploads} neue Uploads benötigen Moderation.`,
|
||
}),
|
||
tone: 'warning',
|
||
action: event.slug
|
||
? {
|
||
label: t('notifications.pendingUploads.cta', { defaultValue: 'Uploads öffnen' }),
|
||
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', { defaultValue: 'Neue Fotos eingetroffen' }),
|
||
description: t('notifications.newPhotos.description', {
|
||
defaultValue: `${summary?.new_photos ?? 0} Uploads warten auf dich.`,
|
||
}),
|
||
tone: 'success',
|
||
action: primary?.slug
|
||
? {
|
||
label: t('notifications.newPhotos.cta', { defaultValue: 'Galerie öffnen' }),
|
||
onSelect: () => navigate(ADMIN_EVENT_VIEW_PATH(primary.slug!)),
|
||
}
|
||
: {
|
||
label: t('notifications.newPhotos.ctaFallback', { defaultValue: 'Events ansehen' }),
|
||
onSelect: () => navigate(ADMIN_EVENTS_PATH),
|
||
},
|
||
});
|
||
}
|
||
|
||
return items;
|
||
}
|