761 lines
26 KiB
TypeScript
761 lines
26 KiB
TypeScript
import React from 'react';
|
|
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, 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';
|
|
import { useEventData } from '../context/EventDataContext';
|
|
import { buildEventPath } from '../lib/routes';
|
|
import { useStaggeredReveal } from '../lib/useStaggeredReveal';
|
|
import { usePollStats } from '../hooks/usePollStats';
|
|
import { fetchGallery } from '../services/photosApi';
|
|
import { useTranslation } from '@/guest/i18n/useTranslation';
|
|
import { useGuestThemeVariant } from '../lib/guestTheme';
|
|
import { useLocale } from '@/guest/i18n/LocaleContext';
|
|
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;
|
|
icon: React.ReactNode;
|
|
onPress: () => void;
|
|
};
|
|
|
|
type GalleryPreview = {
|
|
id: number;
|
|
imageUrl: string;
|
|
};
|
|
|
|
type TaskPhoto = TaskHeroPhoto & {
|
|
taskId?: number | null;
|
|
};
|
|
|
|
function ActionTile({
|
|
label,
|
|
icon,
|
|
onPress,
|
|
isDark,
|
|
}: ActionTileProps & { isDark: boolean }) {
|
|
const surface = getBentoSurfaceTokens(isDark);
|
|
|
|
return (
|
|
<Button unstyled onPress={onPress} pressStyle={{ y: 2, opacity: 0.96 }}>
|
|
<YStack
|
|
flex={1}
|
|
minHeight={86}
|
|
padding="$2.5"
|
|
borderRadius="$bento"
|
|
borderWidth={1}
|
|
borderBottomWidth={3}
|
|
borderColor={surface.borderColor}
|
|
borderBottomColor={surface.borderBottomColor}
|
|
backgroundColor={surface.backgroundColor}
|
|
alignItems="center"
|
|
justifyContent="center"
|
|
gap="$1.5"
|
|
style={{
|
|
boxShadow: surface.shadow,
|
|
}}
|
|
>
|
|
{icon}
|
|
<Text
|
|
fontSize={11}
|
|
fontWeight="$7"
|
|
textTransform="none"
|
|
letterSpacing={0.2}
|
|
color="$color"
|
|
opacity={0.8}
|
|
textAlign="center"
|
|
>
|
|
{label}
|
|
</Text>
|
|
</YStack>
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
function QuickStats({
|
|
reveal,
|
|
stats,
|
|
isDark,
|
|
}: {
|
|
reveal: number;
|
|
stats: { guestCount: number; likesCount: number };
|
|
isDark: boolean;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const surface = getBentoSurfaceTokens(isDark);
|
|
return (
|
|
<XStack
|
|
gap="$3"
|
|
animation="slow"
|
|
animateOnly={['transform', 'opacity']}
|
|
opacity={reveal >= 3 ? 1 : 0}
|
|
y={reveal >= 3 ? 0 : 12}
|
|
>
|
|
<YStack
|
|
flex={1}
|
|
padding="$3"
|
|
borderRadius="$bento"
|
|
backgroundColor={surface.backgroundColor}
|
|
borderWidth={1}
|
|
borderBottomWidth={3}
|
|
borderColor={surface.borderColor}
|
|
borderBottomColor={surface.borderBottomColor}
|
|
gap="$1"
|
|
style={{
|
|
boxShadow: surface.shadow,
|
|
}}
|
|
>
|
|
<Text fontSize="$4" fontWeight="$8">
|
|
{stats.guestCount}
|
|
</Text>
|
|
<Text fontSize="$1" color="$color" opacity={0.7} textTransform="uppercase" letterSpacing={1.2}>
|
|
{t('homeV2.stats.guestCount', 'Guests')}
|
|
</Text>
|
|
</YStack>
|
|
<YStack
|
|
flex={1}
|
|
padding="$3"
|
|
borderRadius="$bento"
|
|
backgroundColor={surface.backgroundColor}
|
|
borderWidth={1}
|
|
borderBottomWidth={3}
|
|
borderColor={surface.borderColor}
|
|
borderBottomColor={surface.borderBottomColor}
|
|
gap="$1"
|
|
style={{
|
|
boxShadow: surface.shadow,
|
|
}}
|
|
>
|
|
<Text fontSize="$4" fontWeight="$8">
|
|
{stats.likesCount}
|
|
</Text>
|
|
<Text fontSize="$1" color="$color" opacity={0.7} textTransform="uppercase" letterSpacing={1.2}>
|
|
{t('homeV2.stats.likesCount', 'Likes')}
|
|
</Text>
|
|
</YStack>
|
|
</XStack>
|
|
);
|
|
}
|
|
|
|
function normalizeImageUrl(src?: string | null) {
|
|
if (!src) {
|
|
return '';
|
|
}
|
|
|
|
if (/^https?:/i.test(src)) {
|
|
return src;
|
|
}
|
|
|
|
let cleanPath = src.replace(/^\/+/g, '').replace(/\/+/g, '/');
|
|
if (!cleanPath.startsWith('storage/')) {
|
|
cleanPath = `storage/${cleanPath}`;
|
|
}
|
|
|
|
return `/${cleanPath}`.replace(/\/+/g, '/');
|
|
}
|
|
|
|
export default function HomeScreen() {
|
|
const { tasksEnabled, token, event } = useEventData();
|
|
const navigate = useNavigate();
|
|
const revealStage = useStaggeredReveal({ steps: 5, intervalMs: 140, delayMs: 120 });
|
|
const { stats } = usePollStats(token ?? null);
|
|
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);
|
|
const [tasks, setTasks] = React.useState<TaskHero[]>([]);
|
|
const [currentTask, setCurrentTask] = React.useState<TaskHero | null>(null);
|
|
const [taskLoading, setTaskLoading] = React.useState(false);
|
|
const [taskError, setTaskError] = React.useState<string | null>(null);
|
|
const [hasSwiped, setHasSwiped] = React.useState(false);
|
|
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 = [
|
|
{
|
|
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'} />,
|
|
path: '/gallery',
|
|
},
|
|
{
|
|
label: t('homeV2.rings.topMoments', 'Top moments'),
|
|
icon: <Star size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />,
|
|
path: '/gallery',
|
|
},
|
|
{
|
|
label: t('navigation.achievements', 'Achievements'),
|
|
icon: <Trophy size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />,
|
|
path: '/achievements',
|
|
},
|
|
];
|
|
|
|
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 =
|
|
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 emotionValue = record.emotion;
|
|
const slugValue = typeof record.emotion_slug === 'string' ? (record.emotion_slug as string) : undefined;
|
|
const nameValue = typeof record.emotion_name === 'string'
|
|
? (record.emotion_name as string)
|
|
: typeof record.emotion_title === 'string'
|
|
? (record.emotion_title as string)
|
|
: slugValue
|
|
? emotionMap[slugValue]
|
|
: undefined;
|
|
const emotion =
|
|
typeof emotionValue === 'object' && emotionValue
|
|
? {
|
|
slug: typeof (emotionValue as Record<string, unknown>).slug === 'string'
|
|
? (emotionValue as Record<string, unknown>).slug as string
|
|
: undefined,
|
|
name: typeof (emotionValue as Record<string, unknown>).name === 'string'
|
|
? (emotionValue as Record<string, unknown>).name as string
|
|
: undefined,
|
|
emoji: typeof (emotionValue as Record<string, unknown>).emoji === 'string'
|
|
? (emotionValue as Record<string, unknown>).emoji as string
|
|
: undefined,
|
|
}
|
|
: {
|
|
slug: slugValue,
|
|
name: nameValue,
|
|
};
|
|
|
|
return {
|
|
id,
|
|
title,
|
|
description,
|
|
instructions,
|
|
duration: Number.isFinite(duration) ? duration : null,
|
|
emotion,
|
|
};
|
|
}, []);
|
|
|
|
const selectRandomTask = React.useCallback(
|
|
(list: TaskHero[]) => {
|
|
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 (!token) {
|
|
setGalleryPhotos([]);
|
|
setGalleryError(null);
|
|
return;
|
|
}
|
|
|
|
let active = true;
|
|
setGalleryLoading(true);
|
|
setGalleryError(null);
|
|
|
|
fetchGallery(token, { limit: 72, locale })
|
|
.then((response) => {
|
|
if (!active) return;
|
|
const photos = Array.isArray(response.data) ? response.data : [];
|
|
const mapped = photos.map((photo) => {
|
|
const record = photo as Record<string, unknown>;
|
|
const id = Number(record.id ?? 0);
|
|
const imageUrl = normalizeImageUrl(
|
|
(record.thumbnail_url as string | null | undefined)
|
|
?? (record.thumbnail_path as string | null | undefined)
|
|
?? (record.file_path as string | null | undefined)
|
|
?? (record.full_url as string | null | undefined)
|
|
?? (record.url as string | null | undefined)
|
|
?? (record.image_url as string | null | undefined)
|
|
);
|
|
const taskId = Number(record.task_id ?? record.taskId ?? 0);
|
|
const likesCount =
|
|
typeof record.likes_count === 'number'
|
|
? record.likes_count
|
|
: typeof record.likesCount === 'number'
|
|
? record.likesCount
|
|
: undefined;
|
|
return {
|
|
id,
|
|
imageUrl,
|
|
taskId: Number.isFinite(taskId) && taskId > 0 ? taskId : null,
|
|
likesCount,
|
|
};
|
|
});
|
|
setGalleryPhotos(mapped.filter((item) => item.id && item.imageUrl));
|
|
})
|
|
.catch((error) => {
|
|
console.error('Failed to load gallery preview', error);
|
|
if (active) {
|
|
setGalleryPhotos([]);
|
|
setGalleryError(t('gallery.error', 'Gallery could not be loaded.'));
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (active) {
|
|
setGalleryLoading(false);
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, [locale, t, token]);
|
|
|
|
const reloadTasks = React.useCallback(() => {
|
|
if (!token || !tasksEnabled) {
|
|
setTasks([]);
|
|
setCurrentTask(null);
|
|
setTaskError(null);
|
|
setTaskLoading(false);
|
|
return Promise.resolve();
|
|
}
|
|
|
|
setTaskLoading(true);
|
|
setTaskError(null);
|
|
|
|
return Promise.all([
|
|
fetchTasks(token, { locale }),
|
|
fetchEmotions(token, locale),
|
|
])
|
|
.then(([taskList, emotionList]) => {
|
|
const nextMap: 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) {
|
|
nextMap[slug] = title || slug;
|
|
}
|
|
}
|
|
const mapped = taskList.map((task) => mapTaskItem(task, nextMap)).filter(Boolean) as TaskHero[];
|
|
setTasks(mapped);
|
|
})
|
|
.catch((error) => {
|
|
console.error('Failed to load tasks', error);
|
|
setTasks([]);
|
|
setCurrentTask(null);
|
|
setTaskError(t('tasks.error', 'Tasks could not be loaded.'));
|
|
})
|
|
.finally(() => {
|
|
setTaskLoading(false);
|
|
});
|
|
}, [locale, mapTaskItem, t, tasksEnabled, token]);
|
|
|
|
React.useEffect(() => {
|
|
let active = true;
|
|
if (!token || !tasksEnabled) {
|
|
setTasks([]);
|
|
setCurrentTask(null);
|
|
setTaskError(null);
|
|
setTaskLoading(false);
|
|
return;
|
|
}
|
|
|
|
setTaskLoading(true);
|
|
setTaskError(null);
|
|
|
|
Promise.all([
|
|
fetchTasks(token, { locale }),
|
|
fetchEmotions(token, locale),
|
|
])
|
|
.then(([taskList, emotionList]) => {
|
|
if (!active) return;
|
|
const nextMap: 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) {
|
|
nextMap[slug] = title || slug;
|
|
}
|
|
}
|
|
const mapped = taskList.map((task) => mapTaskItem(task, nextMap)).filter(Boolean) as TaskHero[];
|
|
setTasks(mapped);
|
|
})
|
|
.catch((error) => {
|
|
console.error('Failed to load tasks', error);
|
|
if (active) {
|
|
setTasks([]);
|
|
setCurrentTask(null);
|
|
setTaskError(t('tasks.error', 'Tasks could not be loaded.'));
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (active) {
|
|
setTaskLoading(false);
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, [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 preview = React.useMemo<GalleryPreview[]>(
|
|
() =>
|
|
galleryPhotos.slice(0, 4).map((photo) => ({
|
|
id: photo.id,
|
|
imageUrl: photo.imageUrl,
|
|
})),
|
|
[galleryPhotos]
|
|
);
|
|
const taskPhotos = React.useMemo<TaskHeroPhoto[]>(() => {
|
|
if (!currentTask) return [];
|
|
const matches = galleryPhotos.filter((photo) => photo.taskId === currentTask.id);
|
|
if (matches.length >= 6) {
|
|
return matches.slice(0, 6).map((photo) => ({
|
|
id: photo.id,
|
|
imageUrl: photo.imageUrl,
|
|
likesCount: photo.likesCount,
|
|
}));
|
|
}
|
|
const fallback = galleryPhotos.filter((photo) => photo.taskId !== currentTask.id);
|
|
const combined = [...matches, ...fallback].slice(0, 6);
|
|
return combined.map((photo) => ({
|
|
id: photo.id,
|
|
imageUrl: photo.imageUrl,
|
|
likesCount: photo.likesCount,
|
|
}));
|
|
}, [currentTask, galleryPhotos]);
|
|
const openPreviewPhoto = React.useCallback(
|
|
(photoId: number) => {
|
|
navigate(buildEventPath(token, `/gallery?photo=${photoId}`));
|
|
},
|
|
[navigate, token]
|
|
);
|
|
const openTaskPhoto = React.useCallback(
|
|
(photoId: number) => {
|
|
if (!currentTask) return;
|
|
navigate(buildEventPath(token, `/gallery?photo=${photoId}&task=${currentTask.id}`));
|
|
},
|
|
[currentTask, navigate, token]
|
|
);
|
|
const handleStartTask = React.useCallback(() => {
|
|
if (!currentTask) return;
|
|
navigate(buildEventPath(token, `/upload?taskId=${currentTask.id}`));
|
|
}, [currentTask, navigate, token]);
|
|
const handleViewSimilar = React.useCallback(() => {
|
|
if (!currentTask) return;
|
|
navigate(buildEventPath(token, `/gallery?task=${currentTask.id}`));
|
|
}, [currentTask, navigate, token]);
|
|
const handleShuffle = React.useCallback(() => {
|
|
selectRandomTask(tasks);
|
|
setHasSwiped(true);
|
|
}, [selectRandomTask, tasks]);
|
|
|
|
return (
|
|
<AppShell>
|
|
<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
|
|
task={currentTask}
|
|
loading={taskLoading}
|
|
error={taskError}
|
|
hasSwiped={hasSwiped}
|
|
onSwiped={() => setHasSwiped(true)}
|
|
onStart={handleStartTask}
|
|
onShuffle={handleShuffle}
|
|
onViewSimilar={handleViewSimilar}
|
|
onRetry={reloadTasks}
|
|
onOpenPhoto={openTaskPhoto}
|
|
isCompleted={isCompleted(currentTask?.id)}
|
|
photos={taskPhotos}
|
|
photosLoading={galleryLoading}
|
|
photosError={galleryError}
|
|
/>
|
|
</YStack>
|
|
) : (
|
|
<YStack
|
|
padding="$4"
|
|
borderRadius="$bento"
|
|
backgroundColor={bentoSurface.backgroundColor}
|
|
borderWidth={1}
|
|
borderBottomWidth={3}
|
|
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
|
|
? 'linear-gradient(135deg, rgba(79, 209, 255, 0.18), rgba(255, 79, 216, 0.12))'
|
|
: 'linear-gradient(135deg, color-mix(in oklab, var(--guest-secondary, #F43F5E) 10%, white), color-mix(in oklab, var(--guest-primary, #FF5A5F) 10%, white))',
|
|
boxShadow: cardShadow,
|
|
}}
|
|
>
|
|
<XStack alignItems="center" gap="$2">
|
|
<Camera size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
|
<Text fontSize="$3" fontWeight="$7">
|
|
{t('homeV2.captureReady.label', 'Capture ready')}
|
|
</Text>
|
|
</XStack>
|
|
<Text fontSize="$7" fontFamily="$display" fontWeight="$8">
|
|
{t('homeV2.captureReady.title', 'Add a photo to the shared gallery')}
|
|
</Text>
|
|
<Text fontSize="$3" color="$color" opacity={0.75}>
|
|
{t('homeV2.captureReady.subtitle', 'Quick add from your camera or device.')}
|
|
</Text>
|
|
<Button size="$4" backgroundColor="$primary" borderRadius="$pill" onPress={goTo('/upload')}>
|
|
{t('homeV2.captureReady.cta', 'Upload / Take photo')}
|
|
</Button>
|
|
</YStack>
|
|
)}
|
|
|
|
<QuickStats
|
|
reveal={revealStage}
|
|
stats={{ guestCount: stats.guestCount, likesCount: stats.likesCount }}
|
|
isDark={isDark}
|
|
/>
|
|
|
|
<YStack
|
|
padding="$4"
|
|
borderRadius="$bentoLg"
|
|
backgroundColor={bentoSurface.backgroundColor}
|
|
borderWidth={1}
|
|
borderBottomWidth={3}
|
|
borderColor={bentoSurface.borderColor}
|
|
borderBottomColor={bentoSurface.borderBottomColor}
|
|
gap="$3"
|
|
animation="slow"
|
|
animateOnly={['transform', 'opacity']}
|
|
opacity={revealStage >= 4 ? 1 : 0}
|
|
y={revealStage >= 4 ? 0 : 16}
|
|
style={{
|
|
boxShadow: cardShadow,
|
|
}}
|
|
>
|
|
<XStack alignItems="center" justifyContent="space-between">
|
|
<Text fontSize="$5" fontWeight="$8" fontFamily="$display">
|
|
{t('homeV2.galleryPreview.title', 'Gallery preview')}
|
|
</Text>
|
|
<Button
|
|
size="$3"
|
|
backgroundColor={mutedButton}
|
|
borderRadius="$pill"
|
|
borderWidth={1}
|
|
borderColor={mutedButtonBorder}
|
|
onPress={goTo('/gallery')}
|
|
>
|
|
{t('homeV2.galleryPreview.cta', 'View all')}
|
|
</Button>
|
|
</XStack>
|
|
<XStack
|
|
gap="$2"
|
|
style={{
|
|
overflowX: 'auto',
|
|
WebkitOverflowScrolling: 'touch',
|
|
paddingBottom: 6,
|
|
}}
|
|
>
|
|
{(galleryLoading || preview.length === 0 ? [1, 2, 3, 4] : preview).map((tile) => {
|
|
if (typeof tile === 'number') {
|
|
return (
|
|
<YStack key={tile} flexShrink={0} width={140}>
|
|
<PhotoFrameTile height={110} borderRadius="$bento" shimmer shimmerDelayMs={tile * 140} />
|
|
</YStack>
|
|
);
|
|
}
|
|
return (
|
|
<YStack key={tile.id} flexShrink={0} width={140}>
|
|
<Button
|
|
unstyled
|
|
onPress={() => openPreviewPhoto(tile.id)}
|
|
aria-label={t('galleryPage.photo.alt', { id: tile.id, suffix: '' }, `Foto ${tile.id}`)}
|
|
>
|
|
<PhotoFrameTile height={110} borderRadius="$bento">
|
|
<YStack
|
|
flex={1}
|
|
width="100%"
|
|
height="100%"
|
|
style={{
|
|
backgroundImage: `url(${tile.imageUrl})`,
|
|
backgroundSize: 'cover',
|
|
backgroundPosition: 'center',
|
|
}}
|
|
/>
|
|
</PhotoFrameTile>
|
|
</Button>
|
|
</YStack>
|
|
);
|
|
})}
|
|
</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>
|
|
);
|
|
}
|