Migrate billing from Paddle to Lemon Squeezy
This commit is contained in:
@@ -40,26 +40,55 @@ vi.mock('../services/uploadApi', () => ({
|
||||
useUploadQueue: () => ({ items: [], loading: false, refresh: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('../services/tasksApi', () => ({
|
||||
fetchTasks: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 12,
|
||||
title: 'Capture the dancefloor',
|
||||
description: 'Find the happiest crew.',
|
||||
instructions: 'Look for the brightest smiles.',
|
||||
duration: 5,
|
||||
emotion: { slug: 'freude', name: 'Joy' },
|
||||
},
|
||||
]),
|
||||
}));
|
||||
|
||||
vi.mock('../services/emotionsApi', () => ({
|
||||
fetchEmotions: vi.fn().mockResolvedValue([{ slug: 'freude', name: 'Joy' }]),
|
||||
}));
|
||||
|
||||
vi.mock('../services/photosApi', () => ({
|
||||
fetchGallery: vi.fn().mockResolvedValue({ data: [] }),
|
||||
}));
|
||||
|
||||
vi.mock('@/guest/i18n/useTranslation', () => ({
|
||||
useTranslation: () => ({ t: (key: string, fallback?: string) => fallback ?? key, locale: 'de' }),
|
||||
}));
|
||||
|
||||
vi.mock('@/guest/i18n/LocaleContext', () => ({
|
||||
useLocale: () => ({ locale: 'de' }),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-appearance', () => ({
|
||||
useAppearance: () => ({ resolved: 'light' }),
|
||||
}));
|
||||
|
||||
vi.mock('@/guest/hooks/useGuestTaskProgress', () => ({
|
||||
useGuestTaskProgress: () => ({ isCompleted: () => false }),
|
||||
}));
|
||||
|
||||
import HomeScreen from '../screens/HomeScreen';
|
||||
|
||||
describe('HomeScreen', () => {
|
||||
it('shows prompt quest content when tasks are enabled', () => {
|
||||
it('shows task hero content when tasks are enabled', async () => {
|
||||
render(
|
||||
<EventDataProvider tasksEnabledFallback>
|
||||
<HomeScreen />
|
||||
</EventDataProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Prompt quest')).toBeInTheDocument();
|
||||
expect(screen.getByText('Start prompt')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Capture the dancefloor')).toBeInTheDocument();
|
||||
expect(screen.getByText("Let's go!")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows capture-ready content when tasks are disabled', () => {
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
94
resources/js/guest-v2/components/FabActionRing.tsx
Normal file
94
resources/js/guest-v2/components/FabActionRing.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
364
resources/js/guest-v2/components/TaskHeroCard.tsx
Normal file
364
resources/js/guest-v2/components/TaskHeroCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
24
resources/js/guest-v2/lib/bento.ts
Normal file
24
resources/js/guest-v2/lib/bento.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export type BentoSurfaceTokens = {
|
||||
borderColor: string;
|
||||
borderBottomColor: string;
|
||||
backgroundColor: string;
|
||||
shadow: string;
|
||||
};
|
||||
|
||||
export function getBentoSurfaceTokens(isDark: boolean): BentoSurfaceTokens {
|
||||
if (isDark) {
|
||||
return {
|
||||
borderColor: 'rgba(255, 255, 255, 0.12)',
|
||||
borderBottomColor: 'rgba(255, 255, 255, 0.28)',
|
||||
backgroundColor: 'rgba(14, 20, 34, 0.92)',
|
||||
shadow: '0 22px 36px rgba(2, 6, 23, 0.55), 0 6px 0 rgba(2, 6, 23, 0.35), inset 0 -4px 0 rgba(255, 255, 255, 0.08)',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
borderColor: 'rgba(15, 23, 42, 0.1)',
|
||||
borderBottomColor: 'rgba(15, 23, 42, 0.18)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.96)',
|
||||
shadow: '0 22px 34px rgba(15, 23, 42, 0.18), 0 6px 0 rgba(15, 23, 42, 0.08), inset 0 -4px 0 rgba(15, 23, 42, 0.06)',
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { Button } from '@tamagui/button';
|
||||
import { Camera, Sparkles, Image as ImageIcon, Trophy, Star } 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';
|
||||
@@ -14,8 +15,13 @@ import { fetchGallery } from '../services/photosApi';
|
||||
import { useUploadQueue } from '../services/uploadApi';
|
||||
import { useTranslation } from '@/guest/i18n/useTranslation';
|
||||
import { useAppearance } from '@/hooks/use-appearance';
|
||||
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';
|
||||
|
||||
type ActionRingProps = {
|
||||
type ActionTileProps = {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
onPress: () => void;
|
||||
@@ -26,36 +32,47 @@ type GalleryPreview = {
|
||||
imageUrl: string;
|
||||
};
|
||||
|
||||
function ActionRing({
|
||||
type TaskPhoto = TaskHeroPhoto & {
|
||||
taskId?: number | null;
|
||||
};
|
||||
|
||||
function ActionTile({
|
||||
label,
|
||||
icon,
|
||||
onPress,
|
||||
isDark,
|
||||
}: ActionRingProps & { isDark: boolean }) {
|
||||
}: ActionTileProps & { isDark: boolean }) {
|
||||
const surface = getBentoSurfaceTokens(isDark);
|
||||
|
||||
return (
|
||||
<Button unstyled onPress={onPress}>
|
||||
<YStack alignItems="center" gap="$2">
|
||||
<YStack
|
||||
width={74}
|
||||
height={74}
|
||||
borderRadius={37}
|
||||
backgroundColor="$surface"
|
||||
borderWidth={2}
|
||||
borderColor="$primary"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
style={{
|
||||
backgroundImage: isDark
|
||||
? 'radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.25), rgba(255, 255, 255, 0.05))'
|
||||
: 'radial-gradient(circle at 30% 30%, color-mix(in oklab, var(--guest-primary, #FF5A5F) 20%, white), rgba(255, 255, 255, 0.7))',
|
||||
boxShadow: isDark
|
||||
? '0 10px 24px rgba(255, 79, 216, 0.2)'
|
||||
: '0 10px 24px rgba(15, 23, 42, 0.12)',
|
||||
}}
|
||||
<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"
|
||||
>
|
||||
{icon}
|
||||
</YStack>
|
||||
<Text fontSize="$2" fontWeight="$6" color="$color" opacity={0.9}>
|
||||
{label}
|
||||
</Text>
|
||||
</YStack>
|
||||
@@ -75,8 +92,7 @@ function QuickStats({
|
||||
isDark: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const cardBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)';
|
||||
const cardShadow = isDark ? '0 16px 30px rgba(2, 6, 23, 0.35)' : '0 14px 24px rgba(15, 23, 42, 0.12)';
|
||||
const surface = getBentoSurfaceTokens(isDark);
|
||||
return (
|
||||
<XStack
|
||||
gap="$3"
|
||||
@@ -88,38 +104,42 @@ function QuickStats({
|
||||
<YStack
|
||||
flex={1}
|
||||
padding="$3"
|
||||
borderRadius="$card"
|
||||
backgroundColor="$surface"
|
||||
borderRadius="$bento"
|
||||
backgroundColor={surface.backgroundColor}
|
||||
borderWidth={1}
|
||||
borderColor={cardBorder}
|
||||
borderBottomWidth={3}
|
||||
borderColor={surface.borderColor}
|
||||
borderBottomColor={surface.borderBottomColor}
|
||||
gap="$1"
|
||||
style={{
|
||||
boxShadow: cardShadow,
|
||||
boxShadow: surface.shadow,
|
||||
}}
|
||||
>
|
||||
<Text fontSize="$4" fontWeight="$8">
|
||||
{stats.onlineGuests}
|
||||
</Text>
|
||||
<Text fontSize="$2" color="$color" opacity={0.7}>
|
||||
<Text fontSize="$1" color="$color" opacity={0.7} textTransform="uppercase" letterSpacing={1.2}>
|
||||
{t('home.stats.online', 'Guests online')}
|
||||
</Text>
|
||||
</YStack>
|
||||
<YStack
|
||||
flex={1}
|
||||
padding="$3"
|
||||
borderRadius="$card"
|
||||
backgroundColor="$surface"
|
||||
borderRadius="$bento"
|
||||
backgroundColor={surface.backgroundColor}
|
||||
borderWidth={1}
|
||||
borderColor={cardBorder}
|
||||
borderBottomWidth={3}
|
||||
borderColor={surface.borderColor}
|
||||
borderBottomColor={surface.borderBottomColor}
|
||||
gap="$1"
|
||||
style={{
|
||||
boxShadow: cardShadow,
|
||||
boxShadow: surface.shadow,
|
||||
}}
|
||||
>
|
||||
<Text fontSize="$4" fontWeight="$8">
|
||||
{queueCount}
|
||||
</Text>
|
||||
<Text fontSize="$2" color="$color" opacity={0.7}>
|
||||
<Text fontSize="$1" color="$color" opacity={0.7} textTransform="uppercase" letterSpacing={1.2}>
|
||||
{t('homeV2.stats.uploadsQueued', 'Uploads queued')}
|
||||
</Text>
|
||||
</YStack>
|
||||
@@ -150,95 +170,344 @@ export default function HomeScreen() {
|
||||
const revealStage = useStaggeredReveal({ steps: 4, intervalMs: 140, delayMs: 120 });
|
||||
const { stats } = usePollStats(token ?? null);
|
||||
const { items } = useUploadQueue();
|
||||
const [preview, setPreview] = React.useState<GalleryPreview[]>([]);
|
||||
const [previewLoading, setPreviewLoading] = React.useState(false);
|
||||
const { t } = useTranslation();
|
||||
const { locale } = useLocale();
|
||||
const { isCompleted } = useGuestTaskProgress(token ?? undefined);
|
||||
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 [emotionMap, setEmotionMap] = React.useState<Record<string, string>>({});
|
||||
const { resolved } = useAppearance();
|
||||
const isDark = resolved === 'dark';
|
||||
const cardBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)';
|
||||
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 cardShadow = isDark ? '0 18px 40px rgba(2, 6, 23, 0.4)' : '0 16px 32px rgba(15, 23, 42, 0.12)';
|
||||
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 goTo = (path: string) => () => navigate(buildEventPath(token, path));
|
||||
|
||||
const recentTaskIdsRef = React.useRef<number[]>([]);
|
||||
|
||||
const rings = [
|
||||
tasksEnabled
|
||||
? {
|
||||
label: t('home.actions.items.tasks.label', 'Draw a task card'),
|
||||
icon: <Sparkles size={20} color={isDark ? '#F8FAFF' : '#0F172A'} />,
|
||||
icon: <Sparkles size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />,
|
||||
path: '/tasks',
|
||||
}
|
||||
: {
|
||||
label: t('home.actions.items.upload.label', 'Upload photo'),
|
||||
icon: <Camera size={20} color={isDark ? '#F8FAFF' : '#0F172A'} />,
|
||||
icon: <Camera size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />,
|
||||
path: '/upload',
|
||||
},
|
||||
{
|
||||
label: t('homeV2.rings.newUploads', 'New uploads'),
|
||||
icon: <ImageIcon size={20} color={isDark ? '#F8FAFF' : '#0F172A'} />,
|
||||
icon: <ImageIcon size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />,
|
||||
path: '/gallery',
|
||||
},
|
||||
{
|
||||
label: t('homeV2.rings.topMoments', 'Top moments'),
|
||||
icon: <Star size={20} color={isDark ? '#F8FAFF' : '#0F172A'} />,
|
||||
icon: <Star size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />,
|
||||
path: '/gallery',
|
||||
},
|
||||
{
|
||||
label: t('navigation.achievements', 'Achievements'),
|
||||
icon: <Trophy size={20} color={isDark ? '#F8FAFF' : '#0F172A'} />,
|
||||
icon: <Trophy size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />,
|
||||
path: '/achievements',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const mapTaskItem = React.useCallback((task: TaskItem): 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,
|
||||
};
|
||||
}, [emotionMap]);
|
||||
|
||||
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) {
|
||||
setPreview([]);
|
||||
setGalleryPhotos([]);
|
||||
setGalleryError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
setPreviewLoading(true);
|
||||
setGalleryLoading(true);
|
||||
setGalleryError(null);
|
||||
|
||||
fetchGallery(token, { limit: 3 })
|
||||
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)
|
||||
);
|
||||
return { id, imageUrl };
|
||||
})
|
||||
.filter((item) => item.id && item.imageUrl);
|
||||
setPreview(mapped);
|
||||
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) {
|
||||
setPreview([]);
|
||||
setGalleryPhotos([]);
|
||||
setGalleryError(t('gallery.error', 'Gallery could not be loaded.'));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) {
|
||||
setPreviewLoading(false);
|
||||
setGalleryLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [token]);
|
||||
}, [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;
|
||||
}
|
||||
}
|
||||
setEmotionMap(nextMap);
|
||||
const mapped = taskList.map(mapTaskItem).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);
|
||||
setTasks([]);
|
||||
setCurrentTask(null);
|
||||
setTaskError(t('tasks.error', 'Tasks could not be loaded.'));
|
||||
})
|
||||
.finally(() => {
|
||||
setTaskLoading(false);
|
||||
});
|
||||
}, [currentTask, locale, mapTaskItem, selectRandomTask, 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;
|
||||
}
|
||||
}
|
||||
setEmotionMap(nextMap);
|
||||
const mapped = taskList.map(mapTaskItem).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);
|
||||
if (active) {
|
||||
setTasks([]);
|
||||
setCurrentTask(null);
|
||||
setTaskError(t('tasks.error', 'Tasks could not be loaded.'));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) {
|
||||
setTaskLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [currentTask, locale, mapTaskItem, selectRandomTask, t, tasksEnabled, token]);
|
||||
|
||||
const queueCount = items.filter((item) => item.status !== 'done').length;
|
||||
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 openTaskPhoto = React.useCallback(
|
||||
(photoId: number) => {
|
||||
if (!currentTask) return;
|
||||
navigate(buildEventPath(token, `/gallery?photoId=${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>
|
||||
@@ -250,10 +519,10 @@ export default function HomeScreen() {
|
||||
opacity={revealStage >= 1 ? 1 : 0}
|
||||
y={revealStage >= 1 ? 0 : 12}
|
||||
>
|
||||
<XStack gap="$2" justifyContent="space-between">
|
||||
<XStack gap="$2" flexWrap="nowrap">
|
||||
{rings.map((ring) => (
|
||||
<YStack key={ring.label} flex={1} alignItems="center">
|
||||
<ActionRing label={ring.label} icon={ring.icon} onPress={goTo(ring.path)} isDark={isDark} />
|
||||
<YStack key={ring.label} flex={1} minWidth={0}>
|
||||
<ActionTile label={ring.label} icon={ring.icon} onPress={goTo(ring.path)} isDark={isDark} />
|
||||
</YStack>
|
||||
))}
|
||||
</XStack>
|
||||
@@ -261,55 +530,34 @@ export default function HomeScreen() {
|
||||
|
||||
{tasksEnabled ? (
|
||||
<YStack
|
||||
padding="$4"
|
||||
borderRadius="$card"
|
||||
backgroundColor="$surface"
|
||||
borderWidth={1}
|
||||
borderColor={cardBorder}
|
||||
gap="$3"
|
||||
y={revealStage >= 2 ? 0 : 16}
|
||||
style={{
|
||||
backgroundImage: isDark
|
||||
? 'linear-gradient(135deg, rgba(255, 79, 216, 0.25), rgba(79, 209, 255, 0.12))'
|
||||
: 'linear-gradient(135deg, color-mix(in oklab, var(--guest-primary, #FF5A5F) 18%, white), color-mix(in oklab, var(--guest-secondary, #F43F5E) 10%, white))',
|
||||
boxShadow: cardShadow,
|
||||
}}
|
||||
>
|
||||
<XStack alignItems="center" gap="$2">
|
||||
<Sparkles size={18} color="#FF4FD8" />
|
||||
<Text fontSize="$3" fontWeight="$7">
|
||||
{t('homeV2.promptQuest.label', 'Prompt quest')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$7" fontFamily="$display" fontWeight="$8">
|
||||
{t('homeV2.promptQuest.title', 'Capture the happiest laugh')}
|
||||
</Text>
|
||||
<Text fontSize="$3" color="$color" opacity={0.75}>
|
||||
{t('homeV2.promptQuest.subtitle', 'Earn points and keep the gallery lively.')}
|
||||
</Text>
|
||||
<XStack gap="$2" flexWrap="wrap">
|
||||
<Button size="$4" backgroundColor="$primary" borderRadius="$pill" onPress={goTo('/upload')}>
|
||||
{t('homeV2.promptQuest.ctaStart', 'Start prompt')}
|
||||
</Button>
|
||||
<Button
|
||||
size="$4"
|
||||
backgroundColor={mutedButton}
|
||||
borderRadius="$pill"
|
||||
borderWidth={1}
|
||||
borderColor={mutedButtonBorder}
|
||||
onPress={goTo('/tasks')}
|
||||
>
|
||||
{t('homeV2.promptQuest.ctaBrowse', 'Browse tasks')}
|
||||
</Button>
|
||||
</XStack>
|
||||
<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="$card"
|
||||
backgroundColor="$surface"
|
||||
borderRadius="$bento"
|
||||
backgroundColor={bentoSurface.backgroundColor}
|
||||
borderWidth={1}
|
||||
borderColor={cardBorder}
|
||||
borderBottomWidth={3}
|
||||
borderColor={bentoSurface.borderColor}
|
||||
borderBottomColor={bentoSurface.borderBottomColor}
|
||||
gap="$3"
|
||||
y={revealStage >= 2 ? 0 : 16}
|
||||
style={{
|
||||
@@ -346,10 +594,12 @@ export default function HomeScreen() {
|
||||
|
||||
<YStack
|
||||
padding="$4"
|
||||
borderRadius="$card"
|
||||
backgroundColor="$surface"
|
||||
borderRadius="$bentoLg"
|
||||
backgroundColor={bentoSurface.backgroundColor}
|
||||
borderWidth={1}
|
||||
borderColor={cardBorder}
|
||||
borderBottomWidth={3}
|
||||
borderColor={bentoSurface.borderColor}
|
||||
borderBottomColor={bentoSurface.borderBottomColor}
|
||||
gap="$3"
|
||||
animation="slow"
|
||||
animateOnly={['transform', 'opacity']}
|
||||
@@ -360,7 +610,7 @@ export default function HomeScreen() {
|
||||
}}
|
||||
>
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<Text fontSize="$4" fontWeight="$7">
|
||||
<Text fontSize="$5" fontWeight="$8" fontFamily="$display">
|
||||
{t('homeV2.galleryPreview.title', 'Gallery preview')}
|
||||
</Text>
|
||||
<Button
|
||||
@@ -382,17 +632,17 @@ export default function HomeScreen() {
|
||||
paddingBottom: 6,
|
||||
}}
|
||||
>
|
||||
{(previewLoading || preview.length === 0 ? [1, 2, 3, 4] : preview).map((tile, index) => {
|
||||
{(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} shimmer shimmerDelayMs={tile * 140} />
|
||||
<PhotoFrameTile height={110} borderRadius="$bento" shimmer shimmerDelayMs={tile * 140} />
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<YStack key={tile.id} flexShrink={0} width={140}>
|
||||
<PhotoFrameTile height={110}>
|
||||
<PhotoFrameTile height={110} borderRadius="$bento">
|
||||
<YStack
|
||||
flex={1}
|
||||
width="100%"
|
||||
|
||||
@@ -458,6 +458,19 @@ export default function UploadScreen() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{cameraState === 'ready' && !previewUrl ? (
|
||||
<Button
|
||||
size="$4"
|
||||
circular
|
||||
position="absolute"
|
||||
bottom="$4"
|
||||
backgroundColor="$primary"
|
||||
onPress={handleCapture}
|
||||
aria-label={t('upload.captureButton', 'Capture')}
|
||||
>
|
||||
<Camera size={20} color="#FFFFFF" />
|
||||
</Button>
|
||||
) : null}
|
||||
{(cameraState === 'ready' || cameraState === 'starting' || cameraState === 'preview') ? (
|
||||
<Button
|
||||
size="$3"
|
||||
|
||||
Reference in New Issue
Block a user