318 lines
11 KiB
TypeScript
318 lines
11 KiB
TypeScript
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 { 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 { DEFAULT_EVENT_BRANDING, useOptionalEventBranding } from '../context/EventBrandingContext';
|
||
import { useOptionalNotificationCenter } from '../context/NotificationCenterContext';
|
||
import { useGuestTaskProgress, TASK_BADGE_TARGET } from '../hooks/useGuestTaskProgress';
|
||
|
||
const EVENT_ICON_COMPONENTS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||
heart: Heart,
|
||
guests: Users,
|
||
party: PartyPopper,
|
||
camera: 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 renderEventAvatar(name: string, icon: unknown, accentColor: string, textColor: string) {
|
||
if (typeof icon === 'string') {
|
||
const trimmed = icon.trim();
|
||
if (trimmed) {
|
||
const normalized = trimmed.toLowerCase();
|
||
const IconComponent = EVENT_ICON_COMPONENTS[normalized];
|
||
if (IconComponent) {
|
||
return (
|
||
<div
|
||
className="flex h-10 w-10 items-center justify-center rounded-full shadow-sm"
|
||
style={{ backgroundColor: accentColor, color: textColor }}
|
||
>
|
||
<IconComponent className="h-5 w-5" aria-hidden />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (isLikelyEmoji(trimmed)) {
|
||
return (
|
||
<div
|
||
className="flex h-10 w-10 items-center justify-center rounded-full text-xl shadow-sm"
|
||
style={{ backgroundColor: accentColor, color: textColor }}
|
||
>
|
||
<span aria-hidden>{trimmed}</span>
|
||
<span className="sr-only">{name}</span>
|
||
</div>
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className="flex h-10 w-10 items-center justify-center rounded-full font-semibold text-sm shadow-sm"
|
||
style={{ backgroundColor: accentColor, color: textColor }}
|
||
>
|
||
{getInitials(name)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function Header({ eventToken, title = '' }: { eventToken?: string; title?: string }) {
|
||
const statsContext = useOptionalEventStats();
|
||
const identity = useOptionalGuestIdentity();
|
||
const { t } = useTranslation();
|
||
const brandingContext = useOptionalEventBranding();
|
||
const branding = brandingContext?.branding ?? DEFAULT_EVENT_BRANDING;
|
||
const primaryForeground = '#ffffff';
|
||
const { event, status } = useEventData();
|
||
const notificationCenter = useOptionalNotificationCenter();
|
||
const [notificationsOpen, setNotificationsOpen] = React.useState(false);
|
||
const taskProgress = useGuestTaskProgress(eventToken);
|
||
const panelRef = React.useRef<HTMLDivElement | null>(null);
|
||
const checklistItems = React.useMemo(
|
||
() => [
|
||
t('home.checklist.steps.first'),
|
||
t('home.checklist.steps.second'),
|
||
t('home.checklist.steps.third'),
|
||
],
|
||
[t],
|
||
);
|
||
|
||
React.useEffect(() => {
|
||
if (!notificationsOpen) {
|
||
return;
|
||
}
|
||
const handler = (event: MouseEvent) => {
|
||
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]);
|
||
|
||
if (!eventToken) {
|
||
const guestName = identity?.name && identity?.hydrated ? identity.name : null;
|
||
return (
|
||
<div
|
||
className="sticky top-0 z-20 flex items-center justify-between border-b bg-white/70 px-4 py-2 backdrop-blur dark:bg-black/40"
|
||
style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}
|
||
>
|
||
<div className="flex flex-col">
|
||
<div className="font-semibold">{title}</div>
|
||
{guestName && (
|
||
<span className="text-xs text-muted-foreground">
|
||
{`${t('common.hi')} ${guestName}`}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<AppearanceToggleDropdown />
|
||
<SettingsSheet />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const guestName =
|
||
identity && identity.eventKey === eventToken && identity.hydrated && identity.name ? identity.name : null;
|
||
|
||
const headerStyle: React.CSSProperties = {
|
||
background: `linear-gradient(135deg, ${branding.primaryColor}, ${branding.secondaryColor})`,
|
||
color: primaryForeground,
|
||
fontFamily: branding.fontFamily ?? undefined,
|
||
};
|
||
|
||
const accentColor = branding.secondaryColor;
|
||
|
||
if (status === 'loading') {
|
||
return (
|
||
<div className="sticky top-0 z-20 flex items-center justify-between border-b border-white/10 px-4 py-2 text-white shadow-sm backdrop-blur" style={headerStyle}>
|
||
<div className="font-semibold" style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>{t('header.loading')}</div>
|
||
<div className="flex items-center gap-2">
|
||
<AppearanceToggleDropdown />
|
||
<SettingsSheet />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (status !== 'ready' || !event) {
|
||
return null;
|
||
}
|
||
|
||
const stats =
|
||
statsContext && statsContext.eventKey === eventToken ? statsContext : undefined;
|
||
return (
|
||
<div
|
||
className="sticky top-0 z-20 flex items-center justify-between border-b border-white/10 px-4 py-3 text-white shadow-sm backdrop-blur"
|
||
style={headerStyle}
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
{renderEventAvatar(event.name, event.type?.icon, accentColor, primaryForeground)}
|
||
<div className="flex flex-col" style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>
|
||
<div className="font-semibold text-base">{event.name}</div>
|
||
{guestName && (
|
||
<span className="text-xs text-white/80">
|
||
{`${t('common.hi')} ${guestName}`}
|
||
</span>
|
||
)}
|
||
<div className="flex items-center gap-2 text-xs text-white/70">
|
||
{stats && (
|
||
<>
|
||
<span className="flex items-center gap-1">
|
||
<User className="h-3 w-3" />
|
||
<span>{`${stats.onlineGuests} ${t('header.stats.online')}`}</span>
|
||
</span>
|
||
<span className="text-muted-foreground">|</span>
|
||
<span className="flex items-center gap-1">
|
||
<span className="font-medium">{stats.tasksSolved}</span>{' '}
|
||
{t('header.stats.tasksSolved')}
|
||
</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{notificationCenter && (
|
||
<NotificationButton
|
||
eventToken={eventToken}
|
||
center={notificationCenter}
|
||
open={notificationsOpen}
|
||
onToggle={() => setNotificationsOpen((prev) => !prev)}
|
||
panelRef={panelRef}
|
||
checklistItems={checklistItems}
|
||
taskProgress={taskProgress?.hydrated ? taskProgress : undefined}
|
||
/>
|
||
)}
|
||
<AppearanceToggleDropdown />
|
||
<SettingsSheet />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function NotificationButton({
|
||
center,
|
||
eventToken,
|
||
open,
|
||
onToggle,
|
||
panelRef,
|
||
checklistItems,
|
||
taskProgress,
|
||
}: {
|
||
center: {
|
||
queueCount: number;
|
||
inviteCount: number;
|
||
totalCount: number;
|
||
};
|
||
eventToken: string;
|
||
open: boolean;
|
||
onToggle: () => void;
|
||
panelRef: React.RefObject<HTMLDivElement>;
|
||
checklistItems: string[];
|
||
taskProgress?: ReturnType<typeof useGuestTaskProgress>;
|
||
}) {
|
||
if (!center) {
|
||
return null;
|
||
}
|
||
|
||
const totalCount = center.totalCount;
|
||
const progressRatio = taskProgress
|
||
? Math.min(1, taskProgress.completedCount / TASK_BADGE_TARGET)
|
||
: 0;
|
||
|
||
return (
|
||
<div className="relative">
|
||
<button
|
||
type="button"
|
||
onClick={onToggle}
|
||
className="relative rounded-full bg-white/15 p-2 text-white transition hover:bg-white/30"
|
||
aria-label="Benachrichtigungen anzeigen"
|
||
>
|
||
<Bell className="h-5 w-5" aria-hidden />
|
||
{totalCount > 0 && (
|
||
<span className="absolute -right-1 -top-1 rounded-full bg-white px-1.5 text-[10px] font-semibold text-pink-600">
|
||
{totalCount}
|
||
</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"
|
||
>
|
||
<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>
|
||
{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-lg font-semibold text-slate-900">
|
||
{taskProgress.completedCount}/{TASK_BADGE_TARGET}
|
||
</p>
|
||
</div>
|
||
<Link
|
||
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
|
||
</Link>
|
||
</div>
|
||
<div className="mt-3 h-1.5 w-full rounded-full bg-slate-100">
|
||
<div
|
||
className="h-full rounded-full bg-pink-500"
|
||
style={{ width: `${progressRatio * 100}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div className="my-3 h-px w-full bg-slate-100" />
|
||
<p className="text-[11px] uppercase tracking-[0.3em] text-slate-400">So funktioniert’s</p>
|
||
<ul className="mt-2 space-y-2 text-sm text-slate-600">
|
||
{checklistItems.map((item) => (
|
||
<li key={item} className="flex gap-2">
|
||
<span className="mt-0.5 h-1.5 w-1.5 rounded-full bg-pink-500" />
|
||
<span>{item}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|