feat: add guest notification insights
This commit is contained in:
@@ -4,9 +4,11 @@ import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
Bell,
|
||||
Camera,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Clock3,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Printer,
|
||||
@@ -269,13 +271,16 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
|
||||
|
||||
<MetricsGrid metrics={toolkitData?.metrics} stats={stats} />
|
||||
|
||||
<SectionCard className="space-y-4">
|
||||
<SectionCard className="space-y-6">
|
||||
<SectionHeader
|
||||
eyebrow={t('events.notifications.badge', 'Gästefeeds')}
|
||||
title={t('events.notifications.panelTitle', 'Nachrichten an Gäste')}
|
||||
description={t('events.notifications.panelDescription', 'Verschicke kurze Hinweise oder Hilfe an die Gästepwa. Links werden direkt im Notification-Center angezeigt.')}
|
||||
/>
|
||||
<GuestBroadcastCard eventSlug={event.slug} eventName={eventName} />
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
|
||||
<GuestNotificationStatsCard notifications={toolkitData?.notifications} />
|
||||
<GuestBroadcastCard eventSlug={event.slug} eventName={eventName} />
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,0.8fr)]">
|
||||
@@ -883,6 +888,154 @@ function FeedbackCard({ slug }: { slug: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function GuestNotificationStatsCard({ notifications }: { notifications?: EventToolkit['notifications'] }) {
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
if (!notifications || notifications.summary.total === 0) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center rounded-2xl border border-dashed border-slate-200 bg-slate-50 px-4 py-6 text-center text-sm text-slate-500">
|
||||
<MessageSquare className="mb-3 h-6 w-6 text-slate-400" aria-hidden />
|
||||
<p>{t('events.notifications.statsEmpty', 'Noch keine Nachrichten versendet – starte mit einem Broadcast.')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { summary, recent } = notifications;
|
||||
const topTypes = Object.entries(summary.by_type ?? {})
|
||||
.sort(([, a], [, b]) => (b ?? 0) - (a ?? 0))
|
||||
.slice(0, 3);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<StatPill
|
||||
icon={<Bell className="h-4 w-4" />}
|
||||
label={t('events.notifications.statsTotal', 'Gesendete Nachrichten')}
|
||||
value={summary.total}
|
||||
/>
|
||||
<StatPill
|
||||
icon={<MessageSquare className="h-4 w-4" />}
|
||||
label={t('events.notifications.statsBroadcasts', 'Broadcasts')}
|
||||
value={summary.broadcasts.total}
|
||||
sublabel={summary.broadcasts.last_title ?? t('events.notifications.statsBroadcastsEmpty', 'Noch kein Broadcast')}
|
||||
/>
|
||||
<StatPill
|
||||
icon={<Clock3 className="h-4 w-4" />}
|
||||
label={t('events.notifications.statsLastSent', 'Letzte Sendung')}
|
||||
value={summary.last_sent_at ? formatRelativeDateTime(summary.last_sent_at) : t('events.notifications.never', 'Noch nie')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-slate-200 bg-white/80 p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
{t('events.notifications.topTypes', 'Beliebteste Typen')}
|
||||
</p>
|
||||
{topTypes.length > 0 ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{topTypes.map(([type, count]) => (
|
||||
<Badge key={type} variant="secondary" className="gap-2">
|
||||
{getNotificationTypeLabel(type, t)}
|
||||
<span className="rounded-full bg-slate-200 px-2 text-xs font-semibold text-slate-700">{count as number}</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-slate-500">{t('events.notifications.topTypesEmpty', 'Noch keine Verteilung verfügbar.')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-slate-200 bg-white/80 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
{t('events.notifications.recent', 'Letzte Nachrichten')}
|
||||
</p>
|
||||
<span className="text-xs text-slate-400">{recent.length} {t('events.notifications.recentCount', 'Einträge')}</span>
|
||||
</div>
|
||||
{recent.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-slate-500">{t('events.notifications.recentEmpty', 'Noch keine Historie vorhanden.')}</p>
|
||||
) : (
|
||||
<ul className="mt-3 space-y-3">
|
||||
{recent.map((item) => (
|
||||
<li key={item.id} className="rounded-xl border border-slate-100 bg-white/70 px-3 py-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">{item.title || t('events.notifications.untitled', 'Ohne Titel')}</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{formatRelativeDateTime(item.created_at)} · {getAudienceLabel(item.audience_scope, t)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs capitalize">
|
||||
{getNotificationTypeLabel(item.type, t)}
|
||||
</Badge>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatPill({ icon, label, value, sublabel }: { icon: React.ReactNode; label: string; value: string | number; sublabel?: string | null }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-200 bg-white/80 p-4">
|
||||
<div className="flex items-center gap-2 text-slate-500">
|
||||
<span className="rounded-full bg-slate-100 p-2 text-slate-600">{icon}</span>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide">{label}</p>
|
||||
</div>
|
||||
<p className="mt-2 text-2xl font-semibold text-slate-900">{value}</p>
|
||||
{sublabel && <p className="text-xs text-slate-500">{sublabel}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatRelativeDateTime(value?: string | null): string {
|
||||
if (!value) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
return date.toLocaleString(undefined, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function getNotificationTypeLabel(type: string, t: ReturnType<typeof useTranslation>['t']): string {
|
||||
switch (type) {
|
||||
case 'broadcast':
|
||||
return t('events.notifications.types.broadcast', 'Broadcast');
|
||||
case 'upload_alert':
|
||||
return t('events.notifications.types.upload', 'Upload-Status');
|
||||
case 'support_tip':
|
||||
return t('events.notifications.types.support', 'Support-Tipp');
|
||||
case 'feedback_request':
|
||||
return t('events.notifications.types.feedback', 'Feedback');
|
||||
case 'achievement_major':
|
||||
return t('events.notifications.types.achievement', 'Achievement');
|
||||
case 'photo_activity':
|
||||
return t('events.notifications.types.activity', 'Aktivität');
|
||||
default:
|
||||
return t('events.notifications.types.generic', 'System');
|
||||
}
|
||||
}
|
||||
|
||||
function getAudienceLabel(scope: string, t: ReturnType<typeof useTranslation>['t']): string {
|
||||
if (scope === 'guest') {
|
||||
return t('events.notifications.audienceGuest', 'Gezielte Gäste');
|
||||
}
|
||||
|
||||
return t('events.notifications.audienceAll', 'Alle Gäste');
|
||||
}
|
||||
|
||||
function InfoRow({ icon, label, value }: { icon: React.ReactNode; label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-slate-100 bg-white/70 px-3 py-2 text-sm text-slate-700">
|
||||
|
||||
Reference in New Issue
Block a user