Files
fotospiel-app/resources/js/guest/pages/TaskPickerPage.tsx

529 lines
18 KiB
TypeScript

import React from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Sparkles, RefreshCw, Smile, Timer as TimerIcon, CheckCircle2, AlertTriangle } from 'lucide-react';
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
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;
};
const TASK_PROGRESS_TARGET = 5;
const TIMER_VIBRATION = [0, 60, 120, 60];
export default function TaskPickerPage() {
const { token: slug } = useParams<{ token: string }>();
const eventKey = slug ?? '';
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const { completedCount, markCompleted, isCompleted } = useGuestTaskProgress(eventKey);
const [tasks, setTasks] = React.useState<Task[]>([]);
const [currentTask, setCurrentTask] = React.useState<Task | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [selectedEmotion, setSelectedEmotion] = React.useState<string>('all');
const [timeLeft, setTimeLeft] = React.useState<number>(0);
const [timerRunning, setTimerRunning] = React.useState(false);
const [timeUp, setTimeUp] = React.useState(false);
const [isFetching, setIsFetching] = React.useState(false);
const recentTaskIdsRef = React.useRef<number[]>([]);
const initialEmotionRef = React.useRef(false);
const fetchTasks = React.useCallback(async () => {
if (!eventKey) return;
setIsFetching(true);
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}/tasks`);
if (!response.ok) throw new Error('Aufgaben konnten nicht geladen werden.');
const payload = await response.json();
if (Array.isArray(payload)) {
setTasks(payload);
} else {
setTasks([]);
}
} catch (err) {
console.error('Failed to load tasks', err);
setError(err instanceof Error ? err.message : 'Unbekannter Fehler');
setTasks([]);
} finally {
setIsFetching(false);
setLoading(false);
}
}, [eventKey]);
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<EmotionOption[]>(() => {
const map = new Map<string, string>();
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 filteredTasks = React.useMemo(() => {
if (selectedEmotion === 'all') return tasks;
return tasks.filter((task) => task.emotion?.slug === selectedEmotion);
}, [tasks, selectedEmotion]);
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]
);
React.useEffect(() => {
if (!filteredTasks.length) {
setCurrentTask(null);
return;
}
if (!currentTask || !filteredTasks.some((task) => task.id === currentTask.id)) {
selectRandomTask(filteredTasks);
return;
}
const matchingTask = filteredTasks.find((task) => task.id === currentTask.id);
const durationMinutes = matchingTask?.duration ?? currentTask.duration;
setTimeLeft(durationMinutes * 60);
setTimerRunning(false);
setTimeUp(false);
}, [filteredTasks, currentTask, selectRandomTask]);
React.useEffect(() => {
if (!currentTask) {
setTimeLeft(0);
setTimerRunning(false);
setTimeUp(false);
return;
}
setTimeLeft(currentTask.duration * 60);
setTimerRunning(false);
setTimeUp(false);
}, [currentTask]);
React.useEffect(() => {
if (!timerRunning) return;
if (timeLeft <= 0) {
setTimerRunning(false);
triggerTimeUp();
return;
}
const tick = window.setInterval(() => {
setTimeLeft((prev) => {
if (prev <= 1) {
window.clearInterval(tick);
triggerTimeUp();
return 0;
}
return prev - 1;
});
}, 1000);
return () => window.clearInterval(tick);
}, [timerRunning, timeLeft]);
function triggerTimeUp() {
const supportsVibration = typeof navigator !== 'undefined' && typeof navigator.vibrate === 'function';
setTimerRunning(false);
setTimeUp(true);
if (supportsVibration) {
try {
navigator.vibrate(TIMER_VIBRATION);
} catch (error) {
console.warn('Vibration not permitted', error);
}
}
window.setTimeout(() => setTimeUp(false), 4000);
return;
}
const formatTime = React.useCallback((seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.max(0, seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}, []);
const progressRatio = currentTask ? Math.min(1, completedCount / TASK_PROGRESS_TARGET) : 0;
const handleSelectEmotion = (slugValue: string) => {
setSelectedEmotion(slugValue);
const next = new URLSearchParams(searchParams.toString());
if (slugValue === 'all') {
next.delete('emotion');
} else {
next.set('emotion', slugValue);
}
setSearchParams(next, { replace: true });
};
const handleNewTask = () => {
selectRandomTask(filteredTasks);
};
const handleStartUpload = () => {
if (!currentTask || !eventKey) return;
if (!eventKey) return;
navigate(`/e/${encodeURIComponent(eventKey)}/upload?task=${currentTask.id}&emotion=${currentTask.emotion?.slug || ''}`);
};
const handleMarkCompleted = () => {
if (!currentTask) return;
markCompleted(currentTask.id);
selectRandomTask(filteredTasks);
};
const handleRetryFetch = () => {
fetchTasks();
};
const handleTimerToggle = () => {
if (!currentTask) return;
if (timerRunning) {
setTimerRunning(false);
setTimeLeft(currentTask.duration * 60);
setTimeUp(false);
} else {
if (timeLeft <= 0) {
setTimeLeft(currentTask.duration * 60);
}
setTimerRunning(true);
setTimeUp(false);
}
};
const emptyState = !loading && (!filteredTasks.length || !currentTask);
return (
<div className="space-y-6">
<header className="space-y-3">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-semibold text-foreground">Aufgabe auswaehlen</h1>
<Badge variant="secondary" className="whitespace-nowrap">
Schon {completedCount} Aufgaben erledigt
</Badge>
</div>
<div className="rounded-xl border bg-muted/40 p-4">
<div className="flex items-center justify-between text-sm font-medium text-muted-foreground">
<span>Auf dem Weg zum naechsten Erfolg</span>
<span>
{completedCount >= TASK_PROGRESS_TARGET
? 'Stark!'
: `${Math.max(0, TASK_PROGRESS_TARGET - completedCount)} bis zum Badge`}
</span>
</div>
<div className="mt-3 h-2 w-full rounded-full bg-muted">
<div
className="h-2 rounded-full bg-gradient-to-r from-pink-500 to-purple-500 transition-all"
style={{ width: `${progressRatio * 100}%` }}
/>
</div>
</div>
{emotionOptions.length > 0 && (
<div className="flex flex-wrap gap-2">
<EmotionChip
active={selectedEmotion === 'all'}
label="Alle Stimmungen"
onClick={() => handleSelectEmotion('all')}
/>
{emotionOptions.map((emotion) => (
<EmotionChip
key={emotion.slug}
active={selectedEmotion === emotion.slug}
label={emotion.name}
onClick={() => handleSelectEmotion(emotion.slug)}
/>
))}
</div>
)}
</header>
{loading && (
<div className="space-y-4">
<SkeletonBlock />
<SkeletonBlock />
</div>
)}
{error && !loading && (
<Alert variant="destructive">
<AlertDescription className="flex items-center justify-between gap-3">
<span>{error}</span>
<Button variant="outline" size="sm" onClick={handleRetryFetch} disabled={isFetching}>
Erneut versuchen
</Button>
</AlertDescription>
</Alert>
)}
{emptyState && (
<EmptyState
hasTasks={Boolean(tasks.length)}
onRetry={handleRetryFetch}
emotionOptions={emotionOptions}
onEmotionSelect={handleSelectEmotion}
/>
)}
{!emptyState && currentTask && (
<div className="space-y-6">
<article className="overflow-hidden rounded-2xl border bg-card">
<div className="relative">
<div className="flex aspect-video items-center justify-center bg-gradient-to-br from-pink-500/80 via-purple-500/60 to-indigo-500/60 text-white">
<div className="text-center">
<Sparkles className="mx-auto mb-3 h-10 w-10" />
<p className="text-sm uppercase tracking-[.2em]">Deine Mission</p>
<h2 className="mt-2 text-2xl font-semibold">{currentTask.title}</h2>
</div>
</div>
<div className="absolute right-3 top-3 flex flex-col items-end gap-2">
<BadgeTimer
label="Countdown"
value={formatTime(timeLeft)}
tone={timerTone(timeLeft, currentTask.duration)}
/>
{timeUp && (
<Badge variant="destructive" className="flex items-center gap-1">
<AlertTriangle className="h-3.5 w-3.5" />
Zeit abgelaufen!
</Badge>
)}
</div>
</div>
<div className="space-y-4 p-5">
<div className="flex flex-wrap gap-2">
<Badge variant="outline" className="flex items-center gap-2">
<TimerIcon className="h-4 w-4" />
{currentTask.duration} Min
</Badge>
{currentTask.emotion?.name && (
<Badge variant="outline" className="flex items-center gap-2">
<Smile className="h-4 w-4" />
{currentTask.emotion.name}
</Badge>
)}
{isCompleted(currentTask.id) && (
<Badge variant="secondary" className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4" />
Bereits erledigt
</Badge>
)}
</div>
<p className="text-sm leading-relaxed text-muted-foreground">{currentTask.description}</p>
{currentTask.instructions && (
<div className="rounded-xl border border-dashed border-pink-200 bg-pink-50/60 p-4 text-sm font-medium text-pink-800 dark:border-pink-500/40 dark:bg-pink-500/10 dark:text-pink-100">
{currentTask.instructions}
</div>
)}
<ul className="space-y-2 text-sm">
<ChecklistItem text="Stimme dich auf die Aufgabe ein." />
<ChecklistItem text="Hol dir dein Team oder Motiv ins Bild." />
<ChecklistItem text="Halte Emotion und Aufgabe im Foto fest." />
</ul>
{timerRunning && currentTask.duration > 0 && (
<div className="mt-4">
<div className="mb-1 flex items-center justify-between text-xs text-muted-foreground">
<span>Countdown</span>
<span>Restzeit: {formatTime(timeLeft)}</span>
</div>
<div className="h-2 w-full rounded-full bg-muted">
<div
className="h-2 rounded-full bg-gradient-to-r from-amber-400 to-rose-500 transition-all"
style={{
width: `${Math.max(0, Math.min(100, (timeLeft / (currentTask.duration * 60)) * 100))}%`,
}}
/>
</div>
</div>
)}
</div>
</article>
<div className="grid gap-3 sm:grid-cols-2">
<Button onClick={handleStartUpload} className="h-14 text-base font-semibold">
<span className="flex items-center justify-center gap-2">
<Sparkles className="h-5 w-5" />
Los geht's
</span>
</Button>
<Button
variant={timerRunning ? 'destructive' : 'outline'}
onClick={handleTimerToggle}
className="h-14 text-base"
>
<span className="flex items-center justify-center gap-2">
<TimerIcon className="h-5 w-5" />
{timerRunning ? 'Timer stoppen' : 'Timer starten'}
</span>
</Button>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<Button variant="secondary" onClick={handleMarkCompleted} className="h-12">
<span className="flex items-center justify-center gap-2">
<CheckCircle2 className="h-5 w-5" />
Aufgabe erledigt
</span>
</Button>
<Button variant="ghost" onClick={handleNewTask} className="h-12">
<span className="flex items-center justify-center gap-2">
<RefreshCw className="h-5 w-5" />
Neue Aufgabe anzeigen
</span>
</Button>
</div>
</div>
)}
{!loading && !tasks.length && !error && (
<Alert>
<AlertDescription>Fuer dieses Event sind derzeit keine Aufgaben hinterlegt.</AlertDescription>
</Alert>
)}
</div>
);
}
function timerTone(timeLeft: number, durationMinutes: number) {
const totalSeconds = Math.max(1, durationMinutes * 60);
const ratio = timeLeft / totalSeconds;
if (ratio > 0.5) return 'okay';
if (ratio > 0.25) return 'warm';
return 'hot';
}
function EmotionChip({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className={`rounded-full border px-4 py-1 text-sm transition ${
active
? 'border-pink-500 bg-pink-500 text-white shadow-sm'
: 'border-border bg-background text-muted-foreground hover:border-pink-400 hover:text-foreground'
}`}
>
{label}
</button>
);
}
function ChecklistItem({ text }: { text: string }) {
return (
<li className="flex items-start gap-2">
<CheckCircle2 className="mt-0.5 h-4 w-4 text-emerald-500" />
<span className="text-muted-foreground">{text}</span>
</li>
);
}
function BadgeTimer({ label, value, tone }: { label: string; value: string; tone: 'okay' | 'warm' | 'hot' }) {
const toneClasses = {
okay: 'bg-emerald-500/15 text-emerald-500 border-emerald-500/30',
warm: 'bg-amber-500/15 text-amber-500 border-amber-500/30',
hot: 'bg-rose-500/15 text-rose-500 border-rose-500/30',
}[tone];
return (
<div className={`flex items-center gap-1 rounded-full border px-3 py-1 text-xs font-medium ${toneClasses}`}>
<TimerIcon className="h-3.5 w-3.5" />
<span>{label}</span>
<span>{value}</span>
</div>
);
}
function SkeletonBlock() {
return <div className="h-24 animate-pulse rounded-xl bg-muted/60" />;
}
function EmptyState({
hasTasks,
onRetry,
emotionOptions,
onEmotionSelect,
}: {
hasTasks: boolean;
onRetry: () => void;
emotionOptions: EmotionOption[];
onEmotionSelect: (slug: string) => void;
}) {
return (
<div className="flex flex-col items-center justify-center gap-4 rounded-2xl border border-dashed border-muted-foreground/30 bg-muted/20 p-8 text-center">
<Smile className="h-12 w-12 text-pink-500" />
<div className="space-y-2">
<h2 className="text-xl font-semibold">Keine passende Aufgabe gefunden</h2>
<p className="text-sm text-muted-foreground">
{hasTasks
? 'Fuer deine aktuelle Stimmung gibt es gerade keine Aufgabe. Waehle eine andere Stimmung oder lade neue Aufgaben.'
: 'Hier sind noch keine Aufgaben hinterlegt. Bitte versuche es spaeter erneut.'}
</p>
</div>
{hasTasks && emotionOptions.length > 0 && (
<div className="flex flex-wrap justify-center gap-2">
{emotionOptions.map((emotion) => (
<EmotionChip
key={emotion.slug}
label={emotion.name}
active={false}
onClick={() => onEmotionSelect(emotion.slug)}
/>
))}
</div>
)}
<Button onClick={onRetry} variant="outline" className="mt-2">
Aufgaben neu laden
</Button>
</div>
);
}