Files
gridpilot.gg/apps/website/components/onboarding/AvatarStep.tsx
2026-01-18 16:43:32 +01:00

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