import React from 'react'; import { useNavigate, useNavigationType, useParams, useSearchParams } from 'react-router-dom'; import { Button } from '@/components/ui/button'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Sparkles, RefreshCw, Smile, Camera, Timer as TimerIcon, Heart, ChevronRight, CheckCircle2 } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress'; import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; import { cn } from '@/lib/utils'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { useEventBranding } from '../context/EventBrandingContext'; import { useTranslation, type TranslateFn } from '../i18n/useTranslation'; import { motion } from 'framer-motion'; import { getEmotionIcon, getEmotionTheme, type EmotionIdentity, type EmotionTheme, } from '../lib/emotionTheme'; import { getDeviceId } from '../lib/device'; import { FADE_SCALE, FADE_UP, STAGGER_FAST, getMotionContainerPropsForNavigation, getMotionItemProps, prefersReducedMotion } from '../lib/motion'; import PullToRefresh from '../components/PullToRefresh'; import { triggerHaptic } from '../lib/haptics'; import { dedupeTasksById } from '../lib/taskUtils'; interface Task { id: number; title: string; description: string; instructions: string; duration: number; // minutes emotion?: { slug: string; name: string; }; is_completed: boolean; } type EmotionOption = { slug: string; name: string; }; type EventPhoto = { id: number; thumbnail_path?: string | null; file_path?: string | null; likes_count?: number | null; task_id?: number | null; }; const SWIPE_THRESHOLD_PX = 40; const SIMILAR_PHOTO_LIMIT = 6; export default function TaskPickerPage() { const { token } = useParams<{ token: string }>(); const eventKey = token ?? ''; const navigate = useNavigate(); const navigationType = useNavigationType(); const [searchParams, setSearchParams] = useSearchParams(); const { branding } = useEventBranding(); const { t, locale } = useTranslation(); const radius = branding.buttons?.radius ?? 12; const buttonStyle = branding.buttons?.style ?? 'filled'; const linkColor = branding.buttons?.linkColor ?? branding.secondaryColor; const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined; const headingFont = branding.typography?.heading ?? branding.fontFamily ?? undefined; const { isCompleted } = useGuestTaskProgress(eventKey); const [tasks, setTasks] = React.useState([]); const [currentTask, setCurrentTask] = React.useState(null); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); const [selectedEmotion, setSelectedEmotion] = React.useState('all'); const [isFetching, setIsFetching] = React.useState(false); const [photoPool, setPhotoPool] = React.useState([]); const [photoPoolLoading, setPhotoPoolLoading] = React.useState(false); const [photoPoolError, setPhotoPoolError] = React.useState(null); const [hasSwiped, setHasSwiped] = React.useState(false); const [emotionPickerOpen, setEmotionPickerOpen] = React.useState(false); const [recentEmotionSlug, setRecentEmotionSlug] = React.useState(null); const heroCardRef = React.useRef(null); const cameraButtonStyle = React.useMemo(() => ({ background: `radial-gradient(circle at 20% 20%, ${branding.secondaryColor}, ${branding.primaryColor})`, boxShadow: `0 18px 30px ${branding.primaryColor}44`, color: '#ffffff', }), [branding.primaryColor, branding.secondaryColor]); const recentTaskIdsRef = React.useRef([]); const tasksCacheRef = React.useRef>(new Map()); const initialEmotionRef = React.useRef(false); const fetchTasks = React.useCallback(async () => { if (!eventKey) return; const cacheKey = `${eventKey}:${locale}`; const cached = tasksCacheRef.current.get(cacheKey); setIsFetching(true); setLoading(!cached); setError(null); if (cached) { setTasks(cached.data); } try { const headers: HeadersInit = { Accept: 'application/json', 'X-Locale': locale, 'X-Device-Id': getDeviceId(), }; if (cached?.etag) { headers['If-None-Match'] = cached.etag; } const response = await fetch( `/api/v1/events/${encodeURIComponent(eventKey)}/tasks?locale=${encodeURIComponent(locale)}`, { headers } ); if (response.status === 304 && cached) { return; } if (!response.ok) throw new Error('Aufgaben konnten nicht geladen werden.'); const payload = await response.json(); const taskList: Task[] = Array.isArray(payload) ? payload : Array.isArray(payload?.data) ? payload.data : Array.isArray(payload?.tasks) ? payload.tasks : []; const uniqueTasks = dedupeTasksById(taskList); const entry = { data: uniqueTasks, etag: response.headers.get('ETag') }; tasksCacheRef.current.set(cacheKey, entry); setTasks(uniqueTasks); } catch (err) { console.error('Failed to load tasks', err); setError(err instanceof Error ? err.message : 'Unbekannter Fehler'); if (!cached) { setTasks([]); } } finally { setIsFetching(false); setLoading(false); } }, [eventKey, locale]); React.useEffect(() => { fetchTasks(); }, [fetchTasks]); React.useEffect(() => { if (initialEmotionRef.current) return; const queryEmotion = searchParams.get('emotion'); if (queryEmotion) { setSelectedEmotion(queryEmotion); } initialEmotionRef.current = true; }, [searchParams]); const emotionOptions = React.useMemo(() => { const map = new Map(); tasks.forEach((task) => { if (task.emotion?.slug) { map.set(task.emotion.slug, task.emotion.name); } }); return Array.from(map.entries()).map(([slugValue, name]) => ({ slug: slugValue, name })); }, [tasks]); const emotionCounts = React.useMemo(() => { const map = new Map(); tasks.forEach((task) => { const slugValue = task.emotion?.slug; if (!slugValue) return; map.set(slugValue, (map.get(slugValue) ?? 0) + 1); }); return map; }, [tasks]); const filteredTasks = React.useMemo(() => { if (selectedEmotion === 'all') return tasks; return tasks.filter((task) => task.emotion?.slug === selectedEmotion); }, [tasks, selectedEmotion]); const alternativeTasks = React.useMemo(() => { return filteredTasks.filter((task) => task.id !== currentTask?.id).slice(0, 6); }, [filteredTasks, currentTask]); const selectRandomTask = React.useCallback( (list: Task[]) => { if (!list.length) { setCurrentTask(null); return; } const avoidIds = recentTaskIdsRef.current; const available = list.filter((task) => !isCompleted(task.id)); const base = available.length ? available : list; let candidates = base.filter((task) => !avoidIds.includes(task.id)); if (!candidates.length) { candidates = base; } const chosen = candidates[Math.floor(Math.random() * candidates.length)]; setCurrentTask(chosen); recentTaskIdsRef.current = [...avoidIds.filter((id) => id !== chosen.id), chosen.id].slice(-3); }, [isCompleted] ); const handleSelectEmotion = React.useCallback( (slugValue: string) => { setSelectedEmotion(slugValue); const next = new URLSearchParams(searchParams.toString()); if (slugValue === 'all') { next.delete('emotion'); } else { next.set('emotion', slugValue); setRecentEmotionSlug(slugValue); } setSearchParams(next, { replace: true }); }, [searchParams, setSearchParams] ); const handleNewTask = React.useCallback(() => { selectRandomTask(filteredTasks); triggerHaptic('selection'); }, [filteredTasks, selectRandomTask]); const handleStartUpload = () => { if (!currentTask || !eventKey) return; triggerHaptic('light'); navigate(`/e/${encodeURIComponent(eventKey)}/upload?task=${currentTask.id}&emotion=${currentTask.emotion?.slug || ''}`); }; const handleViewSimilar = React.useCallback(() => { if (!currentTask || !eventKey) return; navigate(`/e/${encodeURIComponent(eventKey)}/gallery?task=${currentTask.id}`); }, [currentTask, eventKey, navigate]); const handleSelectTask = React.useCallback((task: Task) => { setCurrentTask(task); triggerHaptic('selection'); }, []); const handleRetryFetch = () => { fetchTasks(); }; const handleRefresh = React.useCallback(async () => { tasksCacheRef.current.clear(); await fetchTasks(); setPhotoPool([]); setPhotoPoolError(null); }, [fetchTasks]); const handlePhotoPreview = React.useCallback( (photoId: number) => { if (!eventKey) return; navigate(`/e/${encodeURIComponent(eventKey)}/gallery?photoId=${photoId}&task=${currentTask?.id ?? ''}`); }, [eventKey, navigate, currentTask?.id] ); React.useEffect(() => { if (!filteredTasks.length) { setCurrentTask(null); return; } if (!currentTask || !filteredTasks.some((task) => task.id === currentTask.id)) { selectRandomTask(filteredTasks); } }, [filteredTasks, currentTask, selectRandomTask]); React.useEffect(() => { if (currentTask?.emotion?.slug) { setRecentEmotionSlug(currentTask.emotion.slug); } }, [currentTask?.emotion?.slug]); React.useEffect(() => { if (!eventKey || photoPool.length) return; const controller = new AbortController(); setPhotoPoolLoading(true); setPhotoPoolError(null); fetch(`/api/v1/events/${encodeURIComponent(eventKey)}/photos?locale=${encodeURIComponent(locale)}`, { signal: controller.signal, headers: { Accept: 'application/json', 'X-Locale': locale, }, }) .then((res) => { if (!res.ok) { throw new Error(t('tasks.page.inspirationError')); } return res.json(); }) .then((payload) => { const data = Array.isArray(payload?.data) ? (payload.data as EventPhoto[]) : []; setPhotoPool(data); }) .catch((err) => { if (controller.signal.aborted) return; console.error('Failed to load photos', err); setPhotoPoolError(t('tasks.page.inspirationError')); }) .finally(() => { if (!controller.signal.aborted) { setPhotoPoolLoading(false); } }); return () => controller.abort(); }, [eventKey, photoPool.length, t, locale]); const similarPhotos = React.useMemo(() => { if (!currentTask) return []; const matches = photoPool.filter((photo) => photo.task_id === currentTask.id); return matches.slice(0, SIMILAR_PHOTO_LIMIT); }, [photoPool, currentTask]); React.useEffect(() => { const card = heroCardRef.current; if (!card) return; let startX: number | null = null; let startY: number | null = null; const onTouchStart = (event: TouchEvent) => { const touch = event.touches[0]; startX = touch.clientX; startY = touch.clientY; }; const onTouchEnd = (event: TouchEvent) => { if (startX === null || startY === null) return; const touch = event.changedTouches[0]; const deltaX = touch.clientX - startX; const deltaY = touch.clientY - startY; if (Math.abs(deltaX) > SWIPE_THRESHOLD_PX && Math.abs(deltaY) < 60) { if (deltaX < 0) { handleNewTask(); } else { handleViewSimilar(); } setHasSwiped(true); } startX = null; startY = null; }; card.addEventListener('touchstart', onTouchStart, { passive: true }); card.addEventListener('touchend', onTouchEnd); return () => { card.removeEventListener('touchstart', onTouchStart); card.removeEventListener('touchend', onTouchEnd); }; }, [handleNewTask, handleViewSimilar]); const emptyState = !loading && (!filteredTasks.length || !currentTask); const heroTheme = React.useMemo(() => getEmotionTheme(currentTask?.emotion ?? null), [currentTask?.emotion]); const heroEmotionIcon = getEmotionIcon(currentTask?.emotion ?? null); const recentEmotionOption = React.useMemo( () => emotionOptions.find((option) => option.slug === recentEmotionSlug) ?? null, [emotionOptions, recentEmotionSlug] ); const toggleValue = selectedEmotion === 'all' ? 'none' : 'recent'; const motionEnabled = !prefersReducedMotion(); const containerMotion = getMotionContainerPropsForNavigation(motionEnabled, STAGGER_FAST, navigationType); const fadeUpMotion = getMotionItemProps(motionEnabled, FADE_UP); const fadeScaleMotion = getMotionItemProps(motionEnabled, FADE_SCALE); const handleToggleChange = React.useCallback( (value: string) => { if (!value) return; if (value === 'picker') { setEmotionPickerOpen(true); return; } if (value === 'none') { handleSelectEmotion('all'); return; } if (value === 'recent') { if (recentEmotionSlug) { handleSelectEmotion(recentEmotionSlug); } else { setEmotionPickerOpen(true); } } }, [handleSelectEmotion, recentEmotionSlug] ); return ( <>

