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

@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api\Tenant; namespace App\Http\Controllers\Api\Tenant;
use App\Enums\GuestNotificationType;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Tenant\EventStoreRequest; use App\Http\Requests\Tenant\EventStoreRequest;
use App\Http\Resources\Tenant\EventJoinTokenResource; use App\Http\Resources\Tenant\EventJoinTokenResource;
@@ -9,6 +10,7 @@ use App\Http\Resources\Tenant\EventResource;
use App\Http\Resources\Tenant\PhotoResource; use App\Http\Resources\Tenant\PhotoResource;
use App\Models\Event; use App\Models\Event;
use App\Models\EventPackage; use App\Models\EventPackage;
use App\Models\GuestNotification;
use App\Models\Package; use App\Models\Package;
use App\Models\Photo; use App\Models\Photo;
use App\Models\Tenant; use App\Models\Tenant;
@@ -426,6 +428,47 @@ class EventController extends Controller
->take(3) ->take(3)
->get(); ->get();
$notificationQuery = GuestNotification::query()->where('event_id', $event->id);
$notificationTotal = (clone $notificationQuery)->count();
$notificationTypeCounts = (clone $notificationQuery)
->select('type', DB::raw('COUNT(*) as total'))
->groupBy('type')
->pluck('total', 'type')
->map(fn ($value) => (int) $value)
->toArray();
$lastNotificationAt = $notificationTotal > 0
? (clone $notificationQuery)->latest('created_at')->value('created_at')
: null;
$lastBroadcast = (clone $notificationQuery)
->where('type', GuestNotificationType::BROADCAST->value)
->latest('created_at')
->first(['id', 'title', 'created_at']);
$recentNotifications = (clone $notificationQuery)
->latest('created_at')
->limit(5)
->get(['id', 'title', 'type', 'status', 'audience_scope', 'created_at']);
$notificationsPayload = [
'summary' => [
'total' => $notificationTotal,
'last_sent_at' => $lastNotificationAt ? $lastNotificationAt->toAtomString() : null,
'by_type' => $notificationTypeCounts,
'broadcasts' => [
'total' => $notificationTypeCounts[GuestNotificationType::BROADCAST->value] ?? 0,
'last_title' => $lastBroadcast?->title,
'last_sent_at' => $lastBroadcast?->created_at?->toAtomString(),
],
],
'recent' => $recentNotifications->map(fn (GuestNotification $notification) => [
'id' => $notification->id,
'title' => $notification->title,
'type' => $notification->type->value,
'status' => $notification->status->value,
'audience_scope' => $notification->audience_scope->value,
'created_at' => $notification->created_at?->toAtomString(),
])->all(),
];
$alerts = []; $alerts = [];
if (($event->settings['engagement_mode'] ?? 'tasks') !== 'photo_only' && $taskSummary['total'] === 0) { if (($event->settings['engagement_mode'] ?? 'tasks') !== 'photo_only' && $taskSummary['total'] === 0) {
$alerts[] = 'no_tasks'; $alerts[] = 'no_tasks';
@@ -461,6 +504,7 @@ class EventController extends Controller
], ],
'items' => EventJoinTokenResource::collection($recentInvites)->resolve($request), 'items' => EventJoinTokenResource::collection($recentInvites)->resolve($request),
], ],
'notifications' => $notificationsPayload,
'alerts' => $alerts, 'alerts' => $alerts,
]); ]);
} }

View File

@@ -16,6 +16,7 @@ Capabilities
- Dashboard highlights tenant quota status (photo uploads, guest slots, gallery expiry) with traffic-light cards fed by package limit metrics. - Dashboard highlights tenant quota status (photo uploads, guest slots, gallery expiry) with traffic-light cards fed by package limit metrics.
- Global toast handler consumes the shared API error schema and surfaces localized error messages for tenant operators. - Global toast handler consumes the shared API error schema and surfaces localized error messages for tenant operators.
- Guest broadcast module on the Event detail page: tenant admins can compose short guest-facing notifications (broadcast/support tip/upload alert/feedback) with optional CTA links and expirations. Calls `/api/v1/tenant/events/{slug}/guest-notifications` and stores history (last 5 messages) for quick status checks. - Guest broadcast module on the Event detail page: tenant admins can compose short guest-facing notifications (broadcast/support tip/upload alert/feedback) with optional CTA links and expirations. Calls `/api/v1/tenant/events/{slug}/guest-notifications` and stores history (last 5 messages) for quick status checks.
- Event detail includes notification analytics: total sends, broadcast counts, last send time, type distribution, and the latest guest-visible messages so hosts can monitor reach without leaving the toolkit view.
Support Playbook (Limits) Support Playbook (Limits)
- Wenn Tenant-Admins Upload- oder Gäste-Limits erreichen, zeigt der Header Warn-Badges + Toast mit derselben Fehlermeldung wie im Backend (`code`, `title`, `message`). - Wenn Tenant-Admins Upload- oder Gäste-Limits erreichen, zeigt der Header Warn-Badges + Toast mit derselben Fehlermeldung wie im Backend (`code`, `title`, `message`).

