Update guest v2 home and tasks experience
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user