302 lines
10 KiB
TypeScript
302 lines
10 KiB
TypeScript
import React from 'react';
|
|
import { Link, useParams } from 'react-router-dom';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Separator } from '@/components/ui/separator';
|
|
import EmotionPicker from '../components/EmotionPicker';
|
|
import GalleryPreview from '../components/GalleryPreview';
|
|
import { useGuestIdentity } from '../context/GuestIdentityContext';
|
|
import { useEventStats } from '../context/EventStatsContext';
|
|
import { useEventData } from '../hooks/useEventData';
|
|
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
|
|
import { Sparkles, UploadCloud, Images, CheckCircle2, Users, TimerReset, X } from 'lucide-react';
|
|
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
|
|
import { useEventBranding } from '../context/EventBrandingContext';
|
|
import type { EventBranding } from '../types/event-branding';
|
|
|
|
export default function HomePage() {
|
|
const { token } = useParams<{ token: string }>();
|
|
const { name, hydrated } = useGuestIdentity();
|
|
const stats = useEventStats();
|
|
const { event } = useEventData();
|
|
const { completedCount } = useGuestTaskProgress(token);
|
|
const { t } = useTranslation();
|
|
const { branding } = useEventBranding();
|
|
|
|
const heroStorageKey = token ? `guestHeroDismissed_${token}` : 'guestHeroDismissed';
|
|
const [heroVisible, setHeroVisible] = React.useState(() => {
|
|
if (typeof window === 'undefined') {
|
|
return true;
|
|
}
|
|
|
|
try {
|
|
return window.sessionStorage.getItem(heroStorageKey) !== '1';
|
|
} catch {
|
|
return true;
|
|
}
|
|
});
|
|
|
|
React.useEffect(() => {
|
|
if (typeof window === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setHeroVisible(window.sessionStorage.getItem(heroStorageKey) !== '1');
|
|
} catch {
|
|
setHeroVisible(true);
|
|
}
|
|
}, [heroStorageKey]);
|
|
|
|
const dismissHero = React.useCallback(() => {
|
|
setHeroVisible(false);
|
|
if (typeof window === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
window.sessionStorage.setItem(heroStorageKey, '1');
|
|
} catch {
|
|
// ignore storage exceptions (e.g. private mode)
|
|
}
|
|
}, [heroStorageKey]);
|
|
|
|
const displayName = hydrated && name ? name : t('home.fallbackGuestName');
|
|
const eventNameDisplay = event?.name ?? t('home.hero.defaultEventName');
|
|
const latestUploadText = formatLatestUpload(stats.latestPhotoAt, t);
|
|
const accentColor = branding.primaryColor;
|
|
const secondaryAccent = branding.secondaryColor;
|
|
|
|
const primaryActions = React.useMemo(
|
|
() => [
|
|
{
|
|
to: 'tasks',
|
|
label: t('home.actions.items.tasks.label'),
|
|
description: t('home.actions.items.tasks.description'),
|
|
icon: <Sparkles className="h-5 w-5" aria-hidden />,
|
|
},
|
|
{
|
|
to: 'upload',
|
|
label: t('home.actions.items.upload.label'),
|
|
description: t('home.actions.items.upload.description'),
|
|
icon: <UploadCloud className="h-5 w-5" aria-hidden />,
|
|
},
|
|
{
|
|
to: 'gallery',
|
|
label: t('home.actions.items.gallery.label'),
|
|
description: t('home.actions.items.gallery.description'),
|
|
icon: <Images className="h-5 w-5" aria-hidden />,
|
|
},
|
|
],
|
|
[t],
|
|
);
|
|
|
|
const checklistItems = React.useMemo(
|
|
() => [
|
|
t('home.checklist.steps.first'),
|
|
t('home.checklist.steps.second'),
|
|
t('home.checklist.steps.third'),
|
|
],
|
|
[t],
|
|
);
|
|
|
|
if (!token) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6 pb-24">
|
|
{heroVisible && (
|
|
<HeroCard
|
|
name={displayName}
|
|
eventName={eventNameDisplay}
|
|
tasksCompleted={completedCount}
|
|
t={t}
|
|
branding={branding}
|
|
onDismiss={dismissHero}
|
|
/>
|
|
)}
|
|
|
|
<Card style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>
|
|
<CardContent className="grid grid-cols-1 gap-4 py-4 sm:grid-cols-4">
|
|
<StatTile
|
|
icon={<Users className="h-4 w-4" aria-hidden />}
|
|
label={t('home.stats.online')}
|
|
value={`${stats.onlineGuests}`}
|
|
accentColor={accentColor}
|
|
/>
|
|
<StatTile
|
|
icon={<Sparkles className="h-4 w-4" aria-hidden />}
|
|
label={t('home.stats.tasksSolved')}
|
|
value={`${stats.tasksSolved}`}
|
|
accentColor={accentColor}
|
|
/>
|
|
<StatTile
|
|
icon={<TimerReset className="h-4 w-4" aria-hidden />}
|
|
label={t('home.stats.lastUpload')}
|
|
value={latestUploadText}
|
|
accentColor={accentColor}
|
|
/>
|
|
<StatTile
|
|
icon={<CheckCircle2 className="h-4 w-4" aria-hidden />}
|
|
label={t('home.stats.completedTasks')}
|
|
value={`${completedCount}`}
|
|
accentColor={accentColor}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<section className="space-y-3">
|
|
<div className="flex items-center justify-between" style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>
|
|
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
|
{t('home.actions.title')}
|
|
</h2>
|
|
<span className="text-xs text-muted-foreground">{t('home.actions.subtitle')}</span>
|
|
</div>
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
{primaryActions.map((action) => (
|
|
<Link to={action.to} key={action.to} className="block touch-manipulation">
|
|
<Card className="transition-all hover:shadow-lg">
|
|
<CardContent className="flex items-center gap-3 py-4">
|
|
<div
|
|
className="flex h-10 w-10 items-center justify-center rounded-lg shadow-sm"
|
|
style={{
|
|
backgroundColor: `${secondaryAccent}1a`,
|
|
color: secondaryAccent,
|
|
}}
|
|
>
|
|
{action.icon}
|
|
</div>
|
|
<div className="flex flex-col">
|
|
<span className="text-base font-semibold" style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>{action.label}</span>
|
|
<span className="text-sm text-muted-foreground">{action.description}</span>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
asChild
|
|
className="w-full touch-manipulation"
|
|
style={{ borderColor: `${accentColor}33` }}
|
|
>
|
|
<Link to="queue">{t('home.actions.queueButton')}</Link>
|
|
</Button>
|
|
</section>
|
|
|
|
<Card>
|
|
<CardHeader style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>
|
|
<CardTitle>{t('home.checklist.title')}</CardTitle>
|
|
<CardDescription>{t('home.checklist.description')}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
{checklistItems.map((item) => (
|
|
<div key={item} className="flex items-start gap-3">
|
|
<CheckCircle2 className="mt-0.5 h-5 w-5 text-green-500" aria-hidden />
|
|
<span className="text-sm leading-relaxed text-muted-foreground">{item}</span>
|
|
</div>
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Separator />
|
|
|
|
<EmotionPicker />
|
|
|
|
<GalleryPreview token={token} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function HeroCard({
|
|
name,
|
|
eventName,
|
|
tasksCompleted,
|
|
t,
|
|
branding,
|
|
onDismiss,
|
|
}: {
|
|
name: string;
|
|
eventName: string;
|
|
tasksCompleted: number;
|
|
t: TranslateFn;
|
|
branding: EventBranding;
|
|
onDismiss: () => void;
|
|
}) {
|
|
const heroTitle = t('home.hero.title').replace('{name}', name);
|
|
const heroDescription = t('home.hero.description').replace('{eventName}', eventName);
|
|
const progressMessage = tasksCompleted > 0
|
|
? t('home.hero.progress.some').replace('{count}', `${tasksCompleted}`)
|
|
: t('home.hero.progress.none');
|
|
|
|
const style = React.useMemo(() => ({
|
|
background: `linear-gradient(135deg, ${branding.primaryColor}, ${branding.secondaryColor})`,
|
|
color: '#ffffff',
|
|
fontFamily: branding.fontFamily ?? undefined,
|
|
}), [branding.fontFamily, branding.primaryColor, branding.secondaryColor]);
|
|
|
|
return (
|
|
<Card className="relative overflow-hidden border-0 text-white shadow-md" style={style}>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="absolute right-3 top-3 h-8 w-8 rounded-full bg-white/20 text-white hover:bg-white/30"
|
|
onClick={onDismiss}
|
|
>
|
|
<X className="h-4 w-4" aria-hidden />
|
|
<span className="sr-only">{t('common.actions.close')}</span>
|
|
</Button>
|
|
<CardHeader className="space-y-2 pr-10">
|
|
<CardDescription className="text-sm text-white/80">{t('home.hero.subtitle')}</CardDescription>
|
|
<CardTitle className="text-2xl font-bold leading-snug">{heroTitle}</CardTitle>
|
|
<p className="text-sm text-white/85">{heroDescription}</p>
|
|
<p className="text-sm font-medium text-white/90">{progressMessage}</p>
|
|
</CardHeader>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function StatTile({ icon, label, value, accentColor }: { icon: React.ReactNode; label: string; value: string; accentColor: string }) {
|
|
return (
|
|
<div className="flex items-center gap-3 rounded-lg border bg-muted/30 px-3 py-2">
|
|
<div
|
|
className="flex h-10 w-10 items-center justify-center rounded-full bg-white shadow-sm"
|
|
style={{ color: accentColor }}
|
|
>
|
|
{icon}
|
|
</div>
|
|
<div className="flex flex-col">
|
|
<span className="text-xs uppercase tracking-wide text-muted-foreground">{label}</span>
|
|
<span className="text-lg font-semibold text-foreground">{value}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function formatLatestUpload(isoDate: string | null, t: TranslateFn) {
|
|
if (!isoDate) {
|
|
return t('home.latestUpload.none');
|
|
}
|
|
const date = new Date(isoDate);
|
|
if (Number.isNaN(date.getTime())) {
|
|
return t('home.latestUpload.invalid');
|
|
}
|
|
const diffMs = Date.now() - date.getTime();
|
|
const diffMinutes = Math.round(diffMs / 60000);
|
|
if (diffMinutes < 1) {
|
|
return t('home.latestUpload.justNow');
|
|
}
|
|
if (diffMinutes < 60) {
|
|
return t('home.latestUpload.minutes').replace('{count}', `${diffMinutes}`);
|
|
}
|
|
const diffHours = Math.round(diffMinutes / 60);
|
|
if (diffHours < 24) {
|
|
return t('home.latestUpload.hours').replace('{count}', `${diffHours}`);
|
|
}
|
|
const diffDays = Math.round(diffHours / 24);
|
|
return t('home.latestUpload.days').replace('{count}', `${diffDays}`);
|
|
}
|