{t('tasks.page.eyebrow')}

{t('tasks.page.title')}

{t('tasks.page.subtitle')}

{emotionOptions.length > 0 && ( 🎲 {t('tasks.page.filters.none')} {getEmotionIcon(recentEmotionOption)} {recentEmotionOption?.name ?? t('tasks.page.filters.recentFallback')} 🗂️ {t('tasks.page.filters.showAll')} )}
{loading && ( )} {error && !loading && ( {error} )} {emptyState && ( )} {!emptyState && currentTask && (
{heroEmotionIcon} {currentTask.emotion?.name ?? 'Neue Mission'} {currentTask.duration} Min

{currentTask.title}

{currentTask.description}

{!hasSwiped && (

{t('tasks.page.swipeHint')}

)} {currentTask.instructions && (
{currentTask.instructions}
)}
{isCompleted(currentTask.id) && ( {t('tasks.page.completedLabel')} )}
{(photoPoolLoading || photoPoolError || similarPhotos.length > 0) && (
{t('tasks.page.inspirationTitle')} {photoPoolLoading && {t('tasks.page.inspirationLoading')}}
{photoPoolError && similarPhotos.length === 0 ? (

{photoPoolError}

) : similarPhotos.length > 0 ? (
{similarPhotos.map((photo) => ( ))}
) : ( )}
)}
{alternativeTasks.length > 0 && (

{t('tasks.page.suggestionsEyebrow')}

{t('tasks.page.suggestionsTitle')}

{alternativeTasks.map((task) => ( ))}
)}
)} {!loading && !tasks.length && !error && ( {t('tasks.page.noTasksAlert')} )}
{t('tasks.page.filters.dialogTitle')} {emotionOptions.length ? (
{emotionOptions.map((emotion) => { const count = emotionCounts.get(emotion.slug) ?? 0; return ( ); })}
) : (

{t('tasks.page.filters.empty')}

)}
); } function SkeletonBlock() { return
; } function EmptyState({ hasTasks, onRetry, emotionOptions, onEmotionSelect, t, }: { hasTasks: boolean; onRetry: () => void; emotionOptions: EmotionOption[]; onEmotionSelect: (slug: string) => void; t: TranslateFn; }) { return (

{t('tasks.page.emptyTitle')}

{hasTasks ? t('tasks.page.emptyDescriptionWithTasks') : t('tasks.page.emptyDescriptionNoTasks')}

{hasTasks && emotionOptions.length > 0 && (
{emotionOptions.map((emotion) => ( ))}
)}
); } function HeroActionButton({ icon: Icon, label, detail, onClick, className, }: { icon: LucideIcon; label: string; detail?: string; onClick: () => void; className?: string; }) { return ( ); } function SimilarPhotoChip({ photo, onOpen }: { photo: EventPhoto; onOpen: (photoId: number) => void }) { const cover = photo.thumbnail_path || photo.file_path || ''; return ( ); } function TaskSuggestionCard({ task, onSelect }: { task: Task; onSelect: (task: Task) => void }) { const theme = getEmotionTheme(task.emotion ?? null); const emotionIcon = getEmotionIcon(task.emotion ?? null); return ( ); }