diff --git a/app/Http/Controllers/Api/Tenant/EventController.php b/app/Http/Controllers/Api/Tenant/EventController.php index ab40d75..c45029d 100644 --- a/app/Http/Controllers/Api/Tenant/EventController.php +++ b/app/Http/Controllers/Api/Tenant/EventController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api\Tenant; +use App\Enums\GuestNotificationType; use App\Http\Controllers\Controller; use App\Http\Requests\Tenant\EventStoreRequest; use App\Http\Resources\Tenant\EventJoinTokenResource; @@ -9,6 +10,7 @@ use App\Http\Resources\Tenant\EventResource; use App\Http\Resources\Tenant\PhotoResource; use App\Models\Event; use App\Models\EventPackage; +use App\Models\GuestNotification; use App\Models\Package; use App\Models\Photo; use App\Models\Tenant; @@ -426,6 +428,47 @@ class EventController extends Controller ->take(3) ->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 = []; if (($event->settings['engagement_mode'] ?? 'tasks') !== 'photo_only' && $taskSummary['total'] === 0) { $alerts[] = 'no_tasks'; @@ -461,6 +504,7 @@ class EventController extends Controller ], 'items' => EventJoinTokenResource::collection($recentInvites)->resolve($request), ], + 'notifications' => $notificationsPayload, 'alerts' => $alerts, ]); } diff --git a/docs/prp/06-tenant-admin-pwa.md b/docs/prp/06-tenant-admin-pwa.md index bfbeffd..6a82d89 100644 --- a/docs/prp/06-tenant-admin-pwa.md +++ b/docs/prp/06-tenant-admin-pwa.md @@ -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. - 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. +- 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) - Wenn Tenant-Admins Upload- oder Gäste-Limits erreichen, zeigt der Header Warn-Badges + Toast mit derselben Fehlermeldung wie im Backend (`code`, `title`, `message`). diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index 6589666..2cbb53f 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -509,8 +509,30 @@ export type EventToolkit = { }; items: EventQrInvite[]; }; + notifications?: { + summary: { + total: number; + last_sent_at: string | null; + by_type: Record; + 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 { 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).pending) ? (photos as Record).pending @@ -1284,12 +1307,63 @@ export async function getEventToolkit(slug: string): Promise { ? ((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; + 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, + 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; + + 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 { const response = await authorizedFetch(guestNotificationsEndpoint(slug)); const data = await jsonOrThrow<{ data?: JsonValue[] }>(response, 'Failed to load guest notifications'); diff --git a/resources/js/admin/pages/EventDetailPage.tsx b/resources/js/admin/pages/EventDetailPage.tsx index 6d1953c..af3963f 100644 --- a/resources/js/admin/pages/EventDetailPage.tsx +++ b/resources/js/admin/pages/EventDetailPage.tsx @@ -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 - + - +
+ + +
@@ -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 ( +
+ +

{t('events.notifications.statsEmpty', 'Noch keine Nachrichten versendet – starte mit einem Broadcast.')}

+
+ ); + } + + const { summary, recent } = notifications; + const topTypes = Object.entries(summary.by_type ?? {}) + .sort(([, a], [, b]) => (b ?? 0) - (a ?? 0)) + .slice(0, 3); + + return ( +
+
+ } + label={t('events.notifications.statsTotal', 'Gesendete Nachrichten')} + value={summary.total} + /> + } + label={t('events.notifications.statsBroadcasts', 'Broadcasts')} + value={summary.broadcasts.total} + sublabel={summary.broadcasts.last_title ?? t('events.notifications.statsBroadcastsEmpty', 'Noch kein Broadcast')} + /> + } + label={t('events.notifications.statsLastSent', 'Letzte Sendung')} + value={summary.last_sent_at ? formatRelativeDateTime(summary.last_sent_at) : t('events.notifications.never', 'Noch nie')} + /> +
+ +
+

+ {t('events.notifications.topTypes', 'Beliebteste Typen')} +

+ {topTypes.length > 0 ? ( +
+ {topTypes.map(([type, count]) => ( + + {getNotificationTypeLabel(type, t)} + {count as number} + + ))} +
+ ) : ( +

{t('events.notifications.topTypesEmpty', 'Noch keine Verteilung verfügbar.')}

+ )} +
+ +
+
+

+ {t('events.notifications.recent', 'Letzte Nachrichten')} +

+ {recent.length} {t('events.notifications.recentCount', 'Einträge')} +
+ {recent.length === 0 ? ( +

{t('events.notifications.recentEmpty', 'Noch keine Historie vorhanden.')}

+ ) : ( +
    + {recent.map((item) => ( +
  • +
    +
    +

    {item.title || t('events.notifications.untitled', 'Ohne Titel')}

    +

    + {formatRelativeDateTime(item.created_at)} · {getAudienceLabel(item.audience_scope, t)} +

    +
    + + {getNotificationTypeLabel(item.type, t)} + +
    +
  • + ))} +
+ )} +
+
+ ); +} + +function StatPill({ icon, label, value, sublabel }: { icon: React.ReactNode; label: string; value: string | number; sublabel?: string | null }) { + return ( +
+
+ {icon} +

{label}

+
+

{value}

+ {sublabel &&

{sublabel}

} +
+ ); +} + +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['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['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 (
diff --git a/resources/js/guest/components/Header.tsx b/resources/js/guest/components/Header.tsx index b9defd4..40abdde 100644 --- a/resources/js/guest/components/Header.tsx +++ b/resources/js/guest/components/Header.tsx @@ -259,6 +259,33 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, chec const progressRatio = taskProgress ? Math.min(1, taskProgress.completedCount / TASK_BADGE_TARGET) : 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 (
@@ -299,13 +326,36 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, chec {t('header.notifications.refresh', 'Aktualisieren')}
+ setActiveTab(next as typeof activeTab)} + /> +
{center.loading ? ( - ) : center.notifications.length === 0 ? ( - + ) : filteredNotifications.length === 0 ? ( + ) : ( - center.notifications.map((item) => ( + filteredNotifications.map((item) => ( -

{t('header.notifications.empty', 'Gerade gibt es keine neuen Hinweise.')}

+

{message ?? t('header.notifications.empty', 'Gerade gibt es keine neuen Hinweise.')}

); } @@ -542,3 +592,51 @@ function formatRelativeTime(value: string): string { const diffDays = Math.round(diffHours / 24); return `${diffDays} d`; } + +function NotificationTabs({ + tabs, + activeTab, + onTabChange, +}: { + tabs: Array<{ key: string; label: string; badge?: number }>; + activeTab: string; + onTabChange: (key: string) => void; +}) { + return ( +
+ {tabs.map((tab) => ( + + ))} +
+ ); +} + +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 ( +
+ + {t('header.notifications.lastSync', 'Zuletzt aktualisiert')}: {label} + + {isOffline && ( + + + {t('header.notifications.offline', 'Offline')} + + )} +
+ ); +} diff --git a/resources/js/guest/context/NotificationCenterContext.tsx b/resources/js/guest/context/NotificationCenterContext.tsx index 48197a1..6ec6e7e 100644 --- a/resources/js/guest/context/NotificationCenterContext.tsx +++ b/resources/js/guest/context/NotificationCenterContext.tsx @@ -19,6 +19,8 @@ export type NotificationCenterValue = { markAsRead: (id: number) => Promise; dismiss: (id: number) => Promise; eventToken: string; + lastFetchedAt: Date | null; + isOffline: boolean; }; const NotificationCenterContext = React.createContext(null); @@ -30,6 +32,8 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke const [loadingNotifications, setLoadingNotifications] = React.useState(true); const etagRef = React.useRef(null); const fetchLockRef = React.useRef(false); + const [lastFetchedAt, setLastFetchedAt] = React.useState(null); + const [isOffline, setIsOffline] = React.useState(typeof navigator !== 'undefined' ? !navigator.onLine : false); const queueCount = React.useMemo( () => items.filter((item) => item.status !== 'done').length, @@ -59,14 +63,19 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke if (!result.notModified) { setNotifications(result.notifications); setUnreadCount(result.unreadCount); + setLastFetchedAt(new Date()); } etagRef.current = result.etag; + setIsOffline(false); } catch (error) { console.error('Failed to load guest notifications', error); if (!options.silent) { setNotifications([]); setUnreadCount(0); } + if (typeof navigator !== 'undefined' && !navigator.onLine) { + setIsOffline(true); + } } finally { fetchLockRef.current = false; if (!options.silent) { @@ -103,6 +112,19 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke return () => window.clearInterval(interval); }, [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( async (id: number) => { if (!eventToken) { @@ -199,6 +221,8 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke markAsRead, dismiss, eventToken, + lastFetchedAt, + isOffline, }; return ( diff --git a/tests/Feature/Tenant/EventToolkitNotificationsTest.php b/tests/Feature/Tenant/EventToolkitNotificationsTest.php new file mode 100644 index 0000000..3810ed7 --- /dev/null +++ b/tests/Feature/Tenant/EventToolkitNotificationsTest.php @@ -0,0 +1,39 @@ +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'); + } +}