'use client'; import { useState, useRef, FormEvent, ChangeEvent } from 'react'; import { useRouter } from 'next/navigation'; import Image from 'next/image'; import { User, Flag, Camera, Clock, Check, ChevronRight, ChevronLeft, AlertCircle, Upload, Loader2, Sparkles, Palette, } from 'lucide-react'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import Input from '@/components/ui/Input'; import Heading from '@/components/ui/Heading'; import CountrySelect from '@/components/ui/CountrySelect'; // ============================================================================ // TYPES // ============================================================================ type OnboardingStep = 1 | 2; interface PersonalInfo { firstName: string; lastName: string; displayName: string; country: string; timezone: string; } interface AvatarInfo { facePhoto: string | null; suitColor: RacingSuitColor; generatedAvatars: string[]; selectedAvatarIndex: number | null; isGenerating: boolean; isValidating: boolean; } interface FormErrors { firstName?: string; lastName?: string; displayName?: string; country?: string; facePhoto?: string; avatar?: string; submit?: string; } type RacingSuitColor = | 'red' | 'blue' | 'green' | 'yellow' | 'orange' | 'purple' | 'black' | 'white' | 'pink' | 'cyan'; // ============================================================================ // CONSTANTS // ============================================================================ const TIMEZONES = [ { value: 'America/New_York', label: 'Eastern Time (ET)' }, { value: 'America/Chicago', label: 'Central Time (CT)' }, { value: 'America/Denver', label: 'Mountain Time (MT)' }, { value: 'America/Los_Angeles', label: 'Pacific Time (PT)' }, { value: 'Europe/London', label: 'Greenwich Mean Time (GMT)' }, { value: 'Europe/Berlin', label: 'Central European Time (CET)' }, { value: 'Europe/Paris', label: 'Central European Time (CET)' }, { value: 'Australia/Sydney', label: 'Australian Eastern Time (AET)' }, { value: 'Asia/Tokyo', label: 'Japan Standard Time (JST)' }, { value: 'America/Sao_Paulo', label: 'Brasília Time (BRT)' }, ]; 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' }, ]; // ============================================================================ // HELPER COMPONENTS // ============================================================================ function StepIndicator({ currentStep }: { currentStep: number }) { const steps = [ { id: 1, label: 'Personal', icon: User }, { id: 2, label: 'Avatar', icon: Camera }, ]; return (
{steps.map((step, index) => { const Icon = step.icon; const isCompleted = step.id < currentStep; const isCurrent = step.id === currentStep; return (
{isCompleted ? ( ) : ( )}
{step.label}
{index < steps.length - 1 && (
)}
); })}
); } // ============================================================================ // MAIN COMPONENT // ============================================================================ export default function OnboardingWizard() { const router = useRouter(); const fileInputRef = useRef(null); const [step, setStep] = useState(1); const [loading, setLoading] = useState(false); const [errors, setErrors] = useState({}); // Form state const [personalInfo, setPersonalInfo] = useState({ firstName: '', lastName: '', displayName: '', country: '', timezone: '', }); const [avatarInfo, setAvatarInfo] = useState({ facePhoto: null, suitColor: 'blue', generatedAvatars: [], selectedAvatarIndex: null, isGenerating: false, isValidating: false, }); // Validation const validateStep = (currentStep: OnboardingStep): boolean => { const newErrors: FormErrors = {}; if (currentStep === 1) { if (!personalInfo.firstName.trim()) { newErrors.firstName = 'First name is required'; } if (!personalInfo.lastName.trim()) { newErrors.lastName = 'Last name is required'; } if (!personalInfo.displayName.trim()) { newErrors.displayName = 'Display name is required'; } else if (personalInfo.displayName.length < 3) { newErrors.displayName = 'Display name must be at least 3 characters'; } if (!personalInfo.country) { newErrors.country = 'Please select your country'; } } if (currentStep === 2) { if (!avatarInfo.facePhoto) { newErrors.facePhoto = 'Please upload a photo of your face'; } if (avatarInfo.generatedAvatars.length > 0 && avatarInfo.selectedAvatarIndex === null) { newErrors.avatar = 'Please select one of the generated avatars'; } } setErrors(newErrors); return Object.keys(newErrors).length === 0; }; const handleNext = () => { const isValid = validateStep(step); if (isValid && step < 2) { setStep((step + 1) as OnboardingStep); } }; const handleBack = () => { if (step > 1) { setStep((step - 1) as OnboardingStep); } }; const handleFileSelect = async (e: ChangeEvent) => { 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, }); setErrors((prev) => { const { facePhoto, ...rest } = prev; return rest; }); // Validate face await validateFacePhoto(base64); }; reader.readAsDataURL(file); }; const validateFacePhoto = async (photoData: string) => { setAvatarInfo(prev => ({ ...prev, isValidating: true })); setErrors(prev => { const { facePhoto, ...rest } = prev; return rest; }); try { const response = await fetch('/api/avatar/validate-face', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ imageData: photoData }), }); const result = await response.json(); if (!result.isValid) { setErrors(prev => ({ ...prev, facePhoto: result.errorMessage || 'Face validation failed' })); setAvatarInfo(prev => ({ ...prev, facePhoto: null, isValidating: false })); } else { setAvatarInfo(prev => ({ ...prev, isValidating: false })); } } catch (error) { // For now, just accept the photo if validation fails setAvatarInfo(prev => ({ ...prev, isValidating: false })); } }; const generateAvatars = async () => { if (!avatarInfo.facePhoto) { setErrors({ ...errors, facePhoto: 'Please upload a photo first' }); return; } setAvatarInfo(prev => ({ ...prev, isGenerating: true, generatedAvatars: [], selectedAvatarIndex: null })); setErrors(prev => { const { avatar, ...rest } = prev; return rest; }); try { const response = await fetch('/api/avatar/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ facePhotoData: avatarInfo.facePhoto, suitColor: avatarInfo.suitColor, }), }); const result = await response.json(); if (result.success && result.avatarUrls) { setAvatarInfo(prev => ({ ...prev, generatedAvatars: result.avatarUrls, isGenerating: false, })); } else { setErrors(prev => ({ ...prev, avatar: result.errorMessage || 'Failed to generate avatars' })); setAvatarInfo(prev => ({ ...prev, isGenerating: false })); } } catch (error) { setErrors(prev => ({ ...prev, avatar: 'Failed to generate avatars. Please try again.' })); setAvatarInfo(prev => ({ ...prev, isGenerating: false })); } }; const handleSubmit = async (e: FormEvent) => { e.preventDefault(); if (loading) return; // Validate step 2 - must have selected an avatar if (!validateStep(2)) { return; } if (avatarInfo.selectedAvatarIndex === null) { setErrors({ ...errors, avatar: 'Please select an avatar' }); return; } setLoading(true); setErrors({}); try { const selectedAvatarUrl = avatarInfo.generatedAvatars[avatarInfo.selectedAvatarIndex]; const response = await fetch('/api/auth/complete-onboarding', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ firstName: personalInfo.firstName.trim(), lastName: personalInfo.lastName.trim(), displayName: personalInfo.displayName.trim(), country: personalInfo.country, timezone: personalInfo.timezone || undefined, avatarUrl: selectedAvatarUrl, }), }); const data = await response.json(); if (!response.ok) { throw new Error(data.error || 'Failed to create profile'); } router.push('/dashboard'); router.refresh(); } catch (error) { setErrors({ submit: error instanceof Error ? error.message : 'Failed to create profile', }); setLoading(false); } }; const getCountryFlag = (countryCode: string): string => { const code = countryCode.toUpperCase(); if (code.length === 2) { const codePoints = [...code].map(char => 127397 + char.charCodeAt(0)); return String.fromCodePoint(...codePoints); } return '🏁'; }; return (
{/* Header */}
Welcome to GridPilot

