Add guest push notifications and queue alerts

This commit is contained in:
Codex Agent
2025-11-12 20:38:49 +01:00
parent 2c412e3764
commit 574aa47ce7
34 changed files with 1806 additions and 74 deletions

View File

@@ -26,6 +26,7 @@ import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
import { DEFAULT_EVENT_BRANDING, useOptionalEventBranding } from '../context/EventBrandingContext';
import { useOptionalNotificationCenter, type NotificationCenterValue } from '../context/NotificationCenterContext';
import { useGuestTaskProgress, TASK_BADGE_TARGET } from '../hooks/useGuestTaskProgress';
import { usePushSubscription } from '../hooks/usePushSubscription';
const EVENT_ICON_COMPONENTS: Record<string, React.ComponentType<{ className?: string }>> = {
heart: Heart,
@@ -224,7 +225,7 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
</div>
</div>
<div className="flex items-center gap-2">
{notificationCenter && (
{notificationCenter && eventToken && (
<NotificationButton
eventToken={eventToken}
center={notificationCenter}
@@ -254,12 +255,15 @@ type NotificationButtonProps = {
t: TranslateFn;
};
type PushState = ReturnType<typeof usePushSubscription>;
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;
const [activeTab, setActiveTab] = React.useState<'unread' | 'all' | 'status'>(center.unreadCount > 0 ? 'unread' : 'all');
const pushState = usePushSubscription(eventToken);
React.useEffect(() => {
if (!open) {
@@ -338,6 +342,7 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, chec
<NotificationStatusBar
lastFetchedAt={center.lastFetchedAt}
isOffline={center.isOffline}
push={pushState}
t={t}
/>
<div className="mt-3 max-h-80 space-y-2 overflow-y-auto pr-1">
@@ -528,7 +533,6 @@ function NotificationCta({ cta, onFollow }: { cta: { label?: string; href?: stri
return (
<a
href={cta.href}
href={href}
target="_blank"
rel="noopener noreferrer"
@@ -623,19 +627,68 @@ function NotificationTabs({
);
}
function NotificationStatusBar({ lastFetchedAt, isOffline, t }: { lastFetchedAt: Date | null; isOffline: boolean; t: TranslateFn }) {
function NotificationStatusBar({
lastFetchedAt,
isOffline,
push,
t,
}: {
lastFetchedAt: Date | null;
isOffline: boolean;
push: PushState;
t: TranslateFn;
}) {
const label = lastFetchedAt ? formatRelativeTime(lastFetchedAt.toISOString()) : t('header.notifications.never', 'Noch keine Aktualisierung');
const pushDescription = React.useMemo(() => {
if (!push.supported) {
return t('header.notifications.pushUnsupported', 'Push wird nicht unterstützt');
}
if (push.permission === 'denied') {
return t('header.notifications.pushDenied', 'Browser blockiert Benachrichtigungen');
}
if (push.subscribed) {
return t('header.notifications.pushActive', 'Push aktiv');
}
return t('header.notifications.pushInactive', 'Push deaktiviert');
}, [push.permission, push.subscribed, push.supported, t]);
const buttonLabel = push.subscribed
? t('header.notifications.pushDisable', 'Deaktivieren')
: t('header.notifications.pushEnable', 'Aktivieren');
const pushButtonDisabled = push.loading || !push.supported || push.permission === 'denied';
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')}
<div className="mt-2 space-y-2 text-[11px] text-slate-500">
<div className="flex items-center justify-between">
<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>
<div className="flex items-center justify-between gap-2 rounded-full bg-slate-100/80 px-3 py-1 text-[11px] font-semibold text-slate-600">
<div className="flex items-center gap-1">
<Bell className="h-3.5 w-3.5" aria-hidden />
<span>{pushDescription}</span>
</div>
<button
type="button"
onClick={() => (push.subscribed ? push.disable() : push.enable())}
disabled={pushButtonDisabled}
className="rounded-full bg-white/80 px-3 py-0.5 text-[11px] font-semibold text-pink-600 shadow disabled:cursor-not-allowed disabled:opacity-60"
>
{push.loading ? t('header.notifications.pushLoading', '…') : buttonLabel}
</button>
</div>
{push.error && (
<p className="text-[11px] font-semibold text-rose-600">
{push.error}
</p>
)}
</div>
);

View File

@@ -125,6 +125,20 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke
};
}, []);
React.useEffect(() => {
const handler = (event: MessageEvent) => {
if (event.data?.type === 'guest-notification-refresh') {
void loadNotifications({ silent: true });
}
};
navigator.serviceWorker?.addEventListener('message', handler);
return () => {
navigator.serviceWorker?.removeEventListener('message', handler);
};
}, [loadNotifications]);
const markAsRead = React.useCallback(
async (id: number) => {
if (!eventToken) {

View File

@@ -0,0 +1,168 @@
import React from 'react';
import { getPushConfig } from '../lib/runtime-config';
import { registerPushSubscription, unregisterPushSubscription } from '../services/pushApi';
type PushSubscriptionState = {
supported: boolean;
permission: NotificationPermission;
subscribed: boolean;
loading: boolean;
error: string | null;
enable: () => Promise<void>;
disable: () => Promise<void>;
refresh: () => Promise<void>;
};
export function usePushSubscription(eventToken?: string): PushSubscriptionState {
const pushConfig = React.useMemo(() => getPushConfig(), []);
const supported = React.useMemo(() => {
return typeof window !== 'undefined'
&& typeof navigator !== 'undefined'
&& typeof Notification !== 'undefined'
&& 'serviceWorker' in navigator
&& 'PushManager' in window
&& pushConfig.enabled;
}, [pushConfig.enabled]);
const [permission, setPermission] = React.useState<NotificationPermission>(() => {
if (typeof Notification === 'undefined') {
return 'default';
}
return Notification.permission;
});
const [subscription, setSubscription] = React.useState<PushSubscription | null>(null);
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const refresh = React.useCallback(async () => {
if (!supported || !eventToken) {
return;
}
try {
const registration = await navigator.serviceWorker.ready;
const current = await registration.pushManager.getSubscription();
setSubscription(current);
} catch (err) {
console.warn('Unable to refresh push subscription', err);
setSubscription(null);
}
}, [eventToken, supported]);
React.useEffect(() => {
if (!supported) {
return;
}
void refresh();
const handleMessage = (event: MessageEvent) => {
if (event.data?.type === 'push-subscription-change') {
void refresh();
}
};
navigator.serviceWorker?.addEventListener('message', handleMessage);
return () => {
navigator.serviceWorker?.removeEventListener('message', handleMessage);
};
}, [refresh, supported]);
const enable = React.useCallback(async () => {
if (!supported || !eventToken) {
setError('Push-Benachrichtigungen werden auf diesem Gerät nicht unterstützt.');
return;
}
setLoading(true);
setError(null);
try {
const permissionResult = await Notification.requestPermission();
setPermission(permissionResult);
if (permissionResult !== 'granted') {
throw new Error('Bitte erlaube Benachrichtigungen, um Push zu aktivieren.');
}
const registration = await navigator.serviceWorker.ready;
const existing = await registration.pushManager.getSubscription();
if (existing) {
await registerPushSubscription(eventToken, existing);
setSubscription(existing);
return;
}
if (!pushConfig.vapidPublicKey) {
throw new Error('Push-Konfiguration ist nicht vollständig.');
}
const newSubscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(pushConfig.vapidPublicKey),
});
await registerPushSubscription(eventToken, newSubscription);
setSubscription(newSubscription);
} catch (err) {
const message = err instanceof Error ? err.message : 'Push konnte nicht aktiviert werden.';
setError(message);
console.error(err);
await refresh();
} finally {
setLoading(false);
}
}, [eventToken, pushConfig.vapidPublicKey, refresh, supported]);
const disable = React.useCallback(async () => {
if (!supported || !eventToken || !subscription) {
return;
}
setLoading(true);
setError(null);
try {
await unregisterPushSubscription(eventToken, subscription.endpoint);
await subscription.unsubscribe();
setSubscription(null);
} catch (err) {
const message = err instanceof Error ? err.message : 'Push konnte nicht deaktiviert werden.';
setError(message);
console.error(err);
} finally {
setLoading(false);
}
}, [eventToken, subscription, supported]);
return {
supported,
permission,
subscribed: Boolean(subscription),
loading,
error,
enable,
disable,
refresh,
};
}
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = typeof window !== 'undefined'
? window.atob(base64)
: Buffer.from(base64, 'base64').toString('binary');
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; i += 1) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}

View File

@@ -0,0 +1,24 @@
type PushConfig = {
enabled: boolean;
vapidPublicKey: string | null;
};
type RuntimeConfig = {
push: PushConfig;
};
export function getRuntimeConfig(): RuntimeConfig {
const raw = typeof window !== 'undefined' ? window.__GUEST_RUNTIME_CONFIG__ : undefined;
return {
push: {
enabled: Boolean(raw?.push?.enabled),
vapidPublicKey: raw?.push?.vapidPublicKey ?? null,
},
};
}
export function getPushConfig(): PushConfig {
return getRuntimeConfig().push;
}

View File

@@ -0,0 +1,71 @@
import { getDeviceId } from '../lib/device';
type PushSubscriptionPayload = {
endpoint: string;
keys: {
p256dh: string;
auth: string;
};
expirationTime?: number | null;
contentEncoding?: string | null;
};
function buildHeaders(): HeadersInit {
return {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-Device-Id': getDeviceId(),
};
}
export async function registerPushSubscription(eventToken: string, subscription: PushSubscription): Promise<void> {
const json = subscription.toJSON() as PushSubscriptionPayload;
const body = {
endpoint: json.endpoint,
keys: json.keys,
expiration_time: json.expirationTime ?? null,
content_encoding: json.contentEncoding ?? null,
};
const response = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/push-subscriptions`, {
method: 'POST',
headers: buildHeaders(),
credentials: 'include',
body: JSON.stringify(body),
});
if (!response.ok) {
const message = await parseError(response);
throw new Error(message ?? 'Push-Registrierung fehlgeschlagen.');
}
}
export async function unregisterPushSubscription(eventToken: string, endpoint: string): Promise<void> {
const response = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/push-subscriptions`, {
method: 'DELETE',
headers: buildHeaders(),
credentials: 'include',
body: JSON.stringify({ endpoint }),
});
if (!response.ok) {
const message = await parseError(response);
throw new Error(message ?? 'Push konnte nicht deaktiviert werden.');
}
}
async function parseError(response: Response): Promise<string | null> {
try {
const payload = await response.clone().json();
const errorMessage = payload?.error?.message ?? payload?.message;
if (typeof errorMessage === 'string' && errorMessage.trim() !== '') {
return errorMessage;
}
} catch (error) {
console.warn('Failed to parse push API error', error);
}
return null;
}

13
resources/js/guest/types/global.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
export {};
declare global {
interface Window {
__GUEST_RUNTIME_CONFIG__?: {
push?: {
enabled?: boolean;
vapidPublicKey?: string | null;
};
};
}
}

View File

@@ -7,6 +7,14 @@
<meta name="csrf-token" content="{{ csrf_token() }}">
@viteReactRefresh
@vite(['resources/css/app.css', 'resources/js/guest/main.tsx'])
<script>
window.__GUEST_RUNTIME_CONFIG__ = @json([
'push' => [
'enabled' => config('push.enabled', false),
'vapidPublicKey' => config('push.vapid.public_key'),
],
]);
</script>
</head>
<body>
<div id="root"></div>