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 { Badge } from '@/components/ui/badge'; import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Progress } from '@/components/ui/progress'; import { Skeleton } from '@/components/ui/skeleton'; import { Separator } from '@/components/ui/separator'; import { AnimatePresence, motion } from 'framer-motion'; 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 { ArrowLeft, ArrowRight, Camera, ChevronDown, Sparkles, UploadCloud, X, RefreshCw, Timer } from 'lucide-react'; import { useTranslation, type TranslateFn } from '../i18n/useTranslation'; import { useEventBranding } from '../context/EventBrandingContext'; import type { EventBranding } from '../types/event-branding'; import { Swiper, SwiperSlide } from 'swiper/react'; import { EffectCards } from 'swiper/modules'; import 'swiper/css'; import 'swiper/css/effect-cards'; import { getEmotionIcon, getEmotionTheme, type EmotionIdentity } from '../lib/emotionTheme'; import { getDeviceId } from '../lib/device'; import { useDirectUpload } from '../hooks/useDirectUpload'; import { useNavigate } from 'react-router-dom'; import { isTaskModeEnabled } from '../lib/engagement'; import { FADE_SCALE, FADE_UP, STAGGER_FAST, getMotionContainerProps, getMotionItemProps, prefersReducedMotion } from '../lib/motion'; 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 uploadsRequireApproval = (event?.guest_upload_visibility as 'immediate' | 'review' | undefined) !== 'immediate'; const tasksEnabled = isTaskModeEnabled(event); const motionEnabled = !prefersReducedMotion(); const containerMotion = getMotionContainerProps(motionEnabled, STAGGER_FAST); const fadeUpMotion = getMotionItemProps(motionEnabled, FADE_UP); const fadeScaleMotion = getMotionItemProps(motionEnabled, FADE_SCALE); const [missionDeck, setMissionDeck] = React.useState([]); const [missionPool, setMissionPool] = React.useState([]); const [missionLoading, setMissionLoading] = React.useState(false); const [isLoadingMore, setIsLoadingMore] = React.useState(false); const [hasMore, setHasMore] = React.useState(true); const [page, setPage] = React.useState(1); const [swipeCount, setSwipeCount] = React.useState(0); const seenIdsRef = React.useRef>(new Set()); const poolIndexRef = React.useRef(0); const sliderStateKey = token ? `missionSliderIndex:${token}` : null; const swiperRef = React.useRef(null); const advanceDeck = React.useCallback(() => { if (swiperRef.current) { swiperRef.current.slideNext(); } }, []); const normalizeTasks = React.useCallback( (tasks: Record[]): MissionPreview[] => tasks.map((task) => ({ 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 as EmotionIdentity) ?? null, })), [] ); const mergeIntoPool = React.useCallback( (incoming: MissionPreview[]) => { const slugTitle = (title: string) => title .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .replace(/[^a-z0-9]+/g, ' ') .replace(/\s+/g, ' ') .trim(); setMissionPool((prev) => { const byId = new Map(); const byTitle = new Map(); const addCandidate = (candidate: MissionPreview) => { if (byId.has(candidate.id)) return; const titleKey = slugTitle(candidate.title); if (byTitle.has(titleKey)) return; byId.set(candidate.id, candidate); byTitle.set(titleKey, candidate); }; prev.forEach(addCandidate); incoming.forEach(addCandidate); return Array.from(byId.values()); }); }, [] ); const fetchTasksPage = React.useCallback( async (pageToFetch: number, isInitial: boolean = false) => { if (!token) return; if (isInitial) { setMissionLoading(true); } setIsLoadingMore(true); try { const perPage = 20; const response = await fetch( `/api/v1/events/${encodeURIComponent(token)}/tasks?page=${pageToFetch}&per_page=${perPage}&locale=${encodeURIComponent(locale)}`, { headers: { Accept: 'application/json', 'X-Locale': locale, 'X-Device-Id': getDeviceId(), }, } ); if (!response.ok) throw new Error('Aufgaben konnten nicht geladen werden.'); const payload = await response.json(); let items: Record[] = []; let hasMoreFlag = false; let nextPage = pageToFetch + 1; if (Array.isArray(payload)) { items = payload; hasMoreFlag = false; } else if (payload && Array.isArray(payload.tasks)) { items = payload.tasks; hasMoreFlag = false; } else if (payload && Array.isArray(payload.data)) { items = payload.data; if (payload.meta?.current_page && payload.meta?.last_page) { hasMoreFlag = payload.meta.current_page < payload.meta.last_page; nextPage = (payload.meta.current_page as number) + 1; } else if (payload.next_page_url !== undefined) { hasMoreFlag = Boolean(payload.next_page_url); } else { hasMoreFlag = items.length > 0; } } else { hasMoreFlag = false; } const normalized = normalizeTasks(items); const deduped = normalized.filter((task) => { if (seenIdsRef.current.has(task.id)) return false; seenIdsRef.current.add(task.id); return true; }); if (deduped.length) { mergeIntoPool(deduped); } setHasMore(hasMoreFlag); if (hasMoreFlag) { setPage(nextPage); } } catch (err) { console.warn('Mission fetch failed', err); setHasMore(false); } finally { setIsLoadingMore(false); if (isInitial) { setMissionLoading(false); } } }, [locale, normalizeTasks, token] ); React.useEffect(() => { if (!token) return; seenIdsRef.current = new Set(); setMissionDeck([]); setMissionPool([]); setPage(1); setHasMore(true); // restore persisted slider position for this event let restoredIndex = 0; if (sliderStateKey && typeof window !== 'undefined') { try { const stored = window.sessionStorage.getItem(sliderStateKey); if (stored) { const parsed = Number(stored); if (!Number.isNaN(parsed) && parsed >= 0) { restoredIndex = parsed; } } } catch { restoredIndex = 0; } } poolIndexRef.current = restoredIndex; if (!tasksEnabled) return; fetchTasksPage(1, true); }, [fetchTasksPage, locale, sliderStateKey, tasksEnabled, token]); React.useEffect(() => { if (missionPool.length === 0) return; if (poolIndexRef.current >= missionPool.length) { poolIndexRef.current = poolIndexRef.current % missionPool.length; } setMissionDeck(missionPool); }, [missionPool]); React.useEffect(() => { if (!swiperRef.current) return; if (!missionDeck.length) return; const target = poolIndexRef.current % missionDeck.length; const inst = swiperRef.current; if (typeof inst.slideToLoop === 'function') { inst.slideToLoop(target, 0); } else if (typeof inst.slideTo === 'function') { inst.slideTo(target, 0); } }, [missionDeck.length]); React.useEffect(() => { if (missionLoading) return; if (!hasMore || isLoadingMore) return; // Prefetch when we are within 6 items of the end of the current pool const remaining = missionPool.length - poolIndexRef.current; if (remaining <= 6) { fetchTasksPage(page); } }, [fetchTasksPage, hasMore, isLoadingMore, missionLoading, missionPool.length, page]); 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 introMessageRef = React.useRef(null); if (!introMessageRef.current) { introMessageRef.current = introArray.length > 0 ? introArray[Math.floor(Math.random() * introArray.length)] : ''; } const introMessage = introMessageRef.current; if (!tasksEnabled) { return (

{t('home.welcomeLine').replace('{name}', displayName)}

{introMessage &&

{introMessage}

}
); } return (

{t('home.welcomeLine').replace('{name}', displayName)}

{introMessage && (

{introMessage}

)}
{heroVisible && ( )}
{ poolIndexRef.current = idx % Math.max(1, total); if (sliderStateKey && typeof window !== 'undefined') { try { window.sessionStorage.setItem(sliderStateKey, String(poolIndexRef.current)); } catch { // ignore storage errors } } }} swiperRef={swiperRef} />
); } 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 ( {t('home.hero.subtitle')} {heroTitle}

{heroDescription}

{progressMessage}

{ctaHref && ctaLabel && ( )}
); } type MissionPreview = { id: number; title: string; description?: string; duration?: number; emotion?: EmotionIdentity | null; }; export function MissionActionCard({ token, mission, loading, onAdvance, stack, initialIndex, onIndexChange, swiperRef, swipeHintLabel, }: { token: string; mission: MissionPreview | null; loading: boolean; onAdvance: () => void; stack: MissionPreview[]; initialIndex: number; onIndexChange: (index: number, total: number) => void; swiperRef: React.MutableRefObject; swipeHintLabel?: string; }) { const { branding } = useEventBranding(); const radius = branding.buttons?.radius ?? 12; const primary = branding.buttons?.primary ?? branding.primaryColor; const secondary = branding.buttons?.secondary ?? branding.secondaryColor; const headingFont = branding.typography?.heading ?? branding.fontFamily ?? undefined; const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined; const cards = mission ? [mission, ...stack] : stack; const shellRadius = `${radius + 10}px`; const normalizeText = (value: string | undefined | null) => (value ?? '').trim().toLowerCase().replace(/\s+/g, ' '); const [expandedTaskId, setExpandedTaskId] = React.useState(null); const lastSlideIndexRef = React.useRef(initialIndex); const titleRefs = React.useRef(new Map()); const [expandableTitles, setExpandableTitles] = React.useState>({}); const [showSwipeHint, setShowSwipeHint] = React.useState(false); const hintTimeoutRef = React.useRef(null); const hintStorageKey = token ? `guestMissionSwipeHintSeen_${token}` : 'guestMissionSwipeHintSeen'; const motionEnabled = !prefersReducedMotion(); const shouldShowHint = motionEnabled && cards.length > 1 && Boolean(swipeHintLabel); const dismissSwipeHint = React.useCallback((persist = true) => { if (!showSwipeHint) { return; } setShowSwipeHint(false); if (hintTimeoutRef.current) { window.clearTimeout(hintTimeoutRef.current); hintTimeoutRef.current = null; } if (persist && typeof window !== 'undefined') { try { window.sessionStorage.setItem(hintStorageKey, '1'); } catch { // ignore storage exceptions } } }, [hintStorageKey, showSwipeHint]); React.useEffect(() => { if (!shouldShowHint) { setShowSwipeHint(false); return; } if (typeof window === 'undefined') { return; } try { if (window.sessionStorage.getItem(hintStorageKey)) { setShowSwipeHint(false); return; } } catch { // ignore storage exceptions } setShowSwipeHint(true); hintTimeoutRef.current = window.setTimeout(() => { setShowSwipeHint(false); try { window.sessionStorage.setItem(hintStorageKey, '1'); } catch { // ignore storage exceptions } }, 3200); return () => { if (hintTimeoutRef.current) { window.clearTimeout(hintTimeoutRef.current); hintTimeoutRef.current = null; } }; }, [hintStorageKey, shouldShowHint]); const measureTitleOverflow = React.useCallback(() => { setExpandableTitles((prev) => { let hasChange = false; const next = { ...prev }; titleRefs.current.forEach((element, id) => { if (!element || element.dataset.collapsed !== 'true') { return; } const isOverflowing = element.scrollHeight > element.clientHeight + 1; if (next[id] !== isOverflowing) { next[id] = isOverflowing; hasChange = true; } }); return hasChange ? next : prev; }); }, []); React.useLayoutEffect(() => { if (typeof window === 'undefined') { return; } const raf = window.requestAnimationFrame(measureTitleOverflow); return () => window.cancelAnimationFrame(raf); }, [measureTitleOverflow, cards, headingFont]); React.useEffect(() => { if (typeof window === 'undefined') { return; } const handleResize = () => { window.requestAnimationFrame(measureTitleOverflow); }; window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, [measureTitleOverflow]); const renderCardContent = (card: MissionPreview | null) => { const theme = getEmotionTheme(card?.emotion ?? null); const emotionIcon = getEmotionIcon(card?.emotion ?? null); const durationMinutes = card?.duration ?? 3; const titleFont = headingFont ? { fontFamily: headingFont } : undefined; const gradientBackground = card ? theme.gradientBackground : `linear-gradient(135deg, ${primary}, ${secondary})`; const isExpanded = card ? expandedTaskId === card.id : false; const isExpandable = Boolean(card && expandableTitles[card.id]); const titleClamp = isExpanded ? '' : 'line-clamp-2 sm:line-clamp-3'; const titleClasses = `text-xl font-semibold leading-snug text-slate-900 dark:text-white sm:text-2xl break-words py-1 min-h-[3.75rem] sm:min-h-[4.5rem] ${titleClamp}`; const titleId = card ? `task-title-${card.id}` : undefined; const toggleExpanded = () => { if (!card) return; setExpandedTaskId((prev) => (prev === card.id ? null : card.id)); }; return (
{emotionIcon}
{card?.emotion?.name ?? 'Fotoaufgabe'}
Foto-Challenge
ca. {durationMinutes} min
{card ? ( isExpandable ? ( ) : (

{ if (card) { titleRefs.current.set(card.id, node); } }} data-collapsed={!isExpanded} className={titleClasses} style={{ ...titleFont, textShadow: '0 6px 18px rgba(15,23,42,0.28)' }} > {card.title}

) ) : loading ? (
) : (

Ziehe deine erste Mission oder wähle eine Stimmung.

)}
{card?.description && normalizeText(card.title) !== normalizeText(card.description) ? (

{card.title}

{card.description}

) : null}
); }; const slides = cards.length ? cards : [mission ?? null]; const initialSlide = Math.min(initialIndex, Math.max(0, slides.length - 1)); return (
{showSwipeHint ? (
{swipeHintLabel}
) : null}
1} initialSlide={initialSlide} onSwiper={(instance) => { swiperRef.current = instance; if (initialSlide > 0) { instance.slideToLoop ? instance.slideToLoop(initialSlide, 0) : instance.slideTo(initialSlide, 0); } }} onSlideChange={(instance) => { const realIndex = typeof instance.realIndex === 'number' ? instance.realIndex : instance.activeIndex ?? 0; if (realIndex !== lastSlideIndexRef.current) { setExpandedTaskId(null); dismissSwipeHint(); } lastSlideIndexRef.current = realIndex; onIndexChange(realIndex, slides.length); }} className="!pb-1" style={{ paddingLeft: '0.25rem', paddingRight: '0.25rem' }} > {slides.map((card, index) => { const key = `card-${card?.id ?? 'x'}-${index}`; return (
{renderCardContent(card)}
); })}
); } function EmotionActionCard() { return ( Wähle eine Stimmung und erhalte eine passende Aufgabe Tippe deinen Mood, wir picken die nächste Mission für dich. ); } export function UploadActionCard({ token, accentColor, secondaryAccent, radius, bodyFont, requiresApproval, }: { token: string; accentColor: string; secondaryAccent: string; radius: number; bodyFont?: string; requiresApproval: boolean; }) { const inputRef = React.useRef(null); const [busy, setBusy] = React.useState(false); const [message, setMessage] = React.useState(null); const navigate = useNavigate(); const { upload, uploading, error, warning, progress, reset } = useDirectUpload({ eventToken: token, taskId: undefined, emotionSlug: undefined, onCompleted: () => { setMessage(null); navigate(`/e/${encodeURIComponent(token)}/gallery`); }, }); const onPick = React.useCallback( async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; setBusy(true); setMessage(null); try { await upload(file); } finally { setBusy(false); } if (inputRef.current) { inputRef.current.value = ''; } }, [upload] ); React.useEffect(() => { if (error) { setMessage(error); } else if (warning) { setMessage(warning); } else { setMessage(null); } }, [error, warning]); return (

Kamera öffnen oder ein Foto aus deiner Galerie wählen. Offline möglich – wir laden später hoch.

{requiresApproval ? (

Deine Fotos werden kurz geprüft und erscheinen danach in der Galerie.

) : null} {message && (

{message} {progress > 0 && progress < 100 ? `(${Math.round(progress)}%)` : ''}

)}
); }