Files
fotospiel-app/resources/js/guest/pages/HomePage.tsx
2025-12-15 19:05:27 +01:00

579 lines
19 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, 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>
);
}