coupon code system eingeführt. coupons werden vom super admin gemanaged. coupons werden mit paddle synchronisiert und dort validiert. plus: einige mobil-optimierungen im tenant admin pwa.
This commit is contained in:
306
resources/js/admin/components/NotificationCenter.tsx
Normal file
306
resources/js/admin/components/NotificationCenter.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user