import React from 'react'; import { createPortal } from 'react-dom'; import { Link } from 'react-router-dom'; import AppearanceToggleDropdown from '@/components/appearance-dropdown'; import { User, Heart, Users, PartyPopper, Camera, Bell, ArrowUpRight, Clock, MessageSquare, Sparkles, LifeBuoy, UploadCloud, AlertCircle, Check, X, RefreshCw, } from 'lucide-react'; import { useEventData } from '../hooks/useEventData'; import { useOptionalEventStats } from '../context/EventStatsContext'; import { SettingsSheet } from './settings-sheet'; import { useTranslation, type TranslateFn } from '../i18n/useTranslation'; import { DEFAULT_EVENT_BRANDING, useOptionalEventBranding } from '../context/EventBrandingContext'; import { useOptionalNotificationCenter, type NotificationCenterValue } from '../context/NotificationCenterContext'; import { usePushSubscription } from '../hooks/usePushSubscription'; import { getContrastingTextColor, relativeLuminance, hexToRgb } from '../lib/color'; import { isTaskModeEnabled } from '../lib/engagement'; const EVENT_ICON_COMPONENTS: Record> = { heart: Heart, guests: Users, party: PartyPopper, camera: Camera, }; type LogoSize = 's' | 'm' | 'l'; const LOGO_SIZE_CLASSES: Record = { s: { container: 'h-8 w-8', image: 'h-7 w-7', emoji: 'text-lg', icon: 'h-4 w-4', initials: 'text-[11px]' }, m: { container: 'h-10 w-10', image: 'h-9 w-9', emoji: 'text-xl', icon: 'h-5 w-5', initials: 'text-sm' }, l: { container: 'h-12 w-12', image: 'h-11 w-11', emoji: 'text-2xl', icon: 'h-6 w-6', initials: 'text-base' }, }; function getLogoClasses(size?: LogoSize) { return LOGO_SIZE_CLASSES[size ?? 'm']; } const NOTIFICATION_ICON_MAP: Record> = { 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; } const characters = Array.from(value.trim()); if (characters.length === 0 || characters.length > 2) { return false; } return characters.some((char) => { const codePoint = char.codePointAt(0) ?? 0; return codePoint > 0x2600; }); } function getInitials(name: string): string { const words = name.split(' ').filter(Boolean); if (words.length >= 2) { return `${words[0][0]}${words[1][0]}`.toUpperCase(); } return name.substring(0, 2).toUpperCase(); } function toRgba(value: string, alpha: number): string { const rgb = hexToRgb(value); if (!rgb) { return `rgba(255, 255, 255, ${alpha})`; } return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`; } function EventAvatar({ name, icon, accentColor, textColor, logo, }: { name: string; icon: unknown; accentColor: string; textColor: string; logo?: { mode: 'emoticon' | 'upload'; value: string | null; size?: LogoSize }; }) { const logoValue = logo?.mode === 'upload' ? (logo.value?.trim() || null) : null; const [logoFailed, setLogoFailed] = React.useState(false); React.useEffect(() => { setLogoFailed(false); }, [logoValue]); const sizes = getLogoClasses(logo?.size); if (logo?.mode === 'upload' && logoValue && !logoFailed) { return (
{name} setLogoFailed(true)} />
); } if (logo?.mode === 'emoticon' && logo.value && isLikelyEmoji(logo.value)) { return (
{logo.value} {name}
); } if (typeof icon === 'string') { const trimmed = icon.trim(); if (trimmed) { const normalized = trimmed.toLowerCase(); const IconComponent = EVENT_ICON_COMPONENTS[normalized]; if (IconComponent) { return (
); } if (isLikelyEmoji(trimmed)) { return (
{trimmed} {name}
); } } } return (
{getInitials(name)}
); } export default function Header({ eventToken, title = '' }: { eventToken?: string; title?: string }) { const statsContext = useOptionalEventStats(); const { t } = useTranslation(); const brandingContext = useOptionalEventBranding(); const branding = brandingContext?.branding ?? DEFAULT_EVENT_BRANDING; const headerTextColor = React.useMemo(() => { const primaryLum = relativeLuminance(branding.primaryColor); const secondaryLum = relativeLuminance(branding.secondaryColor); const avgLum = (primaryLum + secondaryLum) / 2; if (avgLum > 0.55) { return getContrastingTextColor(branding.primaryColor, '#0f172a', '#ffffff'); } return '#ffffff'; }, [branding.primaryColor, branding.secondaryColor]); const { event, status } = useEventData(); const notificationCenter = useOptionalNotificationCenter(); const [notificationsOpen, setNotificationsOpen] = React.useState(false); const tasksEnabled = isTaskModeEnabled(event); const panelRef = React.useRef(null); const notificationButtonRef = React.useRef(null); React.useEffect(() => { if (!notificationsOpen) { return; } const handler = (event: MouseEvent) => { if (notificationButtonRef.current?.contains(event.target as Node)) { return; } if (!panelRef.current) return; if (panelRef.current.contains(event.target as Node)) return; setNotificationsOpen(false); }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [notificationsOpen]); const headerFont = branding.typography?.heading ?? branding.fontFamily ?? undefined; const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined; const logoPosition = branding.logo?.position ?? 'left'; const headerStyle: React.CSSProperties = { background: `linear-gradient(135deg, ${branding.primaryColor}, ${branding.secondaryColor})`, color: headerTextColor, fontFamily: headerFont, }; const headerGlowPrimary = toRgba(branding.primaryColor, 0.35); const headerGlowSecondary = toRgba(branding.secondaryColor, 0.35); const headerShimmer = `linear-gradient(120deg, ${toRgba(branding.primaryColor, 0.28)}, transparent 45%, ${toRgba(branding.secondaryColor, 0.32)})`; const headerHairline = `linear-gradient(90deg, transparent, ${toRgba(headerTextColor, 0.4)}, transparent)`; if (!eventToken) { return (
{title}
); } const accentColor = branding.secondaryColor; if (status === 'loading') { return (
{t('header.loading')}
); } if (status !== 'ready' || !event) { return null; } const stats = statsContext && statsContext.eventKey === eventToken ? statsContext : undefined; return (
{event.name}
{stats && tasksEnabled && ( <> {`${stats.onlineGuests} ${t('header.stats.online')}`} | {stats.tasksSolved}{' '} {t('header.stats.tasksSolved')} )}
{notificationCenter && eventToken && ( setNotificationsOpen((prev) => !prev)} panelRef={panelRef} buttonRef={notificationButtonRef} t={t} /> )}
); } type NotificationButtonProps = { center: NotificationCenterValue; eventToken: string; open: boolean; onToggle: () => void; panelRef: React.RefObject; buttonRef: React.RefObject; t: TranslateFn; }; type PushState = ReturnType; function NotificationButton({ center, eventToken, open, onToggle, panelRef, buttonRef, t }: NotificationButtonProps) { const badgeCount = center.unreadCount; const [activeTab, setActiveTab] = React.useState<'unread' | 'all' | 'uploads'>(center.unreadCount > 0 ? 'unread' : 'all'); const [scopeFilter, setScopeFilter] = React.useState<'all' | 'tips' | 'general'>('all'); const pushState = usePushSubscription(eventToken); 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(() => { let base: typeof center.notifications = []; switch (activeTab) { case 'unread': base = unreadNotifications; break; case 'uploads': base = uploadNotifications; break; default: base = center.notifications; } return base; }, [activeTab, center.notifications, unreadNotifications, uploadNotifications]); const scopedNotifications = React.useMemo(() => { if (activeTab === 'uploads' || scopeFilter === 'all') { return filteredNotifications; } return filteredNotifications.filter((item) => { if (scopeFilter === 'tips') { return item.type === 'support_tip' || item.type === 'achievement_major'; } return item.type === 'broadcast' || item.type === 'feedback_request'; }); }, [filteredNotifications, scopeFilter]); return (
{open && createPortal(

{t('header.notifications.title', 'Updates')}

{center.unreadCount > 0 ? t('header.notifications.unread', { defaultValue: '{count} neu', count: center.unreadCount }) : t('header.notifications.allRead', 'Alles gelesen')}

setActiveTab(next as typeof activeTab)} /> {activeTab !== 'uploads' && (
{( [ { key: 'all', label: t('header.notifications.scope.all', 'Alle') }, { key: 'tips', label: t('header.notifications.scope.tips', 'Tipps & Achievements') }, { key: 'general', label: t('header.notifications.scope.general', 'Allgemein') }, ] as const ).map((option) => ( ))}
)} {activeTab === 'uploads' && (center.pendingCount > 0 || center.queueCount > 0) && (
{center.pendingCount > 0 && (
{t('header.notifications.pendingLabel', 'Uploads in Prüfung')} {center.pendingCount}
{ if (center.unreadCount > 0) { void center.refresh(); } }} > {t('header.notifications.pendingCta', 'Details')}
)} {center.queueCount > 0 && (
{t('header.notifications.queueLabel', 'Upload-Warteschlange (offline)')} {center.queueCount}
)}
)}
{center.loading ? ( ) : scopedNotifications.length === 0 ? ( ) : ( scopedNotifications.map((item) => ( center.markAsRead(item.id)} onDismiss={() => center.dismiss(item.id)} t={t} /> )) )}
, (typeof document !== 'undefined' ? document.body : null) as any )}
); } 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 (
{ if (isNew) { onMarkRead(); } }} >

{item.title}

{item.body &&

{item.body}

}
{createdLabel && {createdLabel}} {isNew && ( {t('header.notifications.badge.new', 'Neu')} )}
{item.cta && ( )} {!isNew && item.status !== 'dismissed' && ( )}
); } 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 = ( {label} ); if (isInternal) { return ( {content} ); } return ( {content} ); } function NotificationEmptyState({ t, message }: { t: TranslateFn; message?: string }) { return (

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

); } function NotificationSkeleton() { return (
{[0, 1, 2].map((index) => (
))}
); } 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`; } 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, 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 (
{t('header.notifications.lastSync', 'Zuletzt aktualisiert')}: {label} {isOffline && ( {t('header.notifications.offline', 'Offline')} )}
{pushDescription}
{push.error && (

{push.error}

)}
); }