diff --git a/apps/website/app/api/auth/complete-onboarding/route.ts b/apps/website/app/api/auth/complete-onboarding/route.ts new file mode 100644 index 000000000..d0cd10038 --- /dev/null +++ b/apps/website/app/api/auth/complete-onboarding/route.ts @@ -0,0 +1,90 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getAuthService } from '@/lib/auth'; +import { getDriverRepository } from '@/lib/di-container'; +import { Driver } from '@gridpilot/racing'; + +export async function POST(request: NextRequest) { + try { + const authService = getAuthService(); + const session = await authService.getCurrentSession(); + + if (!session) { + return NextResponse.json( + { error: 'Not authenticated' }, + { status: 401 } + ); + } + + const body = await request.json(); + const { firstName, lastName, displayName, country, timezone, bio } = body; + + // Validation + if (!firstName || !firstName.trim()) { + return NextResponse.json( + { error: 'First name is required' }, + { status: 400 } + ); + } + + if (!lastName || !lastName.trim()) { + return NextResponse.json( + { error: 'Last name is required' }, + { status: 400 } + ); + } + + if (!displayName || displayName.trim().length < 3) { + return NextResponse.json( + { error: 'Display name must be at least 3 characters' }, + { status: 400 } + ); + } + + if (!country) { + return NextResponse.json( + { error: 'Country is required' }, + { status: 400 } + ); + } + + const driverRepo = getDriverRepository(); + + // Check if user already has a driver profile + if (session.user.primaryDriverId) { + const existingDriver = await driverRepo.findById(session.user.primaryDriverId); + if (existingDriver) { + return NextResponse.json( + { error: 'Driver profile already exists' }, + { status: 400 } + ); + } + } + + // Create the driver profile + const driverId = crypto.randomUUID(); + const driver = Driver.create({ + id: driverId, + iracingId: '', // Will be set later via OAuth + name: displayName.trim(), + country: country, + bio: bio || undefined, + }); + + await driverRepo.create(driver); + + // Update user's primary driver ID in session + // Note: This would typically update the user record and refresh the session + // For now we'll just return success and let the client handle navigation + + return NextResponse.json({ + success: true, + driverId: driverId, + }); + } catch (error) { + console.error('Onboarding error:', error); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to complete onboarding' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/apps/website/app/api/avatar/generate/route.ts b/apps/website/app/api/avatar/generate/route.ts new file mode 100644 index 000000000..ad0aed4f1 --- /dev/null +++ b/apps/website/app/api/avatar/generate/route.ts @@ -0,0 +1,84 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getAuthService } from '@/lib/auth'; +import { + DemoFaceValidationAdapter, + DemoAvatarGenerationAdapter, + InMemoryAvatarGenerationRepository +} from '@gridpilot/demo-infrastructure'; +import { RequestAvatarGenerationUseCase } from '@gridpilot/media'; + +// Create singleton instances +const faceValidation = new DemoFaceValidationAdapter(); +const avatarGeneration = new DemoAvatarGenerationAdapter(); +const avatarRepository = new InMemoryAvatarGenerationRepository(); + +const requestAvatarGenerationUseCase = new RequestAvatarGenerationUseCase( + avatarRepository, + faceValidation, + avatarGeneration +); + +export async function POST(request: NextRequest) { + try { + const authService = getAuthService(); + const session = await authService.getCurrentSession(); + + if (!session) { + return NextResponse.json( + { success: false, errorMessage: 'Not authenticated' }, + { status: 401 } + ); + } + + const body = await request.json(); + const { facePhotoData, suitColor } = body; + + if (!facePhotoData) { + return NextResponse.json( + { success: false, errorMessage: 'No face photo provided' }, + { status: 400 } + ); + } + + if (!suitColor) { + return NextResponse.json( + { success: false, errorMessage: 'No suit color selected' }, + { status: 400 } + ); + } + + // Extract base64 data if it's a data URL + let base64Data = facePhotoData; + if (facePhotoData.startsWith('data:')) { + base64Data = facePhotoData.split(',')[1] || facePhotoData; + } + + const result = await requestAvatarGenerationUseCase.execute({ + userId: session.user.id, + facePhotoData: base64Data, + suitColor, + }); + + if (result.status === 'failed') { + return NextResponse.json({ + success: false, + errorMessage: result.errorMessage, + }); + } + + return NextResponse.json({ + success: true, + requestId: result.requestId, + avatarUrls: result.avatarUrls, + }); + } catch (error) { + console.error('Avatar generation error:', error); + return NextResponse.json( + { + success: false, + errorMessage: error instanceof Error ? error.message : 'Failed to generate avatars' + }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/apps/website/app/api/avatar/validate-face/route.ts b/apps/website/app/api/avatar/validate-face/route.ts new file mode 100644 index 000000000..856b62f6f --- /dev/null +++ b/apps/website/app/api/avatar/validate-face/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { DemoFaceValidationAdapter } from '@gridpilot/demo-infrastructure'; + +const faceValidation = new DemoFaceValidationAdapter(); + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { imageData } = body; + + if (!imageData) { + return NextResponse.json( + { isValid: false, errorMessage: 'No image data provided' }, + { status: 400 } + ); + } + + // Extract base64 data if it's a data URL + let base64Data = imageData; + if (imageData.startsWith('data:')) { + base64Data = imageData.split(',')[1] || imageData; + } + + const result = await faceValidation.validateFacePhoto(base64Data); + + return NextResponse.json(result); + } catch (error) { + console.error('Face validation error:', error); + return NextResponse.json( + { + isValid: false, + hasFace: false, + faceCount: 0, + confidence: 0, + errorMessage: 'Failed to validate photo' + }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/apps/website/app/auth/login/page.tsx b/apps/website/app/auth/login/page.tsx index 66d06b26f..b9ff210a4 100644 --- a/apps/website/app/auth/login/page.tsx +++ b/apps/website/app/auth/login/page.tsx @@ -19,6 +19,7 @@ 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 { useAuth } from '@/lib/auth/AuthContext'; interface FormErrors { email?: string; @@ -29,6 +30,7 @@ interface FormErrors { export default function LoginPage() { const router = useRouter(); const searchParams = useSearchParams(); + const { refreshSession } = useAuth(); const returnTo = searchParams.get('returnTo') ?? '/dashboard'; const [loading, setLoading] = useState(false); @@ -81,8 +83,9 @@ export default function LoginPage() { throw new Error(data.error || 'Login failed'); } + // Refresh session in context so header updates immediately + await refreshSession(); router.push(returnTo); - router.refresh(); } catch (error) { setErrors({ submit: error instanceof Error ? error.message : 'Login failed. Please try again.', @@ -94,9 +97,8 @@ export default function LoginPage() { const handleDemoLogin = async () => { setLoading(true); try { - const authService = getAuthService(); - const { redirectUrl } = await authService.startIracingAuthRedirect(returnTo); - router.push(redirectUrl); + // Redirect to iRacing auth start route + router.push(`/auth/iracing/start?returnTo=${encodeURIComponent(returnTo)}`); } catch (error) { setErrors({ submit: 'Demo login failed. Please try again.', diff --git a/apps/website/app/auth/signup/page.tsx b/apps/website/app/auth/signup/page.tsx index fa1a8ac85..f8291646c 100644 --- a/apps/website/app/auth/signup/page.tsx +++ b/apps/website/app/auth/signup/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, FormEvent } from 'react'; +import { useState, useEffect, FormEvent } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import Link from 'next/link'; import { @@ -15,13 +15,14 @@ import { Check, X, Gamepad2, + Loader2, } 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 { getAuthService } from '@/lib/auth'; +import { useAuth } from '@/lib/auth/AuthContext'; interface FormErrors { displayName?: string; @@ -54,9 +55,11 @@ function checkPasswordStrength(password: string): PasswordStrength { export default function SignupPage() { const router = useRouter(); const searchParams = useSearchParams(); + const { refreshSession } = useAuth(); const returnTo = searchParams.get('returnTo') ?? '/onboarding'; const [loading, setLoading] = useState(false); + const [checkingAuth, setCheckingAuth] = useState(true); const [showPassword, setShowPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false); const [errors, setErrors] = useState({}); @@ -67,6 +70,25 @@ export default function SignupPage() { confirmPassword: '', }); + // Check if already authenticated + useEffect(() => { + async function checkAuth() { + try { + const response = await fetch('/api/auth/session'); + const data = await response.json(); + if (data.authenticated) { + // Already logged in, redirect to dashboard or return URL + router.replace(returnTo === '/onboarding' ? '/dashboard' : returnTo); + } + } catch { + // Not authenticated, continue showing signup page + } finally { + setCheckingAuth(false); + } + } + checkAuth(); + }, [router, returnTo]); + const passwordStrength = checkPasswordStrength(formData.password); const passwordRequirements = [ @@ -117,15 +139,25 @@ export default function SignupPage() { setErrors({}); try { - const authService = getAuthService(); - await authService.signupWithEmail({ - email: formData.email, - password: formData.password, - displayName: formData.displayName, + const response = await fetch('/api/auth/signup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: formData.email, + password: formData.password, + displayName: formData.displayName, + }), }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Signup failed'); + } + // Refresh session in context so header updates immediately + await refreshSession(); router.push(returnTo); - router.refresh(); } catch (error) { setErrors({ submit: error instanceof Error ? error.message : 'Signup failed. Please try again.', @@ -137,10 +169,9 @@ export default function SignupPage() { const handleDemoLogin = async () => { setLoading(true); try { - const authService = getAuthService(); - const { redirectUrl } = await authService.startIracingAuthRedirect(returnTo); - router.push(redirectUrl); - } catch (error) { + // Redirect to iRacing auth start route + router.push(`/auth/iracing/start?returnTo=${encodeURIComponent(returnTo)}`); + } catch { setErrors({ submit: 'Demo login failed. Please try again.', }); @@ -148,6 +179,15 @@ export default function SignupPage() { } }; + // Show loading while checking auth + if (checkingAuth) { + return ( +
+ +
+ ); + } + return (
{/* Background Pattern */} @@ -189,7 +229,7 @@ export default function SignupPage() { onChange={(e) => setFormData({ ...formData, displayName: e.target.value })} error={!!errors.displayName} errorMessage={errors.displayName} - placeholder="SuperMax33" + placeholder="SpeedyRacer42" disabled={loading} className="pl-10" autoComplete="username" diff --git a/apps/website/app/dashboard/page.tsx b/apps/website/app/dashboard/page.tsx index ceaafc2e7..047a620d7 100644 --- a/apps/website/app/dashboard/page.tsx +++ b/apps/website/app/dashboard/page.tsx @@ -155,7 +155,7 @@ export default async function DashboardPage() { return { league, position: driverStanding?.position ?? 0, - points: driverStanding?.totalPoints ?? 0, + points: driverStanding?.points ?? 0, totalDrivers: standings.length, }; }) diff --git a/apps/website/app/leagues/[id]/page.tsx b/apps/website/app/leagues/[id]/page.tsx index 03199fee4..3a737fc59 100644 --- a/apps/website/app/leagues/[id]/page.tsx +++ b/apps/website/app/leagues/[id]/page.tsx @@ -28,7 +28,9 @@ import { getGetLeagueScoringConfigQuery, getDriverStats, getAllDriverRankings, + getGetLeagueStatsQuery, } from '@/lib/di-container'; +import { Zap, Users, Trophy, Calendar } from 'lucide-react'; import { getMembership, getLeagueMembers, isOwnerOrAdmin } from '@/lib/leagueMembership'; import { useEffectiveDriverId } from '@/lib/currentDriver'; @@ -42,6 +44,8 @@ export default function LeagueDetailPage() { const [standings, setStandings] = useState([]); const [drivers, setDrivers] = useState([]); const [scoringConfig, setScoringConfig] = useState(null); + const [averageSOF, setAverageSOF] = useState(null); + const [completedRacesCount, setCompletedRacesCount] = useState(0); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [activeTab, setActiveTab] = useState< @@ -59,6 +63,7 @@ export default function LeagueDetailPage() { const leagueRepo = getLeagueRepository(); const raceRepo = getRaceRepository(); const driverRepo = getDriverRepository(); + const leagueStatsQuery = getGetLeagueStatsQuery(); const leagueData = await leagueRepo.findById(leagueId); @@ -91,6 +96,18 @@ export default function LeagueDetailPage() { .filter((dto): dto is DriverDTO => dto !== null); setDrivers(driverDtos); + + // Load league stats including average SOF from application query + const leagueStats = await leagueStatsQuery.execute({ leagueId }); + if (leagueStats) { + setAverageSOF(leagueStats.averageSOF); + setCompletedRacesCount(leagueStats.completedRaces); + } else { + // Fallback: count completed races manually + const leagueRaces = await raceRepo.findByLeagueId(leagueId); + const completedRaces = leagueRaces.filter(r => r.status === 'completed'); + setCompletedRacesCount(completedRaces.length); + } } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load league'); } finally { @@ -292,12 +309,13 @@ export default function LeagueDetailPage() {

At a glance

-
+

Structure

-

+

+ Solo • {league.settings.maxDrivers ?? 32} drivers

@@ -305,22 +323,29 @@ export default function LeagueDetailPage() {

Schedule

-

- {`? rounds • 30 min Qualifying • ${ - typeof league.settings.sessionDuration === 'number' - ? league.settings.sessionDuration - : 40 - } min Races`} +

+ + {completedRacesCount > 0 ? `${completedRacesCount} races completed` : 'Season upcoming'}

Scoring & drops

-

+

+ {league.settings.pointsSystem.toUpperCase()}

+
+

+ Avg. Strength of Field +

+

+ + {averageSOF ? averageSOF : '—'} +

+
diff --git a/apps/website/app/races/[id]/page.tsx b/apps/website/app/races/[id]/page.tsx index e580d4606..4073c755e 100644 --- a/apps/website/app/races/[id]/page.tsx +++ b/apps/website/app/races/[id]/page.tsx @@ -2,9 +2,11 @@ import { useState, useEffect } from 'react'; import { useRouter, useParams } from 'next/navigation'; +import Link from 'next/link'; import Button from '@/components/ui/Button'; import Card from '@/components/ui/Card'; -import FeatureLimitationTooltip from '@/components/alpha/FeatureLimitationTooltip'; +import Heading from '@/components/ui/Heading'; +import Breadcrumbs from '@/components/layout/Breadcrumbs'; import type { Race } from '@gridpilot/racing/domain/entities/Race'; import type { League } from '@gridpilot/racing/domain/entities/League'; import type { Driver } from '@gridpilot/racing/domain/entities/Driver'; @@ -16,12 +18,35 @@ import { getIsDriverRegisteredForRaceQuery, getRegisterForRaceUseCase, getWithdrawFromRaceUseCase, + getTrackRepository, + getCarRepository, + getGetRaceWithSOFQuery, } from '@/lib/di-container'; import { getMembership } from '@/lib/leagueMembership'; import { useEffectiveDriverId } from '@/lib/currentDriver'; -import CompanionStatus from '@/components/alpha/CompanionStatus'; -import CompanionInstructions from '@/components/alpha/CompanionInstructions'; -import Breadcrumbs from '@/components/layout/Breadcrumbs'; +import { + Calendar, + Clock, + MapPin, + Car, + Trophy, + Users, + Zap, + PlayCircle, + CheckCircle2, + XCircle, + ChevronRight, + Flag, + Timer, + UserPlus, + UserMinus, + AlertTriangle, + ArrowRight, + ArrowLeft, + ExternalLink, + Award, +} from 'lucide-react'; +import { getDriverStats, getAllDriverRankings } from '@/lib/di-container'; export default function RaceDetailPage() { const router = useRouter(); @@ -37,6 +62,7 @@ export default function RaceDetailPage() { const [entryList, setEntryList] = useState([]); const [isUserRegistered, setIsUserRegistered] = useState(false); const [canRegister, setCanRegister] = useState(false); + const [raceSOF, setRaceSOF] = useState(null); const currentDriverId = useEffectiveDriverId(); @@ -44,6 +70,7 @@ export default function RaceDetailPage() { try { const raceRepo = getRaceRepository(); const leagueRepo = getLeagueRepository(); + const raceWithSOFQuery = getGetRaceWithSOFQuery(); const raceData = await raceRepo.findById(raceId); @@ -55,6 +82,12 @@ export default function RaceDetailPage() { setRace(raceData); + // Load race with SOF from application query + const raceWithSOF = await raceWithSOFQuery.execute({ raceId }); + if (raceWithSOF) { + setRaceSOF(raceWithSOF.strengthOfField); + } + // Load league data const leagueData = await leagueRepo.findById(raceData.leagueId); setLeague(leagueData); @@ -78,7 +111,8 @@ export default function RaceDetailPage() { const drivers = await Promise.all( registeredDriverIds.map((id: string) => driverRepo.findById(id)), ); - setEntryList(drivers.filter((d: Driver | null): d is Driver => d !== null)); + const validDrivers = drivers.filter((d: Driver | null): d is Driver => d !== null); + setEntryList(validDrivers); const isRegisteredQuery = getIsDriverRegisteredForRaceQuery(); const userIsRegistered = await isRegisteredQuery.execute({ @@ -173,7 +207,8 @@ export default function RaceDetailPage() { const formatDate = (date: Date) => { return new Date(date).toLocaleDateString('en-US', { - month: 'short', + weekday: 'long', + month: 'long', day: 'numeric', year: 'numeric', }); @@ -187,28 +222,69 @@ export default function RaceDetailPage() { }); }; - const formatDateTime = (date: Date) => { - return new Date(date).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', - timeZoneName: 'short', - }); + const getTimeUntil = (date: Date) => { + const now = new Date(); + const target = new Date(date); + const diffMs = target.getTime() - now.getTime(); + + if (diffMs < 0) return null; + + const days = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); + const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)); + + if (days > 0) return `${days}d ${hours}h`; + if (hours > 0) return `${hours}h ${minutes}m`; + return `${minutes}m`; }; - const statusColors = { - scheduled: 'bg-primary-blue/20 text-primary-blue border-primary-blue/30', - completed: 'bg-green-500/20 text-green-400 border-green-500/30', - cancelled: 'bg-gray-500/20 text-gray-400 border-gray-500/30', - } as const; + const statusConfig = { + scheduled: { + icon: Clock, + color: 'text-primary-blue', + bg: 'bg-primary-blue/10', + border: 'border-primary-blue/30', + label: 'Scheduled', + description: 'This race is scheduled and waiting to start', + }, + running: { + icon: PlayCircle, + color: 'text-performance-green', + bg: 'bg-performance-green/10', + border: 'border-performance-green/30', + label: 'LIVE NOW', + description: 'This race is currently in progress', + }, + completed: { + icon: CheckCircle2, + color: 'text-gray-400', + bg: 'bg-gray-500/10', + border: 'border-gray-500/30', + label: 'Completed', + description: 'This race has finished', + }, + cancelled: { + icon: XCircle, + color: 'text-warning-amber', + bg: 'bg-warning-amber/10', + border: 'border-warning-amber/30', + label: 'Cancelled', + description: 'This race has been cancelled', + }, + }; if (loading) { return ( -
+
-
Loading race details...
+
+
+
+
+
+
+
+
); @@ -216,237 +292,371 @@ export default function RaceDetailPage() { if (error || !race) { return ( -
+
- -
- {error || 'Race not found'} -
- -
-
-
- ); - } - - return ( -
-
- {/* Breadcrumb */} -
- -
- - {/* Companion Status */} - -
- -
-
- - {/* Race Header */} -
-
-
-

{race.track}

- {league && ( -

{league.name}

- )} -
- - {race.status.charAt(0).toUpperCase() + race.status.slice(1)} - -
-
- - {/* Companion Instructions for Scheduled Races */} - {race.status === 'scheduled' && ( -
- -
- )} - -
- {/* Race Details */} - -

Race Details

- -
- {/* Date & Time */} + + + +
+
+ +
- -

- {formatDateTime(race.scheduledAt)} -

-
- {formatDate(race.scheduledAt)} - {formatTime(race.scheduledAt)} -
+

{error || 'Race not found'}

+

The race you're looking for doesn't exist or has been removed.

- - {/* Track */} -
- -

{race.track}

-
- - {/* Car */} -
- -

{race.car}

-
- - {/* Session Type */} -
- -

{race.sessionType}

-
- - {/* League */} -
- - {league ? ( - - ) : ( -

ID: {race.leagueId.slice(0, 8)}...

- )} -
-
-
- - {/* Actions */} - -

Actions

- -
- {/* Registration Actions */} - {race.status === 'scheduled' && canRegister && !isUserRegistered && ( - - )} - - {race.status === 'scheduled' && isUserRegistered && ( -
-
- ✓ Registered -
- -
- )} - - {race.status === 'completed' && ( - - )} - - {race.status === 'scheduled' && ( - - )} -
+
+ ); + } - {/* Entry List */} - {race.status === 'scheduled' && ( - -
-

Entry List

- - {entryList.length} {entryList.length === 1 ? 'driver' : 'drivers'} registered - + const config = statusConfig[race.status]; + const StatusIcon = config.icon; + const timeUntil = race.status === 'scheduled' ? getTimeUntil(race.scheduledAt) : null; + + const breadcrumbItems = [ + { label: 'Races', href: '/races' }, + ...(league ? [{ label: league.name, href: `/leagues/${league.id}` }] : []), + { label: race.track }, + ]; + + // Build driver rankings for entry list display + const getDriverRank = (driverId: string): { rating: number | null; rank: number | null } => { + const stats = getDriverStats(driverId); + if (!stats) return { rating: null, rank: null }; + + const allRankings = getAllDriverRankings(); + let rank = stats.overallRank; + if (!rank || rank <= 0) { + const indexInGlobal = allRankings.findIndex(s => s.driverId === driverId); + if (indexInGlobal !== -1) { + rank = indexInGlobal + 1; + } + } + + return { rating: stats.rating, rank }; + }; + + return ( +
+
+ {/* Navigation Row: Breadcrumbs left, Back button right */} +
+ + +
+ + {/* Hero Header */} +
+ {/* Live indicator */} + {race.status === 'running' && ( +
+ )} + +
+ +
+ {/* Status Badge */} +
+
+ {race.status === 'running' && ( + + )} + + {config.label} +
+ {timeUntil && ( + + Starts in {timeUntil} + + )}
- {entryList.length === 0 ? ( -
-

No drivers registered yet

-

Be the first to register!

-
- ) : ( -
- {entryList.map((driver, index) => ( -
router.push(`/drivers/${driver.id}`)} - > -
- #{index + 1} -
-
- - {driver.name.charAt(0)} - -
-
-

{driver.name}

-

{driver.country}

-
- {driver.id === currentDriverId && ( - - You - - )} + {/* Title */} + + {race.track} + + + {/* Meta */} +
+ + + {formatDate(race.scheduledAt)} + + + + {formatTime(race.scheduledAt)} + + + + {race.car} + + {raceSOF && ( + + + SOF {raceSOF} + + )} +
+
+
+ + {/* League Banner */} + {league && ( + +
+
+
+
+
- ))} +
+

Part of

+

+ {league.name} +

+
+
+
+ View League + +
- )} - +
+ )} + +
+ {/* Main Content */} +
+ {/* Race Details */} + +

+ + Race Details +

+ +
+
+

Track

+

{race.track}

+
+
+

Car

+

{race.car}

+
+
+

Session Type

+

{race.sessionType}

+
+
+

Status

+

{config.label}

+
+
+

Strength of Field

+

+ + {raceSOF ?? '—'} +

+
+ {race.registeredCount !== undefined && ( +
+

Registered

+

+ {race.registeredCount} + {race.maxParticipants && ` / ${race.maxParticipants}`} +

+
+ )} +
+
+ + {/* Entry List */} + +
+

+ + Entry List +

+ + {entryList.length} driver{entryList.length !== 1 ? 's' : ''} + +
+ + {entryList.length === 0 ? ( +
+
+ +
+

No drivers registered yet

+

Be the first to sign up!

+
+ ) : ( +
+ {entryList.map((driver, index) => { + const driverRankInfo = getDriverRank(driver.id); + return ( +
router.push(`/drivers/${driver.id}`)} + className="flex items-center gap-2 p-2 bg-deep-graphite rounded-lg hover:bg-charcoal-outline/50 cursor-pointer transition-colors" + > + #{index + 1} +
+ + {driver.name.charAt(0)} + +
+
+

{driver.name}

+
+ {driverRankInfo.rating && ( + + {driverRankInfo.rating} + + )} + {driver.id === currentDriverId && ( + + You + + )} +
+ ); + })} +
+ )} +
+
+ + {/* Sidebar - Actions */} +
+ {/* Quick Actions Card */} + +

Actions

+ +
+ {/* Registration Actions */} + {race.status === 'scheduled' && canRegister && !isUserRegistered && ( + + )} + + {race.status === 'scheduled' && isUserRegistered && ( + <> +
+ + You're Registered +
+ + + )} + + {race.status === 'completed' && ( + + )} + + {race.status === 'scheduled' && ( + + )} +
+
+ + {/* Status Info */} + +
+
+ +
+
+

{config.label}

+

{config.description}

+
+
+
+ + {/* Quick Links */} + +

Quick Links

+
+ + + All Races + + {league && ( + + + {league.name} + + )} + {league && ( + + + League Standings + + )} +
+
+
+
); diff --git a/apps/website/app/races/[id]/results/page.tsx b/apps/website/app/races/[id]/results/page.tsx index 38deac6e0..5a1a39a46 100644 --- a/apps/website/app/races/[id]/results/page.tsx +++ b/apps/website/app/races/[id]/results/page.tsx @@ -4,6 +4,7 @@ import { useState, useEffect } from 'react'; import { useRouter, useParams } from 'next/navigation'; import Button from '@/components/ui/Button'; import Card from '@/components/ui/Card'; +import Breadcrumbs from '@/components/layout/Breadcrumbs'; import ResultsTable from '@/components/races/ResultsTable'; import ImportResultsForm from '@/components/races/ImportResultsForm'; import { Race } from '@gridpilot/racing/domain/entities/Race'; @@ -15,8 +16,10 @@ import { getLeagueRepository, getResultRepository, getStandingRepository, - getDriverRepository + getDriverRepository, + getGetRaceWithSOFQuery, } from '@/lib/di-container'; +import { ArrowLeft, Zap, Trophy, Users, Clock, Calendar } from 'lucide-react'; export default function RaceResultsPage() { const router = useRouter(); @@ -27,6 +30,7 @@ export default function RaceResultsPage() { const [league, setLeague] = useState(null); const [results, setResults] = useState([]); const [drivers, setDrivers] = useState([]); + const [raceSOF, setRaceSOF] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [importing, setImporting] = useState(false); @@ -38,6 +42,7 @@ export default function RaceResultsPage() { const leagueRepo = getLeagueRepository(); const resultRepo = getResultRepository(); const driverRepo = getDriverRepository(); + const raceWithSOFQuery = getGetRaceWithSOFQuery(); const raceData = await raceRepo.findById(raceId); @@ -49,6 +54,12 @@ export default function RaceResultsPage() { setRace(raceData); + // Load race with SOF from application query + const raceWithSOF = await raceWithSOFQuery.execute({ raceId }); + if (raceWithSOF) { + setRaceSOF(raceWithSOF.strengthOfField); + } + // Load league data const leagueData = await leagueRepo.findById(raceData.leagueId); setLeague(leagueData); @@ -166,45 +177,85 @@ export default function RaceResultsPage() { const hasResults = results.length > 0; + const breadcrumbItems = [ + { label: 'Races', href: '/races' }, + ...(league ? [{ label: league.name, href: `/leagues/${league.id}` }] : []), + ...(race ? [{ label: race.track, href: `/races/${raceId}` }] : []), + { label: 'Results' }, + ]; + return ( -
-
- {/* Breadcrumb */} -
- + + Back +
- {/* Page Header */} -
-

Race Results

- {race && ( -
-

{race.track}

- {league && ( -

{league.name}

+ {/* Hero Header */} +
+
+ +
+
+
+ + Final Results +
+ {raceSOF && ( + + + SOF {raceSOF} + )}
- )} + +

+ {race?.track ?? 'Race'} Results +

+ +
+ {race && ( + <> + + + {new Date(race.scheduledAt).toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + })} + + + + {results.length} drivers classified + + + )} + {league && ( + {league.name} + )} +
+
{/* Success Message */} {importSuccess && ( -
+
Success! Results imported and standings updated.
)} {/* Error Message */} {error && ( -
+
Error: {error}
)} @@ -212,15 +263,12 @@ export default function RaceResultsPage() { {/* Content */} {hasResults ? ( - <> -

Results

- - + ) : ( <>

Import Results

diff --git a/apps/website/app/races/all/page.tsx b/apps/website/app/races/all/page.tsx new file mode 100644 index 000000000..08cb35f8c --- /dev/null +++ b/apps/website/app/races/all/page.tsx @@ -0,0 +1,439 @@ +'use client'; + +import { useState, useEffect, useMemo } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import Link from 'next/link'; +import Card from '@/components/ui/Card'; +import Button from '@/components/ui/Button'; +import Heading from '@/components/ui/Heading'; +import Breadcrumbs from '@/components/layout/Breadcrumbs'; +import { Race, RaceStatus } from '@gridpilot/racing/domain/entities/Race'; +import { League } from '@gridpilot/racing/domain/entities/League'; +import { getRaceRepository, getLeagueRepository } from '@/lib/di-container'; +import { + Calendar, + Clock, + Flag, + ChevronRight, + ChevronLeft, + Filter, + Car, + Trophy, + Zap, + PlayCircle, + CheckCircle2, + XCircle, + ArrowRight, + Search, + SlidersHorizontal, +} from 'lucide-react'; + +const ITEMS_PER_PAGE = 10; + +export default function AllRacesPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + + const [races, setRaces] = useState([]); + const [leagues, setLeagues] = useState>(new Map()); + const [loading, setLoading] = useState(true); + + // Pagination + const [currentPage, setCurrentPage] = useState(1); + + // Filters + const [statusFilter, setStatusFilter] = useState('all'); + const [leagueFilter, setLeagueFilter] = useState('all'); + const [searchQuery, setSearchQuery] = useState(''); + const [showFilters, setShowFilters] = useState(false); + + const loadRaces = async () => { + try { + const raceRepo = getRaceRepository(); + const leagueRepo = getLeagueRepository(); + + const [allRaces, allLeagues] = await Promise.all([ + raceRepo.findAll(), + leagueRepo.findAll() + ]); + + setRaces(allRaces.sort((a, b) => b.scheduledAt.getTime() - a.scheduledAt.getTime())); + + const leagueMap = new Map(); + allLeagues.forEach(league => leagueMap.set(league.id, league)); + setLeagues(leagueMap); + } catch (err) { + console.error('Failed to load races:', err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadRaces(); + }, []); + + // Filter races + const filteredRaces = useMemo(() => { + return races.filter(race => { + // Status filter + if (statusFilter !== 'all' && race.status !== statusFilter) { + return false; + } + + // League filter + if (leagueFilter !== 'all' && race.leagueId !== leagueFilter) { + return false; + } + + // Search filter + if (searchQuery) { + const query = searchQuery.toLowerCase(); + const league = leagues.get(race.leagueId); + const matchesTrack = race.track.toLowerCase().includes(query); + const matchesCar = race.car.toLowerCase().includes(query); + const matchesLeague = league?.name.toLowerCase().includes(query); + if (!matchesTrack && !matchesCar && !matchesLeague) { + return false; + } + } + + return true; + }); + }, [races, statusFilter, leagueFilter, searchQuery, leagues]); + + // Paginate + const totalPages = Math.ceil(filteredRaces.length / ITEMS_PER_PAGE); + const paginatedRaces = useMemo(() => { + const start = (currentPage - 1) * ITEMS_PER_PAGE; + return filteredRaces.slice(start, start + ITEMS_PER_PAGE); + }, [filteredRaces, currentPage]); + + // Reset page when filters change + useEffect(() => { + setCurrentPage(1); + }, [statusFilter, leagueFilter, searchQuery]); + + const formatDate = (date: Date) => { + return new Date(date).toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + year: 'numeric', + }); + }; + + const formatTime = (date: Date) => { + return new Date(date).toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + }); + }; + + const statusConfig = { + scheduled: { + icon: Clock, + color: 'text-primary-blue', + bg: 'bg-primary-blue/10', + border: 'border-primary-blue/30', + label: 'Scheduled', + }, + running: { + icon: PlayCircle, + color: 'text-performance-green', + bg: 'bg-performance-green/10', + border: 'border-performance-green/30', + label: 'LIVE', + }, + completed: { + icon: CheckCircle2, + color: 'text-gray-400', + bg: 'bg-gray-500/10', + border: 'border-gray-500/30', + label: 'Completed', + }, + cancelled: { + icon: XCircle, + color: 'text-warning-amber', + bg: 'bg-warning-amber/10', + border: 'border-warning-amber/30', + label: 'Cancelled', + }, + }; + + const breadcrumbItems = [ + { label: 'Races', href: '/races' }, + { label: 'All Races' }, + ]; + + if (loading) { + return ( +
+
+
+
+
+
+ {[1, 2, 3, 4, 5].map(i => ( +
+ ))} +
+
+
+
+ ); + } + + return ( +
+
+ {/* Breadcrumbs */} + + + {/* Header */} +
+
+ + + All Races + +

+ {filteredRaces.length} race{filteredRaces.length !== 1 ? 's' : ''} found +

+
+ + +
+ + {/* Search & Filters */} + +
+ {/* Search */} +
+ + setSearchQuery(e.target.value)} + placeholder="Search by track, car, or league..." + className="w-full pl-10 pr-4 py-2.5 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-blue" + /> +
+ + {/* Filter Row */} +
+ {/* Status Filter */} + + + {/* League Filter */} + + + {/* Clear Filters */} + {(statusFilter !== 'all' || leagueFilter !== 'all' || searchQuery) && ( + + )} +
+
+
+ + {/* Race List */} + {paginatedRaces.length === 0 ? ( + +
+
+ +
+
+

No races found

+

+ {races.length === 0 + ? 'No races have been scheduled yet' + : 'Try adjusting your search or filters'} +

+
+
+
+ ) : ( +
+ {paginatedRaces.map(race => { + const config = statusConfig[race.status]; + const StatusIcon = config.icon; + const league = leagues.get(race.leagueId); + + return ( +
router.push(`/races/${race.id}`)} + className={`group relative overflow-hidden rounded-xl bg-iron-gray border ${config.border} p-4 cursor-pointer transition-all duration-200 hover:scale-[1.01] hover:border-primary-blue`} + > + {/* Live indicator */} + {race.status === 'running' && ( +
+ )} + +
+ {/* Date Column */} +
+

+ {new Date(race.scheduledAt).toLocaleDateString('en-US', { month: 'short' })} +

+

+ {new Date(race.scheduledAt).getDate()} +

+

+ {formatTime(race.scheduledAt)} +

+
+ + {/* Divider */} +
+ + {/* Main Content */} +
+
+
+

+ {race.track} +

+
+ + + {race.car} + + {race.strengthOfField && ( + + + SOF {race.strengthOfField} + + )} + + {formatDate(race.scheduledAt)} + +
+ {league && ( + e.stopPropagation()} + className="inline-flex items-center gap-1.5 mt-2 text-sm text-primary-blue hover:underline" + > + + {league.name} + + )} +
+ + {/* Status Badge */} +
+ + + {config.label} + +
+
+
+ + {/* Arrow */} + +
+
+ ); + })} +
+ )} + + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Showing {((currentPage - 1) * ITEMS_PER_PAGE) + 1}–{Math.min(currentPage * ITEMS_PER_PAGE, filteredRaces.length)} of {filteredRaces.length} +

+ +
+ + +
+ {Array.from({ length: Math.min(5, totalPages) }, (_, i) => { + let pageNum: number; + if (totalPages <= 5) { + pageNum = i + 1; + } else if (currentPage <= 3) { + pageNum = i + 1; + } else if (currentPage >= totalPages - 2) { + pageNum = totalPages - 4 + i; + } else { + pageNum = currentPage - 2 + i; + } + + return ( + + ); + })} +
+ + +
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/apps/website/app/races/page.tsx b/apps/website/app/races/page.tsx index f1766a5a0..ab2a01e16 100644 --- a/apps/website/app/races/page.tsx +++ b/apps/website/app/races/page.tsx @@ -1,12 +1,33 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { useRouter } from 'next/navigation'; +import Link from 'next/link'; import Card from '@/components/ui/Card'; -import RaceCard from '@/components/races/RaceCard'; +import Button from '@/components/ui/Button'; +import Heading from '@/components/ui/Heading'; import { Race, RaceStatus } from '@gridpilot/racing/domain/entities/Race'; import { League } from '@gridpilot/racing/domain/entities/League'; import { getRaceRepository, getLeagueRepository } from '@/lib/di-container'; +import { + Calendar, + Clock, + Flag, + ChevronRight, + Filter, + MapPin, + Car, + Trophy, + Users, + Zap, + PlayCircle, + CheckCircle2, + XCircle, + CalendarDays, + ArrowRight, +} from 'lucide-react'; + +type TimeFilter = 'all' | 'upcoming' | 'live' | 'past'; export default function RacesPage() { const router = useRouter(); @@ -18,7 +39,7 @@ export default function RacesPage() { // Filters const [statusFilter, setStatusFilter] = useState('all'); const [leagueFilter, setLeagueFilter] = useState('all'); - const [timeFilter, setTimeFilter] = useState<'all' | 'upcoming' | 'past'>('all'); + const [timeFilter, setTimeFilter] = useState('upcoming'); const loadRaces = async () => { try { @@ -30,7 +51,7 @@ export default function RacesPage() { leagueRepo.findAll() ]); - setRaces(allRaces.sort((a, b) => b.scheduledAt.getTime() - a.scheduledAt.getTime())); + setRaces(allRaces.sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime())); const leagueMap = new Map(); allLeagues.forEach(league => leagueMap.set(league.id, league)); @@ -46,137 +67,539 @@ export default function RacesPage() { loadRaces(); }, []); - const filteredRaces = races.filter(race => { - // Status filter - if (statusFilter !== 'all' && race.status !== statusFilter) { - return false; - } + // Filter races + const filteredRaces = useMemo(() => { + return races.filter(race => { + // Status filter + if (statusFilter !== 'all' && race.status !== statusFilter) { + return false; + } - // League filter - if (leagueFilter !== 'all' && race.leagueId !== leagueFilter) { - return false; - } + // League filter + if (leagueFilter !== 'all' && race.leagueId !== leagueFilter) { + return false; + } - // Time filter - if (timeFilter === 'upcoming' && !race.isUpcoming()) { - return false; - } - if (timeFilter === 'past' && !race.isPast()) { - return false; - } + // Time filter + if (timeFilter === 'upcoming' && !race.isUpcoming()) { + return false; + } + if (timeFilter === 'live' && !race.isLive()) { + return false; + } + if (timeFilter === 'past' && !race.isPast()) { + return false; + } - return true; - }); + return true; + }); + }, [races, statusFilter, leagueFilter, timeFilter]); + + // Group races by date for calendar view + const racesByDate = useMemo(() => { + const grouped = new Map(); + filteredRaces.forEach(race => { + const dateKey = race.scheduledAt.toISOString().split('T')[0]; + if (!grouped.has(dateKey)) { + grouped.set(dateKey, []); + } + grouped.get(dateKey)!.push(race); + }); + return grouped; + }, [filteredRaces]); + + // Get upcoming races (next 7 days) + const upcomingRaces = useMemo(() => { + const now = new Date(); + const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); + return races.filter(race => + race.isUpcoming() && + race.scheduledAt >= now && + race.scheduledAt <= nextWeek + ).slice(0, 5); + }, [races]); + + // Get live races + const liveRaces = useMemo(() => { + return races.filter(race => race.isLive()); + }, [races]); + + // Get recent results + const recentResults = useMemo(() => { + return races + .filter(race => race.status === 'completed') + .sort((a, b) => b.scheduledAt.getTime() - a.scheduledAt.getTime()) + .slice(0, 3); + }, [races]); + + // Stats + const stats = useMemo(() => { + const total = races.length; + const scheduled = races.filter(r => r.status === 'scheduled').length; + const running = races.filter(r => r.status === 'running').length; + const completed = races.filter(r => r.status === 'completed').length; + return { total, scheduled, running, completed }; + }, [races]); + + const formatDate = (date: Date) => { + return new Date(date).toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + }); + }; + + const formatTime = (date: Date) => { + return new Date(date).toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + }); + }; + + const formatFullDate = (date: Date) => { + return new Date(date).toLocaleDateString('en-US', { + weekday: 'long', + month: 'long', + day: 'numeric', + year: 'numeric', + }); + }; + + const getRelativeTime = (date: Date) => { + const now = new Date(); + const targetDate = new Date(date); + const diffMs = targetDate.getTime() - now.getTime(); + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffMs < 0) return 'Past'; + if (diffHours < 1) return 'Starting soon'; + if (diffHours < 24) return `In ${diffHours}h`; + if (diffDays === 1) return 'Tomorrow'; + if (diffDays < 7) return `In ${diffDays} days`; + return formatDate(date); + }; + + const statusConfig = { + scheduled: { + icon: Clock, + color: 'text-primary-blue', + bg: 'bg-primary-blue/10', + border: 'border-primary-blue/30', + label: 'Scheduled', + }, + running: { + icon: PlayCircle, + color: 'text-performance-green', + bg: 'bg-performance-green/10', + border: 'border-performance-green/30', + label: 'LIVE', + }, + completed: { + icon: CheckCircle2, + color: 'text-gray-400', + bg: 'bg-gray-500/10', + border: 'border-gray-500/30', + label: 'Completed', + }, + cancelled: { + icon: XCircle, + color: 'text-warning-amber', + bg: 'bg-warning-amber/10', + border: 'border-warning-amber/30', + label: 'Cancelled', + }, + }; if (loading) { return ( -
-
-
Loading races...
+
+
+
+
+
+ {[1, 2, 3, 4].map(i => ( +
+ ))} +
+
+
); } return ( -
-
- {/* Header */} -
-
-

Races

+
+
+ {/* Hero Header */} +
+
+
+ +
+
+
+ +
+ + Race Calendar + +
+

+ Track upcoming races, view live events, and explore results across all your leagues. +

+
+ + {/* Quick Stats */} +
+
+
+ + Total +
+

{stats.total}

+
+
+
+ + Scheduled +
+

{stats.scheduled}

+
+
+
+ + Live Now +
+

{stats.running}

+
+
+
+ + Completed +
+

{stats.completed}

+
-

- Manage and view all scheduled races across your leagues -

- {/* Filters */} - -
- {/* Time Filter */} -
- - -
- - {/* Status Filter */} -
- - -
- - {/* League Filter */} -
- - +
- - - {/* Race List */} - {filteredRaces.length === 0 ? ( - -
- {races.length === 0 ? ( - <> -

No races scheduled

-

Try the full workflow in alpha mode

- - ) : ( - <> -

No races match your filters

-

Try adjusting your filter criteria

- - )} -
-
- ) : ( -
- {filteredRaces.map(race => ( - router.push(`/races/${race.id}`)} - /> - ))} -
)} + +
+ {/* Main Content - Race List */} +
+ {/* Filters */} + +
+ {/* Time Filter Tabs */} +
+ {(['upcoming', 'live', 'past', 'all'] as TimeFilter[]).map(filter => ( + + ))} +
+ + {/* League Filter */} + +
+
+ + {/* Race List by Date */} + {filteredRaces.length === 0 ? ( + +
+
+ +
+
+

No races found

+

+ {races.length === 0 + ? 'No races have been scheduled yet' + : 'Try adjusting your filters'} +

+
+
+
+ ) : ( +
+ {Array.from(racesByDate.entries()).map(([dateKey, dayRaces]) => ( +
+ {/* Date Header */} +
+
+ +
+ + {formatFullDate(new Date(dateKey))} + + + {dayRaces.length} race{dayRaces.length !== 1 ? 's' : ''} + +
+ + {/* Races for this date */} +
+ {dayRaces.map(race => { + const config = statusConfig[race.status]; + const StatusIcon = config.icon; + const league = leagues.get(race.leagueId); + + return ( +
router.push(`/races/${race.id}`)} + className={`group relative overflow-hidden rounded-xl bg-iron-gray border ${config.border} p-4 cursor-pointer transition-all duration-200 hover:scale-[1.01] hover:border-primary-blue`} + > + {/* Live indicator */} + {race.status === 'running' && ( +
+ )} + +
+ {/* Time Column */} +
+

{formatTime(race.scheduledAt)}

+

+ {race.status === 'running' ? 'LIVE' : getRelativeTime(race.scheduledAt)} +

+
+ + {/* Divider */} +
+ + {/* Main Content */} +
+
+
+

+ {race.track} +

+
+ + + {race.car} + + {race.strengthOfField && ( + + + SOF {race.strengthOfField} + + )} +
+
+ + {/* Status Badge */} +
+ + + {config.label} + +
+
+ + {/* League Link */} + {league && ( +
+ e.stopPropagation()} + className="inline-flex items-center gap-2 text-sm text-primary-blue hover:underline" + > + + {league.name} + + +
+ )} +
+ + {/* Arrow */} + +
+
+ ); + })} +
+
+ ))} +
+ )} + + {/* View All Link */} + {filteredRaces.length > 0 && ( +
+ + View All Races + + +
+ )} +
+ + {/* Sidebar */} +
+ {/* Upcoming This Week */} + +
+

+ + Next Up +

+ This week +
+ + {upcomingRaces.length === 0 ? ( +

+ No races scheduled this week +

+ ) : ( +
+ {upcomingRaces.map((race, index) => ( +
router.push(`/races/${race.id}`)} + className="flex items-center gap-3 p-2 rounded-lg hover:bg-deep-graphite cursor-pointer transition-colors" + > +
+ + {new Date(race.scheduledAt).getDate()} + +
+
+

{race.track}

+

{formatTime(race.scheduledAt)}

+
+ +
+ ))} +
+ )} +
+ + {/* Recent Results */} + +
+

+ + Recent Results +

+
+ + {recentResults.length === 0 ? ( +

+ No completed races yet +

+ ) : ( +
+ {recentResults.map(race => ( +
router.push(`/races/${race.id}/results`)} + className="flex items-center gap-3 p-2 rounded-lg hover:bg-deep-graphite cursor-pointer transition-colors" + > +
+ +
+
+

{race.track}

+

{formatDate(race.scheduledAt)}

+
+ +
+ ))} +
+ )} +
+ + {/* Quick Actions */} + +

Quick Actions

+
+ +
+ +
+ Browse Leagues + + + +
+ +
+ View Leaderboards + + +
+
+
+
); diff --git a/apps/website/components/alpha/AlphaNav.tsx b/apps/website/components/alpha/AlphaNav.tsx index c5f108608..593e45857 100644 --- a/apps/website/components/alpha/AlphaNav.tsx +++ b/apps/website/components/alpha/AlphaNav.tsx @@ -9,6 +9,7 @@ import { useAuth } from '@/lib/auth/AuthContext'; type AlphaNavProps = Record; const nonHomeLinks = [ { href: '/leagues', label: 'Leagues' }, + { href: '/races', label: 'Races' }, { href: '/teams', label: 'Teams' }, { href: '/drivers', label: 'Drivers' }, { href: '/leaderboards', label: 'Leaderboards' }, diff --git a/apps/website/components/onboarding/OnboardingWizard.tsx b/apps/website/components/onboarding/OnboardingWizard.tsx index 128b8d57e..41b472895 100644 --- a/apps/website/components/onboarding/OnboardingWizard.tsx +++ b/apps/website/components/onboarding/OnboardingWizard.tsx @@ -1,108 +1,77 @@ 'use client'; -import { useState, FormEvent } from 'react'; +import { useState, useRef, FormEvent, ChangeEvent } from 'react'; import { useRouter } from 'next/navigation'; import Image from 'next/image'; import { User, - Globe, Flag, - Car, - Heart, + Camera, Clock, Check, ChevronRight, ChevronLeft, - Gamepad2, - Target, - Zap, - Trophy, - Users, - MapPin, - Mail, - Calendar, 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 { Driver } from '@gridpilot/racing'; -import { getDriverRepository } from '@/lib/di-container'; +import CountrySelect from '@/components/ui/CountrySelect'; // ============================================================================ // TYPES // ============================================================================ -type OnboardingStep = 1 | 2 | 3 | 4; +type OnboardingStep = 1 | 2; interface PersonalInfo { firstName: string; lastName: string; displayName: string; - email: string; country: string; timezone: string; } -interface RacingInfo { - iracingId: string; - experienceLevel: 'beginner' | 'intermediate' | 'advanced' | 'pro'; - preferredDiscipline: string; - yearsRacing: string; -} - -interface PreferencesInfo { - favoriteTrack: string; - favoriteCar: string; - racingStyle: string; - availability: string; - lookingForTeam: boolean; - openToRequests: boolean; -} - -interface BioInfo { - bio: string; - goals: 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; - email?: string; country?: string; - iracingId?: string; + facePhoto?: string; + avatar?: string; submit?: string; } +type RacingSuitColor = + | 'red' + | 'blue' + | 'green' + | 'yellow' + | 'orange' + | 'purple' + | 'black' + | 'white' + | 'pink' + | 'cyan'; + // ============================================================================ // CONSTANTS // ============================================================================ -const COUNTRIES = [ - { code: 'US', name: 'United States' }, - { code: 'GB', name: 'United Kingdom' }, - { code: 'DE', name: 'Germany' }, - { code: 'NL', name: 'Netherlands' }, - { code: 'FR', name: 'France' }, - { code: 'IT', name: 'Italy' }, - { code: 'ES', name: 'Spain' }, - { code: 'AU', name: 'Australia' }, - { code: 'CA', name: 'Canada' }, - { code: 'BR', name: 'Brazil' }, - { code: 'JP', name: 'Japan' }, - { code: 'BE', name: 'Belgium' }, - { code: 'AT', name: 'Austria' }, - { code: 'CH', name: 'Switzerland' }, - { code: 'SE', name: 'Sweden' }, - { code: 'NO', name: 'Norway' }, - { code: 'DK', name: 'Denmark' }, - { code: 'FI', name: 'Finland' }, - { code: 'PL', name: 'Poland' }, - { code: 'PT', name: 'Portugal' }, -]; - const TIMEZONES = [ { value: 'America/New_York', label: 'Eastern Time (ET)' }, { value: 'America/Chicago', label: 'Central Time (CT)' }, @@ -116,47 +85,27 @@ const TIMEZONES = [ { value: 'America/Sao_Paulo', label: 'Brasília Time (BRT)' }, ]; -const EXPERIENCE_LEVELS = [ - { value: 'beginner', label: 'Beginner', description: 'Just getting started with sim racing' }, - { value: 'intermediate', label: 'Intermediate', description: '1-2 years of experience' }, - { value: 'advanced', label: 'Advanced', description: '3+ years, competitive racing' }, - { value: 'pro', label: 'Pro/Semi-Pro', description: 'Professional level competition' }, -]; - -const DISCIPLINES = [ - { value: 'road', label: 'Road Racing', icon: '🏎️' }, - { value: 'oval', label: 'Oval Racing', icon: '🏁' }, - { value: 'dirt-road', label: 'Dirt Road', icon: '🚗' }, - { value: 'dirt-oval', label: 'Dirt Oval', icon: '🏎️' }, - { value: 'multi', label: 'Multi-discipline', icon: '🎯' }, -]; - -const RACING_STYLES = [ - { value: 'aggressive', label: 'Aggressive Overtaker', icon: '⚡' }, - { value: 'consistent', label: 'Consistent Pacer', icon: '📈' }, - { value: 'strategic', label: 'Strategic Calculator', icon: '🧠' }, - { value: 'late-braker', label: 'Late Braker', icon: '🎯' }, - { value: 'smooth', label: 'Smooth Operator', icon: '✨' }, -]; - -const AVAILABILITY = [ - { value: 'weekday-evenings', label: 'Weekday Evenings (18:00-23:00)' }, - { value: 'weekends', label: 'Weekends Only' }, - { value: 'late-nights', label: 'Late Nights (22:00-02:00)' }, - { value: 'flexible', label: 'Flexible Schedule' }, - { value: 'mornings', label: 'Mornings (06:00-12:00)' }, +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, totalSteps }: { currentStep: number; totalSteps: number }) { +function StepIndicator({ currentStep }: { currentStep: number }) { const steps = [ { id: 1, label: 'Personal', icon: User }, - { id: 2, label: 'Racing', icon: Gamepad2 }, - { id: 3, label: 'Preferences', icon: Heart }, - { id: 4, label: 'Bio & Goals', icon: Target }, + { id: 2, label: 'Avatar', icon: Camera }, ]; return ( @@ -194,7 +143,7 @@ function StepIndicator({ currentStep, totalSteps }: { currentStep: number; total
{index < steps.length - 1 && (
@@ -206,54 +155,13 @@ function StepIndicator({ currentStep, totalSteps }: { currentStep: number; total ); } -function SelectableCard({ - selected, - onClick, - icon, - label, - description, - className = '', -}: { - selected: boolean; - onClick: () => void; - icon?: string | React.ReactNode; - label: string; - description?: string; - className?: string; -}) { - return ( - - ); -} - // ============================================================================ // 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({}); @@ -263,34 +171,21 @@ export default function OnboardingWizard() { firstName: '', lastName: '', displayName: '', - email: '', country: '', timezone: '', }); - const [racingInfo, setRacingInfo] = useState({ - iracingId: '', - experienceLevel: 'intermediate', - preferredDiscipline: 'road', - yearsRacing: '', - }); - - const [preferencesInfo, setPreferencesInfo] = useState({ - favoriteTrack: '', - favoriteCar: '', - racingStyle: 'consistent', - availability: 'weekday-evenings', - lookingForTeam: false, - openToRequests: true, - }); - - const [bioInfo, setBioInfo] = useState({ - bio: '', - goals: '', + const [avatarInfo, setAvatarInfo] = useState({ + facePhoto: null, + suitColor: 'blue', + generatedAvatars: [], + selectedAvatarIndex: null, + isGenerating: false, + isValidating: false, }); // Validation - const validateStep = async (currentStep: OnboardingStep): Promise => { + const validateStep = (currentStep: OnboardingStep): boolean => { const newErrors: FormErrors = {}; if (currentStep === 1) { @@ -311,15 +206,11 @@ export default function OnboardingWizard() { } if (currentStep === 2) { - if (!racingInfo.iracingId.trim()) { - newErrors.iracingId = 'iRacing ID is required'; - } else { - // Check if iRacing ID already exists - const driverRepo = getDriverRepository(); - const exists = await driverRepo.existsByIRacingId(racingInfo.iracingId); - if (exists) { - newErrors.iracingId = 'This iRacing ID is already registered'; - } + 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'; } } @@ -327,9 +218,9 @@ export default function OnboardingWizard() { return Object.keys(newErrors).length === 0; }; - const handleNext = async () => { - const isValid = await validateStep(step); - if (isValid && step < 4) { + const handleNext = () => { + const isValid = validateStep(step); + if (isValid && step < 2) { setStep((step + 1) as OnboardingStep); } }; @@ -340,49 +231,143 @@ export default function OnboardingWizard() { } }; + 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({ ...errors, facePhoto: undefined }); + + // Validate face + await validateFacePhoto(base64); + }; + reader.readAsDataURL(file); + }; + + const validateFacePhoto = async (photoData: string) => { + setAvatarInfo(prev => ({ ...prev, isValidating: true })); + setErrors(prev => ({ ...prev, facePhoto: undefined })); + + 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 => ({ ...prev, avatar: undefined })); + + 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 all steps - for (let s = 1; s <= 4; s++) { - const isValid = await validateStep(s as OnboardingStep); - if (!isValid) { - setStep(s as OnboardingStep); - 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 driverRepo = getDriverRepository(); + const selectedAvatarUrl = avatarInfo.generatedAvatars[avatarInfo.selectedAvatarIndex]; - // Build bio with all the additional info - const fullBio = [ - bioInfo.bio, - bioInfo.goals ? `Goals: ${bioInfo.goals}` : '', - `Experience: ${EXPERIENCE_LEVELS.find(e => e.value === racingInfo.experienceLevel)?.label}`, - racingInfo.yearsRacing ? `Years Racing: ${racingInfo.yearsRacing}` : '', - `Discipline: ${DISCIPLINES.find(d => d.value === racingInfo.preferredDiscipline)?.label}`, - `Style: ${RACING_STYLES.find(s => s.value === preferencesInfo.racingStyle)?.label}`, - preferencesInfo.favoriteTrack ? `Favorite Track: ${preferencesInfo.favoriteTrack}` : '', - preferencesInfo.favoriteCar ? `Favorite Car: ${preferencesInfo.favoriteCar}` : '', - `Available: ${AVAILABILITY.find(a => a.value === preferencesInfo.availability)?.label}`, - preferencesInfo.lookingForTeam ? '🔍 Looking for team' : '', - preferencesInfo.openToRequests ? '👋 Open to friend requests' : '', - ].filter(Boolean).join('\n'); - - const driver = Driver.create({ - id: crypto.randomUUID(), - iracingId: racingInfo.iracingId.trim(), - name: personalInfo.displayName.trim(), - country: personalInfo.country, - bio: fullBio || undefined, + 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, + }), }); - await driverRepo.create(driver); + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to create profile'); + } router.push('/dashboard'); router.refresh(); @@ -412,12 +397,12 @@ export default function OnboardingWizard() {
Welcome to GridPilot

- Let's set up your racing profile in just a few steps + Let's set up your racing profile

{/* Progress Indicator */} - + {/* Form Card */} @@ -452,7 +437,7 @@ export default function OnboardingWizard() { } error={!!errors.firstName} errorMessage={errors.firstName} - placeholder="Max" + placeholder="John" disabled={loading} />
@@ -470,7 +455,7 @@ export default function OnboardingWizard() { } error={!!errors.lastName} errorMessage={errors.lastName} - placeholder="Verstappen" + placeholder="Racer" disabled={loading} />
@@ -489,59 +474,25 @@ export default function OnboardingWizard() { } error={!!errors.displayName} errorMessage={errors.displayName} - placeholder="SuperMax33" + placeholder="SpeedyRacer42" disabled={loading} />
-
- -
- - - setPersonalInfo({ ...personalInfo, email: e.target.value }) - } - placeholder="max@racing.com" - disabled={loading} - className="pl-10" - /> -
-
-
-
- - - -
- {errors.country && ( -

{errors.country}

- )} + + setPersonalInfo({ ...personalInfo, country: value }) + } + error={!!errors.country} + errorMessage={errors.country} + disabled={loading} + />
@@ -573,336 +524,197 @@ export default function OnboardingWizard() {
)} - {/* Step 2: Racing Information */} + {/* Step 2: Avatar Generation */} {step === 2 && (
- - Racing Background + + Create Your Racing Avatar

- Tell us about your racing experience -

-
- -
- -
- # - - setRacingInfo({ ...racingInfo, iracingId: e.target.value }) - } - error={!!errors.iracingId} - errorMessage={errors.iracingId} - placeholder="123456" - disabled={loading} - className="pl-8" - /> -
-

- Find this in your iRacing account settings + Upload a photo and we'll generate a unique racing avatar for you

+ {/* Photo Upload */}
-
- {EXPERIENCE_LEVELS.map((level) => ( - - setRacingInfo({ - ...racingInfo, - experienceLevel: level.value as RacingInfo['experienceLevel'], - }) - } - label={level.label} - description={level.description} - /> - ))} -
-
- -
- -
- {DISCIPLINES.map((discipline) => ( - - setRacingInfo({ - ...racingInfo, - preferredDiscipline: discipline.value, - }) - } - icon={discipline.icon} - label={discipline.label} - /> - ))} -
-
- -
- -
- - - setRacingInfo({ ...racingInfo, yearsRacing: e.target.value }) - } - placeholder="e.g., 3 years" - disabled={loading} - className="pl-10" - /> -
-
-
- )} - - {/* Step 3: Preferences */} - {step === 3 && ( -
-
- - - Racing Preferences - -

- Customize your racing profile -

-
- -
-
- -
- - - setPreferencesInfo({ ...preferencesInfo, favoriteTrack: e.target.value }) - } - placeholder="e.g., Spa-Francorchamps" - disabled={loading} - className="pl-10" - /> -
-
- -
- -
- - - setPreferencesInfo({ ...preferencesInfo, favoriteCar: e.target.value }) - } - placeholder="e.g., Porsche 911 GT3 R" - disabled={loading} - className="pl-10" - /> -
-
-
- -
- -
- {RACING_STYLES.map((style) => ( - - setPreferencesInfo({ - ...preferencesInfo, - racingStyle: style.value, - }) - } - icon={style.icon} - label={style.label} - /> - ))} -
-
- -
- -
- {AVAILABILITY.map((avail) => ( - - setPreferencesInfo({ - ...preferencesInfo, - availability: avail.value, - }) - } - icon={} - label={avail.label} - /> - ))} -
-
- -
- -
- - - -
-
-
- )} - - {/* Step 4: Bio & Goals */} - {step === 4 && ( -
-
- - - Bio & Goals - -

- Tell the community about yourself and your racing aspirations -

-
- -
- -