Update guest v2 home and tasks experience

This commit is contained in:
Codex Agent
2026-02-03 18:59:30 +01:00
parent a820ef2e8b
commit 7f1e6c06fb
31 changed files with 753 additions and 259 deletions

View File

@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { XStack, YStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { Camera, Sparkles, Image as ImageIcon, Trophy, Star } from 'lucide-react';
import { Camera, Image as ImageIcon, Trophy, Star, X } from 'lucide-react';
import AppShell from '../components/AppShell';
import PhotoFrameTile from '../components/PhotoFrameTile';
import TaskHeroCard, { type TaskHero, type TaskHeroPhoto } from '../components/TaskHeroCard';
@@ -12,7 +12,6 @@ import { buildEventPath } from '../lib/routes';
import { useStaggeredReveal } from '../lib/useStaggeredReveal';
import { usePollStats } from '../hooks/usePollStats';
import { fetchGallery } from '../services/photosApi';
import { useUploadQueue } from '../services/uploadApi';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { useGuestThemeVariant } from '../lib/guestTheme';
import { useLocale } from '@/guest/i18n/LocaleContext';
@@ -20,6 +19,8 @@ import { fetchTasks, type TaskItem } from '../services/tasksApi';
import { useGuestTaskProgress } from '@/guest/hooks/useGuestTaskProgress';
import { fetchEmotions } from '../services/emotionsApi';
import { getBentoSurfaceTokens } from '../lib/bento';
import { useEventBranding } from '@/guest/context/EventBrandingContext';
import { useOptionalGuestIdentity } from '../context/GuestIdentityContext';
type ActionTileProps = {
label: string;
@@ -83,12 +84,10 @@ function ActionTile({
function QuickStats({
reveal,
stats,
queueCount,
isDark,
}: {
reveal: number;
stats: { onlineGuests: number; tasksSolved: number };
queueCount: number;
stats: { guestCount: number; likesCount: number };
isDark: boolean;
}) {
const { t } = useTranslation();
@@ -116,10 +115,10 @@ function QuickStats({
}}
>
<Text fontSize="$4" fontWeight="$8">
{stats.onlineGuests}
{stats.guestCount}
</Text>
<Text fontSize="$1" color="$color" opacity={0.7} textTransform="uppercase" letterSpacing={1.2}>
{t('home.stats.online', 'Guests online')}
{t('homeV2.stats.guestCount', 'Guests')}
</Text>
</YStack>
<YStack
@@ -137,10 +136,10 @@ function QuickStats({
}}
>
<Text fontSize="$4" fontWeight="$8">
{queueCount}
{stats.likesCount}
</Text>
<Text fontSize="$1" color="$color" opacity={0.7} textTransform="uppercase" letterSpacing={1.2}>
{t('homeV2.stats.uploadsQueued', 'Uploads queued')}
{t('homeV2.stats.likesCount', 'Likes')}
</Text>
</YStack>
</XStack>
@@ -165,14 +164,15 @@ function normalizeImageUrl(src?: string | null) {
}
export default function HomeScreen() {
const { tasksEnabled, token } = useEventData();
const { tasksEnabled, token, event } = useEventData();
const navigate = useNavigate();
const revealStage = useStaggeredReveal({ steps: 4, intervalMs: 140, delayMs: 120 });
const revealStage = useStaggeredReveal({ steps: 5, intervalMs: 140, delayMs: 120 });
const { stats } = usePollStats(token ?? null);
const { items } = useUploadQueue();
const { t } = useTranslation();
const { locale } = useLocale();
const { isCompleted } = useGuestTaskProgress(token ?? undefined);
const { branding } = useEventBranding();
const identity = useOptionalGuestIdentity();
const [galleryPhotos, setGalleryPhotos] = React.useState<TaskPhoto[]>([]);
const [galleryLoading, setGalleryLoading] = React.useState(false);
const [galleryError, setGalleryError] = React.useState<string | null>(null);
@@ -181,29 +181,51 @@ export default function HomeScreen() {
const [taskLoading, setTaskLoading] = React.useState(false);
const [taskError, setTaskError] = React.useState<string | null>(null);
const [hasSwiped, setHasSwiped] = React.useState(false);
const [emotionMap, setEmotionMap] = React.useState<Record<string, string>>({});
const { isDark } = useGuestThemeVariant();
const bentoSurface = getBentoSurfaceTokens(isDark);
const mutedButton = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(255, 255, 255, 0.75)';
const mutedButtonBorder = isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.08)';
const cardShadow = bentoSurface.shadow;
const welcomeStorageKey = token ? `guestWelcomeDismissed_${token}` : 'guestWelcomeDismissed';
const [welcomeVisible, setWelcomeVisible] = React.useState(true);
const eventName = event?.name ?? t('galleryPage.hero.eventFallback', 'Event');
const guestName = identity?.name?.trim() ? identity.name.trim() : t('home.fallbackGuestName', 'Gast');
const welcomeTemplate = branding?.welcomeMessage?.trim()
? branding.welcomeMessage.trim()
: t('homeV2.welcome.message', 'Welcome to {eventName}.');
const welcomeMessage = welcomeTemplate
.replace('{eventName}', eventName)
.replace('{name}', guestName);
const dismissWelcome = React.useCallback(() => {
setWelcomeVisible(false);
if (typeof window === 'undefined') return;
try {
window.sessionStorage.setItem(welcomeStorageKey, '1');
} catch {
// ignore storage errors
}
}, [welcomeStorageKey]);
const goTo = (path: string) => () => navigate(buildEventPath(token, path));
const recentTaskIdsRef = React.useRef<number[]>([]);
React.useEffect(() => {
if (typeof window === 'undefined') return;
try {
const stored = window.sessionStorage.getItem(welcomeStorageKey);
setWelcomeVisible(stored !== '1');
} catch {
setWelcomeVisible(true);
}
}, [welcomeStorageKey]);
const rings = [
tasksEnabled
? {
label: t('home.actions.items.tasks.label', 'Draw a task card'),
icon: <Sparkles size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />,
path: '/tasks',
}
: {
label: t('home.actions.items.upload.label', 'Upload photo'),
icon: <Camera size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />,
path: '/upload',
},
{
label: t('home.actions.items.upload.label', 'Upload photo'),
icon: <Camera size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />,
path: '/upload',
},
{
label: t('homeV2.rings.newUploads', 'New uploads'),
icon: <ImageIcon size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />,
@@ -218,10 +240,10 @@ export default function HomeScreen() {
label: t('navigation.achievements', 'Achievements'),
icon: <Trophy size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />,
path: '/achievements',
},
},
];
const mapTaskItem = React.useCallback((task: TaskItem): TaskHero | null => {
const mapTaskItem = React.useCallback((task: TaskItem, emotionMap: Record<string, string>): TaskHero | null => {
const record = task as Record<string, unknown>;
const id = Number(record.id ?? record.task_id ?? 0);
const title =
@@ -281,7 +303,7 @@ export default function HomeScreen() {
duration: Number.isFinite(duration) ? duration : null,
emotion,
};
}, [emotionMap]);
}, []);
const selectRandomTask = React.useCallback(
(list: TaskHero[]) => {
@@ -389,12 +411,8 @@ export default function HomeScreen() {
nextMap[slug] = title || slug;
}
}
setEmotionMap(nextMap);
const mapped = taskList.map(mapTaskItem).filter(Boolean) as TaskHero[];
const mapped = taskList.map((task) => mapTaskItem(task, nextMap)).filter(Boolean) as TaskHero[];
setTasks(mapped);
if (!currentTask || !mapped.some((task) => task.id === currentTask.id)) {
selectRandomTask(mapped);
}
})
.catch((error) => {
console.error('Failed to load tasks', error);
@@ -405,7 +423,7 @@ export default function HomeScreen() {
.finally(() => {
setTaskLoading(false);
});
}, [currentTask, locale, mapTaskItem, selectRandomTask, t, tasksEnabled, token]);
}, [locale, mapTaskItem, t, tasksEnabled, token]);
React.useEffect(() => {
let active = true;
@@ -435,12 +453,8 @@ export default function HomeScreen() {
nextMap[slug] = title || slug;
}
}
setEmotionMap(nextMap);
const mapped = taskList.map(mapTaskItem).filter(Boolean) as TaskHero[];
const mapped = taskList.map((task) => mapTaskItem(task, nextMap)).filter(Boolean) as TaskHero[];
setTasks(mapped);
if (!currentTask || !mapped.some((task) => task.id === currentTask.id)) {
selectRandomTask(mapped);
}
})
.catch((error) => {
console.error('Failed to load tasks', error);
@@ -459,9 +473,19 @@ export default function HomeScreen() {
return () => {
active = false;
};
}, [currentTask, locale, mapTaskItem, selectRandomTask, t, tasksEnabled, token]);
}, [locale, mapTaskItem, t, tasksEnabled, token]);
React.useEffect(() => {
if (!tasksEnabled || tasks.length === 0) {
setCurrentTask(null);
return;
}
if (currentTask && tasks.some((task) => task.id === currentTask.id) && !isCompleted(currentTask.id)) {
return;
}
selectRandomTask(tasks);
}, [currentTask, isCompleted, selectRandomTask, tasks, tasksEnabled]);
const queueCount = items.filter((item) => item.status !== 'done').length;
const preview = React.useMemo<GalleryPreview[]>(
() =>
galleryPhotos.slice(0, 4).map((photo) => ({
@@ -510,25 +534,66 @@ export default function HomeScreen() {
return (
<AppShell>
<YStack gap="$4">
<YStack
gap="$3"
animation="slow"
animateOnly={['transform', 'opacity']}
opacity={revealStage >= 1 ? 1 : 0}
y={revealStage >= 1 ? 0 : 12}
>
<XStack gap="$2" flexWrap="nowrap">
{rings.map((ring) => (
<YStack key={ring.label} flex={1} minWidth={0}>
<ActionTile label={ring.label} icon={ring.icon} onPress={goTo(ring.path)} isDark={isDark} />
</YStack>
))}
</XStack>
</YStack>
<YStack gap="$4">
{welcomeVisible && welcomeMessage.trim() ? (
<YStack
gap="$3"
animation="slow"
animateOnly={['transform', 'opacity']}
opacity={revealStage >= 1 ? 1 : 0}
y={revealStage >= 1 ? 0 : 12}
>
<YStack
position="relative"
padding="$4"
borderRadius="$bentoLg"
backgroundColor={bentoSurface.backgroundColor}
borderWidth={1}
borderBottomWidth={3}
borderColor={bentoSurface.borderColor}
borderBottomColor={bentoSurface.borderBottomColor}
gap="$2"
style={{
boxShadow: cardShadow,
}}
>
<Button
unstyled
position="absolute"
top={-12}
right={-12}
width={34}
height={34}
borderRadius={999}
backgroundColor={bentoSurface.backgroundColor}
borderWidth={1}
borderColor={bentoSurface.borderColor}
alignItems="center"
justifyContent="center"
onPress={dismissWelcome}
pressStyle={{ y: 2 }}
style={{
boxShadow: isDark ? '0 6px 0 rgba(0, 0, 0, 0.45)' : '0 6px 0 rgba(15, 23, 42, 0.2)',
}}
aria-label={t('common.actions.close', 'Close')}
>
<X size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
</Button>
<Text fontSize="$2" color="$color" opacity={0.75} textTransform="uppercase" letterSpacing={1.4}>
{t('homeV2.welcome.label', 'Welcome')}
</Text>
<Text fontSize="$6" fontFamily="$display" fontWeight="$8">
{welcomeMessage}
</Text>
</YStack>
</YStack>
) : null}
{tasksEnabled ? (
<YStack
animation="slow"
animateOnly={['transform', 'opacity']}
opacity={revealStage >= 2 ? 1 : 0}
y={revealStage >= 2 ? 0 : 16}
>
<TaskHeroCard
@@ -558,6 +623,9 @@ export default function HomeScreen() {
borderColor={bentoSurface.borderColor}
borderBottomColor={bentoSurface.borderBottomColor}
gap="$3"
animation="slow"
animateOnly={['transform', 'opacity']}
opacity={revealStage >= 2 ? 1 : 0}
y={revealStage >= 2 ? 0 : 16}
style={{
backgroundImage: isDark
@@ -586,8 +654,7 @@ export default function HomeScreen() {
<QuickStats
reveal={revealStage}
stats={{ onlineGuests: stats.onlineGuests, tasksSolved: stats.tasksSolved }}
queueCount={queueCount}
stats={{ guestCount: stats.guestCount, likesCount: stats.likesCount }}
isDark={isDark}
/>
@@ -658,6 +725,23 @@ export default function HomeScreen() {
})}
</XStack>
</YStack>
<YStack
gap="$3"
animation="slow"
animateOnly={['transform', 'opacity']}
opacity={revealStage >= 5 ? 1 : 0}
y={revealStage >= 5 ? 0 : 12}
>
<XStack gap="$2" flexWrap="nowrap">
{rings.map((ring) => (
<YStack key={ring.label} flex={1} minWidth={0}>
<ActionTile label={ring.label} icon={ring.icon} onPress={goTo(ring.path)} isDark={isDark} />
</YStack>
))}
</XStack>
</YStack>
<YStack height={70} />
</YStack>
</AppShell>
);