feat: add guest notification center

This commit is contained in:
Codex Agent
2025-11-12 16:56:50 +01:00
parent 062932ce38
commit 4495ac1895
27 changed files with 2042 additions and 64 deletions

View File

@@ -85,6 +85,31 @@ export type TenantEvent = {
[key: string]: unknown;
};
export type GuestNotificationSummary = {
id: number;
type: string;
title: string;
body: string | null;
status: 'draft' | 'active' | 'archived';
audience_scope: 'all' | 'guest';
target_identifier?: string | null;
payload?: Record<string, unknown> | null;
priority: number;
created_at: string | null;
expires_at: string | null;
};
export type SendGuestNotificationPayload = {
title: string;
message: string;
type?: string;
audience?: 'all' | 'guest';
guest_identifier?: string | null;
cta?: { label: string; url: string } | null;
expires_in_minutes?: number | null;
priority?: number | null;
};
export type TenantPhoto = {
id: number;
filename: string;
@@ -968,10 +993,36 @@ function normalizeQrInvite(raw: JsonValue): EventQrInvite {
};
}
function normalizeGuestNotification(raw: JsonValue): GuestNotificationSummary | null {
if (!raw || typeof raw !== 'object') {
return null;
}
const record = raw as Record<string, JsonValue>;
return {
id: Number(record.id ?? 0),
type: typeof record.type === 'string' ? record.type : 'broadcast',
title: typeof record.title === 'string' ? record.title : '',
body: typeof record.body === 'string' ? record.body : null,
status: (record.status as GuestNotificationSummary['status']) ?? 'active',
audience_scope: (record.audience_scope as GuestNotificationSummary['audience_scope']) ?? 'all',
target_identifier: typeof record.target_identifier === 'string' ? record.target_identifier : null,
payload: (record.payload as Record<string, unknown>) ?? null,
priority: Number(record.priority ?? 0),
created_at: typeof record.created_at === 'string' ? record.created_at : null,
expires_at: typeof record.expires_at === 'string' ? record.expires_at : null,
};
}
function eventEndpoint(slug: string): string {
return `/api/v1/tenant/events/${encodeURIComponent(slug)}`;
}
function guestNotificationsEndpoint(slug: string): string {
return `${eventEndpoint(slug)}/guest-notifications`;
}
function photoboothEndpoint(slug: string): string {
return `${eventEndpoint(slug)}/photobooth`;
}
@@ -1239,6 +1290,41 @@ export async function getEventToolkit(slug: string): Promise<EventToolkit> {
return toolkit;
}
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');
const rows = Array.isArray(data.data) ? data.data : [];
return rows
.map((row) => normalizeGuestNotification(row))
.filter((row): row is GuestNotificationSummary => Boolean(row));
}
export async function sendGuestNotification(
slug: string,
payload: SendGuestNotificationPayload
): Promise<GuestNotificationSummary> {
const response = await authorizedFetch(guestNotificationsEndpoint(slug), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await jsonOrThrow<{ data?: JsonValue }>(response, 'Failed to send guest notification');
return normalizeGuestNotification(data.data ?? {}) ?? normalizeGuestNotification({
id: 0,
type: payload.type ?? 'broadcast',
title: payload.title,
body: payload.message,
status: 'active',
audience_scope: payload.audience ?? 'all',
target_identifier: payload.guest_identifier ?? null,
payload: payload.cta ? { cta: payload.cta } : null,
priority: payload.priority ?? 0,
created_at: new Date().toISOString(),
expires_at: null,
});
}
export async function getEventPhotoboothStatus(slug: string): Promise<PhotoboothStatus> {
return requestPhotoboothStatus(slug, '', {}, 'Failed to load photobooth status');
}

View File

@@ -0,0 +1,256 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-hot-toast';
import { AlertCircle, Send, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import type { GuestNotificationSummary, SendGuestNotificationPayload } from '../api';
import { listGuestNotifications, sendGuestNotification } from '../api';
const TYPE_OPTIONS = [
{ value: 'broadcast', label: 'Allgemein' },
{ value: 'support_tip', label: 'Support-Hinweis' },
{ value: 'upload_alert', label: 'Upload-Status' },
{ value: 'feedback_request', label: 'Feedback' },
];
const AUDIENCE_OPTIONS = [
{ value: 'all', label: 'Alle Gäste' },
{ value: 'guest', label: 'Einzelne Geräte-ID' },
];
type GuestBroadcastCardProps = {
eventSlug: string;
eventName?: string | null;
};
export function GuestBroadcastCard({ eventSlug, eventName }: GuestBroadcastCardProps) {
const { t } = useTranslation('management');
const [form, setForm] = React.useState({
title: '',
message: '',
type: 'broadcast',
audience: 'all',
guest_identifier: '',
cta_label: '',
cta_url: '',
expires_in_minutes: 120,
});
const [history, setHistory] = React.useState<GuestNotificationSummary[]>([]);
const [loadingHistory, setLoadingHistory] = React.useState(true);
const [submitting, setSubmitting] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const loadHistory = React.useCallback(async () => {
setLoadingHistory(true);
try {
const data = await listGuestNotifications(eventSlug);
setHistory(data.slice(0, 5));
} catch (err) {
console.error(err);
} finally {
setLoadingHistory(false);
}
}, [eventSlug]);
React.useEffect(() => {
void loadHistory();
}, [loadHistory]);
function updateField(field: string, value: string): void {
setForm((prev) => ({ ...prev, [field]: value }));
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
setSubmitting(true);
setError(null);
const payload: SendGuestNotificationPayload = {
title: form.title.trim(),
message: form.message.trim(),
type: form.type,
audience: form.audience as 'all' | 'guest',
guest_identifier: form.audience === 'guest' ? form.guest_identifier.trim() : undefined,
expires_in_minutes: Number(form.expires_in_minutes) || undefined,
cta:
form.cta_label.trim() && form.cta_url.trim()
? { label: form.cta_label.trim(), url: form.cta_url.trim() }
: undefined,
};
try {
await sendGuestNotification(eventSlug, payload);
toast.success(t('events.notifications.toastSuccess', 'Nachricht gesendet.'));
setForm((prev) => ({
...prev,
title: '',
message: '',
guest_identifier: '',
cta_label: '',
cta_url: '',
}));
void loadHistory();
} catch (err) {
console.error(err);
setError(t('events.notifications.toastError', 'Nachricht konnte nicht gesendet werden.'));
} finally {
setSubmitting(false);
}
}
return (
<div className="space-y-6">
<div>
<p className="text-sm text-muted-foreground">
{t('events.notifications.description', 'Sende kurze Hinweise direkt an deine Gäste. Ideal für Programmpunkte, Upload-Hilfe oder Feedback-Aufrufe.')} {eventName && <span className="font-semibold text-foreground">{eventName}</span>}
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<Label htmlFor="notification-type">{t('events.notifications.type', 'Art der Nachricht')}</Label>
<select
id="notification-type"
className="mt-1 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
value={form.type}
onChange={(event) => updateField('type', event.target.value)}
>
{TYPE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="notification-audience">{t('events.notifications.audience', 'Zielgruppe')}</Label>
<select
id="notification-audience"
className="mt-1 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
value={form.audience}
onChange={(event) => updateField('audience', event.target.value)}
>
{AUDIENCE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</div>
{form.audience === 'guest' && (
<div>
<Label htmlFor="notification-target">{t('events.notifications.target', 'Geräte-ID oder Gastname')}</Label>
<Input
id="notification-target"
value={form.guest_identifier}
onChange={(event) => updateField('guest_identifier', event.target.value)}
placeholder="z. B. device-123"
required
/>
</div>
)}
<div className="space-y-2">
<Label htmlFor="notification-title">{t('events.notifications.titleLabel', 'Überschrift')}</Label>
<Input
id="notification-title"
value={form.title}
onChange={(event) => updateField('title', event.target.value)}
placeholder={t('events.notifications.titlePlaceholder', 'Buffet schließt in 10 Minuten')}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="notification-message">{t('events.notifications.message', 'Nachricht')}</Label>
<Textarea
id="notification-message"
value={form.message}
onChange={(event) => updateField('message', event.target.value)}
rows={4}
placeholder={t('events.notifications.messagePlaceholder', 'Kommt zur Hauptbühne für das Gruppenfoto.')}
required
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<Label htmlFor="notification-cta-label">{t('events.notifications.ctaLabel', 'CTA-Label (optional)')}</Label>
<Input
id="notification-cta-label"
value={form.cta_label}
onChange={(event) => updateField('cta_label', event.target.value)}
placeholder={t('events.notifications.ctaLabelPlaceholder', 'Zum Upload')}
/>
</div>
<div>
<Label htmlFor="notification-cta-url">{t('events.notifications.ctaUrl', 'CTA-Link')}</Label>
<Input
id="notification-cta-url"
value={form.cta_url}
onChange={(event) => updateField('cta_url', event.target.value)}
placeholder="https://... oder /e/token/queue"
/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<Label htmlFor="notification-expiry">{t('events.notifications.expiry', 'Automatisch ausblenden nach (Minuten)')}</Label>
<Input
id="notification-expiry"
type="number"
min={5}
max={2880}
value={form.expires_in_minutes}
onChange={(event) => updateField('expires_in_minutes', event.target.value)}
/>
</div>
</div>
{error && (
<div className="flex items-center gap-2 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
<span>{error}</span>
</div>
)}
<Button type="submit" className="inline-flex items-center gap-2" disabled={submitting}>
{submitting && <RefreshCw className="h-4 w-4 animate-spin" aria-hidden />}
{!submitting && <Send className="h-4 w-4" aria-hidden />}
{t('events.notifications.sendCta', 'Benachrichtigung senden')}
</Button>
</form>
<div>
<div className="mb-2 flex items-center justify-between">
<p className="text-sm font-semibold text-foreground">{t('events.notifications.historyTitle', 'Zuletzt versendet')}</p>
<Button variant="ghost" size="sm" className="gap-2" onClick={() => loadHistory()} disabled={loadingHistory}>
<RefreshCw className={`h-4 w-4 ${loadingHistory ? 'animate-spin' : ''}`} aria-hidden />
{t('events.notifications.reload', 'Aktualisieren')}
</Button>
</div>
{loadingHistory ? (
<p className="text-sm text-muted-foreground">{t('events.notifications.historyLoading', 'Verlauf wird geladen ...')}</p>
) : history.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('events.notifications.historyEmpty', 'Noch keine Benachrichtigungen versendet.')}</p>
) : (
<ul className="space-y-2">
{history.map((notification) => (
<li key={notification.id} className="rounded-lg border border-border bg-card px-3 py-2 text-sm">
<div className="flex items-center justify-between gap-2">
<div>
<p className="font-semibold text-foreground">{notification.title}</p>
<p className="text-xs text-muted-foreground">
{new Date(notification.created_at ?? '').toLocaleString()} · {notification.type}
</p>
</div>
<Badge variant="outline">{notification.audience_scope === 'all' ? t('events.notifications.audienceAll', 'Alle') : t('events.notifications.audienceGuest', 'Gast')}</Badge>
</div>
</li>
))}
</ul>
)}
</div>
</div>
);
}

View File

@@ -52,6 +52,7 @@ import {
ActionGrid,
TenantHeroCard,
} from '../components/tenant';
import { GuestBroadcastCard } from '../components/GuestBroadcastCard';
type EventDetailPageProps = {
mode?: 'detail' | 'toolkit';
@@ -268,6 +269,15 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
<MetricsGrid metrics={toolkitData?.metrics} stats={stats} />
<SectionCard className="space-y-4">
<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} />
</SectionCard>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,0.8fr)]">
<TaskOverviewCard tasks={toolkitData?.tasks} navigateToTasks={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))} />
<InviteSummary