364 lines
11 KiB
TypeScript
364 lines
11 KiB
TypeScript
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 { useGuestThemeVariant } from '../lib/guestTheme';
|
|
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 { isDark } = useGuestThemeVariant();
|
|
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('common.actions.loading', 'Loading...')}
|
|
</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>
|
|
);
|
|
}
|