Files
fotospiel-app/resources/js/guest/components/Header.tsx

318 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 funktionierts</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>
);
}