Let's set up your racing profile

{/* Progress Indicator */} {/* Form Card */} {/* Background accent */}
{/* Step 1: Personal Information */} {step === 1 && (
Personal Information

Tell us a bit about yourself

setPersonalInfo({ ...personalInfo, firstName: e.target.value }) } error={!!errors.firstName} errorMessage={errors.firstName} placeholder="John" disabled={loading} />
setPersonalInfo({ ...personalInfo, lastName: e.target.value }) } error={!!errors.lastName} errorMessage={errors.lastName} placeholder="Racer" disabled={loading} />
setPersonalInfo({ ...personalInfo, displayName: e.target.value }) } error={!!errors.displayName} errorMessage={errors.displayName} placeholder="SpeedyRacer42" disabled={loading} />
setPersonalInfo({ ...personalInfo, country: value }) } error={!!errors.country} errorMessage={errors.country ?? ''} disabled={loading} />
)} {/* Step 2: Avatar Generation */} {step === 2 && (
Create Your Racing Avatar

Upload a photo and we'll generate a unique racing avatar for you

{/* Photo Upload */}
{/* Upload Area */}
fileInputRef.current?.click()} className={`relative flex-1 flex flex-col items-center justify-center p-6 rounded-xl border-2 border-dashed cursor-pointer transition-all ${ avatarInfo.facePhoto ? 'border-performance-green bg-performance-green/5' : errors.facePhoto ? 'border-red-500 bg-red-500/5' : 'border-charcoal-outline hover:border-primary-blue hover:bg-primary-blue/5' }`} > {avatarInfo.isValidating ? ( <>

Validating photo...

) : avatarInfo.facePhoto ? ( <>
Your photo

Photo uploaded

Click to change

) : ( <>

Drop your photo here or click to upload

JPEG or PNG, max 5MB

)}
{/* Preview area */}
{(() => { const selectedAvatarUrl = avatarInfo.selectedAvatarIndex !== null ? avatarInfo.generatedAvatars[avatarInfo.selectedAvatarIndex] : undefined; if (!selectedAvatarUrl) { return ; } return ( Selected avatar ); })()}

Your avatar

{errors.facePhoto && (

{errors.facePhoto}

)}
{/* Suit Color Selection */}
{SUIT_COLORS.map((color) => ( ))}

Selected: {SUIT_COLORS.find(c => c.value === avatarInfo.suitColor)?.label}

{/* Generate Button */} {avatarInfo.facePhoto && !errors.facePhoto && (
)} {/* Generated Avatars */} {avatarInfo.generatedAvatars.length > 0 && (
{avatarInfo.generatedAvatars.map((url, index) => ( ))}
{errors.avatar && (

{errors.avatar}

)}
)}
)} {/* Error Message */} {errors.submit && (

{errors.submit}

)} {/* Navigation Buttons */}
{step < 2 ? ( ) : ( )}
{/* Help Text */}

Your avatar will be AI-generated based on your photo and chosen suit color

); }