579 lines
19 KiB
TypeScript
579 lines
19 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 { useEventData } from '../hooks/useEventData';
|
||
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
|
||
import { Sparkles, UploadCloud, X, RefreshCw } from 'lucide-react';
|
||
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
|
||
import { useEventBranding } from '../context/EventBrandingContext';
|
||
import type { EventBranding } from '../types/event-branding';
|
||
import { animated, useSpring } from '@react-spring/web';
|
||
import { useGesture } from '@use-gesture/react';
|
||
|
||
export default function HomePage() {
|
||
const { token } = useParams<{ token: string }>();
|
||
const { name, hydrated } = useGuestIdentity();
|
||
const { event } = useEventData();
|
||
const { completedCount } = useGuestTaskProgress(token ?? '');
|
||
const { t, locale } = useTranslation();
|
||
const { branding } = useEventBranding();
|
||
const headingFont = branding.typography?.heading ?? branding.fontFamily ?? undefined;
|
||
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
|
||
const radius = branding.buttons?.radius ?? 12;
|
||
|
||
const heroStorageKey = token ? `guestHeroDismissed_${token}` : 'guestHeroDismissed';
|
||
const [heroVisible, setHeroVisible] = React.useState(false);
|
||
|
||
React.useEffect(() => {
|
||
if (typeof window === 'undefined') {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const stored = window.sessionStorage.getItem(heroStorageKey);
|
||
// standardmäßig versteckt, nur sichtbar falls explizit gesetzt (kann später wieder aktiviert werden)
|
||
setHeroVisible(stored === 'show');
|
||
} catch {
|
||
setHeroVisible(false);
|
||
}
|
||
}, [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 [missionDeck, setMissionDeck] = React.useState<MissionPreview[]>([]);
|
||
const [missionLoading, setMissionLoading] = React.useState(false);
|
||
const missionPoolRef = React.useRef<MissionPreview[]>([]);
|
||
|
||
const drawRandom = React.useCallback((excludeIds: Set<number>) => {
|
||
const pool = missionPoolRef.current.filter((item) => !excludeIds.has(item.id));
|
||
if (!pool.length) return null;
|
||
return pool[Math.floor(Math.random() * pool.length)];
|
||
}, []);
|
||
|
||
const resetDeck = React.useCallback(() => {
|
||
const pool = missionPoolRef.current;
|
||
if (!pool.length) {
|
||
setMissionDeck([]);
|
||
return;
|
||
}
|
||
const shuffled = [...pool].sort(() => Math.random() - 0.5);
|
||
setMissionDeck(shuffled.slice(0, 4));
|
||
}, []);
|
||
|
||
const advanceDeck = React.useCallback(() => {
|
||
setMissionDeck((prev) => {
|
||
if (!prev.length) return prev;
|
||
const [, ...rest] = prev;
|
||
const exclude = new Set(rest.map((r) => r.id));
|
||
const nextCandidate = drawRandom(exclude);
|
||
const replenished = nextCandidate ? [...rest, nextCandidate] : rest;
|
||
return replenished;
|
||
});
|
||
}, [drawRandom]);
|
||
|
||
React.useEffect(() => {
|
||
if (!token) return;
|
||
let cancelled = false;
|
||
async function loadMissions() {
|
||
setMissionLoading(true);
|
||
try {
|
||
const safeToken = token ?? '';
|
||
const response = await fetch(
|
||
`/api/v1/events/${encodeURIComponent(safeToken)}/tasks?locale=${encodeURIComponent(locale)}`,
|
||
{
|
||
headers: {
|
||
Accept: 'application/json',
|
||
'X-Locale': locale,
|
||
},
|
||
}
|
||
);
|
||
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: Record<string, unknown>) => ({
|
||
id: Number(task.id),
|
||
title: typeof task.title === 'string' ? task.title : 'Mission',
|
||
description: typeof task.description === 'string' ? task.description : '',
|
||
duration: typeof task.duration === 'number' ? task.duration : 3,
|
||
emotion: task.emotion ?? null,
|
||
}));
|
||
resetDeck();
|
||
} else {
|
||
missionPoolRef.current = [];
|
||
setMissionDeck([]);
|
||
}
|
||
} catch (err) {
|
||
if (!cancelled) {
|
||
console.warn('Mission preview failed', err);
|
||
missionPoolRef.current = [];
|
||
setMissionDeck([]);
|
||
}
|
||
} finally {
|
||
if (!cancelled) {
|
||
setMissionLoading(false);
|
||
}
|
||
}
|
||
}
|
||
loadMissions();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [resetDeck, token, locale]);
|
||
|
||
if (!token) {
|
||
return null;
|
||
}
|
||
|
||
const introArray: string[] = [];
|
||
for (let i = 0; i < 12; i += 1) {
|
||
const candidate = t(`home.introRotating.${i}`, '');
|
||
if (candidate) {
|
||
introArray.push(candidate);
|
||
}
|
||
}
|
||
const introMessage =
|
||
introArray.length > 0 ? introArray[Math.floor(Math.random() * introArray.length)] : '';
|
||
|
||
return (
|
||
<div className="space-y-0.5 pb-24" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
|
||
<section className="space-y-1 px-4">
|
||
<p className="text-sm font-semibold text-foreground">
|
||
{t('home.welcomeLine').replace('{name}', displayName)}
|
||
</p>
|
||
{introMessage && (
|
||
<p className="text-xs text-muted-foreground">
|
||
{introMessage}
|
||
</p>
|
||
)}
|
||
</section>
|
||
|
||
{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-0.5" style={headingFont ? { fontFamily: headingFont } : undefined}>
|
||
<div className="space-y-0.5">
|
||
<MissionActionCard
|
||
token={token}
|
||
mission={missionDeck[0] ?? null}
|
||
loading={missionLoading}
|
||
onAdvance={advanceDeck}
|
||
stack={missionDeck.slice(0, 3)}
|
||
/>
|
||
<UploadActionCard token={token} accentColor={accentColor} secondaryAccent={secondaryAccent} radius={radius} bodyFont={bodyFont} />
|
||
<EmotionActionCard />
|
||
</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,
|
||
onAdvance,
|
||
stack,
|
||
}: {
|
||
token: string;
|
||
mission: MissionPreview | null;
|
||
loading: boolean;
|
||
onAdvance: () => void;
|
||
stack: MissionPreview[];
|
||
}) {
|
||
const { branding } = useEventBranding();
|
||
const radius = branding.buttons?.radius ?? 12;
|
||
const primary = branding.buttons?.primary ?? branding.primaryColor;
|
||
const secondary = branding.buttons?.secondary ?? branding.secondaryColor;
|
||
const buttonStyle = branding.buttons?.style ?? 'filled';
|
||
const headingFont = branding.typography?.heading ?? branding.fontFamily ?? undefined;
|
||
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
|
||
const textColor = '#1f2937';
|
||
const subTextColor = '#334155';
|
||
const swipeThreshold = 120;
|
||
const stackLayers = stack.slice(1, 4);
|
||
|
||
const cardStyle: React.CSSProperties = {
|
||
borderRadius: `${radius + 8}px`,
|
||
backgroundColor: '#fcf7ef',
|
||
backgroundImage: `linear-gradient(0deg, ${primary}33, ${primary}22), url(/patterns/rays-sunburst.svg)`,
|
||
backgroundBlendMode: 'multiply, normal',
|
||
backgroundSize: '330% 330%',
|
||
backgroundPosition: 'center',
|
||
backgroundRepeat: 'no-repeat',
|
||
filter: 'contrast(1.12) saturate(1.1)',
|
||
border: `1px solid ${primary}26`,
|
||
boxShadow: `0 12px 28px ${primary}22, 0 2px 6px ${primary}1f`,
|
||
fontFamily: bodyFont,
|
||
position: 'relative',
|
||
overflow: 'hidden',
|
||
};
|
||
|
||
const [{ x, y, rotateZ, rotateY, rotateX, scale, opacity }, api] = useSpring(() => ({
|
||
x: 0,
|
||
y: 0,
|
||
rotateZ: 0,
|
||
rotateY: 0,
|
||
rotateX: 0,
|
||
scale: 1,
|
||
opacity: 1,
|
||
config: { tension: 320, friction: 26 },
|
||
}));
|
||
|
||
React.useEffect(() => {
|
||
api.start({ x: 0, y: 0, rotateZ: 0, rotateY: 0, rotateX: 0, scale: 1, opacity: 1, immediate: false });
|
||
}, [mission?.id, api]);
|
||
|
||
const bind = useGesture(
|
||
{
|
||
onDrag: ({ active, movement: [mx, my], velocity: [vx], direction: [dx], cancel }) => {
|
||
if (active && Math.abs(mx) > swipeThreshold) {
|
||
cancel?.();
|
||
api.start({
|
||
x: dx > 0 ? 520 : -520,
|
||
y: my,
|
||
rotateZ: dx > 0 ? 12 : -12,
|
||
rotateY: dx > 0 ? 18 : -18,
|
||
rotateX: -my / 10,
|
||
opacity: 0,
|
||
scale: 1,
|
||
immediate: false,
|
||
onRest: () => {
|
||
onAdvance();
|
||
api.start({ x: 0, y: 0, rotateZ: 0, rotateY: 0, rotateX: 0, opacity: 1, scale: 1, immediate: false });
|
||
},
|
||
});
|
||
return;
|
||
}
|
||
|
||
api.start({
|
||
x: mx,
|
||
y: my,
|
||
rotateZ: mx / 18,
|
||
rotateY: mx / 28,
|
||
rotateX: -my / 36,
|
||
scale: active ? 1.02 : 1,
|
||
opacity: 1,
|
||
immediate: false,
|
||
});
|
||
},
|
||
onDragEnd: () => {
|
||
api.start({ x: 0, y: 0, rotateZ: 0, rotateY: 0, rotateX: 0, scale: 1, opacity: 1, immediate: false });
|
||
},
|
||
},
|
||
{
|
||
drag: {
|
||
filterTaps: true,
|
||
bounds: { left: -200, right: 200, top: -120, bottom: 120 },
|
||
rubberband: true,
|
||
},
|
||
}
|
||
);
|
||
|
||
return (
|
||
<Card className="border-0 shadow-none bg-transparent" style={{ fontFamily: bodyFont }}>
|
||
<CardContent className="px-0 py-0">
|
||
<div className="relative min-h-[280px]">
|
||
{stackLayers.map((item, index) => {
|
||
const depth = index + 1;
|
||
const scaleDown = 1 - depth * 0.03;
|
||
const translateY = depth * 12;
|
||
const fade = Math.max(0.25, 0.55 - depth * 0.08);
|
||
return (
|
||
<div
|
||
key={item.id ?? index}
|
||
className="absolute inset-0 pointer-events-none"
|
||
style={{
|
||
...cardStyle,
|
||
transform: `translateY(${translateY}px) scale(${scaleDown})`,
|
||
opacity: fade,
|
||
filter: 'brightness(0.96) contrast(0.98)',
|
||
}}
|
||
aria-hidden
|
||
/>
|
||
);
|
||
})}
|
||
<animated.div
|
||
className="relative overflow-hidden touch-pan-y"
|
||
style={{
|
||
...cardStyle,
|
||
x,
|
||
y,
|
||
rotateZ,
|
||
rotateY,
|
||
rotateX,
|
||
scale,
|
||
opacity,
|
||
transformOrigin: 'center center',
|
||
willChange: 'transform',
|
||
}}
|
||
{...bind()}
|
||
>
|
||
<div
|
||
className="absolute inset-x-0 top-0 h-12"
|
||
style={{
|
||
background: `linear-gradient(90deg, ${secondary}80, ${primary}cc)`,
|
||
filter: 'saturate(1.05)',
|
||
}}
|
||
aria-hidden
|
||
/>
|
||
<div className="relative z-10 flex flex-col gap-3 px-5 pb-4 pt-1">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<div
|
||
className="flex h-11 w-11 items-center justify-center rounded-2xl bg-white/90 text-foreground shadow-sm"
|
||
style={{ borderRadius: `${radius - 2}px` }}
|
||
>
|
||
<Sparkles className="h-5 w-5" aria-hidden />
|
||
</div>
|
||
<div>
|
||
<p
|
||
className="text-xs font-semibold uppercase tracking-wide"
|
||
style={{ ...(headingFont ? { fontFamily: headingFont } : {}), color: subTextColor }}
|
||
>
|
||
Fotoaufgabe
|
||
</p>
|
||
<p className="text-sm" style={{ color: subTextColor }}>
|
||
Wir haben schon etwas für dich vorbereitet.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{mission ? (
|
||
<div className="space-y-2">
|
||
<div
|
||
className="rounded-[14px] py-3 text-center"
|
||
style={{
|
||
background: 'rgba(255,255,255,0.6)',
|
||
paddingLeft: '30px',
|
||
paddingRight: '30px',
|
||
}}
|
||
>
|
||
<p
|
||
className="text-lg font-semibold leading-snug"
|
||
style={{ ...(headingFont ? { fontFamily: headingFont } : {}), color: textColor }}
|
||
>
|
||
{mission.title}
|
||
</p>
|
||
{mission.description && (
|
||
<p className="text-sm leading-relaxed" style={{ color: subTextColor }}>
|
||
{mission.description}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<p className="text-sm text-center" style={{ color: subTextColor }}>
|
||
Ziehe deine erste Mission im Aufgaben-Tab oder wähle eine Stimmung.
|
||
</p>
|
||
)}
|
||
|
||
<div className="grid grid-cols-[2fr_1fr] gap-2 pt-1">
|
||
<Button asChild className="w-full" style={{ borderRadius: `${radius}px` }}>
|
||
<Link
|
||
to={
|
||
mission
|
||
? `/e/${encodeURIComponent(token)}/upload?task=${mission.id}`
|
||
: `/e/${encodeURIComponent(token)}/tasks`
|
||
}
|
||
>
|
||
Aufgabe starten
|
||
</Link>
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
variant="secondary"
|
||
className="w-full"
|
||
onClick={onShuffle}
|
||
disabled={loading}
|
||
style={{
|
||
borderRadius: `${radius}px`,
|
||
backgroundColor: secondary,
|
||
color: '#ffffff',
|
||
}}
|
||
>
|
||
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} aria-hidden />
|
||
Andere Aufgabe
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</animated.div>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function EmotionActionCard() {
|
||
return (
|
||
<Card className="border border-muted/40 shadow-sm">
|
||
<CardHeader className="py-[4px]">
|
||
<CardTitle>Wähle eine Stimmung und erhalte eine passende Aufgabe</CardTitle>
|
||
<CardDescription className="text-sm text-muted-foreground">
|
||
Tippe deinen Mood, wir picken die nächste Mission für dich.
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="py-[4px]">
|
||
<EmotionPicker variant="embedded" showSkip={false} />
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function UploadActionCard({
|
||
token,
|
||
accentColor,
|
||
secondaryAccent,
|
||
radius,
|
||
bodyFont,
|
||
}: {
|
||
token: string;
|
||
accentColor: string;
|
||
secondaryAccent: string;
|
||
radius: number;
|
||
bodyFont?: string;
|
||
}) {
|
||
return (
|
||
<Card
|
||
className="overflow-hidden border border-muted/30 shadow-sm"
|
||
style={{
|
||
background: 'var(--guest-surface)',
|
||
borderRadius: `${radius}px`,
|
||
fontFamily: bodyFont,
|
||
}}
|
||
>
|
||
<CardContent className="flex flex-col gap-1.5 py-[4px]">
|
||
<div className="flex items-center gap-3">
|
||
<div className="rounded-2xl p-3" style={{ background: `${accentColor}15` }}>
|
||
<UploadCloud className="h-5 w-5" aria-hidden style={{ color: accentColor }} />
|
||
</div>
|
||
<div>
|
||
<p className="text-lg font-semibold text-foreground">Direkt hochladen</p>
|
||
<p className="text-sm text-muted-foreground">Kamera öffnen oder ein Foto aus deiner Galerie wählen.</p>
|
||
</div>
|
||
</div>
|
||
<Button
|
||
asChild
|
||
className="text-white"
|
||
style={{
|
||
borderRadius: `${radius}px`,
|
||
background: `linear-gradient(120deg, ${accentColor}, ${secondaryAccent})`,
|
||
boxShadow: `0 12px 28px ${accentColor}25`,
|
||
}}
|
||
>
|
||
<Link to={`/e/${encodeURIComponent(token)}/upload`}>Foto hochladen</Link>
|
||
</Button>
|
||
<p className="text-xs text-muted-foreground">Offline möglich – wir laden später hoch.</p>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|