Add guest push notifications and queue alerts
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
168
resources/js/guest/hooks/usePushSubscription.ts
Normal file
168
resources/js/guest/hooks/usePushSubscription.ts
Normal 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;
|
||||
}
|
||||
24
resources/js/guest/lib/runtime-config.ts
Normal file
24
resources/js/guest/lib/runtime-config.ts
Normal 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;
|
||||
}
|
||||
|
||||
71
resources/js/guest/services/pushApi.ts
Normal file
71
resources/js/guest/services/pushApi.ts
Normal 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
13
resources/js/guest/types/global.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__GUEST_RUNTIME_CONFIG__?: {
|
||||
push?: {
|
||||
enabled?: boolean;
|
||||
vapidPublicKey?: string | null;
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user