Files
fotospiel-app/resources/js/guest-v2/screens/TasksScreen.tsx
Codex Agent 6062b4201b
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Update guest v2 home and tasks experience
2026-02-03 18:59:30 +01:00

381 lines
14 KiB
TypeScript

import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { Trophy, Play } from 'lucide-react';
import AppShell from '../components/AppShell';
import TaskHeroCard, { type TaskHero } from '../components/TaskHeroCard';
import { useEventData } from '../context/EventDataContext';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { useLocale } from '@/guest/i18n/LocaleContext';
import { fetchTasks } from '../services/tasksApi';
import { fetchEmotions } from '../services/emotionsApi';
import { useGuestThemeVariant } from '../lib/guestTheme';
import { useNavigate } from 'react-router-dom';
import { useGuestTaskProgress } from '@/guest/hooks/useGuestTaskProgress';
import { getBentoSurfaceTokens } from '../lib/bento';
import { buildEventPath } from '../lib/routes';
type TaskItem = TaskHero & {
points?: number;
};
export default function TasksScreen() {
const { tasksEnabled, token } = useEventData();
const { t } = useTranslation();
const { locale } = useLocale();
const navigate = useNavigate();
const { completedCount, isCompleted } = useGuestTaskProgress(token ?? undefined);
const { isDark } = useGuestThemeVariant();
const bentoSurface = getBentoSurfaceTokens(isDark);
const cardBorder = bentoSurface.borderColor;
const cardShadow = bentoSurface.shadow;
const mutedButton = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)';
const mutedButtonBorder = isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)';
const [tasks, setTasks] = React.useState<TaskItem[]>([]);
const [highlight, setHighlight] = React.useState<TaskItem | null>(null);
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [hasSwiped, setHasSwiped] = React.useState(false);
const progressTotal = tasks.length || 1;
const progressPercent = Math.min(100, Math.round((completedCount / progressTotal) * 100));
const mapTaskItem = React.useCallback((task: unknown, emotionMap: Record<string, { name: string; emoji?: string }>) => {
const record = task as Record<string, unknown>;
const id = Number(record.id ?? record.task_id ?? 0);
const title =
typeof record.title === 'string'
? record.title
: typeof record.name === 'string'
? record.name
: '';
if (!id || !title) return null;
const description =
typeof record.description === 'string'
? record.description
: typeof record.prompt === 'string'
? record.prompt
: null;
const instructions =
typeof record.instructions === 'string'
? record.instructions
: typeof record.instruction === 'string'
? record.instruction
: null;
const durationValue = record.duration ?? record.time_limit ?? record.minutes ?? null;
const duration = typeof durationValue === 'number' ? durationValue : Number(durationValue);
const emotionSlug = typeof record.emotion_slug === 'string' ? record.emotion_slug : null;
const emotionMeta = emotionSlug ? emotionMap[emotionSlug] : undefined;
return {
id,
title,
description,
instructions,
duration: Number.isFinite(duration) ? duration : null,
emotion: emotionSlug
? {
slug: emotionSlug,
name: emotionMeta?.name ?? emotionSlug,
emoji: emotionMeta?.emoji,
}
: undefined,
points: typeof record.points === 'number' ? record.points : undefined,
} satisfies TaskItem;
}, []);
const loadTasks = React.useCallback(async () => {
if (!token) {
setTasks([]);
setHighlight(null);
setError(null);
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
const [taskList, emotionList] = await Promise.all([
fetchTasks(token, { locale, perPage: 12 }),
fetchEmotions(token, locale),
]);
const emotionMap: Record<string, { name: string; emoji?: string }> = {};
for (const emotion of emotionList) {
const record = emotion as Record<string, unknown>;
const slug = typeof record.slug === 'string' ? record.slug : '';
const title = typeof record.title === 'string' ? record.title : typeof record.name === 'string' ? record.name : '';
const emoji = typeof record.emoji === 'string' ? record.emoji : undefined;
if (slug) {
emotionMap[slug] = { name: title || slug, emoji };
}
}
const mapped = taskList.map((task) => mapTaskItem(task, emotionMap)).filter(Boolean) as TaskItem[];
setTasks(mapped);
setHighlight((prev) => {
if (!mapped.length) return null;
if (prev && mapped.some((task) => task.id === prev.id)) {
return prev;
}
return mapped[0] ?? null;
});
} catch (err) {
console.error('Failed to load tasks', err);
setError(t('pendingUploads.error', 'Loading failed. Please try again.'));
} finally {
setLoading(false);
}
}, [locale, mapTaskItem, t, token]);
React.useEffect(() => {
if (!token) {
setTasks([]);
setHighlight(null);
setError(null);
setLoading(false);
return;
}
loadTasks();
}, [loadTasks, token]);
React.useEffect(() => {
if (!tasks.length) {
setHighlight(null);
return;
}
if (highlight && tasks.some((task) => task.id === highlight.id)) return;
setHighlight(tasks[0] ?? null);
}, [highlight, tasks]);
const handleStartTask = React.useCallback(() => {
if (!highlight) return;
navigate(buildEventPath(token, `/upload?taskId=${highlight.id}`));
}, [highlight, navigate, token]);
const handleShuffle = React.useCallback(() => {
if (!tasks.length) return;
const candidates = tasks.filter((task) => task.id !== highlight?.id);
const nextList = candidates.length ? candidates : tasks;
const next = nextList[Math.floor(Math.random() * nextList.length)];
setHighlight(next);
setHasSwiped(true);
}, [highlight?.id, tasks]);
const handleViewSimilar = React.useCallback(() => {
if (!highlight) return;
navigate(buildEventPath(token, `/gallery?task=${highlight.id}`));
}, [highlight, navigate, token]);
const handleOpenPhoto = React.useCallback(
(photoId: number) => {
if (!highlight) return;
navigate(buildEventPath(token, `/gallery?photoId=${photoId}&task=${highlight.id}`));
},
[highlight, navigate, token]
);
if (!tasksEnabled) {
return (
<AppShell>
<YStack gap="$4">
<YStack
padding="$4"
borderRadius="$bento"
backgroundColor={bentoSurface.backgroundColor}
borderWidth={1}
borderBottomWidth={3}
borderColor={bentoSurface.borderColor}
borderBottomColor={bentoSurface.borderBottomColor}
gap="$2"
style={{ boxShadow: bentoSurface.shadow }}
>
<Text fontSize="$4" fontWeight="$7">
{t('tasks.disabled.title', 'Tasks are disabled')}
</Text>
<Text fontSize="$2" color="$color" opacity={0.7}>
{t('tasks.disabled.subtitle', 'This event is set to photo-only mode.')}
</Text>
</YStack>
</YStack>
</AppShell>
);
}
return (
<AppShell>
<YStack gap="$4">
<YStack
padding="$4"
borderRadius="$bentoLg"
backgroundColor={bentoSurface.backgroundColor}
borderWidth={1}
borderBottomWidth={3}
borderColor={bentoSurface.borderColor}
borderBottomColor={bentoSurface.borderBottomColor}
gap="$2"
style={{ boxShadow: cardShadow }}
>
<Text fontSize="$2" fontWeight="$7" textTransform="uppercase" letterSpacing={2} color="$color" opacity={0.7}>
{t('tasks.page.eyebrow', 'Mission hub')}
</Text>
<Text fontSize="$7" fontFamily="$display" fontWeight="$8">
{t('tasks.page.title', 'Your next task')}
</Text>
<Text fontSize="$2" color="$color" opacity={0.7}>
{t('tasks.page.subtitle', 'Pick a mood or stay spontaneous.')}
</Text>
</YStack>
<TaskHeroCard
task={highlight}
loading={loading}
error={error}
hasSwiped={hasSwiped}
onSwiped={() => setHasSwiped(true)}
onStart={handleStartTask}
onShuffle={handleShuffle}
onViewSimilar={handleViewSimilar}
onRetry={loadTasks}
onOpenPhoto={handleOpenPhoto}
isCompleted={Boolean(highlight && isCompleted(highlight.id))}
photos={[]}
photosLoading={false}
/>
<YStack
padding="$3"
borderRadius="$bento"
backgroundColor={bentoSurface.backgroundColor}
borderWidth={1}
borderBottomWidth={3}
borderColor={bentoSurface.borderColor}
borderBottomColor={bentoSurface.borderBottomColor}
gap="$2"
style={{ boxShadow: cardShadow }}
>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$2" fontWeight="$7">
{t('tasks.page.progressLabel', 'Quest progress')}
</Text>
<Text fontSize="$2" fontWeight="$7">
{highlight ? `${progressPercent}%` : '--'}
</Text>
</XStack>
<YStack backgroundColor="$muted" borderRadius="$pill" height={10} overflow="hidden">
<YStack backgroundColor="$primary" width={highlight ? `${progressPercent}%` : '20%'} height={10} />
</YStack>
<Text fontSize="$2" color="$color" opacity={0.7}>
{t('tasks.page.progressValue', { count: completedCount, total: tasks.length }, '{count}/{total} tasks completed')}
</Text>
</YStack>
{error ? (
<YStack
padding="$3"
borderRadius="$bento"
backgroundColor="rgba(248, 113, 113, 0.1)"
borderWidth={1}
borderColor="rgba(248, 113, 113, 0.4)"
>
<Text fontSize="$2" color="$color" opacity={0.8}>
{error}
</Text>
</YStack>
) : null}
<YStack gap="$3">
{tasks.length === 0 && loading ? (
<YStack
padding="$3"
borderRadius="$bento"
backgroundColor={bentoSurface.backgroundColor}
borderWidth={1}
borderBottomWidth={3}
borderColor={bentoSurface.borderColor}
borderBottomColor={bentoSurface.borderBottomColor}
style={{ boxShadow: cardShadow }}
>
<Text fontSize="$2" color="$color" opacity={0.7}>
{t('common.actions.loading', 'Loading...')}
</Text>
</YStack>
) : null}
{tasks.length === 0 && !loading && !error ? (
<YStack
padding="$3"
borderRadius="$bento"
backgroundColor={bentoSurface.backgroundColor}
borderWidth={1}
borderBottomWidth={3}
borderColor={bentoSurface.borderColor}
borderBottomColor={bentoSurface.borderBottomColor}
style={{ boxShadow: cardShadow }}
>
<Text fontSize="$2" color="$color" opacity={0.8}>
{t('tasks.page.noTasksAlert', 'No tasks available for this event yet.')}
</Text>
</YStack>
) : null}
{tasks.map((task) => (
<YStack
key={task.id}
padding="$3"
borderRadius="$bento"
backgroundColor={bentoSurface.backgroundColor}
borderWidth={1}
borderBottomWidth={3}
borderColor={bentoSurface.borderColor}
borderBottomColor={bentoSurface.borderBottomColor}
gap="$2"
style={{
boxShadow: cardShadow,
}}
>
<XStack alignItems="center" justifyContent="space-between">
<YStack gap="$1" flex={1}>
<Text fontSize="$4" fontWeight="$7">
{task.title}
</Text>
{task.emotion?.name ? (
<Text fontSize="$2" color="$color" opacity={0.7}>
{task.emotion.name}
</Text>
) : null}
</YStack>
{typeof task.points === 'number' ? (
<XStack alignItems="center" gap="$2">
<Trophy size={16} color="#FDE047" />
<Text fontSize="$2" fontWeight="$7">
+{task.points}
</Text>
</XStack>
) : null}
</XStack>
<Button
size="$3"
backgroundColor={mutedButton}
borderRadius="$pill"
borderWidth={1}
borderColor={mutedButtonBorder}
onPress={() => navigate(buildEventPath(token, `/upload?taskId=${task.id}`))}
>
<XStack alignItems="center" gap="$2">
<Play size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$2" fontWeight="$7">
{t('tasks.startTask', 'Start task')}
</Text>
</XStack>
</Button>
</YStack>
))}
</YStack>
</YStack>
</AppShell>
);
}