272 lines
9.7 KiB
TypeScript
272 lines
9.7 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 { Sparkles, Trophy, Play } from 'lucide-react';
|
|
import AppShell from '../components/AppShell';
|
|
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 { useAppearance } from '@/hooks/use-appearance';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { useGuestTaskProgress } from '@/guest/hooks/useGuestTaskProgress';
|
|
|
|
type TaskItem = {
|
|
id: number;
|
|
title: string;
|
|
points?: number;
|
|
description?: string | null;
|
|
emotion?: string | null;
|
|
};
|
|
|
|
export default function TasksScreen() {
|
|
const { tasksEnabled, token } = useEventData();
|
|
const { t } = useTranslation();
|
|
const { locale } = useLocale();
|
|
const navigate = useNavigate();
|
|
const { completedCount } = useGuestTaskProgress(token ?? undefined);
|
|
const { resolved } = useAppearance();
|
|
const isDark = resolved === 'dark';
|
|
const cardBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)';
|
|
const cardShadow = isDark ? '0 16px 32px rgba(2, 6, 23, 0.35)' : '0 14px 24px rgba(15, 23, 42, 0.12)';
|
|
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 [emotions, setEmotions] = React.useState<Record<string, string>>({});
|
|
const progressTotal = tasks.length || 1;
|
|
const progressPercent = Math.min(100, Math.round((completedCount / progressTotal) * 100));
|
|
|
|
React.useEffect(() => {
|
|
if (!token) {
|
|
setTasks([]);
|
|
setHighlight(null);
|
|
return;
|
|
}
|
|
|
|
let active = true;
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
Promise.all([
|
|
fetchTasks(token, { locale, perPage: 12 }),
|
|
fetchEmotions(token, locale),
|
|
])
|
|
.then(([taskList, emotionList]) => {
|
|
if (!active) return;
|
|
|
|
const emotionMap: Record<string, 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 : '';
|
|
if (slug) {
|
|
emotionMap[slug] = title || slug;
|
|
}
|
|
}
|
|
setEmotions(emotionMap);
|
|
|
|
const mapped = taskList
|
|
.map((task) => {
|
|
const record = task as Record<string, unknown>;
|
|
const id = Number(record.id ?? 0);
|
|
const title = typeof record.title === 'string' ? record.title : typeof record.name === 'string' ? record.name : '';
|
|
if (!id || !title) return null;
|
|
return {
|
|
id,
|
|
title,
|
|
points: typeof record.points === 'number' ? record.points : undefined,
|
|
description: typeof record.description === 'string' ? record.description : null,
|
|
emotion: typeof record.emotion_slug === 'string' ? record.emotion_slug : null,
|
|
} satisfies TaskItem;
|
|
})
|
|
.filter(Boolean) as TaskItem[];
|
|
|
|
setTasks(mapped);
|
|
setHighlight(mapped[0] ?? null);
|
|
})
|
|
.catch((err) => {
|
|
console.error('Failed to load tasks', err);
|
|
if (active) {
|
|
setError(t('tasks.error', 'Tasks could not be loaded.'));
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (active) {
|
|
setLoading(false);
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, [token, locale, t]);
|
|
|
|
if (!tasksEnabled) {
|
|
return (
|
|
<AppShell>
|
|
<YStack gap="$4">
|
|
<YStack
|
|
padding="$4"
|
|
borderRadius="$card"
|
|
backgroundColor="$surface"
|
|
borderWidth={1}
|
|
borderColor={cardBorder}
|
|
gap="$2"
|
|
>
|
|
<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="$card"
|
|
backgroundColor="$surface"
|
|
borderWidth={1}
|
|
borderColor={cardBorder}
|
|
gap="$3"
|
|
style={{
|
|
backgroundImage: isDark
|
|
? 'linear-gradient(135deg, rgba(255, 79, 216, 0.22), rgba(79, 209, 255, 0.1))'
|
|
: 'linear-gradient(135deg, color-mix(in oklab, var(--guest-primary, #FF5A5F) 16%, white), color-mix(in oklab, var(--guest-secondary, #F43F5E) 10%, white))',
|
|
boxShadow: isDark ? '0 22px 46px rgba(2, 6, 23, 0.45)' : '0 18px 32px rgba(15, 23, 42, 0.12)',
|
|
}}
|
|
>
|
|
<XStack alignItems="center" gap="$2">
|
|
<Sparkles size={18} color="#FF4FD8" />
|
|
<Text fontSize="$4" fontWeight="$7">
|
|
Prompt quest
|
|
</Text>
|
|
</XStack>
|
|
<Text fontSize="$7" fontFamily="$display" fontWeight="$8">
|
|
{highlight?.title ?? t('tasks.loading', 'Loading tasks...')}
|
|
</Text>
|
|
<Text fontSize="$2" color="$color" opacity={0.7}>
|
|
{highlight?.description ?? t('tasks.subtitle', 'Complete this quest to unlock new prompts.')}
|
|
</Text>
|
|
<YStack gap="$2">
|
|
<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>
|
|
<Button
|
|
size="$4"
|
|
backgroundColor="$primary"
|
|
borderRadius="$pill"
|
|
disabled={!highlight || loading}
|
|
onPress={() => {
|
|
if (highlight) navigate(`./${highlight.id}`);
|
|
}}
|
|
>
|
|
{loading ? t('tasks.loading', 'Loading tasks...') : t('tasks.start', 'Start quest')}
|
|
</Button>
|
|
</YStack>
|
|
|
|
{error ? (
|
|
<YStack
|
|
padding="$3"
|
|
borderRadius="$card"
|
|
backgroundColor="rgba(248, 113, 113, 0.12)"
|
|
borderWidth={1}
|
|
borderColor="rgba(248, 113, 113, 0.4)"
|
|
>
|
|
<Text fontSize="$2" color="#FEE2E2">
|
|
{error}
|
|
</Text>
|
|
</YStack>
|
|
) : null}
|
|
|
|
<YStack gap="$3">
|
|
{tasks.length === 0 && loading ? (
|
|
<YStack
|
|
padding="$3"
|
|
borderRadius="$card"
|
|
backgroundColor="$surface"
|
|
borderWidth={1}
|
|
borderColor={cardBorder}
|
|
>
|
|
<Text fontSize="$2" color="$color" opacity={0.7}>
|
|
{t('tasks.loading', 'Loading tasks...')}
|
|
</Text>
|
|
</YStack>
|
|
) : null}
|
|
{tasks.map((task) => (
|
|
<YStack
|
|
key={task.id}
|
|
padding="$3"
|
|
borderRadius="$card"
|
|
backgroundColor="$surface"
|
|
borderWidth={1}
|
|
borderColor={cardBorder}
|
|
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 ? (
|
|
<Text fontSize="$2" color="$color" opacity={0.7}>
|
|
{emotions[task.emotion] ?? task.emotion}
|
|
</Text>
|
|
) : null}
|
|
</YStack>
|
|
<XStack alignItems="center" gap="$2">
|
|
<Trophy size={16} color="#FDE047" />
|
|
<Text fontSize="$2" fontWeight="$7">
|
|
+{task.points ?? 0}
|
|
</Text>
|
|
</XStack>
|
|
</XStack>
|
|
<Button
|
|
size="$3"
|
|
backgroundColor={mutedButton}
|
|
borderRadius="$pill"
|
|
borderWidth={1}
|
|
borderColor={mutedButtonBorder}
|
|
onPress={() => navigate(`./${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>
|
|
);
|
|
}
|