feat: implement tenant OAuth flow and guest achievements
This commit is contained in:
@@ -1,19 +1,17 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { Page } from './_util';
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAppearance } from '../../hooks/use-appearance';
|
||||
import { Clock, RefreshCw, Smile } from 'lucide-react';
|
||||
import BottomNav from '../components/BottomNav';
|
||||
import { useEventData } from '../hooks/useEventData';
|
||||
import { EventData } from '../services/eventApi';
|
||||
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; // in minutes
|
||||
duration: number; // minutes
|
||||
emotion?: {
|
||||
slug: string;
|
||||
name: string;
|
||||
@@ -21,244 +19,508 @@ interface Task {
|
||||
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 { slug } = useParams<{ slug: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
// emotionSlug = searchParams.get('emotion'); // Temporär deaktiviert, da API-Filter nicht verfügbar
|
||||
const navigate = useNavigate();
|
||||
const { appearance } = useAppearance();
|
||||
const isDark = appearance === 'dark';
|
||||
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [currentTask, setCurrentTask] = useState<Task | null>(null);
|
||||
const [timeLeft, setTimeLeft] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
// Timer state
|
||||
useEffect(() => {
|
||||
if (!currentTask) return;
|
||||
|
||||
const durationMs = currentTask.duration * 60 * 1000;
|
||||
setTimeLeft(durationMs / 1000);
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setTimeLeft(prev => {
|
||||
const { completedCount, markCompleted, isCompleted } = useGuestTaskProgress(slug);
|
||||
|
||||
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 (!slug) return;
|
||||
setIsFetching(true);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`/api/v1/events/${encodeURIComponent(slug)}/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);
|
||||
}
|
||||
}, [slug]);
|
||||
|
||||
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) {
|
||||
clearInterval(interval);
|
||||
window.clearInterval(tick);
|
||||
triggerTimeUp();
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
return () => window.clearInterval(tick);
|
||||
}, [timerRunning, timeLeft]);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [currentTask]);
|
||||
|
||||
// Load tasks
|
||||
useEffect(() => {
|
||||
if (!slug) return;
|
||||
|
||||
async function fetchTasks() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const url = `/api/v1/events/${slug}/tasks`;
|
||||
console.log('Fetching tasks from:', url); // Debug
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error('Tasks konnten nicht geladen werden');
|
||||
|
||||
const data = await response.json();
|
||||
setTasks(Array.isArray(data) ? data : []);
|
||||
|
||||
console.log('Loaded tasks:', data); // Debug
|
||||
|
||||
// Select random task
|
||||
if (data.length > 0) {
|
||||
const randomIndex = Math.floor(Math.random() * data.length);
|
||||
setCurrentTask(data[randomIndex]);
|
||||
console.log('Selected random task:', data[randomIndex]); // Debug
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Fetch tasks error:', err);
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
fetchTasks();
|
||||
}, [slug]);
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const formatTime = React.useCallback((seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = 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 = () => {
|
||||
if (tasks.length === 0) return;
|
||||
const randomIndex = Math.floor(Math.random() * tasks.length);
|
||||
setCurrentTask(tasks[randomIndex]);
|
||||
setTimeLeft(tasks[randomIndex].duration * 60);
|
||||
selectRandomTask(filteredTasks);
|
||||
};
|
||||
|
||||
const handleStartTask = () => {
|
||||
const handleStartUpload = () => {
|
||||
if (!currentTask || !slug) return;
|
||||
navigate(`/e/${encodeURIComponent(slug)}/upload?task=${currentTask.id}&emotion=${currentTask.emotion?.slug || ''}`);
|
||||
};
|
||||
|
||||
const handleMarkCompleted = () => {
|
||||
if (!currentTask) return;
|
||||
// Navigate to upload with task context
|
||||
navigate(`/e/${slug}/upload?task=${currentTask.id}&emotion=${currentTask.emotion?.slug || ''}`);
|
||||
markCompleted(currentTask.id);
|
||||
selectRandomTask(filteredTasks);
|
||||
};
|
||||
|
||||
const handleChangeMood = () => {
|
||||
navigate(`/e/${slug}`);
|
||||
const handleRetryFetch = () => {
|
||||
fetchTasks();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Page title="Aufgabe laden...">
|
||||
<div className={`flex flex-col items-center justify-center min-h-[60vh] p-4 ${
|
||||
isDark ? 'text-white' : 'text-gray-900'
|
||||
}`}>
|
||||
<RefreshCw className="h-8 w-8 animate-spin mb-4" />
|
||||
<p className="text-sm">Lade Aufgabe...</p>
|
||||
</div>
|
||||
<BottomNav />
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
if (error || !currentTask) {
|
||||
return (
|
||||
<Page title="Keine Aufgaben verfügbar">
|
||||
<div className={`flex flex-col items-center justify-center min-h-[60vh] p-4 space-y-4 ${
|
||||
isDark ? 'text-white' : 'text-gray-900'
|
||||
}`}>
|
||||
<Smile className="h-12 w-12 text-pink-500" />
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold mb-2">Keine passende Aufgabe gefunden</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-md">
|
||||
{error || 'Für deine Stimmung gibt es derzeit keine Aufgaben. Versuche eine andere Stimmung oder warte auf neue Inhalte.'}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleChangeMood}
|
||||
className="w-full max-w-sm"
|
||||
>
|
||||
Andere Stimmung wählen
|
||||
</Button>
|
||||
</div>
|
||||
<BottomNav />
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
const emptyState = !loading && (!filteredTasks.length || !currentTask);
|
||||
|
||||
return (
|
||||
<Page title={currentTask.title}>
|
||||
<div className={`min-h-screen flex flex-col ${
|
||||
isDark ? 'bg-gray-900 text-white' : 'bg-white text-gray-900'
|
||||
}`}>
|
||||
{/* Task Header with Selfie Overlay */}
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="relative">
|
||||
{/* Selfie Placeholder */}
|
||||
<div className={`w-full aspect-square rounded-2xl bg-gradient-to-br ${
|
||||
isDark
|
||||
? 'from-gray-800 to-gray-700 shadow-2xl'
|
||||
: 'from-pink-50 to-pink-100 shadow-lg'
|
||||
} flex items-center justify-center`}>
|
||||
<div className="text-center space-y-2">
|
||||
<div className={`w-20 h-20 rounded-full bg-white/20 flex items-center justify-center mx-auto mb-2 ${
|
||||
isDark ? 'text-white' : 'text-gray-600'
|
||||
}`}>
|
||||
📸
|
||||
</div>
|
||||
<p className={`text-sm font-medium ${
|
||||
isDark ? 'text-gray-300' : 'text-gray-600'
|
||||
}`}>
|
||||
Selfie-Vorschau
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timer */}
|
||||
<div className="absolute top-2 right-2">
|
||||
<div className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${
|
||||
timeLeft > 60
|
||||
? 'bg-green-500/20 text-green-400 border-green-500/30'
|
||||
: timeLeft > 30
|
||||
? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30'
|
||||
: 'bg-red-500/20 text-red-400 border-red-500/30'
|
||||
} border`}>
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>{formatTime(timeLeft)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{/* Task Description Overlay */}
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 via-transparent to-transparent p-4 rounded-b-2xl">
|
||||
<div className="space-y-2">
|
||||
<h1 className={`text-xl font-bold ${
|
||||
isDark ? 'text-gray-100' : 'text-white'
|
||||
}`}>
|
||||
{currentTask.title}
|
||||
</h1>
|
||||
<p className={`text-sm leading-relaxed ${
|
||||
isDark ? 'text-gray-200' : 'text-gray-100'
|
||||
}`}>
|
||||
{currentTask.description}
|
||||
</p>
|
||||
{currentTask.instructions && (
|
||||
<div className={`p-2 rounded-lg ${
|
||||
isDark
|
||||
? 'bg-gray-700/80 text-gray-100'
|
||||
: 'bg-gray-800/80 text-white border border-gray-600/50'
|
||||
}`}>
|
||||
<p className="text-xs italic">💡 {currentTask.instructions}</p>
|
||||
</div>
|
||||
{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>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="px-4 pb-4 space-y-3">
|
||||
<Button
|
||||
onClick={handleStartTask}
|
||||
className="w-full h-14 bg-gradient-to-r from-pink-500 to-pink-600 hover:from-pink-600 hover:to-pink-700 text-white rounded-xl text-base font-semibold shadow-lg hover:shadow-xl transition-all duration-200"
|
||||
>
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
📸 Los geht's
|
||||
</span>
|
||||
</Button>
|
||||
<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>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleNewTask}
|
||||
className="flex-1 h-12 border-gray-300 dark:border-gray-600 text-sm rounded-xl transition-all duration-200 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Neue Aufgabe
|
||||
<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="ghost"
|
||||
onClick={handleChangeMood}
|
||||
className="flex-1 h-12 text-sm rounded-xl transition-all duration-200 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
<Button
|
||||
variant={timerRunning ? 'destructive' : 'outline'}
|
||||
onClick={handleTimerToggle}
|
||||
className="h-14 text-base"
|
||||
>
|
||||
<Smile className="h-4 w-4 mr-2" />
|
||||
Andere Stimmung
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Bottom Navigation */}
|
||||
<BottomNav />
|
||||
</div>
|
||||
</Page>
|
||||
{!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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user