351 lines
11 KiB
TypeScript
351 lines
11 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, X, Camera, RefreshCw } 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 accentColor = branding.primaryColor;
|
|
const secondaryAccent = branding.secondaryColor;
|
|
|
|
const [missionPreview, setMissionPreview] = React.useState<MissionPreview | null>(null);
|
|
const [missionLoading, setMissionLoading] = React.useState(false);
|
|
const missionPoolRef = React.useRef<MissionPreview[]>([]);
|
|
|
|
const shuffleMissionPreview = React.useCallback(() => {
|
|
const pool = missionPoolRef.current;
|
|
if (!pool.length) {
|
|
setMissionPreview(null);
|
|
return;
|
|
}
|
|
const choice = pool[Math.floor(Math.random() * pool.length)];
|
|
setMissionPreview(choice);
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
if (!token) return;
|
|
let cancelled = false;
|
|
async function loadMissions() {
|
|
setMissionLoading(true);
|
|
try {
|
|
const response = await fetch(`/api/v1/events/${encodeURIComponent(token)}/tasks`);
|
|
if (!response.ok) throw new Error('Aufgaben konnten nicht geladen werden.');
|
|
const payload = await response.json();
|
|
if (cancelled) return;
|
|
if (Array.isArray(payload) && payload.length) {
|
|
missionPoolRef.current = payload.map((task: any) => ({
|
|
id: Number(task.id),
|
|
title: task.title ?? 'Mission',
|
|
description: task.description ?? '',
|
|
duration: typeof task.duration === 'number' ? task.duration : 3,
|
|
emotion: task.emotion ?? null,
|
|
}));
|
|
shuffleMissionPreview();
|
|
} else {
|
|
missionPoolRef.current = [];
|
|
setMissionPreview(null);
|
|
}
|
|
} catch (err) {
|
|
if (!cancelled) {
|
|
console.warn('Mission preview failed', err);
|
|
missionPoolRef.current = [];
|
|
setMissionPreview(null);
|
|
}
|
|
} finally {
|
|
if (!cancelled) {
|
|
setMissionLoading(false);
|
|
}
|
|
}
|
|
}
|
|
loadMissions();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [shuffleMissionPreview, token]);
|
|
|
|
if (!token) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6 pb-32">
|
|
{heroVisible && (
|
|
<HeroCard
|
|
name={displayName}
|
|
eventName={eventNameDisplay}
|
|
tasksCompleted={completedCount}
|
|
t={t}
|
|
branding={branding}
|
|
onDismiss={dismissHero}
|
|
ctaLabel={t('home.actions.items.tasks.label')}
|
|
ctaHref={`/e/${encodeURIComponent(token)}/tasks`}
|
|
/>
|
|
)}
|
|
|
|
<section className="space-y-4" style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div>
|
|
<h2 className="text-base font-semibold text-foreground">Starte dein Fotospiel</h2>
|
|
<p className="text-xs text-muted-foreground">Wähle, wie du den nächsten Moment einfängst.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
<MissionActionCard
|
|
token={token}
|
|
mission={missionPreview}
|
|
loading={missionLoading}
|
|
onShuffle={shuffleMissionPreview}
|
|
/>
|
|
<EmotionActionCard />
|
|
<UploadActionCard token={token} accentColor={accentColor} secondaryAccent={secondaryAccent} />
|
|
</div>
|
|
</section>
|
|
|
|
<Separator />
|
|
|
|
<GalleryPreview token={token} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function HeroCard({
|
|
name,
|
|
eventName,
|
|
tasksCompleted,
|
|
t,
|
|
branding,
|
|
onDismiss,
|
|
ctaLabel,
|
|
ctaHref,
|
|
}: {
|
|
name: string;
|
|
eventName: string;
|
|
tasksCompleted: number;
|
|
t: TranslateFn;
|
|
branding: EventBranding;
|
|
onDismiss: () => void;
|
|
ctaLabel?: string;
|
|
ctaHref?: string;
|
|
}) {
|
|
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-4 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>
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<p className="text-sm font-medium text-white/90">{progressMessage}</p>
|
|
{ctaHref && ctaLabel && (
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
asChild
|
|
className="rounded-full bg-white/15 px-4 py-1 text-xs font-semibold uppercase tracking-wide text-white hover:bg-white/25"
|
|
>
|
|
<Link to={ctaHref}>{ctaLabel}</Link>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</CardHeader>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
type MissionPreview = {
|
|
id: number;
|
|
title: string;
|
|
description?: string;
|
|
duration?: number;
|
|
emotion?: { name?: string; slug?: string } | null;
|
|
};
|
|
|
|
function MissionActionCard({
|
|
token,
|
|
mission,
|
|
loading,
|
|
onShuffle,
|
|
}: {
|
|
token: string;
|
|
mission: MissionPreview | null;
|
|
loading: boolean;
|
|
onShuffle: () => void;
|
|
}) {
|
|
return (
|
|
<Card className="border-0 shadow-sm">
|
|
<CardHeader className="flex flex-row items-start gap-3">
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-pink-100 text-pink-600">
|
|
<Sparkles className="h-5 w-5" aria-hidden />
|
|
</div>
|
|
<div>
|
|
<CardTitle className="text-base font-semibold text-foreground">Mission starten</CardTitle>
|
|
<CardDescription className="text-xs text-muted-foreground">
|
|
Wir haben bereits eine Aufgabe für dich vorbereitet. Tippe, um direkt loszulegen.
|
|
</CardDescription>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
{mission ? (
|
|
<>
|
|
<p className="text-lg font-semibold text-foreground">{mission.title}</p>
|
|
{mission.description && (
|
|
<p className="text-sm text-muted-foreground line-clamp-2">{mission.description}</p>
|
|
)}
|
|
<div className="flex gap-3 text-xs text-muted-foreground">
|
|
<span>{mission.duration ?? 3} Min</span>
|
|
{mission.emotion?.name && <span>{mission.emotion.name}</span>}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">
|
|
Ziehe deine erste Mission im Aufgaben-Tab oder lade deine Stimmung hoch.
|
|
</p>
|
|
)}
|
|
<div className="flex flex-col gap-2 sm:flex-row">
|
|
<Button asChild className="flex-1">
|
|
<Link to={`/e/${encodeURIComponent(token)}/tasks`}>Mission starten</Link>
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
className="flex-1"
|
|
onClick={onShuffle}
|
|
disabled={loading}
|
|
>
|
|
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} aria-hidden />
|
|
Andere Mission
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function EmotionActionCard() {
|
|
return (
|
|
<Card className="border border-muted/40 shadow-sm">
|
|
<CardHeader>
|
|
<CardTitle>Foto nach Gefühlslage</CardTitle>
|
|
<CardDescription className="text-sm text-muted-foreground">
|
|
Wähle deine Stimmung, wir schlagen dir passende Missionen vor.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<EmotionPicker variant="embedded" showSkip={false} />
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function UploadActionCard({
|
|
token,
|
|
accentColor,
|
|
secondaryAccent,
|
|
}: {
|
|
token: string;
|
|
accentColor: string;
|
|
secondaryAccent: string;
|
|
}) {
|
|
return (
|
|
<Card
|
|
className="overflow-hidden border-0 text-white shadow-sm"
|
|
style={{ background: `linear-gradient(120deg, ${accentColor}, ${secondaryAccent})` }}
|
|
>
|
|
<CardContent className="flex flex-col gap-3 py-5">
|
|
<div className="flex items-center gap-3">
|
|
<div className="rounded-2xl bg-white/15 p-3">
|
|
<UploadCloud className="h-5 w-5" aria-hidden />
|
|
</div>
|
|
<div>
|
|
<p className="text-lg font-semibold">Direkt hochladen</p>
|
|
<p className="text-sm text-white/80">Kamera öffnen oder ein Foto aus deiner Galerie wählen.</p>
|
|
</div>
|
|
</div>
|
|
<Button asChild className="bg-white/90 text-slate-900 hover:bg-white">
|
|
<Link to={`/e/${encodeURIComponent(token)}/upload`}>Foto hochladen</Link>
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|