feat: add guest notification center

This commit is contained in:
Codex Agent
2025-11-12 16:56:50 +01:00
parent 062932ce38
commit 4495ac1895
27 changed files with 2042 additions and 64 deletions

View File

@@ -1,14 +1,30 @@
import React from 'react';
import { Link } from 'react-router-dom';
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
import { User, Heart, Users, PartyPopper, Camera, Bell, ArrowUpRight } from 'lucide-react';
import {
User,
Heart,
Users,
PartyPopper,
Camera,
Bell,
ArrowUpRight,
MessageSquare,
Sparkles,
LifeBuoy,
UploadCloud,
AlertCircle,
Check,
X,
RefreshCw,
} from 'lucide-react';
import { useEventData } from '../hooks/useEventData';
import { useOptionalEventStats } from '../context/EventStatsContext';
import { useOptionalGuestIdentity } from '../context/GuestIdentityContext';
import { SettingsSheet } from './settings-sheet';
import { useTranslation } from '../i18n/useTranslation';
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
import { DEFAULT_EVENT_BRANDING, useOptionalEventBranding } from '../context/EventBrandingContext';
import { useOptionalNotificationCenter } from '../context/NotificationCenterContext';
import { useOptionalNotificationCenter, type NotificationCenterValue } from '../context/NotificationCenterContext';
import { useGuestTaskProgress, TASK_BADGE_TARGET } from '../hooks/useGuestTaskProgress';
const EVENT_ICON_COMPONENTS: Record<string, React.ComponentType<{ className?: string }>> = {
@@ -18,6 +34,15 @@ const EVENT_ICON_COMPONENTS: Record<string, React.ComponentType<{ className?: st
camera: Camera,
};
const NOTIFICATION_ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
broadcast: MessageSquare,
feedback_request: MessageSquare,
achievement_major: Sparkles,
support_tip: LifeBuoy,
upload_alert: UploadCloud,
photo_activity: Camera,
};
function isLikelyEmoji(value: string): boolean {
if (!value) {
return false;
@@ -208,6 +233,7 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
panelRef={panelRef}
checklistItems={checklistItems}
taskProgress={taskProgress?.hydrated ? taskProgress : undefined}
t={t}
/>
)}
<AppearanceToggleDropdown />
@@ -217,32 +243,19 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
);
}
function NotificationButton({
center,
eventToken,
open,
onToggle,
panelRef,
checklistItems,
taskProgress,
}: {
center: {
queueCount: number;
inviteCount: number;
totalCount: number;
};
type NotificationButtonProps = {
center: NotificationCenterValue;
eventToken: string;
open: boolean;
onToggle: () => void;
panelRef: React.RefObject<HTMLDivElement>;
checklistItems: string[];
taskProgress?: ReturnType<typeof useGuestTaskProgress>;
}) {
if (!center) {
return null;
}
t: TranslateFn;
};
const totalCount = center.totalCount;
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;
@@ -253,34 +266,81 @@ function NotificationButton({
type="button"
onClick={onToggle}
className="relative rounded-full bg-white/15 p-2 text-white transition hover:bg-white/30"
aria-label="Benachrichtigungen anzeigen"
aria-label={t('header.notifications.open', 'Benachrichtigungen anzeigen')}
>
<Bell className="h-5 w-5" aria-hidden />
{totalCount > 0 && (
{badgeCount > 0 && (
<span className="absolute -right-1 -top-1 rounded-full bg-white px-1.5 text-[10px] font-semibold text-pink-600">
{totalCount}
{badgeCount > 9 ? '9+' : badgeCount}
</span>
)}
</button>
{open && (
<div
ref={panelRef}
className="absolute right-0 mt-2 w-72 rounded-2xl border border-white/30 bg-white/95 p-4 text-slate-900 shadow-2xl"
className="absolute right-0 mt-2 w-80 rounded-2xl border border-white/30 bg-white/95 p-4 text-slate-900 shadow-2xl"
>
<p className="text-sm font-semibold text-slate-900">Benachrichtigungen</p>
<p className="text-xs text-slate-500">Uploads in Warteschlange: {center.queueCount}</p>
<Link
to={`/e/${encodeURIComponent(eventToken)}/queue`}
className="mt-2 flex items-center justify-between rounded-xl border border-slate-200 px-3 py-2 text-sm font-semibold text-pink-600 transition hover:border-pink-300"
>
Zur Warteschlange
<ArrowUpRight className="h-4 w-4" aria-hidden />
</Link>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-slate-900">{t('header.notifications.title', 'Benachrichtigungen')}</p>
<p className="text-xs text-slate-500">
{center.unreadCount > 0
? t('header.notifications.unread', '{{count}} neu', { count: center.unreadCount })
: t('header.notifications.allRead', 'Alles gelesen')}
</p>
</div>
<button
type="button"
onClick={() => center.refresh()}
disabled={center.loading}
className="flex items-center gap-1 rounded-full border border-slate-200 px-2 py-1 text-xs font-semibold text-slate-600 transition hover:border-pink-300 disabled:cursor-not-allowed"
>
<RefreshCw className={`h-3.5 w-3.5 ${center.loading ? 'animate-spin' : ''}`} aria-hidden />
{t('header.notifications.refresh', 'Aktualisieren')}
</button>
</div>
<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} />
) : (
center.notifications.map((item) => (
<NotificationListItem
key={item.id}
item={item}
onMarkRead={() => center.markAsRead(item.id)}
onDismiss={() => center.dismiss(item.id)}
t={t}
/>
))
)}
</div>
<div className="mt-4 rounded-2xl border border-slate-200 bg-slate-50/80 p-3">
<div className="flex items-center justify-between text-sm">
<span className="text-slate-600">
{t('header.notifications.queueLabel', 'Uploads in Warteschlange')}
</span>
<span className="font-semibold text-slate-900">{center.queueCount}</span>
</div>
<Link
to={`/e/${encodeURIComponent(eventToken)}/queue`}
className="mt-2 inline-flex items-center gap-1 text-sm font-semibold text-pink-600"
onClick={() => {
if (center.unreadCount > 0) {
void center.refresh();
}
}}
>
{t('header.notifications.queueCta', 'Upload-Verlauf öffnen')}
<ArrowUpRight className="h-4 w-4" aria-hidden />
</Link>
</div>
{taskProgress && (
<div className="mt-3 rounded-2xl border border-slate-200 bg-slate-50/90 p-3">
<div className="flex items-center justify-between">
<div>
<p className="text-xs uppercase tracking-[0.3em] text-slate-400">Badge-Fortschritt</p>
<p className="text-xs uppercase tracking-[0.3em] text-slate-400">{t('header.notifications.badgeLabel', 'Badge-Fortschritt')}</p>
<p className="text-lg font-semibold text-slate-900">
{taskProgress.completedCount}/{TASK_BADGE_TARGET}
</p>
@@ -289,7 +349,7 @@ function NotificationButton({
to={`/e/${encodeURIComponent(eventToken)}/tasks`}
className="rounded-full border border-slate-200 px-3 py-1 text-xs font-semibold text-pink-600 transition hover:border-pink-300"
>
Weiter
{t('header.notifications.tasksCta', 'Weiter')}
</Link>
</div>
<div className="mt-3 h-1.5 w-full rounded-full bg-slate-100">
@@ -301,7 +361,7 @@ function NotificationButton({
</div>
)}
<div className="my-3 h-px w-full bg-slate-100" />
<p className="text-[11px] uppercase tracking-[0.3em] text-slate-400">So funktionierts</p>
<p className="text-[11px] uppercase tracking-[0.3em] text-slate-400">{t('header.notifications.checklistTitle', 'So funktionierts')}</p>
<ul className="mt-2 space-y-2 text-sm text-slate-600">
{checklistItems.map((item) => (
<li key={item} className="flex gap-2">
@@ -315,3 +375,170 @@ function NotificationButton({
</div>
);
}
function NotificationListItem({
item,
onMarkRead,
onDismiss,
t,
}: {
item: NotificationCenterValue['notifications'][number];
onMarkRead: () => void;
onDismiss: () => void;
t: TranslateFn;
}) {
const IconComponent = NOTIFICATION_ICON_MAP[item.type] ?? Bell;
const isNew = item.status === 'new';
const createdLabel = item.createdAt ? formatRelativeTime(item.createdAt) : '';
return (
<div
className={`rounded-2xl border px-3 py-2.5 transition ${isNew ? 'border-pink-200 bg-pink-50/70' : 'border-slate-200 bg-white/90'}`}
onClick={() => {
if (isNew) {
onMarkRead();
}
}}
>
<div className="flex items-start gap-3">
<div className={`rounded-full p-1.5 ${isNew ? 'bg-white text-pink-600' : 'bg-slate-100 text-slate-500'}`}>
<IconComponent className="h-4 w-4" aria-hidden />
</div>
<div className="flex-1 space-y-1">
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-sm font-semibold text-slate-900">{item.title}</p>
{item.body && <p className="text-xs text-slate-600">{item.body}</p>}
</div>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onDismiss();
}}
className="rounded-full p-1 text-slate-400 transition hover:text-slate-700"
aria-label={t('header.notifications.dismiss', 'Ausblenden')}
>
<X className="h-3.5 w-3.5" aria-hidden />
</button>
</div>
<div className="flex items-center gap-2 text-[11px] text-slate-400">
{createdLabel && <span>{createdLabel}</span>}
{isNew && (
<span className="inline-flex items-center gap-1 rounded-full bg-pink-100 px-1.5 py-0.5 text-[10px] font-semibold text-pink-600">
<Sparkles className="h-3 w-3" aria-hidden />
{t('header.notifications.badge.new', 'Neu')}
</span>
)}
</div>
{item.cta && (
<NotificationCta cta={item.cta} onFollow={onMarkRead} />
)}
{!isNew && item.status !== 'dismissed' && (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onMarkRead();
}}
className="inline-flex items-center gap-1 text-[11px] font-semibold text-pink-600"
>
<Check className="h-3 w-3" aria-hidden />
{t('header.notifications.markRead', 'Als gelesen markieren')}
</button>
)}
</div>
</div>
</div>
);
}
function NotificationCta({ cta, onFollow }: { cta: { label?: string; href?: string }; onFollow: () => void }) {
const href = cta.href ?? '#';
const label = cta.label ?? '';
const isInternal = /^\//.test(href);
const content = (
<span className="inline-flex items-center gap-1">
{label}
<ArrowUpRight className="h-3.5 w-3.5" aria-hidden />
</span>
);
if (isInternal) {
return (
<Link
to={href}
className="inline-flex items-center gap-1 text-sm font-semibold text-pink-600"
onClick={onFollow}
>
{content}
</Link>
);
}
return (
<a
href={cta.href}
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm font-semibold text-pink-600"
onClick={onFollow}
>
{content}
</a>
);
}
function NotificationEmptyState({ t }: { t: TranslateFn }) {
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>
</div>
);
}
function NotificationSkeleton() {
return (
<div className="space-y-2">
{[0, 1, 2].map((index) => (
<div key={index} className="animate-pulse rounded-2xl border border-slate-200 bg-slate-100/60 p-3">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-slate-200" />
<div className="flex-1 space-y-2">
<div className="h-3 w-3/4 rounded bg-slate-200" />
<div className="h-3 w-1/2 rounded bg-slate-200" />
</div>
</div>
</div>
))}
</div>
);
}
function formatRelativeTime(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
const diffMs = Date.now() - date.getTime();
const diffMinutes = Math.max(0, Math.round(diffMs / 60000));
if (diffMinutes < 1) {
return 'Gerade eben';
}
if (diffMinutes < 60) {
return `${diffMinutes} min`;
}
const diffHours = Math.round(diffMinutes / 60);
if (diffHours < 24) {
return `${diffHours} h`;
}
const diffDays = Math.round(diffHours / 24);
return `${diffDays} d`;
}