Update guest v2 home and tasks experience
This commit is contained in:
@@ -2,8 +2,9 @@ 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 { 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';
|
||||
@@ -12,13 +13,11 @@ 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 = {
|
||||
id: number;
|
||||
title: string;
|
||||
type TaskItem = TaskHero & {
|
||||
points?: number;
|
||||
description?: string | null;
|
||||
emotion?: string | null;
|
||||
};
|
||||
|
||||
export default function TasksScreen() {
|
||||
@@ -26,84 +25,159 @@ export default function TasksScreen() {
|
||||
const { t } = useTranslation();
|
||||
const { locale } = useLocale();
|
||||
const navigate = useNavigate();
|
||||
const { completedCount } = useGuestTaskProgress(token ?? undefined);
|
||||
const { completedCount, isCompleted } = useGuestTaskProgress(token ?? undefined);
|
||||
const { isDark } = useGuestThemeVariant();
|
||||
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 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 [emotions, setEmotions] = React.useState<Record<string, string>>({});
|
||||
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;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
loadTasks();
|
||||
}, [loadTasks, token]);
|
||||
|
||||
Promise.all([
|
||||
fetchTasks(token, { locale, perPage: 12 }),
|
||||
fetchEmotions(token, locale),
|
||||
])
|
||||
.then(([taskList, emotionList]) => {
|
||||
if (!active) return;
|
||||
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 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 handleStartTask = React.useCallback(() => {
|
||||
if (!highlight) return;
|
||||
navigate(buildEventPath(token, `/upload?taskId=${highlight.id}`));
|
||||
}, [highlight, navigate, token]);
|
||||
|
||||
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[];
|
||||
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]);
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
const handleViewSimilar = React.useCallback(() => {
|
||||
if (!highlight) return;
|
||||
navigate(buildEventPath(token, `/gallery?task=${highlight.id}`));
|
||||
}, [highlight, navigate, token]);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [token, locale, t]);
|
||||
const handleOpenPhoto = React.useCallback(
|
||||
(photoId: number) => {
|
||||
if (!highlight) return;
|
||||
navigate(buildEventPath(token, `/gallery?photoId=${photoId}&task=${highlight.id}`));
|
||||
},
|
||||
[highlight, navigate, token]
|
||||
);
|
||||
|
||||
if (!tasksEnabled) {
|
||||
return (
|
||||
@@ -111,11 +185,14 @@ export default function TasksScreen() {
|
||||
<YStack gap="$4">
|
||||
<YStack
|
||||
padding="$4"
|
||||
borderRadius="$card"
|
||||
backgroundColor="$surface"
|
||||
borderRadius="$bento"
|
||||
backgroundColor={bentoSurface.backgroundColor}
|
||||
borderWidth={1}
|
||||
borderColor={cardBorder}
|
||||
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')}
|
||||
@@ -134,68 +211,78 @@ export default function TasksScreen() {
|
||||
<YStack gap="$4">
|
||||
<YStack
|
||||
padding="$4"
|
||||
borderRadius="$card"
|
||||
backgroundColor="$surface"
|
||||
borderRadius="$bentoLg"
|
||||
backgroundColor={bentoSurface.backgroundColor}
|
||||
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)',
|
||||
}}
|
||||
borderBottomWidth={3}
|
||||
borderColor={bentoSurface.borderColor}
|
||||
borderBottomColor={bentoSurface.borderBottomColor}
|
||||
gap="$2"
|
||||
style={{ boxShadow: cardShadow }}
|
||||
>
|
||||
<XStack alignItems="center" gap="$2">
|
||||
<Sparkles size={18} color="#FF4FD8" />
|
||||
<Text fontSize="$4" fontWeight="$7">
|
||||
Prompt quest
|
||||
</Text>
|
||||
</XStack>
|
||||
<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">
|
||||
{highlight?.title ?? t('tasks.loading', 'Loading tasks...')}
|
||||
{t('tasks.page.title', 'Your next task')}
|
||||
</Text>
|
||||
<Text fontSize="$2" color="$color" opacity={0.7}>
|
||||
{highlight?.description ?? t('tasks.subtitle', 'Complete this quest to unlock new prompts.')}
|
||||
{t('tasks.page.subtitle', 'Pick a mood or stay spontaneous.')}
|
||||
</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')}
|
||||
</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>
|
||||
<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>
|
||||
<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="$card"
|
||||
backgroundColor="rgba(248, 113, 113, 0.12)"
|
||||
borderRadius="$bento"
|
||||
backgroundColor="rgba(248, 113, 113, 0.1)"
|
||||
borderWidth={1}
|
||||
borderColor="rgba(248, 113, 113, 0.4)"
|
||||
>
|
||||
<Text fontSize="$2" color="#FEE2E2">
|
||||
<Text fontSize="$2" color="$color" opacity={0.8}>
|
||||
{error}
|
||||
</Text>
|
||||
</YStack>
|
||||
@@ -205,13 +292,32 @@ export default function TasksScreen() {
|
||||
{tasks.length === 0 && loading ? (
|
||||
<YStack
|
||||
padding="$3"
|
||||
borderRadius="$card"
|
||||
backgroundColor="$surface"
|
||||
borderRadius="$bento"
|
||||
backgroundColor={bentoSurface.backgroundColor}
|
||||
borderWidth={1}
|
||||
borderColor={cardBorder}
|
||||
borderBottomWidth={3}
|
||||
borderColor={bentoSurface.borderColor}
|
||||
borderBottomColor={bentoSurface.borderBottomColor}
|
||||
style={{ boxShadow: cardShadow }}
|
||||
>
|
||||
<Text fontSize="$2" color="$color" opacity={0.7}>
|
||||
{t('tasks.loading', 'Loading tasks...')}
|
||||
{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}
|
||||
@@ -219,10 +325,12 @@ export default function TasksScreen() {
|
||||
<YStack
|
||||
key={task.id}
|
||||
padding="$3"
|
||||
borderRadius="$card"
|
||||
backgroundColor="$surface"
|
||||
borderRadius="$bento"
|
||||
backgroundColor={bentoSurface.backgroundColor}
|
||||
borderWidth={1}
|
||||
borderColor={cardBorder}
|
||||
borderBottomWidth={3}
|
||||
borderColor={bentoSurface.borderColor}
|
||||
borderBottomColor={bentoSurface.borderBottomColor}
|
||||
gap="$2"
|
||||
style={{
|
||||
boxShadow: cardShadow,
|
||||
@@ -233,18 +341,20 @@ export default function TasksScreen() {
|
||||
<Text fontSize="$4" fontWeight="$7">
|
||||
{task.title}
|
||||
</Text>
|
||||
{task.emotion ? (
|
||||
{task.emotion?.name ? (
|
||||
<Text fontSize="$2" color="$color" opacity={0.7}>
|
||||
{emotions[task.emotion] ?? task.emotion}
|
||||
{task.emotion.name}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
<XStack alignItems="center" gap="$2">
|
||||
<Trophy size={16} color="#FDE047" />
|
||||
<Text fontSize="$2" fontWeight="$7">
|
||||
+{task.points ?? 0}
|
||||
</Text>
|
||||
</XStack>
|
||||
{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"
|
||||
@@ -252,7 +362,7 @@ export default function TasksScreen() {
|
||||
borderRadius="$pill"
|
||||
borderWidth={1}
|
||||
borderColor={mutedButtonBorder}
|
||||
onPress={() => navigate(`./${task.id}`)}
|
||||
onPress={() => navigate(buildEventPath(token, `/upload?taskId=${task.id}`))}
|
||||
>
|
||||
<XStack alignItems="center" gap="$2">
|
||||
<Play size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
|
||||
Reference in New Issue
Block a user