feat: add guest notification center
This commit is contained in:
@@ -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');
|
||||
}
|
||||
|
||||
256
resources/js/admin/components/GuestBroadcastCard.tsx
Normal file
256
resources/js/admin/components/GuestBroadcastCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -1,14 +1,30 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
|
||||
import { User, Heart, Users, PartyPopper, Camera, Bell, ArrowUpRight } from 'lucide-react';
|
||||
import {
|
||||
User,
|
||||
Heart,
|
||||
Users,
|
||||
PartyPopper,
|
||||
Camera,
|
||||
Bell,
|
||||
ArrowUpRight,
|
||||
MessageSquare,
|
||||
Sparkles,
|
||||
LifeBuoy,
|
||||
UploadCloud,
|
||||
AlertCircle,
|
||||
Check,
|
||||
X,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { useEventData } from '../hooks/useEventData';
|
||||
import { useOptionalEventStats } from '../context/EventStatsContext';
|
||||
import { useOptionalGuestIdentity } from '../context/GuestIdentityContext';
|
||||
import { SettingsSheet } from './settings-sheet';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
|
||||
import { DEFAULT_EVENT_BRANDING, useOptionalEventBranding } from '../context/EventBrandingContext';
|
||||
import { useOptionalNotificationCenter } from '../context/NotificationCenterContext';
|
||||
import { useOptionalNotificationCenter, type NotificationCenterValue } from '../context/NotificationCenterContext';
|
||||
import { useGuestTaskProgress, TASK_BADGE_TARGET } from '../hooks/useGuestTaskProgress';
|
||||
|
||||
const EVENT_ICON_COMPONENTS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
@@ -18,6 +34,15 @@ const EVENT_ICON_COMPONENTS: Record<string, React.ComponentType<{ className?: st
|
||||
camera: Camera,
|
||||
};
|
||||
|
||||
const NOTIFICATION_ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
broadcast: MessageSquare,
|
||||
feedback_request: MessageSquare,
|
||||
achievement_major: Sparkles,
|
||||
support_tip: LifeBuoy,
|
||||
upload_alert: UploadCloud,
|
||||
photo_activity: Camera,
|
||||
};
|
||||
|
||||
function isLikelyEmoji(value: string): boolean {
|
||||
if (!value) {
|
||||
return false;
|
||||
@@ -208,6 +233,7 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
|
||||
panelRef={panelRef}
|
||||
checklistItems={checklistItems}
|
||||
taskProgress={taskProgress?.hydrated ? taskProgress : undefined}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
<AppearanceToggleDropdown />
|
||||
@@ -217,32 +243,19 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationButton({
|
||||
center,
|
||||
eventToken,
|
||||
open,
|
||||
onToggle,
|
||||
panelRef,
|
||||
checklistItems,
|
||||
taskProgress,
|
||||
}: {
|
||||
center: {
|
||||
queueCount: number;
|
||||
inviteCount: number;
|
||||
totalCount: number;
|
||||
};
|
||||
type NotificationButtonProps = {
|
||||
center: NotificationCenterValue;
|
||||
eventToken: string;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
panelRef: React.RefObject<HTMLDivElement>;
|
||||
checklistItems: string[];
|
||||
taskProgress?: ReturnType<typeof useGuestTaskProgress>;
|
||||
}) {
|
||||
if (!center) {
|
||||
return null;
|
||||
}
|
||||
t: TranslateFn;
|
||||
};
|
||||
|
||||
const totalCount = center.totalCount;
|
||||
function NotificationButton({ center, eventToken, open, onToggle, panelRef, checklistItems, taskProgress, t }: NotificationButtonProps) {
|
||||
const badgeCount = center.totalCount;
|
||||
const progressRatio = taskProgress
|
||||
? Math.min(1, taskProgress.completedCount / TASK_BADGE_TARGET)
|
||||
: 0;
|
||||
@@ -253,34 +266,81 @@ function NotificationButton({
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="relative rounded-full bg-white/15 p-2 text-white transition hover:bg-white/30"
|
||||
aria-label="Benachrichtigungen anzeigen"
|
||||
aria-label={t('header.notifications.open', 'Benachrichtigungen anzeigen')}
|
||||
>
|
||||
<Bell className="h-5 w-5" aria-hidden />
|
||||
{totalCount > 0 && (
|
||||
{badgeCount > 0 && (
|
||||
<span className="absolute -right-1 -top-1 rounded-full bg-white px-1.5 text-[10px] font-semibold text-pink-600">
|
||||
{totalCount}
|
||||
{badgeCount > 9 ? '9+' : badgeCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="absolute right-0 mt-2 w-72 rounded-2xl border border-white/30 bg-white/95 p-4 text-slate-900 shadow-2xl"
|
||||
className="absolute right-0 mt-2 w-80 rounded-2xl border border-white/30 bg-white/95 p-4 text-slate-900 shadow-2xl"
|
||||
>
|
||||
<p className="text-sm font-semibold text-slate-900">Benachrichtigungen</p>
|
||||
<p className="text-xs text-slate-500">Uploads in Warteschlange: {center.queueCount}</p>
|
||||
<Link
|
||||
to={`/e/${encodeURIComponent(eventToken)}/queue`}
|
||||
className="mt-2 flex items-center justify-between rounded-xl border border-slate-200 px-3 py-2 text-sm font-semibold text-pink-600 transition hover:border-pink-300"
|
||||
>
|
||||
Zur Warteschlange
|
||||
<ArrowUpRight className="h-4 w-4" aria-hidden />
|
||||
</Link>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">{t('header.notifications.title', 'Benachrichtigungen')}</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{center.unreadCount > 0
|
||||
? t('header.notifications.unread', '{{count}} neu', { count: center.unreadCount })
|
||||
: t('header.notifications.allRead', 'Alles gelesen')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => center.refresh()}
|
||||
disabled={center.loading}
|
||||
className="flex items-center gap-1 rounded-full border border-slate-200 px-2 py-1 text-xs font-semibold text-slate-600 transition hover:border-pink-300 disabled:cursor-not-allowed"
|
||||
>
|
||||
<RefreshCw className={`h-3.5 w-3.5 ${center.loading ? 'animate-spin' : ''}`} aria-hidden />
|
||||
{t('header.notifications.refresh', 'Aktualisieren')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 max-h-80 space-y-2 overflow-y-auto pr-1">
|
||||
{center.loading ? (
|
||||
<NotificationSkeleton />
|
||||
) : center.notifications.length === 0 ? (
|
||||
<NotificationEmptyState t={t} />
|
||||
) : (
|
||||
center.notifications.map((item) => (
|
||||
<NotificationListItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
onMarkRead={() => center.markAsRead(item.id)}
|
||||
onDismiss={() => center.dismiss(item.id)}
|
||||
t={t}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 rounded-2xl border border-slate-200 bg-slate-50/80 p-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-600">
|
||||
{t('header.notifications.queueLabel', 'Uploads in Warteschlange')}
|
||||
</span>
|
||||
<span className="font-semibold text-slate-900">{center.queueCount}</span>
|
||||
</div>
|
||||
<Link
|
||||
to={`/e/${encodeURIComponent(eventToken)}/queue`}
|
||||
className="mt-2 inline-flex items-center gap-1 text-sm font-semibold text-pink-600"
|
||||
onClick={() => {
|
||||
if (center.unreadCount > 0) {
|
||||
void center.refresh();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('header.notifications.queueCta', 'Upload-Verlauf öffnen')}
|
||||
<ArrowUpRight className="h-4 w-4" aria-hidden />
|
||||
</Link>
|
||||
</div>
|
||||
{taskProgress && (
|
||||
<div className="mt-3 rounded-2xl border border-slate-200 bg-slate-50/90 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.3em] text-slate-400">Badge-Fortschritt</p>
|
||||
<p className="text-xs uppercase tracking-[0.3em] text-slate-400">{t('header.notifications.badgeLabel', 'Badge-Fortschritt')}</p>
|
||||
<p className="text-lg font-semibold text-slate-900">
|
||||
{taskProgress.completedCount}/{TASK_BADGE_TARGET}
|
||||
</p>
|
||||
@@ -289,7 +349,7 @@ function NotificationButton({
|
||||
to={`/e/${encodeURIComponent(eventToken)}/tasks`}
|
||||
className="rounded-full border border-slate-200 px-3 py-1 text-xs font-semibold text-pink-600 transition hover:border-pink-300"
|
||||
>
|
||||
Weiter
|
||||
{t('header.notifications.tasksCta', 'Weiter')}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-3 h-1.5 w-full rounded-full bg-slate-100">
|
||||
@@ -301,7 +361,7 @@ function NotificationButton({
|
||||
</div>
|
||||
)}
|
||||
<div className="my-3 h-px w-full bg-slate-100" />
|
||||
<p className="text-[11px] uppercase tracking-[0.3em] text-slate-400">So funktioniert’s</p>
|
||||
<p className="text-[11px] uppercase tracking-[0.3em] text-slate-400">{t('header.notifications.checklistTitle', 'So funktioniert’s')}</p>
|
||||
<ul className="mt-2 space-y-2 text-sm text-slate-600">
|
||||
{checklistItems.map((item) => (
|
||||
<li key={item} className="flex gap-2">
|
||||
@@ -315,3 +375,170 @@ function NotificationButton({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationListItem({
|
||||
item,
|
||||
onMarkRead,
|
||||
onDismiss,
|
||||
t,
|
||||
}: {
|
||||
item: NotificationCenterValue['notifications'][number];
|
||||
onMarkRead: () => void;
|
||||
onDismiss: () => void;
|
||||
t: TranslateFn;
|
||||
}) {
|
||||
const IconComponent = NOTIFICATION_ICON_MAP[item.type] ?? Bell;
|
||||
const isNew = item.status === 'new';
|
||||
const createdLabel = item.createdAt ? formatRelativeTime(item.createdAt) : '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-2xl border px-3 py-2.5 transition ${isNew ? 'border-pink-200 bg-pink-50/70' : 'border-slate-200 bg-white/90'}`}
|
||||
onClick={() => {
|
||||
if (isNew) {
|
||||
onMarkRead();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`rounded-full p-1.5 ${isNew ? 'bg-white text-pink-600' : 'bg-slate-100 text-slate-500'}`}>
|
||||
<IconComponent className="h-4 w-4" aria-hidden />
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">{item.title}</p>
|
||||
{item.body && <p className="text-xs text-slate-600">{item.body}</p>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDismiss();
|
||||
}}
|
||||
className="rounded-full p-1 text-slate-400 transition hover:text-slate-700"
|
||||
aria-label={t('header.notifications.dismiss', 'Ausblenden')}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[11px] text-slate-400">
|
||||
{createdLabel && <span>{createdLabel}</span>}
|
||||
{isNew && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-pink-100 px-1.5 py-0.5 text-[10px] font-semibold text-pink-600">
|
||||
<Sparkles className="h-3 w-3" aria-hidden />
|
||||
{t('header.notifications.badge.new', 'Neu')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.cta && (
|
||||
<NotificationCta cta={item.cta} onFollow={onMarkRead} />
|
||||
)}
|
||||
{!isNew && item.status !== 'dismissed' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onMarkRead();
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-[11px] font-semibold text-pink-600"
|
||||
>
|
||||
<Check className="h-3 w-3" aria-hidden />
|
||||
{t('header.notifications.markRead', 'Als gelesen markieren')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationCta({ cta, onFollow }: { cta: { label?: string; href?: string }; onFollow: () => void }) {
|
||||
const href = cta.href ?? '#';
|
||||
const label = cta.label ?? '';
|
||||
const isInternal = /^\//.test(href);
|
||||
const content = (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{label}
|
||||
<ArrowUpRight className="h-3.5 w-3.5" aria-hidden />
|
||||
</span>
|
||||
);
|
||||
|
||||
if (isInternal) {
|
||||
return (
|
||||
<Link
|
||||
to={href}
|
||||
className="inline-flex items-center gap-1 text-sm font-semibold text-pink-600"
|
||||
onClick={onFollow}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={cta.href}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm font-semibold text-pink-600"
|
||||
onClick={onFollow}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationEmptyState({ t }: { t: TranslateFn }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-slate-200 bg-white/70 p-4 text-center text-sm text-slate-500">
|
||||
<AlertCircle className="mx-auto mb-2 h-5 w-5 text-slate-400" aria-hidden />
|
||||
<p>{t('header.notifications.empty', 'Gerade gibt es keine neuen Hinweise.')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationSkeleton() {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{[0, 1, 2].map((index) => (
|
||||
<div key={index} className="animate-pulse rounded-2xl border border-slate-200 bg-slate-100/60 p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded-full bg-slate-200" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-3 w-3/4 rounded bg-slate-200" />
|
||||
<div className="h-3 w-1/2 rounded bg-slate-200" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatRelativeTime(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const diffMs = Date.now() - date.getTime();
|
||||
const diffMinutes = Math.max(0, Math.round(diffMs / 60000));
|
||||
|
||||
if (diffMinutes < 1) {
|
||||
return 'Gerade eben';
|
||||
}
|
||||
|
||||
if (diffMinutes < 60) {
|
||||
return `${diffMinutes} min`;
|
||||
}
|
||||
|
||||
const diffHours = Math.round(diffMinutes / 60);
|
||||
if (diffHours < 24) {
|
||||
return `${diffHours} h`;
|
||||
}
|
||||
|
||||
const diffDays = Math.round(diffHours / 24);
|
||||
return `${diffDays} d`;
|
||||
}
|
||||
|
||||
@@ -1,52 +1,214 @@
|
||||
import React from 'react';
|
||||
import { useUploadQueue } from '../queue/hooks';
|
||||
import type { QueueItem } from '../queue/queue';
|
||||
import {
|
||||
dismissGuestNotification,
|
||||
fetchGuestNotifications,
|
||||
markGuestNotificationRead,
|
||||
type GuestNotificationItem,
|
||||
} from '../services/notificationApi';
|
||||
|
||||
type NotificationCenterValue = {
|
||||
export type NotificationCenterValue = {
|
||||
notifications: GuestNotificationItem[];
|
||||
unreadCount: number;
|
||||
queueItems: QueueItem[];
|
||||
queueCount: number;
|
||||
inviteCount: number;
|
||||
totalCount: number;
|
||||
loading: boolean;
|
||||
refreshQueue: () => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
markAsRead: (id: number) => Promise<void>;
|
||||
dismiss: (id: number) => Promise<void>;
|
||||
eventToken: string;
|
||||
};
|
||||
|
||||
const NotificationCenterContext = React.createContext<NotificationCenterValue | null>(null);
|
||||
|
||||
export function NotificationCenterProvider({
|
||||
eventToken,
|
||||
children,
|
||||
}: {
|
||||
eventToken: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { items, loading, refresh } = useUploadQueue();
|
||||
export function NotificationCenterProvider({ eventToken, children }: { eventToken: string; children: React.ReactNode }) {
|
||||
const { items, loading: queueLoading, refresh: refreshQueue } = useUploadQueue();
|
||||
const [notifications, setNotifications] = React.useState<GuestNotificationItem[]>([]);
|
||||
const [unreadCount, setUnreadCount] = React.useState(0);
|
||||
const [loadingNotifications, setLoadingNotifications] = React.useState(true);
|
||||
const etagRef = React.useRef<string | null>(null);
|
||||
const fetchLockRef = React.useRef(false);
|
||||
|
||||
const queueCount = React.useMemo(
|
||||
() => items.filter((item) => item.status !== 'done').length,
|
||||
[items],
|
||||
[items]
|
||||
);
|
||||
|
||||
const value = React.useMemo<NotificationCenterValue>(
|
||||
() => ({
|
||||
queueItems: items,
|
||||
queueCount,
|
||||
inviteCount: 0,
|
||||
totalCount: queueCount,
|
||||
loading,
|
||||
refreshQueue: refresh,
|
||||
eventToken,
|
||||
}),
|
||||
[items, queueCount, loading, refresh, eventToken],
|
||||
const loadNotifications = React.useCallback(
|
||||
async (options: { silent?: boolean } = {}) => {
|
||||
if (!eventToken) {
|
||||
if (!options.silent) {
|
||||
setLoadingNotifications(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (fetchLockRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetchLockRef.current = true;
|
||||
if (!options.silent) {
|
||||
setLoadingNotifications(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await fetchGuestNotifications(eventToken, etagRef.current);
|
||||
if (!result.notModified) {
|
||||
setNotifications(result.notifications);
|
||||
setUnreadCount(result.unreadCount);
|
||||
}
|
||||
etagRef.current = result.etag;
|
||||
} catch (error) {
|
||||
console.error('Failed to load guest notifications', error);
|
||||
if (!options.silent) {
|
||||
setNotifications([]);
|
||||
setUnreadCount(0);
|
||||
}
|
||||
} finally {
|
||||
fetchLockRef.current = false;
|
||||
if (!options.silent) {
|
||||
setLoadingNotifications(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[eventToken]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
setNotifications([]);
|
||||
setUnreadCount(0);
|
||||
etagRef.current = null;
|
||||
|
||||
if (!eventToken) {
|
||||
setLoadingNotifications(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingNotifications(true);
|
||||
void loadNotifications();
|
||||
}, [eventToken, loadNotifications]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!eventToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
void loadNotifications({ silent: true });
|
||||
}, 90000);
|
||||
|
||||
return () => window.clearInterval(interval);
|
||||
}, [eventToken, loadNotifications]);
|
||||
|
||||
const markAsRead = React.useCallback(
|
||||
async (id: number) => {
|
||||
if (!eventToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
let decremented = false;
|
||||
setNotifications((prev) =>
|
||||
prev.map((item) => {
|
||||
if (item.id !== id) {
|
||||
return item;
|
||||
}
|
||||
|
||||
if (item.status === 'new') {
|
||||
decremented = true;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
status: 'read',
|
||||
readAt: new Date().toISOString(),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
if (decremented) {
|
||||
setUnreadCount((prev) => Math.max(0, prev - 1));
|
||||
}
|
||||
|
||||
try {
|
||||
await markGuestNotificationRead(eventToken, id);
|
||||
} catch (error) {
|
||||
console.error('Failed to mark notification as read', error);
|
||||
void loadNotifications({ silent: true });
|
||||
}
|
||||
},
|
||||
[eventToken, loadNotifications]
|
||||
);
|
||||
|
||||
const dismiss = React.useCallback(
|
||||
async (id: number) => {
|
||||
if (!eventToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
let decremented = false;
|
||||
setNotifications((prev) =>
|
||||
prev.map((item) => {
|
||||
if (item.id !== id) {
|
||||
return item;
|
||||
}
|
||||
|
||||
if (item.status === 'new') {
|
||||
decremented = true;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
status: 'dismissed',
|
||||
dismissedAt: new Date().toISOString(),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
if (decremented) {
|
||||
setUnreadCount((prev) => Math.max(0, prev - 1));
|
||||
}
|
||||
|
||||
try {
|
||||
await dismissGuestNotification(eventToken, id);
|
||||
} catch (error) {
|
||||
console.error('Failed to dismiss notification', error);
|
||||
void loadNotifications({ silent: true });
|
||||
}
|
||||
},
|
||||
[eventToken, loadNotifications]
|
||||
);
|
||||
|
||||
const refresh = React.useCallback(async () => {
|
||||
await Promise.all([loadNotifications(), refreshQueue()]);
|
||||
}, [loadNotifications, refreshQueue]);
|
||||
|
||||
const loading = loadingNotifications || queueLoading;
|
||||
const totalCount = unreadCount + queueCount;
|
||||
|
||||
const value: NotificationCenterValue = {
|
||||
notifications,
|
||||
unreadCount,
|
||||
queueItems: items,
|
||||
queueCount,
|
||||
totalCount,
|
||||
loading,
|
||||
refresh,
|
||||
markAsRead,
|
||||
dismiss,
|
||||
eventToken,
|
||||
};
|
||||
|
||||
return (
|
||||
<NotificationCenterContext.Provider value={value}>{children}</NotificationCenterContext.Provider>
|
||||
<NotificationCenterContext.Provider value={value}>
|
||||
{children}
|
||||
</NotificationCenterContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useNotificationCenter() {
|
||||
export function useNotificationCenter(): NotificationCenterValue {
|
||||
const ctx = React.useContext(NotificationCenterContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useNotificationCenter must be used within NotificationCenterProvider');
|
||||
@@ -54,6 +216,6 @@ export function useNotificationCenter() {
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useOptionalNotificationCenter() {
|
||||
export function useOptionalNotificationCenter(): NotificationCenterValue | null {
|
||||
return React.useContext(NotificationCenterContext);
|
||||
}
|
||||
|
||||
146
resources/js/guest/services/notificationApi.ts
Normal file
146
resources/js/guest/services/notificationApi.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { getDeviceId } from '../lib/device';
|
||||
|
||||
export type GuestNotificationCta = {
|
||||
label: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
export type GuestNotificationItem = {
|
||||
id: number;
|
||||
type: string;
|
||||
title: string;
|
||||
body: string | null;
|
||||
status: 'new' | 'read' | 'dismissed';
|
||||
createdAt: string;
|
||||
readAt?: string | null;
|
||||
dismissedAt?: string | null;
|
||||
cta?: GuestNotificationCta | null;
|
||||
payload?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type GuestNotificationFetchResult = {
|
||||
notifications: GuestNotificationItem[];
|
||||
unreadCount: number;
|
||||
etag: string | null;
|
||||
notModified: boolean;
|
||||
};
|
||||
|
||||
type GuestNotificationResponse = {
|
||||
data?: Array<{
|
||||
id?: number | string;
|
||||
type?: string;
|
||||
title?: string;
|
||||
body?: string | null;
|
||||
status?: 'new' | 'read' | 'dismissed';
|
||||
created_at?: string;
|
||||
read_at?: string | null;
|
||||
dismissed_at?: string | null;
|
||||
cta?: GuestNotificationCta | null;
|
||||
payload?: Record<string, unknown> | null;
|
||||
}>;
|
||||
meta?: {
|
||||
unread_count?: number;
|
||||
};
|
||||
};
|
||||
|
||||
type GuestNotificationRow = NonNullable<GuestNotificationResponse['data']>[number];
|
||||
|
||||
function buildHeaders(etag?: string | null): HeadersInit {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-Device-Id': getDeviceId(),
|
||||
};
|
||||
|
||||
if (etag) {
|
||||
headers['If-None-Match'] = etag;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
function mapNotification(payload: GuestNotificationRow): GuestNotificationItem {
|
||||
return {
|
||||
id: Number(payload.id ?? 0),
|
||||
type: payload.type ?? 'broadcast',
|
||||
title: payload.title ?? '',
|
||||
body: payload.body ?? null,
|
||||
status: payload.status === 'read' || payload.status === 'dismissed' ? payload.status : 'new',
|
||||
createdAt: payload.created_at ?? new Date().toISOString(),
|
||||
readAt: payload.read_at ?? null,
|
||||
dismissedAt: payload.dismissed_at ?? null,
|
||||
cta: payload.cta ?? null,
|
||||
payload: payload.payload ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchGuestNotifications(eventToken: string, etag?: string | null): Promise<GuestNotificationFetchResult> {
|
||||
const response = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/notifications`, {
|
||||
method: 'GET',
|
||||
headers: buildHeaders(etag),
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.status === 304 && etag) {
|
||||
return {
|
||||
notifications: [],
|
||||
unreadCount: 0,
|
||||
etag,
|
||||
notModified: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const reason = await safeParseError(response);
|
||||
throw new Error(reason ?? 'Benachrichtigungen konnten nicht geladen werden.');
|
||||
}
|
||||
|
||||
const body = (await response.json()) as GuestNotificationResponse;
|
||||
const rows = Array.isArray(body.data) ? body.data : [];
|
||||
const notifications = rows.map(mapNotification);
|
||||
const unreadCount = typeof body.meta?.unread_count === 'number'
|
||||
? body.meta.unread_count
|
||||
: notifications.filter((item) => item.status === 'new').length;
|
||||
|
||||
return {
|
||||
notifications,
|
||||
unreadCount,
|
||||
etag: response.headers.get('ETag'),
|
||||
notModified: false,
|
||||
};
|
||||
}
|
||||
|
||||
export async function markGuestNotificationRead(eventToken: string, notificationId: number): Promise<void> {
|
||||
await postNotificationAction(eventToken, notificationId, 'read');
|
||||
}
|
||||
|
||||
export async function dismissGuestNotification(eventToken: string, notificationId: number): Promise<void> {
|
||||
await postNotificationAction(eventToken, notificationId, 'dismiss');
|
||||
}
|
||||
|
||||
async function postNotificationAction(eventToken: string, notificationId: number, action: 'read' | 'dismiss'): Promise<void> {
|
||||
const response = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/notifications/${notificationId}/${action}`, {
|
||||
method: 'POST',
|
||||
headers: buildHeaders(),
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const reason = await safeParseError(response);
|
||||
throw new Error(reason ?? 'Aktion konnte nicht ausgeführt werden.');
|
||||
}
|
||||
}
|
||||
|
||||
async function safeParseError(response: Response): Promise<string | null> {
|
||||
try {
|
||||
const payload = await response.clone().json();
|
||||
const message = payload?.error?.message ?? payload?.message;
|
||||
if (typeof message === 'string' && message.trim() !== '') {
|
||||
return message.trim();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse notification API error', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user