Migrate billing from Paddle to Lemon Squeezy

This commit is contained in:
Codex Agent
2026-02-03 10:59:54 +01:00
parent 2f4ebfefd4
commit a0ef90e13a
228 changed files with 4369 additions and 4067 deletions

View File

@@ -16,8 +16,8 @@ export default function AmbientBackground({ children }: AmbientBackgroundProps)
position="relative"
style={{
backgroundImage: isDark
? 'radial-gradient(circle at 15% 10%, rgba(255, 79, 216, 0.2), transparent 48%), radial-gradient(circle at 90% 20%, rgba(79, 209, 255, 0.18), transparent 40%), linear-gradient(180deg, rgba(6, 10, 22, 0.96), rgba(10, 15, 31, 1))'
: 'radial-gradient(circle at 15% 10%, color-mix(in oklab, var(--guest-primary, #FF5A5F) 28%, white), transparent 48%), radial-gradient(circle at 90% 20%, color-mix(in oklab, var(--guest-secondary, #F43F5E) 24%, white), transparent 40%), linear-gradient(180deg, var(--guest-background, #FFF8F5), color-mix(in oklab, var(--guest-background, #FFF8F5) 85%, white))',
? 'radial-gradient(circle at 18% 12%, rgba(255, 110, 110, 0.22), transparent 46%), radial-gradient(circle at 82% 18%, rgba(78, 205, 196, 0.18), transparent 44%), linear-gradient(180deg, rgba(6, 9, 20, 0.96), rgba(10, 14, 28, 1))'
: 'radial-gradient(circle at 18% 12%, color-mix(in oklab, var(--guest-primary, #FF6B6B) 24%, white), transparent 50%), radial-gradient(circle at 82% 18%, color-mix(in oklab, var(--guest-secondary, #4ECDC4) 20%, white), transparent 48%), linear-gradient(180deg, color-mix(in oklab, var(--guest-background, #FFF5F5) 96%, white), color-mix(in oklab, var(--guest-background, #FFF5F5) 72%, white))',
backgroundSize: '140% 140%, 140% 140%, 100% 100%',
animation: 'guestNightAmbientDrift 18s ease-in-out infinite',
}}

View File

