Files
fotospiel-app/resources/js/guest-v2/components/TaskHeroCard.tsx
Codex Agent 6062b4201b
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Update guest v2 home and tasks experience
2026-02-03 18:59:30 +01:00

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>
);
}