Migrate billing from Paddle to Lemon Squeezy
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user