View File

@@ -509,8 +509,30 @@ export type EventToolkit = {
}; };
items: EventQrInvite[]; 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[]; 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 CreatedEventResponse = { message: string; data: JsonValue; balance: number };
type PhotoResponse = { message: string; data: TenantPhoto }; type PhotoResponse = { message: string; data: TenantPhoto };
@@ -1238,6 +1260,7 @@ export async function getEventToolkit(slug: string): Promise<EventToolkit> {
const tasks = json.tasks ?? {}; const tasks = json.tasks ?? {};
const photos = json.photos ?? {}; const photos = json.photos ?? {};
const invites = json.invites ?? {}; const invites = json.invites ?? {};
const notifications = normalizeToolkitNotifications(json.notifications ?? null);
const pendingPhotosRaw = Array.isArray((photos as Record<string, JsonValue>).pending) const pendingPhotosRaw = Array.isArray((photos as Record<string, JsonValue>).pending)
? (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)) ? ((invites as JsonValue).items as JsonValue[]).map((item) => normalizeQrInvite(item))
: [], : [],
}, },
notifications,
alerts: Array.isArray(json.alerts) ? (json.alerts as string[]) : [], alerts: Array.isArray(json.alerts) ? (json.alerts as string[]) : [],
}; };
return toolkit; 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[]> { export async function listGuestNotifications(slug: string): Promise<GuestNotificationSummary[]> {
const response = await authorizedFetch(guestNotificationsEndpoint(slug)); const response = await authorizedFetch(guestNotificationsEndpoint(slug));
const data = await jsonOrThrow<{ data?: JsonValue[] }>(response, 'Failed to load guest notifications'); const data = await jsonOrThrow<{ data?: JsonValue[] }>(response, 'Failed to load guest notifications');

View File

@@ -4,9 +4,11 @@ import { useTranslation } from 'react-i18next';
import { import {
AlertTriangle, AlertTriangle,
ArrowLeft, ArrowLeft,
Bell,
Camera, Camera,
CheckCircle2, CheckCircle2,
Circle, Circle,
Clock3,
Loader2, Loader2,
MessageSquare, MessageSquare,
Printer, Printer,
@@ -269,13 +271,16 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
<MetricsGrid metrics={toolkitData?.metrics} stats={stats} /> <MetricsGrid metrics={toolkitData?.metrics} stats={stats} />
<SectionCard className="space-y-4"> <SectionCard className="space-y-6">
<SectionHeader <SectionHeader
eyebrow={t('events.notifications.badge', 'Gästefeeds')} eyebrow={t('events.notifications.badge', 'Gästefeeds')}
title={t('events.notifications.panelTitle', 'Nachrichten an Gäste')} 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.')} 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> </SectionCard>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,0.8fr)]"> <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 }) { function InfoRow({ icon, label, value }: { icon: React.ReactNode; label: string; value: React.ReactNode }) {
return ( 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"> <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">

View File

@@ -259,6 +259,33 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, chec
const progressRatio = taskProgress const progressRatio = taskProgress
? Math.min(1, taskProgress.completedCount / TASK_BADGE_TARGET) ? Math.min(1, taskProgress.completedCount / TASK_BADGE_TARGET)
: 0; : 0;
const [activeTab, setActiveTab] = React.useState<'unread' | 'all' | 'status'>(center.unreadCount > 0 ? 'unread' : 'all');
React.useEffect(() => {
if (!open) {
setActiveTab(center.unreadCount > 0 ? 'unread' : 'all');
}
}, [open, center.unreadCount]);
const uploadNotifications = React.useMemo(
() => center.notifications.filter((item) => item.type === 'upload_alert'),
[center.notifications]
);
const unreadNotifications = React.useMemo(
() => center.notifications.filter((item) => item.status === 'new'),
[center.notifications]
);
const filteredNotifications = React.useMemo(() => {
switch (activeTab) {
case 'unread':
return unreadNotifications;
case 'status':
return uploadNotifications;
default:
return center.notifications;
}
}, [activeTab, center.notifications, unreadNotifications, uploadNotifications]);
return ( return (
<div className="relative"> <div className="relative">
@@ -299,13 +326,36 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, chec
{t('header.notifications.refresh', 'Aktualisieren')} {t('header.notifications.refresh', 'Aktualisieren')}
</button> </button>
</div> </div>
<NotificationTabs
tabs={[
{ key: 'unread', label: t('header.notifications.tabUnread', 'Neu'), badge: unreadNotifications.length },
{ key: 'status', label: t('header.notifications.tabStatus', 'Uploads/Status'), badge: uploadNotifications.length },
{ key: 'all', label: t('header.notifications.tabAll', 'Alle'), badge: center.notifications.length },
]}
activeTab={activeTab}
onTabChange={(next) => setActiveTab(next as typeof activeTab)}
/>
<NotificationStatusBar
lastFetchedAt={center.lastFetchedAt}
isOffline={center.isOffline}
t={t}
/>
<div className="mt-3 max-h-80 space-y-2 overflow-y-auto pr-1"> <div className="mt-3 max-h-80 space-y-2 overflow-y-auto pr-1">
{center.loading ? ( {center.loading ? (
<NotificationSkeleton /> <NotificationSkeleton />
) : center.notifications.length === 0 ? ( ) : filteredNotifications.length === 0 ? (
<NotificationEmptyState t={t} /> <NotificationEmptyState
t={t}
message={
activeTab === 'unread'
? t('header.notifications.emptyUnread', 'Du bist auf dem neuesten Stand!')
: activeTab === 'status'
? t('header.notifications.emptyStatus', 'Keine Upload-Hinweise oder Wartungen aktiv.')
: undefined
}
/>
) : ( ) : (
center.notifications.map((item) => ( filteredNotifications.map((item) => (
<NotificationListItem <NotificationListItem
key={item.id} key={item.id}
item={item} item={item}
@@ -490,11 +540,11 @@ function NotificationCta({ cta, onFollow }: { cta: { label?: string; href?: stri
); );
} }
function NotificationEmptyState({ t }: { t: TranslateFn }) { function NotificationEmptyState({ t, message }: { t: TranslateFn; message?: string }) {
return ( return (
<div className="rounded-2xl border border-dashed border-slate-200 bg-white/70 p-4 text-center text-sm text-slate-500"> <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 /> <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> <p>{message ?? t('header.notifications.empty', 'Gerade gibt es keine neuen Hinweise.')}</p>
</div> </div>
); );
} }
@@ -542,3 +592,51 @@ function formatRelativeTime(value: string): string {
const diffDays = Math.round(diffHours / 24); const diffDays = Math.round(diffHours / 24);
return `${diffDays} d`; return `${diffDays} d`;
} }
function NotificationTabs({
tabs,
activeTab,
onTabChange,
}: {
tabs: Array<{ key: string; label: string; badge?: number }>;
activeTab: string;
onTabChange: (key: string) => void;
}) {
return (
<div className="mt-3 flex gap-2 rounded-full bg-slate-100/80 p-1 text-xs font-semibold text-slate-600">
{tabs.map((tab) => (
<button
key={tab.key}
type="button"
className={`flex flex-1 items-center justify-center gap-1 rounded-full px-3 py-1 transition ${
activeTab === tab.key ? 'bg-white text-pink-600 shadow' : 'text-slate-500'
}`}
onClick={() => onTabChange(tab.key)}
>
{tab.label}
{typeof tab.badge === 'number' && tab.badge > 0 && (
<span className="rounded-full bg-pink-100 px-2 text-[11px] text-pink-600">{tab.badge}</span>
)}
</button>
))}
</div>
);
}
function NotificationStatusBar({ lastFetchedAt, isOffline, t }: { lastFetchedAt: Date | null; isOffline: boolean; t: TranslateFn }) {
const label = lastFetchedAt ? formatRelativeTime(lastFetchedAt.toISOString()) : t('header.notifications.never', 'Noch keine Aktualisierung');
return (
<div className="mt-2 flex items-center justify-between text-[11px] text-slate-500">
<span>
{t('header.notifications.lastSync', 'Zuletzt aktualisiert')}: {label}
</span>
{isOffline && (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 font-semibold text-amber-700">
<AlertCircle className="h-3 w-3" aria-hidden />
{t('header.notifications.offline', 'Offline')}
</span>
)}
</div>
);
}

View File

@@ -19,6 +19,8 @@ export type NotificationCenterValue = {
markAsRead: (id: number) => Promise<void>; markAsRead: (id: number) => Promise<void>;
dismiss: (id: number) => Promise<void>; dismiss: (id: number) => Promise<void>;
eventToken: string; eventToken: string;
lastFetchedAt: Date | null;
isOffline: boolean;
}; };
const NotificationCenterContext = React.createContext<NotificationCenterValue | null>(null); const NotificationCenterContext = React.createContext<NotificationCenterValue | null>(null);
@@ -30,6 +32,8 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke
const [loadingNotifications, setLoadingNotifications] = React.useState(true); const [loadingNotifications, setLoadingNotifications] = React.useState(true);
const etagRef = React.useRef<string | null>(null); const etagRef = React.useRef<string | null>(null);
const fetchLockRef = React.useRef(false); const fetchLockRef = React.useRef(false);
const [lastFetchedAt, setLastFetchedAt] = React.useState<Date | null>(null);
const [isOffline, setIsOffline] = React.useState<boolean>(typeof navigator !== 'undefined' ? !navigator.onLine : false);
const queueCount = React.useMemo( const queueCount = React.useMemo(
() => items.filter((item) => item.status !== 'done').length, () => items.filter((item) => item.status !== 'done').length,
@@ -59,14 +63,19 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke
if (!result.notModified) { if (!result.notModified) {
setNotifications(result.notifications); setNotifications(result.notifications);
setUnreadCount(result.unreadCount); setUnreadCount(result.unreadCount);
setLastFetchedAt(new Date());
} }
etagRef.current = result.etag; etagRef.current = result.etag;
setIsOffline(false);
} catch (error) { } catch (error) {
console.error('Failed to load guest notifications', error); console.error('Failed to load guest notifications', error);
if (!options.silent) { if (!options.silent) {
setNotifications([]); setNotifications([]);
setUnreadCount(0); setUnreadCount(0);
} }
if (typeof navigator !== 'undefined' && !navigator.onLine) {
setIsOffline(true);
}
} finally { } finally {
fetchLockRef.current = false; fetchLockRef.current = false;
if (!options.silent) { if (!options.silent) {
@@ -103,6 +112,19 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke
return () => window.clearInterval(interval); return () => window.clearInterval(interval);
}, [eventToken, loadNotifications]); }, [eventToken, loadNotifications]);
React.useEffect(() => {
const handleOnline = () => setIsOffline(false);
const handleOffline = () => setIsOffline(true);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
const markAsRead = React.useCallback( const markAsRead = React.useCallback(
async (id: number) => { async (id: number) => {
if (!eventToken) { if (!eventToken) {
@@ -199,6 +221,8 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke
markAsRead, markAsRead,
dismiss, dismiss,
eventToken, eventToken,
lastFetchedAt,
isOffline,
}; };
return ( return (

View File

@@ -0,0 +1,39 @@
<?php
namespace Tests\Feature\Tenant;
use App\Enums\GuestNotificationType;
use App\Models\Event;
use App\Models\GuestNotification;
class EventToolkitNotificationsTest extends TenantTestCase
{
public function test_toolkit_response_contains_notification_summary(): void
{
$event = Event::factory()->for($this->tenant)->create([
'status' => 'published',
'slug' => 'toolkit-event',
]);
GuestNotification::factory()->create([
'tenant_id' => $this->tenant->id,
'event_id' => $event->id,
'type' => GuestNotificationType::BROADCAST,
'title' => 'Broadcast',
]);
GuestNotification::factory()->create([
'tenant_id' => $this->tenant->id,
'event_id' => $event->id,
'type' => GuestNotificationType::UPLOAD_ALERT,
'title' => 'Upload Hinweis',
]);
$response = $this->authenticatedRequest('GET', "/api/v1/tenant/events/{$event->slug}/toolkit");
$response->assertOk();
$response->assertJsonPath('notifications.summary.total', 2);
$response->assertJsonPath('notifications.summary.broadcasts.total', 1);
$response->assertJsonCount(2, 'notifications.recent');
}
}