@@ -1,11 +1,11 @@
import React from 'react';
import { YStack } from '@tamagui/stacks';
import { Trophy, UploadCloud, Sparkles, Cast, Share2, Compass, Image, Camera, Settings, Home } from 'lucide-react';
import { Button } from '@tamagui/button';
import { Sparkles, Share2, Compass, Image, Camera, Settings, Home } from 'lucide-react';
import { useLocation, useNavigate } from 'react-router-dom';
import TopBar from './TopBar';
import BottomDock from './BottomDock';
import FloatingActionButton from './FloatingActionButton';
import FabActionSheet from './FabActionSheet';
import FabActionRing from './FabActionRing';
import CompassHub, { type CompassAction } from './CompassHub';
import AmbientBackground from './AmbientBackground';
import NotificationSheet from './NotificationSheet';
@@ -22,7 +22,7 @@ type AppShellProps = {
};
export default function AppShell({ children }: AppShellProps) {
const [sheetOpen, setSheetOpen] = React.useState(false);
const [fabOpen, setFabOpen] = React.useState(false);
const [compassOpen, setCompassOpen] = React.useState(false);
const [notificationsOpen, setNotificationsOpen] = React.useState(false);
const [settingsOpen, setSettingsOpen] = React.useState(false);
@@ -38,100 +38,24 @@ export default function AppShell({ children }: AppShellProps) {
const showFab = !/\/photo\/\d+/.test(location.pathname);
const goTo = (path: string) => () => {
setSheetOpen(false);
setFabOpen(false);
setCompassOpen(false);
setNotificationsOpen(false);
setSettingsOpen(false);
navigate(buildEventPath(token, path));
};
const openSheet = () => {
setCompassOpen(false);
setNotificationsOpen(false);
setSettingsOpen(false);
setSheetOpen(true);
const target = buildEventPath(token, path);
if (location.pathname === target) {
return;
}
navigate(target);
};
const openCompass = () => {
setSheetOpen(false);
setFabOpen(false);
setNotificationsOpen(false);
setSettingsOpen(false);
setCompassOpen(true);
};
const actions = [
{
key: 'upload',
label: t('appShell.actions.upload.label', 'Upload / Take photo'),
description: t('appShell.actions.upload.description', 'Add a moment from your device or camera.'),
icon: <UploadCloud size={18} color={actionIconColor} />,
onPress: goTo('/upload'),
},
{
key: 'compass',
label: t('appShell.actions.compass.label', 'Compass hub'),
description: t('appShell.actions.compass.description', 'Quick jump to key areas.'),
icon: <Compass size={18} color={actionIconColor} />,
onPress: () => {
setSheetOpen(false);
openCompass();
},
},
tasksEnabled
? {
key: 'task',
label: t('appShell.actions.task.label', 'Start a task'),
description: t('appShell.actions.task.description', 'Pick a challenge and capture it now.'),
icon: <Sparkles size={18} color={actionIconColor} />,
onPress: goTo('/tasks'),
}
: null,
{
key: 'live',
label: t('appShell.actions.live.label', 'Live show'),
description: t('appShell.actions.live.description', 'See the real-time highlight stream.'),
icon: <Cast size={18} color={actionIconColor} />,
onPress: () => {
setSheetOpen(false);
setCompassOpen(false);
setNotificationsOpen(false);
setSettingsOpen(false);
if (token) {
navigate(`/show/${encodeURIComponent(token)}`);
}
},
},
{
key: 'slideshow',
label: t('appShell.actions.slideshow.label', 'Slideshow'),
description: t('appShell.actions.slideshow.description', 'Lean back and watch the gallery roll.'),
icon: <Image size={18} color={actionIconColor} />,
onPress: goTo('/slideshow'),
},
{
key: 'share',
label: t('appShell.actions.share.label', 'Share invite'),
description: t('appShell.actions.share.description', 'Send the event link or QR code.'),
icon: <Share2 size={18} color={actionIconColor} />,
onPress: goTo('/share'),
},
tasksEnabled
? {
key: 'achievements',
label: t('appShell.actions.achievements.label', 'Achievements'),
description: t('appShell.actions.achievements.description', 'Track your photo streaks.'),
icon: <Trophy size={18} color={actionIconColor} />,
onPress: goTo('/achievements'),
}
: null,
].filter(Boolean) as Array<{
key: string;
label: string;
description: string;
icon: React.ReactNode;
onPress?: () => void;
}>;
const compassQuadrants: [CompassAction, CompassAction, CompassAction, CompassAction] = [
{
key: 'home',
@@ -166,6 +90,13 @@ export default function AppShell({ children }: AppShellProps) {
},
];
const fabActions = compassQuadrants.map((action) => ({
key: action.key,
label: action.label,
icon: action.icon,
onPress: action.onPress,
}));
return (
<AmbientBackground>
<YStack minHeight="100vh" position="relative">
@@ -176,23 +107,23 @@ export default function AppShell({ children }: AppShellProps) {
right={0}
zIndex={1000}
style={{
backgroundColor: isDark ? 'rgba(10, 14, 28, 0.72)' : 'rgba(255, 255, 255, 0.85)',
backdropFilter: 'saturate(160%) blur(18px)',
WebkitBackdropFilter: 'saturate(160%) blur(18px)',
backgroundColor: 'transparent',
backdropFilter: 'saturate(120%) blur(8px)',
WebkitBackdropFilter: 'saturate(120%) blur(8px)',
}}
>
<TopBar
eventName={event?.name ?? t('galleryPage.hero.eventFallback', 'Event')}
onProfilePress={() => {
setNotificationsOpen(false);
setSheetOpen(false);
setCompassOpen(false);
setFabOpen(false);
setSettingsOpen(true);
}}
onNotificationsPress={() => {
setSettingsOpen(false);
setSheetOpen(false);
setCompassOpen(false);
setFabOpen(false);
setNotificationsOpen(true);
}}
notificationCount={notificationCenter?.unreadCount ?? 0}
@@ -204,18 +135,58 @@ export default function AppShell({ children }: AppShellProps) {
gap="$4"
position="relative"
zIndex={1}
style={{ paddingTop: '88px', paddingBottom: '128px' }}
style={{ paddingTop: '88px', paddingBottom: '112px' }}
>
{children}
</YStack>
{showFab ? <FloatingActionButton onPress={openSheet} onLongPress={openCompass} /> : null}
<BottomDock />
<FabActionSheet
open={sheetOpen}
onOpenChange={(next) => setSheetOpen(next)}
title={t('appShell.fab.title', 'Create a moment')}
actions={actions}
/>
{showFab ? (
<>
<Button
size="$3"
circular
position="fixed"
bottom={28}
left="calc(50% - 84px)"
zIndex={1100}
backgroundColor={isDark ? 'rgba(12, 16, 32, 0.75)' : 'rgba(255, 255, 255, 0.9)'}
borderWidth={1}
borderColor={isDark ? 'rgba(255,255,255,0.16)' : 'rgba(15,23,42,0.12)'}
onPress={goTo('/')}
style={{ boxShadow: isDark ? '0 10px 20px rgba(2, 6, 23, 0.45)' : '0 8px 16px rgba(15, 23, 42, 0.14)' }}
>
<Home size={16} color={actionIconColor} />
</Button>
<FloatingActionButton
onPress={() => {
setCompassOpen(false);
setNotificationsOpen(false);
setSettingsOpen(false);
setFabOpen((prev) => !prev);
}}
onLongPress={openCompass}
/>
<Button
size="$3"
circular
position="fixed"
bottom={28}
left="calc(50% + 48px)"
zIndex={1100}
backgroundColor={isDark ? 'rgba(12, 16, 32, 0.75)' : 'rgba(255, 255, 255, 0.9)'}
borderWidth={1}
borderColor={isDark ? 'rgba(255,255,255,0.16)' : 'rgba(15,23,42,0.12)'}
onPress={location.pathname.includes('/gallery') && tasksEnabled ? goTo('/tasks') : goTo('/gallery')}
style={{ boxShadow: isDark ? '0 10px 20px rgba(2, 6, 23, 0.45)' : '0 8px 16px rgba(15, 23, 42, 0.14)' }}
>
{location.pathname.includes('/gallery') && tasksEnabled ? (
<Sparkles size={16} color={actionIconColor} />
) : (
<Image size={16} color={actionIconColor} />
)}
</Button>
</>
) : null}
<FabActionRing open={fabOpen} onOpenChange={setFabOpen} actions={fabActions} />
<CompassHub
open={compassOpen}
onOpenChange={setCompassOpen}

View File

@@ -1,5 +1,4 @@
import React from 'react';
import { Sheet } from '@tamagui/sheet';
import { YStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
@@ -42,57 +41,63 @@ export default function CompassHub({
const close = () => onOpenChange(false);
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
const [visible, setVisible] = React.useState(open);
const [closing, setClosing] = React.useState(false);
if (!open) {
React.useEffect(() => {
if (open) {
setVisible(true);
setClosing(false);
return;
}
if (!visible) return;
setClosing(true);
const timeout = window.setTimeout(() => {
setVisible(false);
setClosing(false);
}, 520);
return () => {
window.clearTimeout(timeout);
};
}, [open, visible]);
if (!visible) {
return null;
}
return (
<Sheet
modal
open={open}
onOpenChange={onOpenChange}
snapPoints={[100]}
snapPointsMode="percent"
dismissOnOverlayPress
dismissOnSnapToBottom
zIndex={100000}
>
<Sheet.Overlay
{...({
backgroundColor: isDark ? 'rgba(15, 23, 42, 0.55)' : 'rgba(15, 23, 42, 0.22)',
pointerEvents: 'auto',
onClick: close,
onMouseDown: close,
onTouchStart: close,
} as any)}
<YStack position="fixed" inset={0} zIndex={100000} pointerEvents="box-none">
<YStack
position="absolute"
inset={0}
backgroundColor={isDark ? 'rgba(15, 23, 42, 0.55)' : 'rgba(15, 23, 42, 0.22)'}
pointerEvents="auto"
onPress={close}
onClick={close}
onMouseDown={close}
onTouchStart={close}
/>
<Sheet.Frame
{...({
width: '100%',
height: '100%',
alignSelf: 'center',
backgroundColor: 'transparent',
padding: 24,
pointerEvents: 'box-none',
} as any)}
<YStack
position="absolute"
inset={0}
padding={24}
alignItems="center"
justifyContent="center"
pointerEvents="box-none"
>
<YStack
position="absolute"
top={0}
right={0}
bottom={0}
left={0}
pointerEvents="auto"
onPress={close}
onClick={close}
onTouchStart={close}
/>
<YStack flex={1} alignItems="center" justifyContent="center" gap="$3" pointerEvents="auto">
<YStack alignItems="center" justifyContent="center" gap="$3" pointerEvents="auto">
<Text fontSize="$5" fontFamily="$display" fontWeight="$8" color="$color">
{title}
</Text>
<YStack width={280} height={280} position="relative" className="guest-compass-flyin">
<YStack
key={closing ? 'compass-out' : 'compass-in'}
width={280}
height={280}
position="relative"
className={closing ? 'guest-compass-flyout' : 'guest-compass-flyin'}
>
{quadrants.map((action, index) => (
<Button
key={action.key}
@@ -144,7 +149,7 @@ export default function CompassHub({
Tap outside to close
</Text>
</YStack>
</Sheet.Frame>
</Sheet>
</YStack>
</YStack>
);
}

View File

@@ -0,0 +1,94 @@
import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { Button } from '@tamagui/button';
import { SizableText as Text } from '@tamagui/text';
import { useAppearance } from '@/hooks/use-appearance';
type FabAction = {
key: string;
label: string;
icon: React.ReactNode;
onPress?: () => void;
};
type FabActionRingProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
actions: FabAction[];
};
const positions = [
{ x: 0, y: -120 },
{ x: -70, y: -100 },
{ x: -120, y: -24 },
{ x: -92, y: 52 },
];
export default function FabActionRing({ open, onOpenChange, actions }: FabActionRingProps) {
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
const borderColor = isDark ? 'rgba(255,255,255,0.18)' : 'rgba(15, 23, 42, 0.12)';
const surfaceColor = isDark ? 'rgba(12, 16, 32, 0.92)' : 'rgba(255, 255, 255, 0.95)';
const textColor = isDark ? '#F8FAFF' : '#0F172A';
const shadow = isDark ? '0 16px 28px rgba(2, 6, 23, 0.55)' : '0 14px 24px rgba(15, 23, 42, 0.18)';
const ringActions = actions.slice(0, positions.length);
if (!open) return null;
return (
<YStack position="fixed" inset={0} zIndex={1090} pointerEvents="box-none">
<YStack
position="absolute"
inset={0}
backgroundColor={isDark ? 'rgba(2,6,23,0.5)' : 'rgba(15,23,42,0.2)'}
onPress={() => onOpenChange(false)}
/>
<YStack position="absolute" bottom={24} right={20} pointerEvents="box-none">
{ringActions.map((action, index) => {
const offset = positions[index];
return (
<XStack
key={action.key}
alignItems="center"
gap="$2"
position="absolute"
style={{
transform: `translate(${offset.x}px, ${offset.y}px)`,
opacity: open ? 1 : 0,
transition: 'transform 260ms ease, opacity 200ms ease',
}}
>
<Button
size="$3"
circular
backgroundColor={surfaceColor}
borderWidth={1}
borderColor={borderColor}
onPress={() => {
onOpenChange(false);
action.onPress?.();
}}
style={{ boxShadow: shadow }}
>
{action.icon}
</Button>
<XStack
paddingHorizontal="$3"
paddingVertical="$2"
borderRadius="$pill"
backgroundColor={surfaceColor}
borderWidth={1}
borderColor={borderColor}
style={{ boxShadow: shadow }}
>
<Text fontSize="$2" fontWeight="$6" color={textColor}>
{action.label}
</Text>
</XStack>
</XStack>
);
})}
</YStack>
</YStack>
);
}

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { Button } from '@tamagui/button';
import { Plus } from 'lucide-react';
import { Flower } from 'lucide-react';
import { useAppearance } from '@/hooks/use-appearance';
type FloatingActionButtonProps = {
@@ -30,26 +30,27 @@ export default function FloatingActionButton({ onPress, onLongPress }: FloatingA
onLongPress?.();
}}
position="fixed"
bottom={88}
right={20}
bottom={20}
left="50%"
zIndex={1100}
width={56}
height={56}
width={68}
height={68}
borderRadius={999}
backgroundColor="$primary"
borderWidth={0}
elevation={4}
shadowColor={isDark ? 'rgba(255, 79, 216, 0.5)' : 'rgba(15, 23, 42, 0.2)'}
shadowOpacity={0.5}
shadowRadius={18}
shadowRadius={22}
shadowOffset={{ width: 0, height: 10 }}
style={{
transform: 'translateX(-50%)',
boxShadow: isDark
? '0 18px 36px rgba(255, 79, 216, 0.35), 0 0 0 6px rgba(255, 79, 216, 0.15)'
: '0 16px 28px rgba(15, 23, 42, 0.18), 0 0 0 6px rgba(255, 255, 255, 0.7)',
? '0 20px 40px rgba(255, 79, 216, 0.38), 0 0 0 8px rgba(255, 79, 216, 0.16)'
: '0 18px 32px rgba(15, 23, 42, 0.2), 0 0 0 8px rgba(255, 255, 255, 0.7)',
}}
>
<Plus size={22} color="white" />
<Flower size={26} color="white" />
</Button>
);
}

View File

@@ -0,0 +1,364 @@
import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { Camera, CheckCircle2, Heart, RefreshCw, Sparkles, Timer as TimerIcon } from 'lucide-react';
import PhotoFrameTile from './PhotoFrameTile';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { getEmotionIcon, getEmotionTheme, type EmotionIdentity } from '@/guest/lib/emotionTheme';
import { useAppearance } from '@/hooks/use-appearance';
import { getBentoSurfaceTokens } from '../lib/bento';
type TaskHeroEmotion = EmotionIdentity & { emoji?: string | null };
export type TaskHero = {
id: number;
title: string;
description?: string | null;
instructions?: string | null;
duration?: number | null;
emotion?: TaskHeroEmotion | null;
};
export type TaskHeroPhoto = {
id: number;
imageUrl: string;
likesCount?: number;
};
type TaskHeroCardProps = {
task: TaskHero | null;
loading: boolean;
error?: string | null;
hasSwiped: boolean;
onSwiped: () => void;
onStart: () => void;
onShuffle: () => void;
onViewSimilar: () => void;
onRetry: () => void;
onOpenPhoto: (photoId: number) => void;
isCompleted: boolean;
photos: TaskHeroPhoto[];
photosLoading: boolean;
photosError?: string | null;
};
const SWIPE_THRESHOLD_PX = 40;
export default function TaskHeroCard({
task,
loading,
error,
hasSwiped,
onSwiped,
onStart,
onShuffle,
onViewSimilar,
onRetry,
onOpenPhoto,
isCompleted,
photos,
photosLoading,
photosError,
}: TaskHeroCardProps) {
const { t } = useTranslation();
const heroCardRef = React.useRef<HTMLDivElement | null>(null);
const theme = getEmotionTheme(task?.emotion ?? null);
const emotionIcon = getEmotionIcon(task?.emotion ?? null);
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
const bentoSurface = getBentoSurfaceTokens(isDark);
React.useEffect(() => {
const card = heroCardRef.current;
if (!card) return;
let startX: number | null = null;
let startY: number | null = null;
const onTouchStart = (event: TouchEvent) => {
const touch = event.touches[0];
startX = touch.clientX;
startY = touch.clientY;
};
const onTouchEnd = (event: TouchEvent) => {
if (startX === null || startY === null) return;
const touch = event.changedTouches[0];
const deltaX = touch.clientX - startX;
const deltaY = touch.clientY - startY;
if (Math.abs(deltaX) > SWIPE_THRESHOLD_PX && Math.abs(deltaY) < 60) {
if (deltaX < 0) {
onShuffle();
} else {
onViewSimilar();
}
onSwiped();
}
startX = null;
startY = null;
};
card.addEventListener('touchstart', onTouchStart, { passive: true });
card.addEventListener('touchend', onTouchEnd);
return () => {
card.removeEventListener('touchstart', onTouchStart);
card.removeEventListener('touchend', onTouchEnd);
};
}, [onShuffle, onSwiped, onViewSimilar]);
if (loading && !task) {
return (
<YStack
padding="$4"
borderRadius="$bento"
backgroundColor={bentoSurface.backgroundColor}
borderWidth={1}
borderBottomWidth={3}
borderColor={bentoSurface.borderColor}
borderBottomColor={bentoSurface.borderBottomColor}
gap="$3"
style={{ boxShadow: bentoSurface.shadow }}
>
<Text fontSize="$3" opacity={0.7}>
{t('tasks.loading', 'Loading tasks...')}
</Text>
</YStack>
);
}
if (error && !task) {
return (
<YStack
padding="$4"
borderRadius="$bento"
backgroundColor={bentoSurface.backgroundColor}
borderWidth={1}
borderBottomWidth={3}
borderColor={bentoSurface.borderColor}
borderBottomColor={bentoSurface.borderBottomColor}
gap="$3"
style={{ boxShadow: bentoSurface.shadow }}
>
<Text fontSize="$3">{error}</Text>
<Button size="$3" borderRadius="$pill" onPress={onRetry}>
{t('tasks.page.reloadButton', 'Reload')}
</Button>
</YStack>
);
}
if (!task) {
return (
<YStack
padding="$4"
borderRadius="$bento"
backgroundColor={bentoSurface.backgroundColor}
borderWidth={1}
borderBottomWidth={3}
borderColor={bentoSurface.borderColor}
borderBottomColor={bentoSurface.borderBottomColor}
gap="$3"
style={{ boxShadow: bentoSurface.shadow }}
>
<Text fontSize="$3">{t('tasks.page.emptyTitle', 'No matching task found')}</Text>
</YStack>
);
}
return (
<YStack
ref={heroCardRef}
padding="$4"
borderRadius="$bentoLg"
gap="$3"
backgroundColor="$surface"
borderWidth={1}
borderBottomWidth={3}
borderColor="rgba(255, 255, 255, 0.2)"
borderBottomColor="rgba(255, 255, 255, 0.35)"
style={{
backgroundImage: theme.gradientBackground,
boxShadow: bentoSurface.shadow,
overflow: 'hidden',
}}
>
<XStack alignItems="center" justifyContent="space-between">
<XStack
alignItems="center"
gap="$2"
paddingHorizontal="$3"
paddingVertical="$1"
borderRadius="$pill"
borderWidth={1}
borderColor="rgba(255,255,255,0.32)"
backgroundColor="rgba(255,255,255,0.18)"
>
<Sparkles size={14} color="rgba(255,255,255,0.9)" />
<Text fontSize="$1" fontWeight="$7" color="rgba(255,255,255,0.85)" textTransform="uppercase" letterSpacing={1.4}>
{emotionIcon} {task.emotion?.name ?? t('tasks.page.eyebrow', 'New mission')}
</Text>
</XStack>
{task.duration ? (
<XStack alignItems="center" gap="$1">
<TimerIcon size={14} color="rgba(255,255,255,0.8)" />
<Text fontSize="$2" color="rgba(255,255,255,0.8)">
{task.duration} min
</Text>
</XStack>
) : null}
</XStack>
<YStack gap="$2">
<Text fontSize="$8" fontFamily="$display" fontWeight="$8" color="white">
{task.title}
</Text>
{task.description ? (
<Text fontSize="$3" color="rgba(255,255,255,0.8)">
{task.description}
</Text>
) : null}
</YStack>
{!hasSwiped ? (
<Text fontSize="$2" color="rgba(255,255,255,0.7)" letterSpacing={2.4} textTransform="uppercase">
{t('tasks.page.swipeHint', 'Swipe for more missions')}
</Text>
) : null}
{task.instructions ? (
<YStack
backgroundColor="rgba(255,255,255,0.16)"
padding="$3"
borderRadius="$bento"
borderWidth={1}
borderColor="rgba(255,255,255,0.2)"
>
<Text fontSize="$2" color="rgba(255,255,255,0.9)">
{task.instructions}
</Text>
</YStack>
) : null}
{isCompleted ? (
<XStack alignItems="center" gap="$2">
<CheckCircle2 size={16} color="rgba(255,255,255,0.9)" />
<Text fontSize="$2" color="rgba(255,255,255,0.85)">
{t('tasks.page.completedLabel', 'Completed')}
</Text>
</XStack>
) : null}
<XStack gap="$2" flexWrap="wrap">
<Button
size="$4"
borderRadius="$bento"
onPress={onStart}
backgroundColor="rgba(255,255,255,0.92)"
borderWidth={1}
borderColor="rgba(255,255,255,0.9)"
>
<XStack alignItems="center" gap="$2">
<Camera size={18} color="rgba(15,23,42,0.9)" />
<Text color="rgba(15,23,42,0.9)" fontWeight="$7">
{t('tasks.page.ctaStart', "Let's go!")}
</Text>
</XStack>
</Button>
<Button
size="$4"
borderRadius="$bento"
onPress={onShuffle}
backgroundColor="rgba(255,255,255,0.12)"
borderWidth={1}
borderColor="rgba(255,255,255,0.25)"
>
<XStack alignItems="center" gap="$2">
<RefreshCw size={16} color="white" />
<Text color="white" fontWeight="$6">
{t('tasks.page.shuffleCta', 'Shuffle')}
</Text>
</XStack>
</Button>
</XStack>
{(photosLoading || photosError || photos.length > 0) && (
<YStack
backgroundColor="rgba(255,255,255,0.14)"
padding="$3"
borderRadius="$bento"
borderWidth={1}
borderColor="rgba(255,255,255,0.2)"
gap="$2"
>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$2" color="rgba(255,255,255,0.85)">
{t('tasks.page.inspirationTitle', 'Inspiration')}
</Text>
{photosLoading ? (
<Text fontSize="$1" color="rgba(255,255,255,0.7)">
{t('tasks.page.inspirationLoading', 'Loading')}
</Text>
) : null}
</XStack>
{photosError && photos.length === 0 ? (
<Text fontSize="$2" color="rgba(255,255,255,0.8)">
{photosError}
</Text>
) : photos.length > 0 ? (
<XStack
gap="$2"
style={{
overflowX: 'auto',
WebkitOverflowScrolling: 'touch',
paddingBottom: 4,
}}
>
{photos.map((photo) => (
<Button key={photo.id} unstyled onPress={() => onOpenPhoto(photo.id)}>
<YStack width={64} height={64}>
<PhotoFrameTile height={64} borderRadius="$bento">
<YStack
flex={1}
width="100%"
height="100%"
style={{
backgroundImage: `url(${photo.imageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
/>
</PhotoFrameTile>
</YStack>
</Button>
))}
<Button
size="$2"
borderRadius="$pill"
borderWidth={1}
borderColor="rgba(255,255,255,0.35)"
backgroundColor="rgba(255,255,255,0.08)"
onPress={onViewSimilar}
>
{t('tasks.page.inspirationMore', 'More')}
</Button>
</XStack>
) : (
<Button
size="$3"
borderRadius="$pill"
borderWidth={1}
borderColor="rgba(255,255,255,0.3)"
backgroundColor="rgba(255,255,255,0.12)"
onPress={onStart}
>
<XStack alignItems="center" gap="$2">
<Heart size={14} color="white" />
<Text color="white">{t('tasks.page.inspirationEmptyTitle', 'Be the first')}</Text>
</XStack>
</Button>
)}
</YStack>
)}
</YStack>
);
}

View File

@@ -20,6 +20,11 @@ export default function TopBar({
}: TopBarProps) {
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
const [animationKey, setAnimationKey] = React.useState(0);
React.useEffect(() => {
setAnimationKey((prev) => prev + 1);
}, [eventName]);
return (
<XStack
@@ -28,17 +33,21 @@ export default function TopBar({
paddingHorizontal="$4"
paddingVertical="$3"
style={{
backgroundColor: isDark ? 'rgba(10, 14, 28, 0.72)' : 'rgba(255, 255, 255, 0.85)',
backdropFilter: 'saturate(160%) blur(18px)',
WebkitBackdropFilter: 'saturate(160%) blur(18px)',
backgroundColor: 'transparent',
backdropFilter: 'saturate(120%) blur(8px)',
WebkitBackdropFilter: 'saturate(120%) blur(8px)',
}}
>
<Text
fontSize="$6"
key={animationKey}
fontSize="$8"
fontFamily="$display"
fontWeight="$8"
numberOfLines={1}
style={{ textShadow: '0 6px 18px rgba(2, 6, 23, 0.7)' }}
className="guest-topbar-title"
flexShrink={1}
minWidth={0}
style={{ fontSize: 'clamp(20px, 4.6vw, 30px)', lineHeight: '1.1' }}
>
{eventName}
</Text>