feat: add guest notification insights
This commit is contained in:
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -509,8 +509,30 @@ export type EventToolkit = {
|
||||
};
|
||||
items: EventQrInvite[];
|
||||
};
|
||||
notifications?: {
|
||||
summary: {
|
||||
total: number;
|
||||
last_sent_at: string | null;
|
||||
by_type: Record<string, number>;
|
||||
broadcasts: {
|
||||
total: number;
|
||||
last_title: string | null;
|
||||
last_sent_at: string | null;
|
||||
};
|
||||
};
|
||||
recent: EventToolkitNotification[];
|
||||
};
|
||||
alerts: string[];
|
||||
};
|
||||
|
||||
export type EventToolkitNotification = {
|
||||
id: number;
|
||||
title: string;
|
||||
type: string;
|
||||
status: string;
|
||||
audience_scope: string;
|
||||
created_at: string | null;
|
||||
};
|
||||
type CreatedEventResponse = { message: string; data: JsonValue; balance: number };
|
||||
type PhotoResponse = { message: string; data: TenantPhoto };
|
||||
|
||||
@@ -1238,6 +1260,7 @@ export async function getEventToolkit(slug: string): Promise<EventToolkit> {
|
||||
const tasks = json.tasks ?? {};
|
||||
const photos = json.photos ?? {};
|
||||
const invites = json.invites ?? {};
|
||||
const notifications = normalizeToolkitNotifications(json.notifications ?? null);
|
||||
|
||||
const pendingPhotosRaw = Array.isArray((photos as Record<string, JsonValue>).pending)
|
||||
? (photos as Record<string, JsonValue>).pending
|
||||
@@ -1284,12 +1307,63 @@ export async function getEventToolkit(slug: string): Promise<EventToolkit> {
|
||||
? ((invites as JsonValue).items as JsonValue[]).map((item) => normalizeQrInvite(item))
|
||||
: [],
|
||||
},
|
||||
notifications,
|
||||
alerts: Array.isArray(json.alerts) ? (json.alerts as string[]) : [],
|
||||
};
|
||||
|
||||
return toolkit;
|
||||
}
|
||||
|
||||
function normalizeToolkitNotifications(payload: JsonValue | null | undefined): EventToolkit['notifications'] | undefined {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const record = payload as Record<string, JsonValue>;
|
||||
const summaryRaw = record.summary ?? {};
|
||||
const broadcastsRaw = summaryRaw?.broadcasts ?? {};
|
||||
|
||||
return {
|
||||
summary: {
|
||||
total: Number(summaryRaw?.total ?? 0),
|
||||
last_sent_at: typeof summaryRaw?.last_sent_at === 'string' ? summaryRaw.last_sent_at : null,
|
||||
by_type: (summaryRaw?.by_type ?? {}) as Record<string, number>,
|
||||
broadcasts: {
|
||||
total: Number(broadcastsRaw?.total ?? 0),
|
||||
last_title: typeof broadcastsRaw?.last_title === 'string' ? broadcastsRaw.last_title : null,
|
||||
last_sent_at: typeof broadcastsRaw?.last_sent_at === 'string' ? broadcastsRaw.last_sent_at : null,
|
||||
},
|
||||
},
|
||||
recent: Array.isArray(record.recent)
|
||||
? (record.recent as JsonValue[]).map((row) => normalizeToolkitNotification(row))
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeToolkitNotification(row: JsonValue): EventToolkitNotification {
|
||||
if (!row || typeof row !== 'object') {
|
||||
return {
|
||||
id: 0,
|
||||
title: '',
|
||||
type: 'broadcast',
|
||||
status: 'active',
|
||||
audience_scope: 'all',
|
||||
created_at: null,
|
||||
};
|
||||
}
|
||||
|
||||
const record = row as Record<string, JsonValue>;
|
||||
|
||||
return {
|
||||
id: Number(record.id ?? 0),
|
||||
title: typeof record.title === 'string' ? record.title : '',
|
||||
type: typeof record.type === 'string' ? record.type : 'broadcast',
|
||||
status: typeof record.status === 'string' ? record.status : 'active',
|
||||
audience_scope: typeof record.audience_scope === 'string' ? record.audience_scope : 'all',
|
||||
created_at: typeof record.created_at === 'string' ? record.created_at : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listGuestNotifications(slug: string): Promise<GuestNotificationSummary[]> {
|
||||
const response = await authorizedFetch(guestNotificationsEndpoint(slug));
|
||||
const data = await jsonOrThrow<{ data?: JsonValue[] }>(response, 'Failed to load guest notifications');
|
||||
|
||||
@@ -4,9 +4,11 @@ import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
Bell,
|
||||
Camera,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Clock3,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Printer,
|
||||
@@ -269,13 +271,16 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
|
||||
|
||||
<MetricsGrid metrics={toolkitData?.metrics} stats={stats} />
|
||||
|
||||
<SectionCard className="space-y-4">
|
||||
<SectionCard className="space-y-6">
|
||||
<SectionHeader
|
||||
eyebrow={t('events.notifications.badge', 'Gästefeeds')}
|
||||
title={t('events.notifications.panelTitle', 'Nachrichten an Gäste')}
|
||||
description={t('events.notifications.panelDescription', 'Verschicke kurze Hinweise oder Hilfe an die Gästepwa. Links werden direkt im Notification-Center angezeigt.')}
|
||||
/>
|
||||
<GuestBroadcastCard eventSlug={event.slug} eventName={eventName} />
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
|
||||
<GuestNotificationStatsCard notifications={toolkitData?.notifications} />
|
||||
<GuestBroadcastCard eventSlug={event.slug} eventName={eventName} />
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,0.8fr)]">
|
||||
@@ -883,6 +888,154 @@ function FeedbackCard({ slug }: { slug: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function GuestNotificationStatsCard({ notifications }: { notifications?: EventToolkit['notifications'] }) {
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
if (!notifications || notifications.summary.total === 0) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center rounded-2xl border border-dashed border-slate-200 bg-slate-50 px-4 py-6 text-center text-sm text-slate-500">
|
||||
<MessageSquare className="mb-3 h-6 w-6 text-slate-400" aria-hidden />
|
||||
<p>{t('events.notifications.statsEmpty', 'Noch keine Nachrichten versendet – starte mit einem Broadcast.')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { summary, recent } = notifications;
|
||||
const topTypes = Object.entries(summary.by_type ?? {})
|
||||
.sort(([, a], [, b]) => (b ?? 0) - (a ?? 0))
|
||||
.slice(0, 3);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<StatPill
|
||||
icon={<Bell className="h-4 w-4" />}
|
||||
label={t('events.notifications.statsTotal', 'Gesendete Nachrichten')}
|
||||
value={summary.total}
|
||||
/>
|
||||
<StatPill
|
||||
icon={<MessageSquare className="h-4 w-4" />}
|
||||
label={t('events.notifications.statsBroadcasts', 'Broadcasts')}
|
||||
value={summary.broadcasts.total}
|
||||
sublabel={summary.broadcasts.last_title ?? t('events.notifications.statsBroadcastsEmpty', 'Noch kein Broadcast')}
|
||||
/>
|
||||
<StatPill
|
||||
icon={<Clock3 className="h-4 w-4" />}
|
||||
label={t('events.notifications.statsLastSent', 'Letzte Sendung')}
|
||||
value={summary.last_sent_at ? formatRelativeDateTime(summary.last_sent_at) : t('events.notifications.never', 'Noch nie')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-slate-200 bg-white/80 p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
{t('events.notifications.topTypes', 'Beliebteste Typen')}
|
||||
</p>
|
||||
{topTypes.length > 0 ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{topTypes.map(([type, count]) => (
|
||||
<Badge key={type} variant="secondary" className="gap-2">
|
||||
{getNotificationTypeLabel(type, t)}
|
||||
<span className="rounded-full bg-slate-200 px-2 text-xs font-semibold text-slate-700">{count as number}</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-slate-500">{t('events.notifications.topTypesEmpty', 'Noch keine Verteilung verfügbar.')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-slate-200 bg-white/80 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
{t('events.notifications.recent', 'Letzte Nachrichten')}
|
||||
</p>
|
||||
<span className="text-xs text-slate-400">{recent.length} {t('events.notifications.recentCount', 'Einträge')}</span>
|
||||
</div>
|
||||
{recent.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-slate-500">{t('events.notifications.recentEmpty', 'Noch keine Historie vorhanden.')}</p>
|
||||
) : (
|
||||
<ul className="mt-3 space-y-3">
|
||||
{recent.map((item) => (
|
||||
<li key={item.id} className="rounded-xl border border-slate-100 bg-white/70 px-3 py-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">{item.title || t('events.notifications.untitled', 'Ohne Titel')}</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{formatRelativeDateTime(item.created_at)} · {getAudienceLabel(item.audience_scope, t)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs capitalize">
|
||||
{getNotificationTypeLabel(item.type, t)}
|
||||
</Badge>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatPill({ icon, label, value, sublabel }: { icon: React.ReactNode; label: string; value: string | number; sublabel?: string | null }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-200 bg-white/80 p-4">
|
||||
<div className="flex items-center gap-2 text-slate-500">
|
||||
<span className="rounded-full bg-slate-100 p-2 text-slate-600">{icon}</span>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide">{label}</p>
|
||||
</div>
|
||||
<p className="mt-2 text-2xl font-semibold text-slate-900">{value}</p>
|
||||
{sublabel && <p className="text-xs text-slate-500">{sublabel}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatRelativeDateTime(value?: string | null): string {
|
||||
if (!value) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
return date.toLocaleString(undefined, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function getNotificationTypeLabel(type: string, t: ReturnType<typeof useTranslation>['t']): string {
|
||||
switch (type) {
|
||||
case 'broadcast':
|
||||
return t('events.notifications.types.broadcast', 'Broadcast');
|
||||
case 'upload_alert':
|
||||
return t('events.notifications.types.upload', 'Upload-Status');
|
||||
case 'support_tip':
|
||||
return t('events.notifications.types.support', 'Support-Tipp');
|
||||
case 'feedback_request':
|
||||
return t('events.notifications.types.feedback', 'Feedback');
|
||||
case 'achievement_major':
|
||||
return t('events.notifications.types.achievement', 'Achievement');
|
||||
case 'photo_activity':
|
||||
return t('events.notifications.types.activity', 'Aktivität');
|
||||
default:
|
||||
return t('events.notifications.types.generic', 'System');
|
||||
}
|
||||
}
|
||||
|
||||
function getAudienceLabel(scope: string, t: ReturnType<typeof useTranslation>['t']): string {
|
||||
if (scope === 'guest') {
|
||||
return t('events.notifications.audienceGuest', 'Gezielte Gäste');
|
||||
}
|
||||
|
||||
return t('events.notifications.audienceAll', 'Alle Gäste');
|
||||
}
|
||||
|
||||
function InfoRow({ icon, label, value }: { icon: React.ReactNode; label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-slate-100 bg-white/70 px-3 py-2 text-sm text-slate-700">
|
||||
|
||||
@@ -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 (
|
||||
<div className="relative">
|
||||
@@ -299,13 +326,36 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, chec
|
||||
{t('header.notifications.refresh', 'Aktualisieren')}
|
||||
</button>
|
||||
</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">
|
||||
{center.loading ? (
|
||||
<NotificationSkeleton />
|
||||
) : center.notifications.length === 0 ? (
|
||||
<NotificationEmptyState t={t} />
|
||||
) : filteredNotifications.length === 0 ? (
|
||||
<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
|
||||
key={item.id}
|
||||
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 (
|
||||
<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>
|
||||
<p>{message ?? t('header.notifications.empty', 'Gerade gibt es keine neuen Hinweise.')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ export type NotificationCenterValue = {
|
||||
markAsRead: (id: number) => Promise<void>;
|
||||
dismiss: (id: number) => Promise<void>;
|
||||
eventToken: string;
|
||||
lastFetchedAt: Date | null;
|
||||
isOffline: boolean;
|
||||
};
|
||||
|
||||
const NotificationCenterContext = React.createContext<NotificationCenterValue | null>(null);
|
||||
@@ -30,6 +32,8 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke
|
||||
const [loadingNotifications, setLoadingNotifications] = React.useState(true);
|
||||
const etagRef = React.useRef<string | null>(null);
|
||||
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(
|
||||
() => 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 (
|
||||
|
||||
39
tests/Feature/Tenant/EventToolkitNotificationsTest.php
Normal file
39
tests/Feature/Tenant/EventToolkitNotificationsTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user