feat: add guest notification insights

This commit is contained in:
Codex Agent
2025-11-12 19:31:13 +01:00
parent 642541c8fb
commit 2c412e3764
7 changed files with 440 additions and 7 deletions

View File

@@ -509,8 +509,30 @@ export type EventToolkit = {
};
items: EventQrInvite[];
};
notifications?: {
summary: {
total: number;
last_sent_at: string | null;
by_type: Record<string, number>;
broadcasts: {
total: number;
last_title: string | null;
last_sent_at: string | null;
};
};
recent: EventToolkitNotification[];
};
alerts: string[];
};
export type EventToolkitNotification = {
id: number;
title: string;
type: string;
status: string;
audience_scope: string;
created_at: string | null;
};
type CreatedEventResponse = { message: string; data: JsonValue; balance: number };
type PhotoResponse = { message: string; data: TenantPhoto };
@@ -1238,6 +1260,7 @@ export async function getEventToolkit(slug: string): Promise<EventToolkit> {
const tasks = json.tasks ?? {};
const photos = json.photos ?? {};
const invites = json.invites ?? {};
const notifications = normalizeToolkitNotifications(json.notifications ?? null);
const pendingPhotosRaw = Array.isArray((photos as Record<string, JsonValue>).pending)
? (photos as Record<string, JsonValue>).pending
@@ -1284,12 +1307,63 @@ export async function getEventToolkit(slug: string): Promise<EventToolkit> {
? ((invites as JsonValue).items as JsonValue[]).map((item) => normalizeQrInvite(item))
: [],
},
notifications,
alerts: Array.isArray(json.alerts) ? (json.alerts as string[]) : [],
};
return toolkit;
}
function normalizeToolkitNotifications(payload: JsonValue | null | undefined): EventToolkit['notifications'] | undefined {
if (!payload || typeof payload !== 'object') {
return undefined;
}
const record = payload as Record<string, JsonValue>;
const summaryRaw = record.summary ?? {};
const broadcastsRaw = summaryRaw?.broadcasts ?? {};
return {
summary: {
total: Number(summaryRaw?.total ?? 0),
last_sent_at: typeof summaryRaw?.last_sent_at === 'string' ? summaryRaw.last_sent_at : null,
by_type: (summaryRaw?.by_type ?? {}) as Record<string, number>,
broadcasts: {
total: Number(broadcastsRaw?.total ?? 0),
last_title: typeof broadcastsRaw?.last_title === 'string' ? broadcastsRaw.last_title : null,
last_sent_at: typeof broadcastsRaw?.last_sent_at === 'string' ? broadcastsRaw.last_sent_at : null,
},
},
recent: Array.isArray(record.recent)
? (record.recent as JsonValue[]).map((row) => normalizeToolkitNotification(row))
: [],
};
}
function normalizeToolkitNotification(row: JsonValue): EventToolkitNotification {
if (!row || typeof row !== 'object') {
return {
id: 0,
title: '',
type: 'broadcast',
status: 'active',
audience_scope: 'all',
created_at: null,
};
}
const record = row as Record<string, JsonValue>;
return {
id: Number(record.id ?? 0),
title: typeof record.title === 'string' ? record.title : '',
type: typeof record.type === 'string' ? record.type : 'broadcast',
status: typeof record.status === 'string' ? record.status : 'active',
audience_scope: typeof record.audience_scope === 'string' ? record.audience_scope : 'all',
created_at: typeof record.created_at === 'string' ? record.created_at : null,
};
}
export async function listGuestNotifications(slug: string): Promise<GuestNotificationSummary[]> {
const response = await authorizedFetch(guestNotificationsEndpoint(slug));
const data = await jsonOrThrow<{ data?: JsonValue[] }>(response, 'Failed to load guest notifications');

View File

@@ -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">