291 lines
11 KiB
TypeScript
291 lines
11 KiB
TypeScript
import { Button } from '@/ui/Button';
|
|
import { Icon } from '@/ui/Icon';
|
|
import { Image } from '@/ui/Image';
|
|
import { Grid } from '@/ui/primitives/Grid';
|
|
import { Stack } from '@/ui/primitives/Stack';
|
|
import { Surface } from '@/ui/primitives/Surface';
|
|
import { Text } from '@/ui/Text';
|
|
import { Check, Loader2, Palette, Sparkles, Upload, User } from 'lucide-react';
|
|
import { ChangeEvent, useRef } from 'react';
|
|
|
|
export type RacingSuitColor =
|
|
| 'red'
|
|
| 'blue'
|
|
| 'green'
|
|
| 'yellow'
|
|
| 'orange'
|
|
| 'purple'
|
|
| 'black'
|
|
| 'white'
|
|
| 'pink'
|
|
| 'cyan';
|
|
|
|
export interface AvatarInfo {
|
|
facePhoto: string | null;
|
|
suitColor: RacingSuitColor;
|
|
generatedAvatars: string[];
|
|
selectedAvatarIndex: number | null;
|
|
isGenerating: boolean;
|
|
isValidating: boolean;
|
|
}
|
|
|
|
interface FormErrors {
|
|
[key: string]: string | undefined;
|
|
}
|
|
|
|
interface AvatarStepProps {
|
|
avatarInfo: AvatarInfo;
|
|
setAvatarInfo: (info: AvatarInfo) => void;
|
|
errors: FormErrors;
|
|
setErrors: (errors: FormErrors) => void;
|
|
onGenerateAvatars: () => void;
|
|
}
|
|
|
|
const SUIT_COLORS: { value: RacingSuitColor; label: string; hex: string }[] = [
|
|
{ value: 'red', label: 'Racing Red', hex: '#EF4444' },
|
|
{ value: 'blue', label: 'Motorsport Blue', hex: '#3B82F6' },
|
|
{ value: 'green', label: 'Racing Green', hex: '#22C55E' },
|
|
{ value: 'yellow', label: 'Championship Yellow', hex: '#EAB308' },
|
|
{ value: 'orange', label: 'Papaya Orange', hex: '#F97316' },
|
|
{ value: 'purple', label: 'Royal Purple', hex: '#A855F7' },
|
|
{ value: 'black', label: 'Stealth Black', hex: '#1F2937' },
|
|
{ value: 'white', label: 'Clean White', hex: '#F9FAFB' },
|
|
{ value: 'pink', label: 'Hot Pink', hex: '#EC4899' },
|
|
{ value: 'cyan', label: 'Electric Cyan', hex: '#06B6D4' },
|
|
];
|
|
|
|
export function AvatarStep({ avatarInfo, setAvatarInfo, errors, setErrors, onGenerateAvatars }: AvatarStepProps) {
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const handleFileSelect = async (e: ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
// Validate file type
|
|
if (!file.type.startsWith('image/')) {
|
|
setErrors({ ...errors, facePhoto: 'Please upload an image file' });
|
|
return;
|
|
}
|
|
|
|
// Validate file size (max 5MB)
|
|
if (file.size > 5 * 1024 * 1024) {
|
|
setErrors({ ...errors, facePhoto: 'Image must be less than 5MB' });
|
|
return;
|
|
}
|
|
|
|
// Convert to base64
|
|
const reader = new FileReader();
|
|
reader.onload = async (event) => {
|
|
const base64 = event.target?.result as string;
|
|
setAvatarInfo({
|
|
...avatarInfo,
|
|
facePhoto: base64,
|
|
generatedAvatars: [],
|
|
selectedAvatarIndex: null,
|
|
});
|
|
const newErrors = { ...errors };
|
|
delete newErrors.facePhoto;
|
|
setErrors(newErrors);
|
|
};
|
|
reader.readAsDataURL(file);
|
|
};
|
|
|
|
return (
|
|
<Stack gap={8}>
|
|
{/* Photo Upload */}
|
|
<Stack>
|
|
<Text as="label" size="sm" weight="medium" color="text-gray-300" block mb={3}>
|
|
Upload Your Photo *
|
|
</Text>
|
|
<Stack direction="row" gap={6}>
|
|
{/* Upload Area */}
|
|
<Stack
|
|
onClick={() => fileInputRef.current?.click()}
|
|
flex={1}
|
|
display="flex"
|
|
flexDirection="column"
|
|
alignItems="center"
|
|
justifyContent="center"
|
|
p={6}
|
|
rounded="xl"
|
|
border
|
|
borderStyle="dashed"
|
|
borderWidth="2px"
|
|
borderColor={avatarInfo.facePhoto ? 'border-performance-green' : errors.facePhoto ? 'border-racing-red' : 'border-charcoal-outline'}
|
|
bg={avatarInfo.facePhoto ? 'bg-performance-green/10' : errors.facePhoto ? 'bg-racing-red/10' : 'transparent'}
|
|
cursor="pointer"
|
|
transition
|
|
hoverBorderColor="border-primary-blue"
|
|
hoverBg="bg-primary-blue/5"
|
|
position="relative"
|
|
>
|
|
<Stack
|
|
as="input"
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
onChange={handleFileSelect}
|
|
display="none"
|
|
/>
|
|
|
|
{avatarInfo.isValidating ? (
|
|
<Stack alignItems="center" center>
|
|
<Icon icon={Loader2} size={10} color="text-primary-blue" animate="spin" mb={3} />
|
|
<Text size="sm" color="text-gray-400">Validating photo...</Text>
|
|
</Stack>
|
|
) : avatarInfo.facePhoto ? (
|
|
<Stack alignItems="center" center>
|
|
<Stack w="24" h="24" rounded="xl" overflow="hidden" mb={3} ring="ring-2 ring-performance-green">
|
|
<Image
|
|
src={avatarInfo.facePhoto}
|
|
alt="Your photo"
|
|
width={96}
|
|
height={96}
|
|
objectFit="cover"
|
|
fullWidth
|
|
fullHeight
|
|
/>
|
|
</Stack>
|
|
<Text size="sm" color="text-performance-green" block>
|
|
<Stack flexDirection="row" alignItems="center" gap={1}>
|
|
<Icon icon={Check} size={4} />
|
|
Photo uploaded
|
|
</Stack>
|
|
</Text>
|
|
<Text size="xs" color="text-gray-500" mt={1}>Click to change</Text>
|
|
</Stack>
|
|
) : (
|
|
<Stack alignItems="center" center>
|
|
<Icon icon={Upload} size={10} color="text-gray-500" mb={3} />
|
|
<Text size="sm" color="text-gray-300" weight="medium" block mb={1}>
|
|
Drop your photo here or click to upload
|
|
</Text>
|
|
<Text size="xs" color="text-gray-500">
|
|
JPEG or PNG, max 5MB
|
|
</Text>
|
|
</Stack>
|
|
)}
|
|
</Stack>
|
|
|
|
{/* Preview area */}
|
|
<Stack w="32" alignItems="center" center>
|
|
<Surface variant="muted" rounded="xl" border w="24" h="24" display="flex" center overflow="hidden" borderColor="border-charcoal-outline">
|
|
{(() => {
|
|
const selectedAvatarUrl =
|
|
avatarInfo.selectedAvatarIndex !== null
|
|
? avatarInfo.generatedAvatars[avatarInfo.selectedAvatarIndex]
|
|
: undefined;
|
|
if (!selectedAvatarUrl) {
|
|
return <Icon icon={User} size={8} color="text-gray-600" />;
|
|
}
|
|
return (
|
|
<Image src={selectedAvatarUrl} alt="Selected avatar" width={96} height={96} objectFit="cover" fullWidth fullHeight />
|
|
);
|
|
})()}
|
|
</Surface>
|
|
<Text size="xs" color="text-gray-500" mt={2} align="center" block>Your avatar</Text>
|
|
</Stack>
|
|
</Stack>
|
|
{errors.facePhoto && (
|
|
<Text size="sm" color="text-racing-red" block mt={2}>{errors.facePhoto}</Text>
|
|
)}
|
|
</Stack>
|
|
|
|
{/* Suit Color Selection */}
|
|
<Stack>
|
|
<Text as="label" size="sm" weight="medium" color="text-gray-300" block mb={3}>
|
|
<Stack flexDirection="row" alignItems="center" gap={2}>
|
|
<Icon icon={Palette} size={4} />
|
|
Racing Suit Color
|
|
</Stack>
|
|
</Text>
|
|
<Stack flexDirection="row" flexWrap="wrap" gap={2}>
|
|
{SUIT_COLORS.map((color) => (
|
|
<Button
|
|
key={color.value}
|
|
type="button"
|
|
onClick={() => setAvatarInfo({ ...avatarInfo, suitColor: color.value })}
|
|
rounded="lg"
|
|
transition
|
|
p={0}
|
|
w="10"
|
|
h="10"
|
|
position="relative"
|
|
ring={avatarInfo.suitColor === color.value ? 'ring-2 ring-primary-blue ring-offset-2 ring-offset-iron-gray' : ''}
|
|
transform={avatarInfo.suitColor === color.value ? 'scale(1.1)' : ''}
|
|
hoverScale={avatarInfo.suitColor !== color.value}
|
|
bg={color.hex}
|
|
title={color.label}
|
|
>
|
|
{avatarInfo.suitColor === color.value && (
|
|
<Icon icon={Check} size={5} color={
|
|
['white', 'yellow', 'cyan'].includes(color.value) ? 'text-gray-800' : 'text-white'
|
|
} />
|
|
)}
|
|
</Button>
|
|
))}
|
|
</Stack>
|
|
<Text size="xs" color="text-gray-500" block mt={2}>
|
|
Selected: {SUIT_COLORS.find(c => c.value === avatarInfo.suitColor)?.label}
|
|
</Text>
|
|
</Stack>
|
|
|
|
{/* Generate Button */}
|
|
{avatarInfo.facePhoto && !errors.facePhoto && (
|
|
<Stack>
|
|
<Button
|
|
type="button"
|
|
variant="primary"
|
|
onClick={onGenerateAvatars}
|
|
disabled={avatarInfo.isGenerating || avatarInfo.isValidating}
|
|
fullWidth
|
|
icon={avatarInfo.isGenerating ? <Icon icon={Loader2} size={5} animate="spin" /> : <Icon icon={Sparkles} size={5} />}
|
|
>
|
|
{avatarInfo.isGenerating ? 'Generating your avatars...' : (avatarInfo.generatedAvatars.length > 0 ? 'Regenerate Avatars' : 'Generate Racing Avatars')}
|
|
</Button>
|
|
</Stack>
|
|
)}
|
|
|
|
{/* Generated Avatars */}
|
|
{avatarInfo.generatedAvatars.length > 0 && (
|
|
<Stack>
|
|
<Text as="label" size="sm" weight="medium" color="text-gray-300" block mb={3}>
|
|
Choose Your Avatar *
|
|
</Text>
|
|
<Grid cols={3} gap={4}>
|
|
{avatarInfo.generatedAvatars.map((url, index) => (
|
|
<Button
|
|
key={index}
|
|
type="button"
|
|
onClick={() => setAvatarInfo({ ...avatarInfo, selectedAvatarIndex: index })}
|
|
position="relative"
|
|
rounded="xl"
|
|
overflow="hidden"
|
|
border
|
|
borderWidth="2px"
|
|
transition
|
|
p={0}
|
|
borderColor={avatarInfo.selectedAvatarIndex === index ? 'border-primary-blue' : 'border-charcoal-outline'}
|
|
ring={avatarInfo.selectedAvatarIndex === index ? 'ring-2 ring-primary-blue/30' : ''}
|
|
transform={avatarInfo.selectedAvatarIndex === index ? 'scale(1.05)' : ''}
|
|
hoverBorderColor={avatarInfo.selectedAvatarIndex !== index ? 'border-gray-500' : ''}
|
|
aspectRatio="1/1"
|
|
>
|
|
<Image src={url} alt={`Avatar option ${index + 1}`} width={200} height={200} objectFit="cover" fullWidth fullHeight />
|
|
{avatarInfo.selectedAvatarIndex === index && (
|
|
<Stack position="absolute" top={2} right={2} w="6" h="6" rounded="full" bg="bg-primary-blue" display="flex" center>
|
|
<Icon icon={Check} size={4} color="text-white" />
|
|
</Stack>
|
|
)}
|
|
</Button>
|
|
))}
|
|
</Grid>
|
|
{errors.avatar && (
|
|
<Text size="sm" color="text-error-red" block mt={2}>{errors.avatar}</Text>
|
|
)}
|
|
</Stack>
|
|
)}
|
|
</Stack>
|
|
);
|
|
}
|