diff --git a/apps/website/app/api/auth/login/route.ts b/apps/website/app/api/auth/login/route.ts new file mode 100644 index 000000000..fa415ef48 --- /dev/null +++ b/apps/website/app/api/auth/login/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from 'next/server'; +import { getAuthService } from '@/lib/auth'; + +export async function POST(request: Request) { + try { + const body = await request.json(); + const { email, password } = body; + + if (!email || !password) { + return NextResponse.json( + { error: 'Email and password are required' }, + { status: 400 } + ); + } + + const authService = getAuthService(); + const session = await authService.loginWithEmail({ email, password }); + + return NextResponse.json({ success: true, session }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Login failed' }, + { status: 401 } + ); + } +} \ No newline at end of file diff --git a/apps/website/app/api/auth/signup/route.ts b/apps/website/app/api/auth/signup/route.ts new file mode 100644 index 000000000..776a2e0f8 --- /dev/null +++ b/apps/website/app/api/auth/signup/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from 'next/server'; +import { getAuthService } from '@/lib/auth'; + +export async function POST(request: Request) { + try { + const body = await request.json(); + const { email, password, displayName } = body; + + if (!email || !password || !displayName) { + return NextResponse.json( + { error: 'Email, password, and display name are required' }, + { status: 400 } + ); + } + + const authService = getAuthService(); + const session = await authService.signupWithEmail({ email, password, displayName }); + + return NextResponse.json({ success: true, session }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Signup failed' }, + { status: 400 } + ); + } +} \ No newline at end of file diff --git a/apps/website/app/auth/login/page.tsx b/apps/website/app/auth/login/page.tsx new file mode 100644 index 000000000..66d06b26f --- /dev/null +++ b/apps/website/app/auth/login/page.tsx @@ -0,0 +1,263 @@ +'use client'; + +import { useState, FormEvent } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import Link from 'next/link'; +import { + Mail, + Lock, + Eye, + EyeOff, + LogIn, + AlertCircle, + Flag, + ArrowRight, + Gamepad2, +} 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'; + +interface FormErrors { + email?: string; + password?: string; + submit?: string; +} + +export default function LoginPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const returnTo = searchParams.get('returnTo') ?? '/dashboard'; + + const [loading, setLoading] = useState(false); + const [showPassword, setShowPassword] = useState(false); + const [errors, setErrors] = useState({}); + const [formData, setFormData] = useState({ + email: '', + password: '', + }); + + const validateForm = (): boolean => { + const newErrors: FormErrors = {}; + + if (!formData.email.trim()) { + newErrors.email = 'Email is required'; + } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) { + newErrors.email = 'Invalid email format'; + } + + if (!formData.password) { + newErrors.password = 'Password is required'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + if (loading) return; + + if (!validateForm()) return; + + setLoading(true); + setErrors({}); + + try { + const response = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: formData.email, + password: formData.password, + }), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Login failed'); + } + + router.push(returnTo); + router.refresh(); + } catch (error) { + setErrors({ + submit: error instanceof Error ? error.message : 'Login failed. Please try again.', + }); + setLoading(false); + } + }; + + const handleDemoLogin = async () => { + setLoading(true); + try { + const authService = getAuthService(); + const { redirectUrl } = await authService.startIracingAuthRedirect(returnTo); + router.push(redirectUrl); + } catch (error) { + setErrors({ + submit: 'Demo login failed. Please try again.', + }); + setLoading(false); + } + }; + + return ( +
+ {/* Background Pattern */} +
+
+
+
+ +
+ {/* Logo/Header */} +
+
+ +
+ Welcome Back +

+ Sign in to continue to GridPilot +

+
+ + + {/* Background accent */} +
+ +
+ {/* Email */} +
+ +
+ + setFormData({ ...formData, email: e.target.value })} + error={!!errors.email} + errorMessage={errors.email} + placeholder="you@example.com" + disabled={loading} + className="pl-10" + autoComplete="email" + /> +
+
+ + {/* Password */} +
+
+ + + Forgot password? + +
+
+ + setFormData({ ...formData, password: e.target.value })} + error={!!errors.password} + errorMessage={errors.password} + placeholder="••••••••" + disabled={loading} + className="pl-10 pr-10" + autoComplete="current-password" + /> + +
+
+ + {/* Error Message */} + {errors.submit && ( +
+ +

{errors.submit}

+
+ )} + + {/* Submit Button */} + +
+ + {/* Divider */} +
+
+
+
+
+ or continue with +
+
+ + {/* Demo Login */} + + + {/* Sign Up Link */} +

+ Don't have an account?{' '} + + Create one + +

+ + + {/* Footer */} +

+ By signing in, you agree to our{' '} + Terms of Service + {' '}and{' '} + Privacy Policy +

+
+
+ ); +} \ No newline at end of file diff --git a/apps/website/app/auth/signup/page.tsx b/apps/website/app/auth/signup/page.tsx new file mode 100644 index 000000000..fa1a8ac85 --- /dev/null +++ b/apps/website/app/auth/signup/page.tsx @@ -0,0 +1,394 @@ +'use client'; + +import { useState, FormEvent } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import Link from 'next/link'; +import { + Mail, + Lock, + Eye, + EyeOff, + UserPlus, + AlertCircle, + Flag, + User, + Check, + X, + Gamepad2, +} 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'; + +interface FormErrors { + displayName?: string; + email?: string; + password?: string; + confirmPassword?: string; + submit?: string; +} + +interface PasswordStrength { + score: number; + label: string; + color: string; +} + +function checkPasswordStrength(password: string): PasswordStrength { + let score = 0; + if (password.length >= 8) score++; + if (password.length >= 12) score++; + if (/[a-z]/.test(password) && /[A-Z]/.test(password)) score++; + if (/\d/.test(password)) score++; + if (/[^a-zA-Z\d]/.test(password)) score++; + + if (score <= 1) return { score, label: 'Weak', color: 'bg-red-500' }; + if (score <= 2) return { score, label: 'Fair', color: 'bg-warning-amber' }; + if (score <= 3) return { score, label: 'Good', color: 'bg-primary-blue' }; + return { score, label: 'Strong', color: 'bg-performance-green' }; +} + +export default function SignupPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const returnTo = searchParams.get('returnTo') ?? '/onboarding'; + + const [loading, setLoading] = useState(false); + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const [errors, setErrors] = useState({}); + const [formData, setFormData] = useState({ + displayName: '', + email: '', + password: '', + confirmPassword: '', + }); + + const passwordStrength = checkPasswordStrength(formData.password); + + const passwordRequirements = [ + { met: formData.password.length >= 8, label: 'At least 8 characters' }, + { met: /[a-z]/.test(formData.password) && /[A-Z]/.test(formData.password), label: 'Upper and lowercase letters' }, + { met: /\d/.test(formData.password), label: 'At least one number' }, + { met: /[^a-zA-Z\d]/.test(formData.password), label: 'At least one special character' }, + ]; + + const validateForm = (): boolean => { + const newErrors: FormErrors = {}; + + if (!formData.displayName.trim()) { + newErrors.displayName = 'Display name is required'; + } else if (formData.displayName.trim().length < 3) { + newErrors.displayName = 'Display name must be at least 3 characters'; + } + + if (!formData.email.trim()) { + newErrors.email = 'Email is required'; + } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) { + newErrors.email = 'Invalid email format'; + } + + if (!formData.password) { + newErrors.password = 'Password is required'; + } else if (formData.password.length < 8) { + newErrors.password = 'Password must be at least 8 characters'; + } + + if (!formData.confirmPassword) { + newErrors.confirmPassword = 'Please confirm your password'; + } else if (formData.password !== formData.confirmPassword) { + newErrors.confirmPassword = 'Passwords do not match'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + if (loading) return; + + if (!validateForm()) return; + + setLoading(true); + setErrors({}); + + try { + const authService = getAuthService(); + await authService.signupWithEmail({ + email: formData.email, + password: formData.password, + displayName: formData.displayName, + }); + + router.push(returnTo); + router.refresh(); + } catch (error) { + setErrors({ + submit: error instanceof Error ? error.message : 'Signup failed. Please try again.', + }); + setLoading(false); + } + }; + + const handleDemoLogin = async () => { + setLoading(true); + try { + const authService = getAuthService(); + const { redirectUrl } = await authService.startIracingAuthRedirect(returnTo); + router.push(redirectUrl); + } catch (error) { + setErrors({ + submit: 'Demo login failed. Please try again.', + }); + setLoading(false); + } + }; + + return ( +
+ {/* Background Pattern */} +
+
+
+
+ +
+ {/* Logo/Header */} +
+
+ +
+ Join GridPilot +

+ Create your account and start racing +

+
+ + + {/* Background accent */} +
+ +
+ {/* Display Name */} +
+ +
+ + setFormData({ ...formData, displayName: e.target.value })} + error={!!errors.displayName} + errorMessage={errors.displayName} + placeholder="SuperMax33" + disabled={loading} + className="pl-10" + autoComplete="username" + /> +
+

This is how other drivers will see you

+
+ + {/* Email */} +
+ +
+ + setFormData({ ...formData, email: e.target.value })} + error={!!errors.email} + errorMessage={errors.email} + placeholder="you@example.com" + disabled={loading} + className="pl-10" + autoComplete="email" + /> +
+
+ + {/* Password */} +
+ +
+ + setFormData({ ...formData, password: e.target.value })} + error={!!errors.password} + errorMessage={errors.password} + placeholder="••••••••" + disabled={loading} + className="pl-10 pr-10" + autoComplete="new-password" + /> + +
+ + {/* Password Strength */} + {formData.password && ( +
+
+
+
+
+ + {passwordStrength.label} + +
+
+ {passwordRequirements.map((req, index) => ( +
+ {req.met ? ( + + ) : ( + + )} + + {req.label} + +
+ ))} +
+
+ )} +
+ + {/* Confirm Password */} +
+ +
+ + setFormData({ ...formData, confirmPassword: e.target.value })} + error={!!errors.confirmPassword} + errorMessage={errors.confirmPassword} + placeholder="••••••••" + disabled={loading} + className="pl-10 pr-10" + autoComplete="new-password" + /> + +
+ {formData.confirmPassword && formData.password === formData.confirmPassword && ( +

+ Passwords match +

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

{errors.submit}

+
+ )} + + {/* Submit Button */} + + + + {/* Divider */} +
+
+
+
+
+ or continue with +
+
+ + {/* Demo Login */} + + + {/* Login Link */} +

+ Already have an account?{' '} + + Sign in + +

+ + + {/* Footer */} +

+ By creating an account, you agree to our{' '} + Terms of Service + {' '}and{' '} + Privacy Policy +

+
+
+ ); +} \ No newline at end of file diff --git a/apps/website/app/dashboard/page.tsx b/apps/website/app/dashboard/page.tsx index 777efdc15..ceaafc2e7 100644 --- a/apps/website/app/dashboard/page.tsx +++ b/apps/website/app/dashboard/page.tsx @@ -1,16 +1,97 @@ import { redirect } from 'next/navigation'; +import Image from 'next/image'; +import Link from 'next/link'; +import { + Calendar, + Trophy, + Users, + Star, + Clock, + Flag, + TrendingUp, + ChevronRight, + Zap, + Target, + Award, + Activity, + Play, + Bell, + Medal, + Crown, + Heart, + MessageCircle, + UserPlus, +} from 'lucide-react'; -import FeedLayout from '@/components/feed/FeedLayout'; +import Card from '@/components/ui/Card'; +import Button from '@/components/ui/Button'; import { getAuthService } from '@/lib/auth'; import { getFeedRepository, getRaceRepository, getResultRepository, getDriverRepository, + getLeagueRepository, + getStandingRepository, + getSocialRepository, + getDriverStats, + getImageService, + getLeagueMembershipRepository, } from '@/lib/di-container'; +import type { FeedItem } from '@gridpilot/social/domain/entities/FeedItem'; +import type { Race } from '@gridpilot/racing/domain/entities/Race'; +import type { Driver } from '@gridpilot/racing/domain/entities/Driver'; export const dynamic = 'force-dynamic'; +// Helper functions +function 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 '🏁'; +} + +function timeUntil(date: Date): string { + const now = new Date(); + const diffMs = date.getTime() - now.getTime(); + + if (diffMs < 0) return 'Started'; + + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffDays = Math.floor(diffHours / 24); + + if (diffDays > 0) { + return `${diffDays}d ${diffHours % 24}h`; + } + if (diffHours > 0) { + const diffMinutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)); + return `${diffHours}h ${diffMinutes}m`; + } + const diffMinutes = Math.floor(diffMs / (1000 * 60)); + return `${diffMinutes}m`; +} + +function timeAgo(timestamp: Date): string { + const diffMs = Date.now() - timestamp.getTime(); + const diffMinutes = Math.floor(diffMs / 60000); + if (diffMinutes < 1) return 'Just now'; + if (diffMinutes < 60) return `${diffMinutes}m ago`; + const diffHours = Math.floor(diffMinutes / 60); + if (diffHours < 24) return `${diffHours}h ago`; + const diffDays = Math.floor(diffHours / 24); + return `${diffDays}d ago`; +} + +function getGreeting(): string { + const hour = new Date().getHours(); + if (hour < 12) return 'Good morning'; + if (hour < 18) return 'Good afternoon'; + return 'Good evening'; +} + export default async function DashboardPage() { const authService = getAuthService(); const session = await authService.getCurrentSession(); @@ -23,59 +104,498 @@ export default async function DashboardPage() { const raceRepository = getRaceRepository(); const resultRepository = getResultRepository(); const driverRepository = getDriverRepository(); - - const [feedItems, upcomingRaces, allResults] = await Promise.all([ - feedRepository.getFeedForDriver(session.user.primaryDriverId ?? ''), + const leagueRepository = getLeagueRepository(); + const standingRepository = getStandingRepository(); + const socialRepository = getSocialRepository(); + const leagueMembershipRepository = getLeagueMembershipRepository(); + const imageService = getImageService(); + + const currentDriverId = session.user.primaryDriverId ?? ''; + const currentDriver = await driverRepository.findById(currentDriverId); + + const [feedItems, allRaces, allResults, allLeagues, friends] = await Promise.all([ + feedRepository.getFeedForDriver(currentDriverId), raceRepository.findAll(), resultRepository.findAll(), + leagueRepository.findAll(), + socialRepository.getFriends(currentDriverId), ]); - const upcoming = upcomingRaces + // Get driver's leagues by checking membership in each league + const driverLeagues: typeof allLeagues = []; + for (const league of allLeagues) { + const membership = await leagueMembershipRepository.getMembership(league.id, currentDriverId); + if (membership && membership.status === 'active') { + driverLeagues.push(league); + } + } + const driverLeagueIds = driverLeagues.map(l => l.id); + + // Upcoming races (prioritize driver's leagues) + const upcomingRaces = allRaces .filter((race) => race.status === 'scheduled') - .sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime()) - .slice(0, 5); + .sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime()); + + const myUpcomingRaces = upcomingRaces.filter(r => driverLeagueIds.includes(r.leagueId)); + const otherUpcomingRaces = upcomingRaces.filter(r => !driverLeagueIds.includes(r.leagueId)); + const nextRace = myUpcomingRaces[0] || otherUpcomingRaces[0]; - const completedRaces = upcomingRaces.filter((race) => race.status === 'completed'); + // Recent results for driver + const driverResults = allResults.filter(r => r.driverId === currentDriverId); + const recentResults = driverResults.slice(0, 5); - const latestResults = await Promise.all( - completedRaces.slice(0, 4).map(async (race) => { - const raceResults = allResults.filter((result) => result.raceId === race.id); - const winner = raceResults.slice().sort((a, b) => a.position - b.position)[0]; - const winnerDriverId = winner?.driverId ?? ''; - const winnerDriver = winnerDriverId - ? await driverRepository.findById(winnerDriverId) - : null; - + // Get stats + const driverStats = getDriverStats(currentDriverId); + + // Get standings for driver's leagues + const leagueStandings = await Promise.all( + driverLeagues.slice(0, 3).map(async (league) => { + const standings = await standingRepository.findByLeagueId(league.id); + const driverStanding = standings.find(s => s.driverId === currentDriverId); return { - raceId: race.id, - leagueId: race.leagueId, - track: race.track, - car: race.car, - scheduledAt: race.scheduledAt, - winnerDriverId, - winnerName: winnerDriver?.name ?? 'Race Winner', - positionChange: winner ? winner.getPositionChange() : 0, + league, + position: driverStanding?.position ?? 0, + points: driverStanding?.totalPoints ?? 0, + totalDrivers: standings.length, }; - }), + }) ); + // Calculate quick stats + const totalRaces = driverStats?.totalRaces ?? 0; + const wins = driverStats?.wins ?? 0; + const podiums = driverStats?.podiums ?? 0; + const rating = driverStats?.rating ?? 1500; + const globalRank = driverStats?.overallRank ?? 0; + return (
-
-
-
-

Dashboard

-

- Personalized activity from your friends, leagues, and teams. -

+ {/* Hero Section */} +
+ {/* Background Pattern */} +
+
+
+
+ +
+
+ {/* Welcome Message */} +
+ {currentDriver && ( +
+
+
+ {currentDriver.name} +
+
+
+
+ )} +
+

{getGreeting()},

+

+ {currentDriver?.name ?? 'Racer'} + {currentDriver ? getCountryFlag(currentDriver.country) : '🏁'} +

+
+
+ + {rating} +
+
+ + #{globalRank} +
+ {totalRaces} races completed +
+
+
+ + {/* Quick Actions */} +
+ + + + + + +
+
+ + {/* Quick Stats Row */} +
+
+
+
+ +
+
+

{wins}

+

Wins

+
+
+
+
+
+
+ +
+
+

{podiums}

+

Podiums

+
+
+
+
+
+
+ +
+
+

{driverStats?.consistency ?? 0}%

+

Consistency

+
+
+
+
+
+
+ +
+
+

{driverLeagues.length}

+

Active Leagues

+
+
+
+
+
+
+ + {/* Main Content */} +
+
+ {/* Left Column - Main Content */} +
+ {/* Next Race Card */} + {nextRace && ( + +
+
+
+
+ + Next Race +
+ {myUpcomingRaces.includes(nextRace) && ( + + Your League + + )} +
+ +
+
+

{nextRace.track}

+

{nextRace.car}

+
+ + + {nextRace.scheduledAt.toLocaleDateString('en-US', { + weekday: 'long', + month: 'short', + day: 'numeric', + })} + + + + {nextRace.scheduledAt.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + })} + +
+
+ +
+
+

Starts in

+

{timeUntil(nextRace.scheduledAt)}

+
+ + + +
+
+
+ + )} + + {/* League Standings Preview */} + {leagueStandings.length > 0 && ( + +
+

+ + Your Championship Standings +

+ + View all + +
+
+ {leagueStandings.map(({ league, position, points, totalDrivers }) => ( + +
+ {position > 0 ? `P${position}` : '-'} +
+
+

+ {league.name} +

+

+ {points} points • {totalDrivers} drivers +

+
+
+ {position <= 3 && position > 0 && ( + + )} + +
+ + ))} +
+
+ )} + + {/* Activity Feed */} + +
+

+ + Recent Activity +

+
+ {feedItems.length > 0 ? ( +
+ {feedItems.slice(0, 5).map((item) => ( + + ))} +
+ ) : ( +
+ +

No activity yet

+

Join leagues and add friends to see activity here

+
+ )} +
+
+ + {/* Right Column - Sidebar */} +
+ {/* Upcoming Races */} + +
+

+ + Upcoming Races +

+ + View all + +
+ {upcomingRaces.length > 0 ? ( +
+ {upcomingRaces.slice(0, 5).map((race) => { + const isMyRace = driverLeagueIds.includes(race.leagueId); + return ( + +
+

{race.track}

+ {isMyRace && ( + + )} +
+

{race.car}

+
+ + {race.scheduledAt.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} + + {timeUntil(race.scheduledAt)} +
+ + ); + })} +
+ ) : ( +

No upcoming races

+ )} +
+ + {/* Friends */} + +
+

+ + Friends +

+ {friends.length} friends +
+ {friends.length > 0 ? ( +
+ {friends.slice(0, 6).map((friend) => ( + +
+ {friend.name} +
+
+

{friend.name}

+

{getCountryFlag(friend.country)}

+
+ + ))} + {friends.length > 6 && ( + + +{friends.length - 6} more + + )} +
+ ) : ( +
+ +

No friends yet

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

+ + Quick Links +

+
+ + + Leaderboards + + + + + Teams + + + + + Create League + + +
+
-
); +} + +// Feed Item Row Component +function FeedItemRow({ item, imageService }: { item: FeedItem; imageService: any }) { + const getActivityIcon = (type: string) => { + if (type.includes('win')) return { icon: Trophy, color: 'text-yellow-400 bg-yellow-400/10' }; + if (type.includes('podium')) return { icon: Medal, color: 'text-warning-amber bg-warning-amber/10' }; + if (type.includes('join')) return { icon: UserPlus, color: 'text-performance-green bg-performance-green/10' }; + if (type.includes('friend')) return { icon: Heart, color: 'text-pink-400 bg-pink-400/10' }; + if (type.includes('league')) return { icon: Flag, color: 'text-primary-blue bg-primary-blue/10' }; + if (type.includes('race')) return { icon: Play, color: 'text-red-400 bg-red-400/10' }; + return { icon: Activity, color: 'text-gray-400 bg-gray-400/10' }; + }; + + const { icon: Icon, color } = getActivityIcon(item.type); + + return ( +
+
+ +
+
+

{item.headline}

+ {item.body && ( +

{item.body}

+ )} +

{timeAgo(item.timestamp)}

+
+ {item.ctaHref && ( + + + + )} +
+ ); } \ No newline at end of file diff --git a/apps/website/app/drivers/[id]/page.tsx b/apps/website/app/drivers/[id]/page.tsx index 4438fc671..6ece46c02 100644 --- a/apps/website/app/drivers/[id]/page.tsx +++ b/apps/website/app/drivers/[id]/page.tsx @@ -1,15 +1,305 @@ 'use client'; import { useState, useEffect, use } from 'react'; +import Image from 'next/image'; import Link from 'next/link'; import { useRouter, useParams } from 'next/navigation'; -import { getDriverRepository } from '@/lib/di-container'; -import DriverProfile from '@/components/drivers/DriverProfile'; +import { + User, + Trophy, + Star, + Calendar, + Users, + Flag, + Award, + TrendingUp, + UserPlus, + ExternalLink, + Target, + Zap, + Clock, + Medal, + Crown, + ChevronRight, + Globe, + Twitter, + Youtube, + Twitch, + MessageCircle, + ArrowLeft, + BarChart3, + History, + Shield, + Percent, + Activity, +} from 'lucide-react'; +import { + getDriverRepository, + getDriverStats, + getAllDriverRankings, + getGetDriverTeamQuery, + getSocialRepository, + getImageService, + getGetAllTeamsQuery, + getGetTeamMembersQuery, +} from '@/lib/di-container'; +import { Driver, EntityMappers, type Team } from '@gridpilot/racing'; +import type { DriverDTO } from '@gridpilot/racing'; import Button from '@/components/ui/Button'; import Card from '@/components/ui/Card'; import Breadcrumbs from '@/components/layout/Breadcrumbs'; -import { EntityMappers } from '@gridpilot/racing'; -import type { DriverDTO } from '@gridpilot/racing'; +import type { GetDriverTeamQueryResultDTO } from '@gridpilot/racing/application/dto/TeamCommandAndQueryDTO'; + +// ============================================================================ +// TYPES +// ============================================================================ + +type ProfileTab = 'overview' | 'stats'; + +interface SocialHandle { + platform: 'twitter' | 'youtube' | 'twitch' | 'discord'; + handle: string; + url: string; +} + +interface Achievement { + id: string; + title: string; + description: string; + icon: 'trophy' | 'medal' | 'star' | 'crown' | 'target' | 'zap'; + rarity: 'common' | 'rare' | 'epic' | 'legendary'; + earnedAt: Date; +} + +interface DriverExtendedProfile { + socialHandles: SocialHandle[]; + achievements: Achievement[]; + racingStyle: string; + favoriteTrack: string; + favoriteCar: string; + timezone: string; + availableHours: string; + lookingForTeam: boolean; + openToRequests: boolean; +} + +interface TeamMembershipInfo { + team: Team; + role: string; + joinedAt: Date; +} + +// ============================================================================ +// DEMO DATA +// ============================================================================ + +function getDemoExtendedProfile(driverId: string): DriverExtendedProfile { + const hash = driverId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); + + const socialOptions: SocialHandle[][] = [ + [ + { platform: 'twitter', handle: '@speedracer', url: 'https://twitter.com/speedracer' }, + { platform: 'youtube', handle: 'SpeedRacer Racing', url: 'https://youtube.com/@speedracer' }, + { platform: 'twitch', handle: 'speedracer_live', url: 'https://twitch.tv/speedracer_live' }, + ], + [ + { platform: 'twitter', handle: '@racingpro', url: 'https://twitter.com/racingpro' }, + { platform: 'discord', handle: 'RacingPro#1234', url: '#' }, + ], + [ + { platform: 'twitch', handle: 'simracer_elite', url: 'https://twitch.tv/simracer_elite' }, + { platform: 'youtube', handle: 'SimRacer Elite', url: 'https://youtube.com/@simracerelite' }, + ], + ]; + + const achievementSets: Achievement[][] = [ + [ + { id: '1', title: 'First Victory', description: 'Win your first race', icon: 'trophy', rarity: 'common', earnedAt: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000) }, + { id: '2', title: 'Clean Racer', description: '10 races without incidents', icon: 'star', rarity: 'rare', earnedAt: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000) }, + { id: '3', title: 'Podium Streak', description: '5 consecutive podium finishes', icon: 'medal', rarity: 'epic', earnedAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) }, + ], + [ + { id: '1', title: 'Rookie No More', description: 'Complete 25 races', icon: 'target', rarity: 'common', earnedAt: new Date(Date.now() - 120 * 24 * 60 * 60 * 1000) }, + { id: '2', title: 'Consistent Performer', description: 'Maintain 80%+ consistency rating', icon: 'zap', rarity: 'rare', earnedAt: new Date(Date.now() - 45 * 24 * 60 * 60 * 1000) }, + ], + [ + { id: '1', title: 'Welcome Racer', description: 'Join GridPilot', icon: 'star', rarity: 'common', earnedAt: new Date(Date.now() - 180 * 24 * 60 * 60 * 1000) }, + { id: '2', title: 'Team Player', description: 'Join a racing team', icon: 'medal', rarity: 'rare', earnedAt: new Date(Date.now() - 80 * 24 * 60 * 60 * 1000) }, + ], + ]; + + const tracks = ['Spa-Francorchamps', 'Nürburgring Nordschleife', 'Suzuka', 'Monza', 'Interlagos', 'Silverstone']; + const cars = ['Porsche 911 GT3 R', 'Ferrari 488 GT3', 'Mercedes-AMG GT3', 'BMW M4 GT3', 'Audi R8 LMS']; + const styles = ['Aggressive Overtaker', 'Consistent Pacer', 'Strategic Calculator', 'Late Braker', 'Smooth Operator']; + const timezones = ['EST (UTC-5)', 'CET (UTC+1)', 'PST (UTC-8)', 'GMT (UTC+0)', 'JST (UTC+9)']; + const hours = ['Evenings (18:00-23:00)', 'Weekends only', 'Late nights (22:00-02:00)', 'Flexible schedule']; + + return { + socialHandles: socialOptions[hash % socialOptions.length], + achievements: achievementSets[hash % achievementSets.length], + racingStyle: styles[hash % styles.length], + favoriteTrack: tracks[hash % tracks.length], + favoriteCar: cars[hash % cars.length], + timezone: timezones[hash % timezones.length], + availableHours: hours[hash % hours.length], + lookingForTeam: hash % 3 === 0, + openToRequests: hash % 2 === 0, + }; +} + +// ============================================================================ +// HELPERS +// ============================================================================ + +function 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 '🏁'; +} + +function getRarityColor(rarity: Achievement['rarity']) { + switch (rarity) { + case 'common': + return 'text-gray-400 bg-gray-400/10 border-gray-400/30'; + case 'rare': + return 'text-primary-blue bg-primary-blue/10 border-primary-blue/30'; + case 'epic': + return 'text-purple-400 bg-purple-400/10 border-purple-400/30'; + case 'legendary': + return 'text-yellow-400 bg-yellow-400/10 border-yellow-400/30'; + } +} + +function getAchievementIcon(icon: Achievement['icon']) { + switch (icon) { + case 'trophy': + return Trophy; + case 'medal': + return Medal; + case 'star': + return Star; + case 'crown': + return Crown; + case 'target': + return Target; + case 'zap': + return Zap; + } +} + +function getSocialIcon(platform: SocialHandle['platform']) { + switch (platform) { + case 'twitter': + return Twitter; + case 'youtube': + return Youtube; + case 'twitch': + return Twitch; + case 'discord': + return MessageCircle; + } +} + +function getSocialColor(platform: SocialHandle['platform']) { + switch (platform) { + case 'twitter': + return 'hover:text-sky-400 hover:bg-sky-400/10'; + case 'youtube': + return 'hover:text-red-500 hover:bg-red-500/10'; + case 'twitch': + return 'hover:text-purple-400 hover:bg-purple-400/10'; + case 'discord': + return 'hover:text-indigo-400 hover:bg-indigo-400/10'; + } +} + +// ============================================================================ +// STAT DIAGRAM COMPONENTS +// ============================================================================ + +interface CircularProgressProps { + value: number; + max: number; + label: string; + color: string; + size?: number; +} + +function CircularProgress({ value, max, label, color, size = 80 }: CircularProgressProps) { + const percentage = Math.min((value / max) * 100, 100); + const strokeWidth = 6; + const radius = (size - strokeWidth) / 2; + const circumference = radius * 2 * Math.PI; + const strokeDashoffset = circumference - (percentage / 100) * circumference; + + return ( +
+
+ + + + +
+ {percentage.toFixed(0)}% +
+
+ {label} +
+ ); +} + +interface BarChartProps { + data: { label: string; value: number; color: string }[]; + maxValue: number; +} + +function HorizontalBarChart({ data, maxValue }: BarChartProps) { + return ( +
+ {data.map((item) => ( +
+
+ {item.label} + {item.value} +
+
+
+
+
+ ))} +
+ ); +} + +// ============================================================================ +// MAIN PAGE +// ============================================================================ export default function DriverDetailPage({ searchParams, @@ -23,26 +313,31 @@ export default function DriverDetailPage({ const [driver, setDriver] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [activeTab, setActiveTab] = useState('overview'); + const [teamData, setTeamData] = useState(null); + const [allTeamMemberships, setAllTeamMemberships] = useState([]); + const [friends, setFriends] = useState([]); + const [friendRequestSent, setFriendRequestSent] = useState(false); const unwrappedSearchParams = use(searchParams) as URLSearchParams | undefined; - + const from = typeof unwrappedSearchParams?.get === 'function' ? unwrappedSearchParams.get('from') ?? undefined : undefined; - + const leagueId = typeof unwrappedSearchParams?.get === 'function' ? unwrappedSearchParams.get('leagueId') ?? undefined : undefined; - + const raceId = typeof unwrappedSearchParams?.get === 'function' ? unwrappedSearchParams.get('raceId') ?? undefined : undefined; - + let backLink: string | null = null; - + if (from === 'league-standings' && leagueId) { backLink = `/leagues/${leagueId}/standings`; } else if (from === 'league' && leagueId) { @@ -64,7 +359,7 @@ export default function DriverDetailPage({ try { const driverRepo = getDriverRepository(); const driverEntity = await driverRepo.findById(driverId); - + if (!driverEntity) { setError('Driver not found'); setLoading(false); @@ -80,6 +375,35 @@ export default function DriverDetailPage({ } setDriver(driverDto); + + // Load team data + const teamQuery = getGetDriverTeamQuery(); + const teamResult = await teamQuery.execute({ driverId }); + setTeamData(teamResult); + + // Load ALL team memberships + const allTeamsQuery = getGetAllTeamsQuery(); + const allTeams = await allTeamsQuery.execute(); + const membershipsQuery = getGetTeamMembersQuery(); + + const memberships: TeamMembershipInfo[] = []; + for (const team of allTeams) { + const members = await membershipsQuery.execute({ teamId: team.id }); + const membership = members.find((m) => m.driverId === driverId); + if (membership) { + memberships.push({ + team, + role: membership.role, + joinedAt: membership.joinedAt, + }); + } + } + setAllTeamMemberships(memberships); + + // Load friends + const socialRepo = getSocialRepository(); + const friendsList = await socialRepo.getFriends(driverId); + setFriends(friendsList); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load driver'); } finally { @@ -87,11 +411,18 @@ export default function DriverDetailPage({ } }; + const handleAddFriend = () => { + setFriendRequestSent(true); + }; + if (loading) { return ( -
-
-
Loading driver profile...
+
+
+
+
+

Loading driver profile...

+
); @@ -99,50 +430,620 @@ export default function DriverDetailPage({ if (error || !driver) { return ( -
-
- -
- {error || 'Driver not found'} -
- -
-
+
+ + +
{error || 'Driver not found'}
+ +
); } + const extendedProfile = getDemoExtendedProfile(driver.id); + const stats = getDriverStats(driver.id); + const allRankings = getAllDriverRankings(); + const globalRank = stats?.overallRank ?? allRankings.findIndex(r => r.driverId === driver.id) + 1; + return ( -
-
- {backLink && ( -
- - ← Back to league - +
+ {/* Back Navigation */} + {backLink ? ( + + + Back to league + + ) : ( + + )} + + {/* Breadcrumb */} + + + {/* Hero Header Section */} +
+ {/* Background Pattern */} +
+
+
+ +
+
+ {/* Avatar */} +
+
+
+ {driver.name} +
+
+
+ + {/* Driver Info */} +
+
+

{driver.name}

+ + {getCountryFlag(driver.country)} + + {teamData?.team.tag && ( + + [{teamData.team.tag}] + + )} +
+ + {/* Rating and Rank */} +
+ {stats && ( + <> +
+ + {stats.rating} + Rating +
+
+ + #{globalRank} + Global +
+ + )} + {teamData && ( + + + {teamData.team.name} + + + )} +
+ + {/* Meta info */} +
+ + + iRacing: {driver.iracingId} + + + + Joined{' '} + {new Date(driver.joinedAt).toLocaleDateString('en-US', { + month: 'short', + year: 'numeric', + })} + + + + {extendedProfile.timezone} + +
+
+ + {/* Action Buttons */} +
+ +
- )} - {/* Breadcrumb */} - - - {/* Driver Profile Component */} - + {/* Social Handles */} + {extendedProfile.socialHandles.length > 0 && ( +
+
+ Connect: + {extendedProfile.socialHandles.map((social) => { + const Icon = getSocialIcon(social.platform); + return ( + + + {social.handle} + + + ); + })} +
+
+ )} +
+ + {/* Bio Section */} + {driver.bio && ( + +

+ + About +

+

{driver.bio}

+
+ )} + + {/* Team Memberships */} + {allTeamMemberships.length > 0 && ( + +

+ + Team Memberships + ({allTeamMemberships.length}) +

+
+ {allTeamMemberships.map((membership) => ( + +
+ +
+
+

+ {membership.team.name} +

+
+ + {membership.role} + + + Since {membership.joinedAt.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })} + +
+
+ + + ))} +
+
+ )} + + {/* Performance Overview with Diagrams */} + {stats && ( + +

+ + Performance Overview +

+
+ {/* Circular Progress Charts */} +
+
+ + +
+
+ + +
+
+ + {/* Bar chart and key metrics */} +
+

+ + Results Breakdown +

+ + +
+
+
+ + Best Finish +
+

P{stats.bestFinish}

+
+
+
+ + Avg Finish +
+

P{stats.avgFinish.toFixed(1)}

+
+
+
+
+
+ )} + + {/* Tab Navigation */} +
+ + +
+ + {/* Tab Content */} + {activeTab === 'overview' && ( + <> + {/* Stats and Profile Grid */} +
+ {/* Career Stats */} + +

+ + Career Statistics +

+ {stats ? ( +
+
+
{stats.totalRaces}
+
Races
+
+
+
{stats.wins}
+
Wins
+
+
+
{stats.podiums}
+
Podiums
+
+
+
{stats.consistency}%
+
Consistency
+
+
+ ) : ( +

No race statistics available yet.

+ )} +
+ + {/* Racing Preferences */} + +

+ + Racing Profile +

+
+
+ Racing Style +

{extendedProfile.racingStyle}

+
+
+ Favorite Track +

{extendedProfile.favoriteTrack}

+
+
+ Favorite Car +

{extendedProfile.favoriteCar}

+
+
+ Available +

{extendedProfile.availableHours}

+
+ + {/* Status badges */} +
+ {extendedProfile.lookingForTeam && ( +
+ + Looking for Team +
+ )} + {extendedProfile.openToRequests && ( +
+ + Open to Friend Requests +
+ )} +
+
+
+
+ + {/* Achievements */} + +

+ + Achievements + {extendedProfile.achievements.length} earned +

+
+ {extendedProfile.achievements.map((achievement) => { + const Icon = getAchievementIcon(achievement.icon); + const rarityClasses = getRarityColor(achievement.rarity); + return ( +
+
+
+ +
+
+

{achievement.title}

+

{achievement.description}

+

+ {achievement.earnedAt.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + })} +

+
+
+
+ ); + })} +
+
+ + {/* Friends Preview */} + {friends.length > 0 && ( + +
+

+ + Friends + ({friends.length}) +

+
+
+ {friends.slice(0, 8).map((friend) => ( + +
+ {friend.name} +
+ {friend.name} + {getCountryFlag(friend.country)} + + ))} + {friends.length > 8 && ( +
+{friends.length - 8} more
+ )} +
+
+ )} + + )} + + {activeTab === 'stats' && stats && ( +
+ {/* Detailed Performance Metrics */} + +

+ + Detailed Performance Metrics +

+ +
+ {/* Performance Bars */} +
+

Results Breakdown

+ +
+ + {/* Key Metrics */} +
+
+
+ + Win Rate +
+

+ {((stats.wins / stats.totalRaces) * 100).toFixed(1)}% +

+
+
+
+ + Podium Rate +
+

+ {((stats.podiums / stats.totalRaces) * 100).toFixed(1)}% +

+
+
+
+ + Consistency +
+

{stats.consistency}%

+
+
+
+ + Finish Rate +
+

+ {(((stats.totalRaces - stats.dnfs) / stats.totalRaces) * 100).toFixed(1)}% +

+
+
+
+
+ + {/* Position Statistics */} + +

+ + Position Statistics +

+ +
+
+
P{stats.bestFinish}
+
Best Finish
+
+
+
P{stats.avgFinish.toFixed(1)}
+
Avg Finish
+
+
+
P{stats.worstFinish}
+
Worst Finish
+
+
+
{stats.dnfs}
+
DNFs
+
+
+
+ + {/* Global Rankings */} + +

+ + Global Rankings +

+ +
+
+ +
#{globalRank}
+
Global Rank
+
+
+ +
{stats.rating}
+
Rating
+
+
+ +
Top {stats.percentile}%
+
Percentile
+
+
+
+
+ )} + + {activeTab === 'stats' && !stats && ( + + +

No statistics available yet

+

This driver hasn't completed any races yet

+
+ )}
); } \ No newline at end of file diff --git a/apps/website/app/drivers/page.tsx b/apps/website/app/drivers/page.tsx index 1796ef595..57a21209c 100644 --- a/apps/website/app/drivers/page.tsx +++ b/apps/website/app/drivers/page.tsx @@ -2,36 +2,418 @@ import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; -import DriverCard from '@/components/drivers/DriverCard'; -import RankBadge from '@/components/drivers/RankBadge'; +import { + Trophy, + Medal, + Crown, + Star, + TrendingUp, + Shield, + Search, + Plus, + Sparkles, + Users, + Target, + Zap, + Award, + ChevronRight, + Flame, + Flag, + Activity, + BarChart3, + UserPlus, +} from 'lucide-react'; +import Button from '@/components/ui/Button'; import Input from '@/components/ui/Input'; import Card from '@/components/ui/Card'; -import { getDriverRepository, getDriverStats, getAllDriverRankings } from '@/lib/di-container'; +import Heading from '@/components/ui/Heading'; +import { getDriverRepository, getDriverStats, getAllDriverRankings, getImageService } from '@/lib/di-container'; +import Image from 'next/image'; + +// ============================================================================ +// TYPES +// ============================================================================ type SkillLevel = 'beginner' | 'intermediate' | 'advanced' | 'pro'; -type DriverListItem = { - id: string; - name: string; - rating: number; - skillLevel: SkillLevel; - nationality: string; - racesCompleted: number; - wins: number; - podiums: number; - isActive: boolean; - rank: number; -}; +interface DriverListItem { + id: string; + name: string; + rating: number; + skillLevel: SkillLevel; + nationality: string; + racesCompleted: number; + wins: number; + podiums: number; + isActive: boolean; + rank: number; +} + +// ============================================================================ +// DEMO DATA +// ============================================================================ + +const DEMO_DRIVERS: DriverListItem[] = [ + { id: 'demo-1', name: 'Max Verstappen', rating: 4250, skillLevel: 'pro', nationality: 'NL', racesCompleted: 156, wins: 47, podiums: 89, isActive: true, rank: 1 }, + { id: 'demo-2', name: 'Lewis Hamilton', rating: 4180, skillLevel: 'pro', nationality: 'GB', racesCompleted: 198, wins: 52, podiums: 112, isActive: true, rank: 2 }, + { id: 'demo-3', name: 'Charles Leclerc', rating: 3950, skillLevel: 'pro', nationality: 'MC', racesCompleted: 134, wins: 28, podiums: 67, isActive: true, rank: 3 }, + { id: 'demo-4', name: 'Lando Norris', rating: 3820, skillLevel: 'advanced', nationality: 'GB', racesCompleted: 112, wins: 18, podiums: 45, isActive: true, rank: 4 }, + { id: 'demo-5', name: 'Carlos Sainz', rating: 3750, skillLevel: 'advanced', nationality: 'ES', racesCompleted: 145, wins: 15, podiums: 52, isActive: true, rank: 5 }, + { id: 'demo-6', name: 'Oscar Piastri', rating: 3680, skillLevel: 'advanced', nationality: 'AU', racesCompleted: 78, wins: 8, podiums: 24, isActive: true, rank: 6 }, + { id: 'demo-7', name: 'George Russell', rating: 3620, skillLevel: 'advanced', nationality: 'GB', racesCompleted: 98, wins: 6, podiums: 31, isActive: true, rank: 7 }, + { id: 'demo-8', name: 'Fernando Alonso', rating: 3580, skillLevel: 'advanced', nationality: 'ES', racesCompleted: 256, wins: 32, podiums: 98, isActive: true, rank: 8 }, + { id: 'demo-9', name: 'Nico Hülkenberg', rating: 3420, skillLevel: 'advanced', nationality: 'DE', racesCompleted: 167, wins: 2, podiums: 18, isActive: true, rank: 9 }, + { id: 'demo-10', name: 'Yuki Tsunoda', rating: 3250, skillLevel: 'intermediate', nationality: 'JP', racesCompleted: 89, wins: 1, podiums: 8, isActive: true, rank: 10 }, + { id: 'demo-11', name: 'Alex Albon', rating: 3180, skillLevel: 'intermediate', nationality: 'TH', racesCompleted: 102, wins: 0, podiums: 4, isActive: true, rank: 11 }, + { id: 'demo-12', name: 'Kevin Magnussen', rating: 3050, skillLevel: 'intermediate', nationality: 'DK', racesCompleted: 145, wins: 0, podiums: 2, isActive: true, rank: 12 }, + { id: 'demo-13', name: 'Pierre Gasly', rating: 2980, skillLevel: 'intermediate', nationality: 'FR', racesCompleted: 124, wins: 1, podiums: 5, isActive: true, rank: 13 }, + { id: 'demo-14', name: 'Esteban Ocon', rating: 2920, skillLevel: 'intermediate', nationality: 'FR', racesCompleted: 118, wins: 1, podiums: 4, isActive: true, rank: 14 }, + { id: 'demo-15', name: 'Lance Stroll', rating: 2850, skillLevel: 'intermediate', nationality: 'CA', racesCompleted: 134, wins: 0, podiums: 3, isActive: true, rank: 15 }, + { id: 'demo-16', name: 'Zhou Guanyu', rating: 2650, skillLevel: 'intermediate', nationality: 'CN', racesCompleted: 67, wins: 0, podiums: 0, isActive: true, rank: 16 }, + { id: 'demo-17', name: 'Daniel Ricciardo', rating: 2500, skillLevel: 'intermediate', nationality: 'AU', racesCompleted: 189, wins: 8, podiums: 32, isActive: false, rank: 17 }, + { id: 'demo-18', name: 'Valtteri Bottas', rating: 2450, skillLevel: 'intermediate', nationality: 'FI', racesCompleted: 212, wins: 10, podiums: 67, isActive: false, rank: 18 }, + { id: 'demo-19', name: 'Logan Sargeant', rating: 1850, skillLevel: 'beginner', nationality: 'US', racesCompleted: 34, wins: 0, podiums: 0, isActive: false, rank: 19 }, + { id: 'demo-20', name: 'Nyck de Vries', rating: 1750, skillLevel: 'beginner', nationality: 'NL', racesCompleted: 12, wins: 0, podiums: 0, isActive: false, rank: 20 }, +]; + +// ============================================================================ +// SKILL LEVEL CONFIG +// ============================================================================ + +const SKILL_LEVELS: { + id: SkillLevel; + label: string; + icon: React.ElementType; + color: string; + bgColor: string; + borderColor: string; + description: string; +}[] = [ + { id: 'pro', label: 'Pro', icon: Crown, color: 'text-yellow-400', bgColor: 'bg-yellow-400/10', borderColor: 'border-yellow-400/30', description: 'Elite competition level' }, + { id: 'advanced', label: 'Advanced', icon: Star, color: 'text-purple-400', bgColor: 'bg-purple-400/10', borderColor: 'border-purple-400/30', description: 'Highly competitive' }, + { id: 'intermediate', label: 'Intermediate', icon: TrendingUp, color: 'text-primary-blue', bgColor: 'bg-primary-blue/10', borderColor: 'border-primary-blue/30', description: 'Developing skills' }, + { id: 'beginner', label: 'Beginner', icon: Shield, color: 'text-green-400', bgColor: 'bg-green-400/10', borderColor: 'border-green-400/30', description: 'Learning the ropes' }, +]; + +// ============================================================================ +// FEATURED DRIVER CARD COMPONENT +// ============================================================================ + +interface FeaturedDriverCardProps { + driver: DriverListItem; + position: number; + onClick: () => void; +} + +function FeaturedDriverCard({ driver, position, onClick }: FeaturedDriverCardProps) { + const imageService = getImageService(); + const levelConfig = SKILL_LEVELS.find((l) => l.id === driver.skillLevel); + + const getBorderColor = (pos: number) => { + switch (pos) { + case 1: return 'border-yellow-400/50 hover:border-yellow-400'; + case 2: return 'border-gray-300/50 hover:border-gray-300'; + case 3: return 'border-amber-600/50 hover:border-amber-600'; + default: return 'border-charcoal-outline hover:border-primary-blue'; + } + }; + + const getMedalColor = (pos: number) => { + switch (pos) { + case 1: return 'text-yellow-400'; + case 2: return 'text-gray-300'; + case 3: return 'text-amber-600'; + default: return 'text-gray-500'; + } + }; + + return ( + + ); +} + +// ============================================================================ +// SKILL DISTRIBUTION COMPONENT +// ============================================================================ + +interface SkillDistributionProps { + drivers: DriverListItem[]; +} + +function SkillDistribution({ drivers }: SkillDistributionProps) { + const distribution = SKILL_LEVELS.map((level) => ({ + ...level, + count: drivers.filter((d) => d.skillLevel === level.id).length, + percentage: drivers.length > 0 + ? Math.round((drivers.filter((d) => d.skillLevel === level.id).length / drivers.length) * 100) + : 0, + })); + + return ( +
+
+
+ +
+
+

Skill Distribution

+

Driver population by skill level

+
+
+ +
+ {distribution.map((level) => { + const Icon = level.icon; + return ( +
+
+ + {level.count} +
+

{level.label}

+
+
+
+

{level.percentage}% of drivers

+
+ ); + })} +
+
+ ); +} + +// ============================================================================ +// LEADERBOARD PREVIEW COMPONENT +// ============================================================================ + +interface LeaderboardPreviewProps { + drivers: DriverListItem[]; + onDriverClick: (id: string) => void; +} + +function LeaderboardPreview({ drivers, onDriverClick }: LeaderboardPreviewProps) { + const router = useRouter(); + const imageService = getImageService(); + const top5 = drivers.slice(0, 5); + + const getMedalColor = (position: number) => { + switch (position) { + case 1: return 'text-yellow-400'; + case 2: return 'text-gray-300'; + case 3: return 'text-amber-600'; + default: return 'text-gray-500'; + } + }; + + const getMedalBg = (position: number) => { + switch (position) { + case 1: return 'bg-yellow-400/10 border-yellow-400/30'; + case 2: return 'bg-gray-300/10 border-gray-300/30'; + case 3: return 'bg-amber-600/10 border-amber-600/30'; + default: return 'bg-iron-gray/50 border-charcoal-outline'; + } + }; + + return ( +
+
+
+
+ +
+
+

Top Drivers

+

Highest rated competitors

+
+
+ + +
+ +
+
+ {top5.map((driver, index) => { + const levelConfig = SKILL_LEVELS.find((l) => l.id === driver.skillLevel); + const position = index + 1; + + return ( + + ); + })} +
+
+
+ ); +} + +// ============================================================================ +// RECENT ACTIVITY COMPONENT +// ============================================================================ + +interface RecentActivityProps { + drivers: DriverListItem[]; + onDriverClick: (id: string) => void; +} + +function RecentActivity({ drivers, onDriverClick }: RecentActivityProps) { + const imageService = getImageService(); + const activeDrivers = drivers.filter((d) => d.isActive).slice(0, 6); + + return ( +
+
+
+ +
+
+

Active Drivers

+

Currently competing in leagues

+
+
+ +
+ {activeDrivers.map((driver) => { + const levelConfig = SKILL_LEVELS.find((l) => l.id === driver.skillLevel); + return ( + + ); + })} +
+
+ ); +} + +// ============================================================================ +// MAIN PAGE COMPONENT +// ============================================================================ export default function DriversPage() { const router = useRouter(); const [drivers, setDrivers] = useState([]); const [loading, setLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(''); - const [selectedSkill, setSelectedSkill] = useState<'all' | SkillLevel>('all'); - const [selectedNationality, setSelectedNationality] = useState('all'); - const [activeOnly, setActiveOnly] = useState(false); - const [sortBy, setSortBy] = useState<'rank' | 'rating' | 'wins' | 'podiums'>('rank'); useEffect(() => { const load = async () => { @@ -47,26 +429,17 @@ export default function DriversPage() { const totalRaces = stats?.totalRaces ?? 0; let effectiveRank = Number.POSITIVE_INFINITY; - if (typeof stats?.overallRank === 'number' && stats.overallRank > 0) { effectiveRank = stats.overallRank; } else { - const indexInGlobal = rankings.findIndex( - (entry) => entry.driverId === driver.id, - ); + const indexInGlobal = rankings.findIndex((entry) => entry.driverId === driver.id); if (indexInGlobal !== -1) { effectiveRank = indexInGlobal + 1; } } const skillLevel: SkillLevel = - rating >= 3000 - ? 'pro' - : rating >= 2500 - ? 'advanced' - : rating >= 1800 - ? 'intermediate' - : 'beginner'; + rating >= 3000 ? 'pro' : rating >= 2500 ? 'advanced' : rating >= 1800 ? 'intermediate' : 'beginner'; const isActive = rankings.some((r) => r.driverId === driver.id); @@ -84,180 +457,183 @@ export default function DriversPage() { }; }); - setDrivers(items); + // Sort by rank + items.sort((a, b) => { + const rankA = Number.isFinite(a.rank) && a.rank > 0 ? a.rank : Number.POSITIVE_INFINITY; + const rankB = Number.isFinite(b.rank) && b.rank > 0 ? b.rank : Number.POSITIVE_INFINITY; + return rankA - rankB || b.rating - a.rating; + }); + + setDrivers(items.length > 0 ? items : DEMO_DRIVERS); setLoading(false); }; void load(); }, []); - const nationalities = Array.from( - new Set(drivers.map((d) => d.nationality).filter(Boolean)), - ).sort(); - - const filteredDrivers = drivers.filter((driver) => { - const matchesSearch = driver.name - .toLowerCase() - .includes(searchQuery.toLowerCase()); - const matchesSkill = - selectedSkill === 'all' || driver.skillLevel === selectedSkill; - const matchesNationality = - selectedNationality === 'all' || driver.nationality === selectedNationality; - const matchesActive = !activeOnly || driver.isActive; - - return matchesSearch && matchesSkill && matchesNationality && matchesActive; - }); - - const sortedDrivers = [...filteredDrivers].sort((a, b) => { - const rankA = Number.isFinite(a.rank) && a.rank > 0 ? a.rank : Number.POSITIVE_INFINITY; - const rankB = Number.isFinite(b.rank) && b.rank > 0 ? b.rank : Number.POSITIVE_INFINITY; - - switch (sortBy) { - case 'rank': - return rankA - rankB || b.rating - a.rating || a.name.localeCompare(b.name); - case 'rating': - return b.rating - a.rating; - case 'wins': - return b.wins - a.wins; - case 'podiums': - return b.podiums - a.podiums; - default: - return 0; - } - }); - const handleDriverClick = (driverId: string) => { + if (driverId.startsWith('demo-')) return; router.push(`/drivers/${driverId}`); }; + // Filter by search + const filteredDrivers = drivers.filter((driver) => { + if (!searchQuery) return true; + return ( + driver.name.toLowerCase().includes(searchQuery.toLowerCase()) || + driver.nationality.toLowerCase().includes(searchQuery.toLowerCase()) + ); + }); + + // Stats + const totalRaces = drivers.reduce((sum, d) => sum + d.racesCompleted, 0); + const totalWins = drivers.reduce((sum, d) => sum + d.wins, 0); + const activeCount = drivers.filter((d) => d.isActive).length; + + // Featured drivers (top 4) + const featuredDrivers = filteredDrivers.slice(0, 4); + if (loading) { return ( -
-
Loading drivers...
+
+
+
+
+

Loading drivers...

+
+
); } return ( -
+
+ {/* Hero Section */} +
+ {/* Background decoration */} +
+
+
+ +
+
+
+
+ +
+ + Drivers + +
+

+ Meet the racers who make every lap count. From rookies to champions, track their journey and see who's dominating the grid. +

+ + {/* Quick Stats */} +
+
+
+ + {drivers.length} drivers + +
+
+
+ + {activeCount} active + +
+
+
+ + {totalWins.toLocaleString()} total wins + +
+
+
+ + {totalRaces.toLocaleString()} races + +
+
+
+ + {/* CTA */} +
+ +

See full driver rankings

+
+
+
+ + {/* Search */}
-

Drivers

-

- Browse driver profiles and stats -

-
- - -
-
- - setSearchQuery(e.target.value)} - /> -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
-
-
- -
-

- {sortedDrivers.length} {sortedDrivers.length === 1 ? 'driver' : 'drivers'} found -

-
- -
- {sortedDrivers.map((driver) => ( - handleDriverClick(driver.id)} +
+ + setSearchQuery(e.target.value)} + className="pl-11" /> - ))} +
- {sortedDrivers.length === 0 && ( -
-

No drivers found matching your filters.

+ {/* Featured Drivers */} + {!searchQuery && ( +
+
+
+ +
+
+

Featured Drivers

+

Top performers on the grid

+
+
+ +
+ {featuredDrivers.map((driver, index) => ( + handleDriverClick(driver.id)} + /> + ))} +
)} + + {/* Active Drivers */} + {!searchQuery && } + + {/* Skill Distribution */} + {!searchQuery && } + + {/* Leaderboard Preview */} + + + {/* Empty State */} + {filteredDrivers.length === 0 && ( + +
+ +

No drivers found matching "{searchQuery}"

+ +
+
+ )}
); } \ No newline at end of file diff --git a/apps/website/app/leaderboards/drivers/page.tsx b/apps/website/app/leaderboards/drivers/page.tsx new file mode 100644 index 000000000..f0a27fa36 --- /dev/null +++ b/apps/website/app/leaderboards/drivers/page.tsx @@ -0,0 +1,545 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { + Trophy, + Medal, + Crown, + Star, + TrendingUp, + Shield, + Search, + Filter, + Flag, + ArrowLeft, + Hash, + Percent, +} from 'lucide-react'; +import Button from '@/components/ui/Button'; +import Input from '@/components/ui/Input'; +import Heading from '@/components/ui/Heading'; +import { getDriverRepository, getDriverStats, getAllDriverRankings, getImageService } from '@/lib/di-container'; +import Image from 'next/image'; + +// ============================================================================ +// TYPES +// ============================================================================ + +type SkillLevel = 'beginner' | 'intermediate' | 'advanced' | 'pro'; +type SortBy = 'rank' | 'rating' | 'wins' | 'podiums' | 'winRate'; + +interface DriverListItem { + id: string; + name: string; + rating: number; + skillLevel: SkillLevel; + nationality: string; + racesCompleted: number; + wins: number; + podiums: number; + rank: number; +} + +// ============================================================================ +// DEMO DATA +// ============================================================================ + +const DEMO_DRIVERS: DriverListItem[] = [ + { id: 'demo-1', name: 'Max Verstappen', rating: 4250, skillLevel: 'pro', nationality: 'NL', racesCompleted: 156, wins: 47, podiums: 89, rank: 1 }, + { id: 'demo-2', name: 'Lewis Hamilton', rating: 4180, skillLevel: 'pro', nationality: 'GB', racesCompleted: 198, wins: 52, podiums: 112, rank: 2 }, + { id: 'demo-3', name: 'Charles Leclerc', rating: 3950, skillLevel: 'pro', nationality: 'MC', racesCompleted: 134, wins: 28, podiums: 67, rank: 3 }, + { id: 'demo-4', name: 'Lando Norris', rating: 3820, skillLevel: 'advanced', nationality: 'GB', racesCompleted: 112, wins: 18, podiums: 45, rank: 4 }, + { id: 'demo-5', name: 'Carlos Sainz', rating: 3750, skillLevel: 'advanced', nationality: 'ES', racesCompleted: 145, wins: 15, podiums: 52, rank: 5 }, + { id: 'demo-6', name: 'Oscar Piastri', rating: 3680, skillLevel: 'advanced', nationality: 'AU', racesCompleted: 78, wins: 8, podiums: 24, rank: 6 }, + { id: 'demo-7', name: 'George Russell', rating: 3620, skillLevel: 'advanced', nationality: 'GB', racesCompleted: 98, wins: 6, podiums: 31, rank: 7 }, + { id: 'demo-8', name: 'Fernando Alonso', rating: 3580, skillLevel: 'advanced', nationality: 'ES', racesCompleted: 256, wins: 32, podiums: 98, rank: 8 }, + { id: 'demo-9', name: 'Nico Hülkenberg', rating: 3420, skillLevel: 'advanced', nationality: 'DE', racesCompleted: 167, wins: 2, podiums: 18, rank: 9 }, + { id: 'demo-10', name: 'Yuki Tsunoda', rating: 3250, skillLevel: 'intermediate', nationality: 'JP', racesCompleted: 89, wins: 1, podiums: 8, rank: 10 }, + { id: 'demo-11', name: 'Alex Albon', rating: 3180, skillLevel: 'intermediate', nationality: 'TH', racesCompleted: 102, wins: 0, podiums: 4, rank: 11 }, + { id: 'demo-12', name: 'Kevin Magnussen', rating: 3050, skillLevel: 'intermediate', nationality: 'DK', racesCompleted: 145, wins: 0, podiums: 2, rank: 12 }, + { id: 'demo-13', name: 'Pierre Gasly', rating: 2980, skillLevel: 'intermediate', nationality: 'FR', racesCompleted: 124, wins: 1, podiums: 5, rank: 13 }, + { id: 'demo-14', name: 'Esteban Ocon', rating: 2920, skillLevel: 'intermediate', nationality: 'FR', racesCompleted: 118, wins: 1, podiums: 4, rank: 14 }, + { id: 'demo-15', name: 'Lance Stroll', rating: 2850, skillLevel: 'intermediate', nationality: 'CA', racesCompleted: 134, wins: 0, podiums: 3, rank: 15 }, +]; + +// ============================================================================ +// SKILL LEVEL CONFIG +// ============================================================================ + +const SKILL_LEVELS: { + id: SkillLevel; + label: string; + icon: React.ElementType; + color: string; + bgColor: string; + borderColor: string; +}[] = [ + { id: 'pro', label: 'Pro', icon: Crown, color: 'text-yellow-400', bgColor: 'bg-yellow-400/10', borderColor: 'border-yellow-400/30' }, + { id: 'advanced', label: 'Advanced', icon: Star, color: 'text-purple-400', bgColor: 'bg-purple-400/10', borderColor: 'border-purple-400/30' }, + { id: 'intermediate', label: 'Intermediate', icon: TrendingUp, color: 'text-primary-blue', bgColor: 'bg-primary-blue/10', borderColor: 'border-primary-blue/30' }, + { id: 'beginner', label: 'Beginner', icon: Shield, color: 'text-green-400', bgColor: 'bg-green-400/10', borderColor: 'border-green-400/30' }, +]; + +// ============================================================================ +// SORT OPTIONS +// ============================================================================ + +const SORT_OPTIONS: { id: SortBy; label: string; icon: React.ElementType }[] = [ + { id: 'rank', label: 'Rank', icon: Hash }, + { id: 'rating', label: 'Rating', icon: Star }, + { id: 'wins', label: 'Wins', icon: Trophy }, + { id: 'podiums', label: 'Podiums', icon: Medal }, + { id: 'winRate', label: 'Win Rate', icon: Percent }, +]; + +// ============================================================================ +// TOP 3 PODIUM COMPONENT +// ============================================================================ + +interface TopThreePodiumProps { + drivers: DriverListItem[]; + onDriverClick: (id: string) => void; +} + +function TopThreePodium({ drivers, onDriverClick }: TopThreePodiumProps) { + const imageService = getImageService(); + const top3 = drivers.slice(0, 3); + + if (top3.length < 3) return null; + + const podiumOrder = [top3[1], top3[0], top3[2]]; // 2nd, 1st, 3rd + const podiumHeights = ['h-32', 'h-40', 'h-24']; + const podiumColors = [ + 'from-gray-400/20 to-gray-500/10 border-gray-400/40', + 'from-yellow-400/20 to-amber-500/10 border-yellow-400/40', + 'from-amber-600/20 to-amber-700/10 border-amber-600/40', + ]; + const crownColors = ['text-gray-300', 'text-yellow-400', 'text-amber-600']; + const positions = [2, 1, 3]; + + return ( +
+
+ {podiumOrder.map((driver, index) => { + const levelConfig = SKILL_LEVELS.find((l) => l.id === driver.skillLevel); + const position = positions[index]; + + return ( + + ); + })} +
+
+ ); +} + +// ============================================================================ +// MAIN PAGE COMPONENT +// ============================================================================ + +export default function DriverLeaderboardPage() { + const router = useRouter(); + const imageService = getImageService(); + const [drivers, setDrivers] = useState([]); + const [loading, setLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedSkill, setSelectedSkill] = useState<'all' | SkillLevel>('all'); + const [sortBy, setSortBy] = useState('rank'); + const [showFilters, setShowFilters] = useState(false); + + useEffect(() => { + const load = async () => { + const driverRepo = getDriverRepository(); + const allDrivers = await driverRepo.findAll(); + const rankings = getAllDriverRankings(); + + const items: DriverListItem[] = allDrivers.map((driver) => { + const stats = getDriverStats(driver.id); + const rating = stats?.rating ?? 0; + const wins = stats?.wins ?? 0; + const podiums = stats?.podiums ?? 0; + const totalRaces = stats?.totalRaces ?? 0; + + let effectiveRank = Number.POSITIVE_INFINITY; + if (typeof stats?.overallRank === 'number' && stats.overallRank > 0) { + effectiveRank = stats.overallRank; + } else { + const indexInGlobal = rankings.findIndex((entry) => entry.driverId === driver.id); + if (indexInGlobal !== -1) { + effectiveRank = indexInGlobal + 1; + } + } + + const skillLevel: SkillLevel = + rating >= 3000 ? 'pro' : rating >= 2500 ? 'advanced' : rating >= 1800 ? 'intermediate' : 'beginner'; + + return { + id: driver.id, + name: driver.name, + rating, + skillLevel, + nationality: driver.country, + racesCompleted: totalRaces, + wins, + podiums, + rank: effectiveRank, + }; + }); + + setDrivers(items.length > 0 ? items : DEMO_DRIVERS); + setLoading(false); + }; + + void load(); + }, []); + + const filteredDrivers = drivers.filter((driver) => { + const matchesSearch = driver.name.toLowerCase().includes(searchQuery.toLowerCase()) || + driver.nationality.toLowerCase().includes(searchQuery.toLowerCase()); + const matchesSkill = selectedSkill === 'all' || driver.skillLevel === selectedSkill; + return matchesSearch && matchesSkill; + }); + + const sortedDrivers = [...filteredDrivers].sort((a, b) => { + const rankA = Number.isFinite(a.rank) && a.rank > 0 ? a.rank : Number.POSITIVE_INFINITY; + const rankB = Number.isFinite(b.rank) && b.rank > 0 ? b.rank : Number.POSITIVE_INFINITY; + + switch (sortBy) { + case 'rank': + return rankA - rankB || b.rating - a.rating || a.name.localeCompare(b.name); + case 'rating': + return b.rating - a.rating; + case 'wins': + return b.wins - a.wins; + case 'podiums': + return b.podiums - a.podiums; + case 'winRate': { + const aRate = a.racesCompleted > 0 ? a.wins / a.racesCompleted : 0; + const bRate = b.racesCompleted > 0 ? b.wins / b.racesCompleted : 0; + return bRate - aRate; + } + default: + return 0; + } + }); + + const handleDriverClick = (driverId: string) => { + if (driverId.startsWith('demo-')) return; + router.push(`/drivers/${driverId}`); + }; + + const getMedalColor = (position: number) => { + switch (position) { + case 1: return 'text-yellow-400'; + case 2: return 'text-gray-300'; + case 3: return 'text-amber-600'; + default: return 'text-gray-500'; + } + }; + + const getMedalBg = (position: number) => { + switch (position) { + case 1: return 'bg-gradient-to-br from-yellow-400/20 to-yellow-600/10 border-yellow-400/40'; + case 2: return 'bg-gradient-to-br from-gray-300/20 to-gray-400/10 border-gray-300/40'; + case 3: return 'bg-gradient-to-br from-amber-600/20 to-amber-700/10 border-amber-600/40'; + default: return 'bg-iron-gray/50 border-charcoal-outline'; + } + }; + + if (loading) { + return ( +
+
+
+
+

Loading driver rankings...

+
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+ + +
+
+ +
+
+ + Driver Leaderboard + +

Full rankings of all drivers by performance metrics

+
+
+
+ + {/* Top 3 Podium */} + {!searchQuery && sortBy === 'rank' && } + + {/* Filters */} +
+
+
+ + setSearchQuery(e.target.value)} + className="pl-11" + /> +
+ +
+ +
+ + {SKILL_LEVELS.map((level) => { + const LevelIcon = level.icon; + return ( + + ); + })} +
+ +
+ Sort by: +
+ {SORT_OPTIONS.map((option) => ( + + ))} +
+
+
+ + {/* Leaderboard Table */} +
+ {/* Table Header */} +
+
Rank
+
Driver
+
Races
+
Rating
+
Wins
+
Podiums
+
Win Rate
+
+ + {/* Table Body */} +
+ {sortedDrivers.map((driver, index) => { + const levelConfig = SKILL_LEVELS.find((l) => l.id === driver.skillLevel); + const LevelIcon = levelConfig?.icon || Shield; + const winRate = driver.racesCompleted > 0 ? ((driver.wins / driver.racesCompleted) * 100).toFixed(1) : '0.0'; + const position = index + 1; + + return ( + + ); + })} +
+ + {/* Empty State */} + {sortedDrivers.length === 0 && ( +
+ +

No drivers found

+

Try adjusting your filters or search query

+ +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/apps/website/app/leaderboards/page.tsx b/apps/website/app/leaderboards/page.tsx new file mode 100644 index 000000000..9c23f1b9a --- /dev/null +++ b/apps/website/app/leaderboards/page.tsx @@ -0,0 +1,515 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { + Trophy, + Users, + Crown, + Star, + TrendingUp, + Shield, + Medal, + ChevronRight, + Award, + Flag, + Target, + Zap, + Hash, + Percent, +} from 'lucide-react'; +import Button from '@/components/ui/Button'; +import Heading from '@/components/ui/Heading'; +import { getDriverRepository, getDriverStats, getAllDriverRankings, getImageService, getGetAllTeamsQuery, getGetTeamMembersQuery } from '@/lib/di-container'; +import Image from 'next/image'; +import type { Team } from '@gridpilot/racing'; + +// ============================================================================ +// TYPES +// ============================================================================ + +type SkillLevel = 'beginner' | 'intermediate' | 'advanced' | 'pro'; + +interface DriverListItem { + id: string; + name: string; + rating: number; + skillLevel: SkillLevel; + nationality: string; + wins: number; + podiums: number; + rank: number; +} + +interface TeamDisplayData { + id: string; + name: string; + memberCount: number; + rating: number | null; + totalWins: number; + totalRaces: number; + performanceLevel: SkillLevel; +} + +// ============================================================================ +// DEMO DATA +// ============================================================================ + +const DEMO_DRIVERS: DriverListItem[] = [ + { id: 'demo-1', name: 'Max Verstappen', rating: 4250, skillLevel: 'pro', nationality: 'NL', wins: 47, podiums: 89, rank: 1 }, + { id: 'demo-2', name: 'Lewis Hamilton', rating: 4180, skillLevel: 'pro', nationality: 'GB', wins: 52, podiums: 112, rank: 2 }, + { id: 'demo-3', name: 'Charles Leclerc', rating: 3950, skillLevel: 'pro', nationality: 'MC', wins: 28, podiums: 67, rank: 3 }, + { id: 'demo-4', name: 'Lando Norris', rating: 3820, skillLevel: 'advanced', nationality: 'GB', wins: 18, podiums: 45, rank: 4 }, + { id: 'demo-5', name: 'Carlos Sainz', rating: 3750, skillLevel: 'advanced', nationality: 'ES', wins: 15, podiums: 52, rank: 5 }, + { id: 'demo-6', name: 'Oscar Piastri', rating: 3680, skillLevel: 'advanced', nationality: 'AU', wins: 8, podiums: 24, rank: 6 }, + { id: 'demo-7', name: 'George Russell', rating: 3620, skillLevel: 'advanced', nationality: 'GB', wins: 6, podiums: 31, rank: 7 }, + { id: 'demo-8', name: 'Fernando Alonso', rating: 3580, skillLevel: 'advanced', nationality: 'ES', wins: 32, podiums: 98, rank: 8 }, + { id: 'demo-9', name: 'Nico Hülkenberg', rating: 3420, skillLevel: 'advanced', nationality: 'DE', wins: 2, podiums: 18, rank: 9 }, + { id: 'demo-10', name: 'Yuki Tsunoda', rating: 3250, skillLevel: 'intermediate', nationality: 'JP', wins: 1, podiums: 8, rank: 10 }, +]; + +const DEMO_TEAMS: TeamDisplayData[] = [ + { id: 'demo-team-1', name: 'Apex Predators Racing', memberCount: 8, rating: 4850, totalWins: 47, totalRaces: 156, performanceLevel: 'pro' }, + { id: 'demo-team-2', name: 'Velocity Esports', memberCount: 12, rating: 5200, totalWins: 63, totalRaces: 198, performanceLevel: 'pro' }, + { id: 'demo-team-3', name: 'Nitro Motorsport', memberCount: 6, rating: 4720, totalWins: 38, totalRaces: 112, performanceLevel: 'pro' }, + { id: 'demo-team-4', name: 'Horizon Racing Collective', memberCount: 10, rating: 3800, totalWins: 24, totalRaces: 89, performanceLevel: 'advanced' }, + { id: 'demo-team-5', name: 'Phoenix Rising eSports', memberCount: 7, rating: 3650, totalWins: 19, totalRaces: 76, performanceLevel: 'advanced' }, +]; + +// ============================================================================ +// SKILL LEVEL CONFIG +// ============================================================================ + +const SKILL_LEVELS: { + id: SkillLevel; + label: string; + icon: React.ElementType; + color: string; + bgColor: string; + borderColor: string; +}[] = [ + { id: 'pro', label: 'Pro', icon: Crown, color: 'text-yellow-400', bgColor: 'bg-yellow-400/10', borderColor: 'border-yellow-400/30' }, + { id: 'advanced', label: 'Advanced', icon: Star, color: 'text-purple-400', bgColor: 'bg-purple-400/10', borderColor: 'border-purple-400/30' }, + { id: 'intermediate', label: 'Intermediate', icon: TrendingUp, color: 'text-primary-blue', bgColor: 'bg-primary-blue/10', borderColor: 'border-primary-blue/30' }, + { id: 'beginner', label: 'Beginner', icon: Shield, color: 'text-green-400', bgColor: 'bg-green-400/10', borderColor: 'border-green-400/30' }, +]; + +// ============================================================================ +// DRIVER LEADERBOARD PREVIEW +// ============================================================================ + +interface DriverLeaderboardPreviewProps { + drivers: DriverListItem[]; + onDriverClick: (id: string) => void; +} + +function DriverLeaderboardPreview({ drivers, onDriverClick }: DriverLeaderboardPreviewProps) { + const router = useRouter(); + const imageService = getImageService(); + const top10 = drivers.slice(0, 10); + + const getMedalColor = (position: number) => { + switch (position) { + case 1: return 'text-yellow-400'; + case 2: return 'text-gray-300'; + case 3: return 'text-amber-600'; + default: return 'text-gray-500'; + } + }; + + const getMedalBg = (position: number) => { + switch (position) { + case 1: return 'bg-yellow-400/10 border-yellow-400/30'; + case 2: return 'bg-gray-300/10 border-gray-300/30'; + case 3: return 'bg-amber-600/10 border-amber-600/30'; + default: return 'bg-iron-gray/50 border-charcoal-outline'; + } + }; + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

Driver Rankings

+

Top performers across all leagues

+
+
+ +
+ + {/* Leaderboard Rows */} +
+ {top10.map((driver, index) => { + const levelConfig = SKILL_LEVELS.find((l) => l.id === driver.skillLevel); + const position = index + 1; + + return ( + + ); + })} +
+
+ ); +} + +// ============================================================================ +// TEAM LEADERBOARD PREVIEW +// ============================================================================ + +interface TeamLeaderboardPreviewProps { + teams: TeamDisplayData[]; + onTeamClick: (id: string) => void; +} + +function TeamLeaderboardPreview({ teams, onTeamClick }: TeamLeaderboardPreviewProps) { + const router = useRouter(); + const imageService = getImageService(); + const top5 = [...teams] + .filter((t) => t.rating !== null) + .sort((a, b) => (b.rating ?? 0) - (a.rating ?? 0)) + .slice(0, 5); + + const getMedalColor = (position: number) => { + switch (position) { + case 1: return 'text-yellow-400'; + case 2: return 'text-gray-300'; + case 3: return 'text-amber-600'; + default: return 'text-gray-500'; + } + }; + + const getMedalBg = (position: number) => { + switch (position) { + case 1: return 'bg-yellow-400/10 border-yellow-400/30'; + case 2: return 'bg-gray-300/10 border-gray-300/30'; + case 3: return 'bg-amber-600/10 border-amber-600/30'; + default: return 'bg-iron-gray/50 border-charcoal-outline'; + } + }; + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

Team Rankings

+

Top performing racing teams

+
+
+ +
+ + {/* Leaderboard Rows */} +
+ {top5.map((team, index) => { + const levelConfig = SKILL_LEVELS.find((l) => l.id === team.performanceLevel); + const LevelIcon = levelConfig?.icon || Shield; + const position = index + 1; + + return ( + + ); + })} +
+
+ ); +} + +// ============================================================================ +// MAIN PAGE COMPONENT +// ============================================================================ + +export default function LeaderboardsPage() { + const router = useRouter(); + const [drivers, setDrivers] = useState([]); + const [teams, setTeams] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const load = async () => { + try { + // Load drivers + const driverRepo = getDriverRepository(); + const allDrivers = await driverRepo.findAll(); + const rankings = getAllDriverRankings(); + + const driverItems: DriverListItem[] = allDrivers.map((driver) => { + const stats = getDriverStats(driver.id); + const rating = stats?.rating ?? 0; + const wins = stats?.wins ?? 0; + const podiums = stats?.podiums ?? 0; + + let effectiveRank = Number.POSITIVE_INFINITY; + if (typeof stats?.overallRank === 'number' && stats.overallRank > 0) { + effectiveRank = stats.overallRank; + } else { + const indexInGlobal = rankings.findIndex((entry) => entry.driverId === driver.id); + if (indexInGlobal !== -1) { + effectiveRank = indexInGlobal + 1; + } + } + + const skillLevel: SkillLevel = + rating >= 3000 ? 'pro' : rating >= 2500 ? 'advanced' : rating >= 1800 ? 'intermediate' : 'beginner'; + + return { + id: driver.id, + name: driver.name, + rating, + skillLevel, + nationality: driver.country, + wins, + podiums, + rank: effectiveRank, + }; + }); + + // Sort by rank + driverItems.sort((a, b) => { + const rankA = Number.isFinite(a.rank) && a.rank > 0 ? a.rank : Number.POSITIVE_INFINITY; + const rankB = Number.isFinite(b.rank) && b.rank > 0 ? b.rank : Number.POSITIVE_INFINITY; + return rankA - rankB || b.rating - a.rating; + }); + + // Load teams + const allTeamsQuery = getGetAllTeamsQuery(); + const teamMembersQuery = getGetTeamMembersQuery(); + const allTeams = await allTeamsQuery.execute(); + const teamData: TeamDisplayData[] = []; + + await Promise.all( + allTeams.map(async (team: Team) => { + const memberships = await teamMembersQuery.execute({ teamId: team.id }); + const memberCount = memberships.length; + + let ratingSum = 0; + let ratingCount = 0; + let totalWins = 0; + let totalRaces = 0; + + for (const membership of memberships) { + const stats = getDriverStats(membership.driverId); + if (!stats) continue; + if (typeof stats.rating === 'number') { + ratingSum += stats.rating; + ratingCount += 1; + } + totalWins += stats.wins ?? 0; + totalRaces += stats.totalRaces ?? 0; + } + + const averageRating = ratingCount > 0 ? ratingSum / ratingCount : null; + + let performanceLevel: SkillLevel = 'beginner'; + if (averageRating !== null) { + if (averageRating >= 4500) performanceLevel = 'pro'; + else if (averageRating >= 3000) performanceLevel = 'advanced'; + else if (averageRating >= 2000) performanceLevel = 'intermediate'; + } + + teamData.push({ + id: team.id, + name: team.name, + memberCount, + rating: averageRating, + totalWins, + totalRaces, + performanceLevel, + }); + }), + ); + + setDrivers(driverItems.length > 0 ? driverItems : DEMO_DRIVERS); + setTeams(teamData.length > 0 ? teamData : DEMO_TEAMS); + } catch (error) { + console.error('Failed to load leaderboard data:', error); + setDrivers(DEMO_DRIVERS); + setTeams(DEMO_TEAMS); + } finally { + setLoading(false); + } + }; + + void load(); + }, []); + + const handleDriverClick = (driverId: string) => { + if (driverId.startsWith('demo-')) return; + router.push(`/drivers/${driverId}`); + }; + + const handleTeamClick = (teamId: string) => { + if (teamId.startsWith('demo-team-')) return; + router.push(`/teams/${teamId}`); + }; + + if (loading) { + return ( +
+
+
+
+

Loading leaderboards...

+
+
+
+ ); + } + + return ( +
+ {/* Hero Section */} +
+ {/* Background decoration */} +
+
+
+ +
+
+
+ +
+
+ + Leaderboards + +

Where champions rise and legends are made

+
+
+ +

+ Track the best drivers and teams across all competitions. Every race counts. Every position matters. Who will claim the throne? +

+ + {/* Quick Nav */} +
+ + +
+
+
+ + {/* Leaderboard Grids */} +
+ + +
+
+ ); +} \ No newline at end of file diff --git a/apps/website/app/onboarding/page.tsx b/apps/website/app/onboarding/page.tsx new file mode 100644 index 000000000..8d7246ac7 --- /dev/null +++ b/apps/website/app/onboarding/page.tsx @@ -0,0 +1,32 @@ +import { redirect } from 'next/navigation'; +import { getAuthService } from '@/lib/auth'; +import { getDriverRepository } from '@/lib/di-container'; +import OnboardingWizard from '@/components/onboarding/OnboardingWizard'; + +export const dynamic = 'force-dynamic'; + +export default async function OnboardingPage() { + const authService = getAuthService(); + const session = await authService.getCurrentSession(); + + if (!session) { + redirect('/auth/iracing?returnTo=/onboarding'); + } + + // Check if user already has a driver profile + const driverRepository = getDriverRepository(); + const primaryDriverId = session.user.primaryDriverId ?? ''; + + if (primaryDriverId) { + const existingDriver = await driverRepository.findById(primaryDriverId); + if (existingDriver) { + redirect('/dashboard'); + } + } + + return ( +
+ +
+ ); +} \ No newline at end of file diff --git a/apps/website/app/profile/page.tsx b/apps/website/app/profile/page.tsx index ef859d18b..8d77062fb 100644 --- a/apps/website/app/profile/page.tsx +++ b/apps/website/app/profile/page.tsx @@ -1,75 +1,472 @@ 'use client'; import { useState, useEffect } from 'react'; -import { getDriverRepository } from '@/lib/di-container'; -import { Driver, EntityMappers, type DriverDTO } from '@gridpilot/racing'; +import Image from 'next/image'; +import Link from 'next/link'; +import { + User, + Trophy, + Star, + Calendar, + Users, + Flag, + Award, + TrendingUp, + Settings, + UserPlus, + ExternalLink, + Target, + Zap, + Clock, + Medal, + Crown, + ChevronRight, + Edit3, + Globe, + Twitter, + Youtube, + Twitch, + MessageCircle, + BarChart3, + History, + Shield, + Percent, + Activity, +} from 'lucide-react'; +import { + getDriverRepository, + getDriverStats, + getAllDriverRankings, + getGetDriverTeamQuery, + getSocialRepository, + getImageService, + getGetAllTeamsQuery, + getGetTeamMembersQuery, +} from '@/lib/di-container'; +import { Driver, EntityMappers, type DriverDTO, type Team } from '@gridpilot/racing'; import CreateDriverForm from '@/components/drivers/CreateDriverForm'; import Card from '@/components/ui/Card'; -import DriverProfile from '@/components/drivers/DriverProfile'; +import Button from '@/components/ui/Button'; +import Heading from '@/components/ui/Heading'; import ProfileRaceHistory from '@/components/drivers/ProfileRaceHistory'; import ProfileSettings from '@/components/drivers/ProfileSettings'; import { useEffectiveDriverId } from '@/lib/currentDriver'; +import type { GetDriverTeamQueryResultDTO } from '@gridpilot/racing/application/dto/TeamCommandAndQueryDTO'; + +// ============================================================================ +// TYPES +// ============================================================================ + +type ProfileTab = 'overview' | 'history' | 'stats'; + +interface SocialHandle { + platform: 'twitter' | 'youtube' | 'twitch' | 'discord'; + handle: string; + url: string; +} + +interface Achievement { + id: string; + title: string; + description: string; + icon: 'trophy' | 'medal' | 'star' | 'crown' | 'target' | 'zap'; + rarity: 'common' | 'rare' | 'epic' | 'legendary'; + earnedAt: Date; +} + +interface DriverExtendedProfile { + socialHandles: SocialHandle[]; + achievements: Achievement[]; + racingStyle: string; + favoriteTrack: string; + favoriteCar: string; + timezone: string; + availableHours: string; + lookingForTeam: boolean; + openToRequests: boolean; +} + +interface TeamMembershipInfo { + team: Team; + role: string; + joinedAt: Date; +} + +// ============================================================================ +// DEMO DATA (Extended profile info not in domain yet) +// ============================================================================ + +function getDemoExtendedProfile(driverId: string): DriverExtendedProfile { + // Demo social handles based on driver id hash + const hash = driverId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); + + const socialOptions: SocialHandle[][] = [ + [ + { platform: 'twitter', handle: '@speedracer', url: 'https://twitter.com/speedracer' }, + { platform: 'youtube', handle: 'SpeedRacer Racing', url: 'https://youtube.com/@speedracer' }, + { platform: 'twitch', handle: 'speedracer_live', url: 'https://twitch.tv/speedracer_live' }, + ], + [ + { platform: 'twitter', handle: '@racingpro', url: 'https://twitter.com/racingpro' }, + { platform: 'discord', handle: 'RacingPro#1234', url: '#' }, + ], + [ + { platform: 'twitch', handle: 'simracer_elite', url: 'https://twitch.tv/simracer_elite' }, + { platform: 'youtube', handle: 'SimRacer Elite', url: 'https://youtube.com/@simracerelite' }, + ], + ]; + + const achievementSets: Achievement[][] = [ + [ + { id: '1', title: 'First Victory', description: 'Win your first race', icon: 'trophy', rarity: 'common', earnedAt: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000) }, + { id: '2', title: 'Clean Racer', description: '10 races without incidents', icon: 'star', rarity: 'rare', earnedAt: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000) }, + { id: '3', title: 'Podium Streak', description: '5 consecutive podium finishes', icon: 'medal', rarity: 'epic', earnedAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) }, + { id: '4', title: 'Championship Glory', description: 'Win a league championship', icon: 'crown', rarity: 'legendary', earnedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) }, + ], + [ + { id: '1', title: 'Rookie No More', description: 'Complete 25 races', icon: 'target', rarity: 'common', earnedAt: new Date(Date.now() - 120 * 24 * 60 * 60 * 1000) }, + { id: '2', title: 'Consistent Performer', description: 'Maintain 80%+ consistency rating', icon: 'zap', rarity: 'rare', earnedAt: new Date(Date.now() - 45 * 24 * 60 * 60 * 1000) }, + { id: '3', title: 'Endurance Master', description: 'Complete a 24-hour race', icon: 'star', rarity: 'epic', earnedAt: new Date(Date.now() - 15 * 24 * 60 * 60 * 1000) }, + ], + [ + { id: '1', title: 'Welcome Racer', description: 'Join GridPilot', icon: 'star', rarity: 'common', earnedAt: new Date(Date.now() - 180 * 24 * 60 * 60 * 1000) }, + { id: '2', title: 'Team Player', description: 'Join a racing team', icon: 'medal', rarity: 'rare', earnedAt: new Date(Date.now() - 80 * 24 * 60 * 60 * 1000) }, + ], + ]; + + const tracks = ['Spa-Francorchamps', 'Nürburgring Nordschleife', 'Suzuka', 'Monza', 'Interlagos', 'Silverstone']; + const cars = ['Porsche 911 GT3 R', 'Ferrari 488 GT3', 'Mercedes-AMG GT3', 'BMW M4 GT3', 'Audi R8 LMS']; + const styles = ['Aggressive Overtaker', 'Consistent Pacer', 'Strategic Calculator', 'Late Braker', 'Smooth Operator']; + const timezones = ['EST (UTC-5)', 'CET (UTC+1)', 'PST (UTC-8)', 'GMT (UTC+0)', 'JST (UTC+9)']; + const hours = ['Evenings (18:00-23:00)', 'Weekends only', 'Late nights (22:00-02:00)', 'Flexible schedule']; + + return { + socialHandles: socialOptions[hash % socialOptions.length], + achievements: achievementSets[hash % achievementSets.length], + racingStyle: styles[hash % styles.length], + favoriteTrack: tracks[hash % tracks.length], + favoriteCar: cars[hash % cars.length], + timezone: timezones[hash % timezones.length], + availableHours: hours[hash % hours.length], + lookingForTeam: hash % 3 === 0, + openToRequests: hash % 2 === 0, + }; +} + +// ============================================================================ +// HELPER COMPONENTS +// ============================================================================ + +function 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 '🏁'; +} + +function getRarityColor(rarity: Achievement['rarity']) { + switch (rarity) { + case 'common': + return 'text-gray-400 bg-gray-400/10 border-gray-400/30'; + case 'rare': + return 'text-primary-blue bg-primary-blue/10 border-primary-blue/30'; + case 'epic': + return 'text-purple-400 bg-purple-400/10 border-purple-400/30'; + case 'legendary': + return 'text-yellow-400 bg-yellow-400/10 border-yellow-400/30'; + } +} + +function getAchievementIcon(icon: Achievement['icon']) { + switch (icon) { + case 'trophy': + return Trophy; + case 'medal': + return Medal; + case 'star': + return Star; + case 'crown': + return Crown; + case 'target': + return Target; + case 'zap': + return Zap; + } +} + +function getSocialIcon(platform: SocialHandle['platform']) { + switch (platform) { + case 'twitter': + return Twitter; + case 'youtube': + return Youtube; + case 'twitch': + return Twitch; + case 'discord': + return MessageCircle; + } +} + +function getSocialColor(platform: SocialHandle['platform']) { + switch (platform) { + case 'twitter': + return 'hover:text-sky-400 hover:bg-sky-400/10'; + case 'youtube': + return 'hover:text-red-500 hover:bg-red-500/10'; + case 'twitch': + return 'hover:text-purple-400 hover:bg-purple-400/10'; + case 'discord': + return 'hover:text-indigo-400 hover:bg-indigo-400/10'; + } +} + +// ============================================================================ +// STAT DIAGRAM COMPONENTS +// ============================================================================ + +interface CircularProgressProps { + value: number; + max: number; + label: string; + color: string; + size?: number; +} + +function CircularProgress({ value, max, label, color, size = 80 }: CircularProgressProps) { + const percentage = Math.min((value / max) * 100, 100); + const strokeWidth = 6; + const radius = (size - strokeWidth) / 2; + const circumference = radius * 2 * Math.PI; + const strokeDashoffset = circumference - (percentage / 100) * circumference; + + return ( +
+
+ + + + +
+ {percentage.toFixed(0)}% +
+
+ {label} +
+ ); +} + +interface BarChartProps { + data: { label: string; value: number; color: string }[]; + maxValue: number; +} + +function HorizontalBarChart({ data, maxValue }: BarChartProps) { + return ( +
+ {data.map((item) => ( +
+
+ {item.label} + {item.value} +
+
+
+
+
+ ))} +
+ ); +} + +interface FinishDistributionProps { + wins: number; + podiums: number; + topTen: number; + total: number; +} + +function FinishDistributionChart({ wins, podiums, topTen, total }: FinishDistributionProps) { + const outsideTopTen = total - topTen; + const podiumsNotWins = podiums - wins; + const topTenNotPodium = topTen - podiums; + + const segments = [ + { label: 'Wins', value: wins, color: 'bg-performance-green', textColor: 'text-performance-green' }, + { label: 'Podiums', value: podiumsNotWins, color: 'bg-warning-amber', textColor: 'text-warning-amber' }, + { label: 'Top 10', value: topTenNotPodium, color: 'bg-primary-blue', textColor: 'text-primary-blue' }, + { label: 'Other', value: outsideTopTen, color: 'bg-gray-600', textColor: 'text-gray-400' }, + ].filter(s => s.value > 0); + + return ( +
+
+ {segments.map((segment, index) => ( +
+ ))} +
+
+ {segments.map((segment) => ( +
+
+ + {segment.label}: {segment.value} ({((segment.value / total) * 100).toFixed(0)}%) + +
+ ))} +
+
+ ); +} + +// ============================================================================ +// MAIN PAGE COMPONENT +// ============================================================================ export default function ProfilePage() { const [driver, setDriver] = useState(null); const [loading, setLoading] = useState(true); - + const [editMode, setEditMode] = useState(false); + const [activeTab, setActiveTab] = useState('overview'); + const [teamData, setTeamData] = useState(null); + const [allTeamMemberships, setAllTeamMemberships] = useState([]); + const [friends, setFriends] = useState([]); + const [friendRequestSent, setFriendRequestSent] = useState(false); + const effectiveDriverId = useEffectiveDriverId(); + const isOwnProfile = true; // This page is always your own profile useEffect(() => { - const loadDriver = async () => { - const driverRepo = getDriverRepository(); - const currentDriverId = effectiveDriverId; - const currentDriver = await driverRepo.findById(currentDriverId); - const driverData = EntityMappers.toDriverDTO(currentDriver); - setDriver(driverData); - setLoading(false); + const loadData = async () => { + try { + const driverRepo = getDriverRepository(); + const currentDriverId = effectiveDriverId; + const currentDriver = await driverRepo.findById(currentDriverId); + + if (currentDriver) { + const driverData = EntityMappers.toDriverDTO(currentDriver); + setDriver(driverData); + + // Load primary team data + const teamQuery = getGetDriverTeamQuery(); + const teamResult = await teamQuery.execute({ driverId: currentDriverId }); + setTeamData(teamResult); + + // Load ALL team memberships + const allTeamsQuery = getGetAllTeamsQuery(); + const allTeams = await allTeamsQuery.execute(); + const membershipsQuery = getGetTeamMembersQuery(); + + const memberships: TeamMembershipInfo[] = []; + for (const team of allTeams) { + const members = await membershipsQuery.execute({ teamId: team.id }); + const membership = members.find((m) => m.driverId === currentDriverId); + if (membership) { + memberships.push({ + team, + role: membership.role, + joinedAt: membership.joinedAt, + }); + } + } + setAllTeamMemberships(memberships); + + // Load friends + const socialRepo = getSocialRepository(); + const friendsList = await socialRepo.getFriends(currentDriverId); + setFriends(friendsList); + } + } catch (error) { + console.error('Failed to load profile:', error); + } finally { + setLoading(false); + } }; - void loadDriver(); + void loadData(); }, [effectiveDriverId]); const handleSaveSettings = async (updates: Partial) => { if (!driver) return; - + const driverRepo = getDriverRepository(); - const drivers = await driverRepo.findAll(); - const currentDriver = drivers[0]; - + const currentDriver = await driverRepo.findById(driver.id); + if (currentDriver) { const updatedDriver: Driver = currentDriver.update({ bio: updates.bio ?? currentDriver.bio, country: updates.country ?? currentDriver.country, }); const persistedDriver = await driverRepo.update(updatedDriver); - const updatedDto = EntityMappers.toDriverDTO(persistedDriver); setDriver(updatedDto); + setEditMode(false); } }; + const handleAddFriend = () => { + setFriendRequestSent(true); + // In production, this would call a use case + }; + if (loading) { return ( -
-
Loading profile...
+
+
+
+
+

Loading profile...

+
+
); } if (!driver) { return ( -
+
-

Driver Profile

+
+ +
+ Create Your Driver Profile

- Create your GridPilot profile to get started + Join the GridPilot community and start your racing journey

-

Create Your Profile

+

Get Started

- Create your driver profile. Alpha data resets on reload, so test freely. + Create your driver profile to join leagues, compete in races, and connect with other drivers.

@@ -78,15 +475,666 @@ export default function ProfilePage() { ); } - return ( -
- - + // Get extended profile data + const extendedProfile = getDemoExtendedProfile(driver.id); + const stats = getDriverStats(driver.id); + const allRankings = getAllDriverRankings(); + const globalRank = stats?.overallRank ?? allRankings.findIndex(r => r.driverId === driver.id) + 1; + + // Show edit mode + if (editMode) { + return ( +
+
+ Edit Profile + +
- - - - +
+ ); + } + + return ( +
+ {/* Hero Header Section */} +
+ {/* Background Pattern */} +
+
+
+ +
+
+ {/* Avatar */} +
+
+
+ {driver.name} +
+
+ {/* Online status indicator */} +
+
+ + {/* Driver Info */} +
+
+

{driver.name}

+ + {getCountryFlag(driver.country)} + + {teamData?.team.tag && ( + + [{teamData.team.tag}] + + )} +
+ + {/* Rating and Rank */} +
+ {stats && ( + <> +
+ + {stats.rating} + Rating +
+
+ + #{globalRank} + Global +
+ + )} + {teamData && ( + + + {teamData.team.name} + + + )} +
+ + {/* Meta info */} +
+ + + iRacing: {driver.iracingId} + + + + Joined {new Date(driver.joinedAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })} + + + + {extendedProfile.timezone} + +
+
+ + {/* Action Buttons */} +
+ {isOwnProfile ? ( + + ) : ( + <> + + + )} + + + +
+
+ + {/* Social Handles */} + {extendedProfile.socialHandles.length > 0 && ( +
+
+ Connect: + {extendedProfile.socialHandles.map((social) => { + const Icon = getSocialIcon(social.platform); + return ( + + + {social.handle} + + + ); + })} +
+
+ )} +
+
+ + {/* Bio Section */} + {driver.bio && ( + +

+ + About +

+

{driver.bio}

+
+ )} + + {/* Team Memberships */} + {allTeamMemberships.length > 0 && ( + +

+ + Team Memberships + ({allTeamMemberships.length}) +

+
+ {allTeamMemberships.map((membership) => ( + +
+ +
+
+

+ {membership.team.name} +

+
+ + {membership.role} + + + Since {membership.joinedAt.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })} + +
+
+ + + ))} +
+
+ )} + + {/* Key Stats Overview with Diagrams */} + {stats && ( + +

+ + Performance Overview +

+
+ {/* Circular Progress Charts */} +
+
+ + +
+
+ + +
+
+ + {/* Finish Distribution */} +
+

+ + Finish Distribution +

+ + +
+
+
+ + Best Finish +
+

P{stats.bestFinish}

+
+
+
+ + Avg Finish +
+

P{stats.avgFinish.toFixed(1)}

+
+
+
+
+
+ )} + + {/* Tab Navigation */} +
+ + + +
+ + {/* Tab Content */} + {activeTab === 'overview' && ( + <> + {/* Racing Profile & Quick Stats */} +
+ {/* Career Stats Summary */} + +

+ + Career Statistics +

+ {stats ? ( +
+
+
{stats.totalRaces}
+
Races
+
+
+
{stats.wins}
+
Wins
+
+
+
{stats.podiums}
+
Podiums
+
+
+
{stats.consistency}%
+
Consistency
+
+
+ ) : ( +

+ No race statistics available yet. Join a league and compete to start building your record! +

+ )} +
+ + {/* Racing Preferences */} + + {/* Background accent */} +
+ +

+
+ +
+ Racing Profile +

+ +
+ {/* Racing Style - Featured */} +
+
+ +
+ Racing Style +

{extendedProfile.racingStyle}

+
+
+
+ + {/* Track & Car Grid */} +
+
+
+ + Track +
+

{extendedProfile.favoriteTrack}

+
+
+
+ + Car +
+

{extendedProfile.favoriteCar}

+
+
+ + {/* Availability */} +
+ +
+ Available +

{extendedProfile.availableHours}

+
+
+ + {/* Status badges */} +
+ {extendedProfile.lookingForTeam && ( +
+
+ +
+
+ Looking for Team + Open to recruitment offers +
+
+ )} + {extendedProfile.openToRequests && ( +
+
+ +
+
+ Open to Requests + Accepting friend invites +
+
+ )} +
+
+ +
+ + {/* Achievements */} + +

+ + Achievements + {extendedProfile.achievements.length} earned +

+
+ {extendedProfile.achievements.map((achievement) => { + const Icon = getAchievementIcon(achievement.icon); + const rarityClasses = getRarityColor(achievement.rarity); + return ( +
+
+
+ +
+
+

{achievement.title}

+

{achievement.description}

+

+ {achievement.earnedAt.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} +

+
+
+
+ ); + })} +
+
+ + {/* Friends Preview */} + {friends.length > 0 && ( + +
+

+ + Friends + ({friends.length}) +

+
+
+ {friends.slice(0, 8).map((friend) => ( + +
+ {friend.name} +
+ {friend.name} + {getCountryFlag(friend.country)} + + ))} + {friends.length > 8 && ( +
+ +{friends.length - 8} more +
+ )} +
+
+ )} + + )} + + {activeTab === 'history' && ( + +

+ + Race History +

+ +
+ )} + + {activeTab === 'stats' && stats && ( +
+ {/* Detailed Performance Metrics */} + +

+ + Detailed Performance Metrics +

+ +
+ {/* Performance Bars */} +
+

Results Breakdown

+ +
+ + {/* Key Metrics */} +
+
+
+ + Win Rate +
+

+ {((stats.wins / stats.totalRaces) * 100).toFixed(1)}% +

+
+
+
+ + Podium Rate +
+

+ {((stats.podiums / stats.totalRaces) * 100).toFixed(1)}% +

+
+
+
+ + Consistency +
+

{stats.consistency}%

+
+
+
+ + Finish Rate +
+

+ {(((stats.totalRaces - stats.dnfs) / stats.totalRaces) * 100).toFixed(1)}% +

+
+
+
+
+ + {/* Position Statistics */} + +

+ + Position Statistics +

+ +
+
+
P{stats.bestFinish}
+
Best Finish
+
+
+
P{stats.avgFinish.toFixed(1)}
+
Avg Finish
+
+
+
P{stats.worstFinish}
+
Worst Finish
+
+
+
{stats.dnfs}
+
DNFs
+
+
+
+ + {/* Global Rankings */} + +

+ + Global Rankings +

+ +
+
+ +
#{globalRank}
+
Global Rank
+
+
+ +
{stats.rating}
+
Rating
+
+
+ +
Top {stats.percentile}%
+
Percentile
+
+
+
+
+ )} + + {activeTab === 'stats' && !stats && ( + + +

No statistics available yet

+

Join a league and complete races to see detailed stats

+
+ )}
); } \ No newline at end of file diff --git a/apps/website/app/teams/leaderboard/page.tsx b/apps/website/app/teams/leaderboard/page.tsx new file mode 100644 index 000000000..553772f19 --- /dev/null +++ b/apps/website/app/teams/leaderboard/page.tsx @@ -0,0 +1,891 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { + Users, + Trophy, + Search, + Crown, + Star, + TrendingUp, + Shield, + Target, + Award, + ArrowLeft, + Medal, + Percent, + Hash, + Globe, + Languages, +} from 'lucide-react'; +import Button from '@/components/ui/Button'; +import Input from '@/components/ui/Input'; +import Heading from '@/components/ui/Heading'; +import { getGetAllTeamsQuery, getGetTeamMembersQuery, getDriverStats } from '@/lib/di-container'; +import type { Team } from '@gridpilot/racing'; + +// ============================================================================ +// TYPES +// ============================================================================ + +type SkillLevel = 'beginner' | 'intermediate' | 'advanced' | 'pro'; +type SortBy = 'rating' | 'wins' | 'winRate' | 'races'; + +interface TeamDisplayData { + id: string; + name: string; + memberCount: number; + rating: number | null; + totalWins: number; + totalRaces: number; + performanceLevel: SkillLevel; + isRecruiting: boolean; + createdAt: Date; + description?: string; + specialization?: 'endurance' | 'sprint' | 'mixed'; + region?: string; + languages?: string[]; +} + +// ============================================================================ +// DEMO TEAMS DATA +// ============================================================================ + +const DEMO_TEAMS: TeamDisplayData[] = [ + { + id: 'demo-team-1', + name: 'Apex Predators Racing', + description: 'Elite GT3 team competing at the highest level.', + memberCount: 8, + rating: 4850, + totalWins: 47, + totalRaces: 156, + performanceLevel: 'pro', + isRecruiting: true, + createdAt: new Date(Date.now() - 180 * 24 * 60 * 60 * 1000), + specialization: 'mixed', + region: '🇺🇸 North America', + languages: ['English'], + }, + { + id: 'demo-team-2', + name: 'Velocity Esports', + description: 'Professional sim racing team with sponsors.', + memberCount: 12, + rating: 5200, + totalWins: 63, + totalRaces: 198, + performanceLevel: 'pro', + isRecruiting: false, + createdAt: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000), + specialization: 'endurance', + region: '🇬🇧 Europe', + languages: ['English', 'German'], + }, + { + id: 'demo-team-3', + name: 'Nitro Motorsport', + description: 'Championship-winning sprint specialists.', + memberCount: 6, + rating: 4720, + totalWins: 38, + totalRaces: 112, + performanceLevel: 'pro', + isRecruiting: true, + createdAt: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000), + specialization: 'sprint', + region: '🇩🇪 Germany', + languages: ['German', 'English'], + }, + { + id: 'demo-team-4', + name: 'Horizon Racing Collective', + description: 'Ambitious team on the rise.', + memberCount: 10, + rating: 3800, + totalWins: 24, + totalRaces: 89, + performanceLevel: 'advanced', + isRecruiting: true, + createdAt: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000), + specialization: 'mixed', + region: '🇳🇱 Netherlands', + languages: ['Dutch', 'English'], + }, + { + id: 'demo-team-5', + name: 'Phoenix Rising eSports', + description: 'From the ashes to the podium.', + memberCount: 7, + rating: 3650, + totalWins: 19, + totalRaces: 76, + performanceLevel: 'advanced', + isRecruiting: true, + createdAt: new Date(Date.now() - 45 * 24 * 60 * 60 * 1000), + specialization: 'endurance', + region: '🇫🇷 France', + languages: ['French', 'English'], + }, + { + id: 'demo-team-6', + name: 'Thunderbolt Racing', + description: 'Fast and furious sprint racing.', + memberCount: 5, + rating: 3420, + totalWins: 15, + totalRaces: 54, + performanceLevel: 'advanced', + isRecruiting: false, + createdAt: new Date(Date.now() - 120 * 24 * 60 * 60 * 1000), + specialization: 'sprint', + region: '🇮🇹 Italy', + languages: ['Italian', 'English'], + }, + { + id: 'demo-team-7', + name: 'Grid Starters', + description: 'Growing together as racers.', + memberCount: 9, + rating: 2800, + totalWins: 11, + totalRaces: 67, + performanceLevel: 'intermediate', + isRecruiting: true, + createdAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), + specialization: 'mixed', + region: '🇪🇸 Spain', + languages: ['Spanish', 'English'], + }, + { + id: 'demo-team-8', + name: 'Midnight Racers', + description: 'Night owls who love endurance racing.', + memberCount: 6, + rating: 2650, + totalWins: 8, + totalRaces: 42, + performanceLevel: 'intermediate', + isRecruiting: true, + createdAt: new Date(Date.now() - 15 * 24 * 60 * 60 * 1000), + specialization: 'endurance', + region: '🌍 International', + languages: ['English'], + }, + { + id: 'demo-team-9', + name: 'Casual Speedsters', + description: 'Racing for fun, improving together.', + memberCount: 4, + rating: 2400, + totalWins: 5, + totalRaces: 31, + performanceLevel: 'intermediate', + isRecruiting: true, + createdAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), + specialization: 'sprint', + region: '🇵🇱 Poland', + languages: ['Polish', 'English'], + }, + { + id: 'demo-team-10', + name: 'Fresh Rubber Racing', + description: 'New team for new racers!', + memberCount: 3, + rating: 1800, + totalWins: 2, + totalRaces: 18, + performanceLevel: 'beginner', + isRecruiting: true, + createdAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000), + specialization: 'mixed', + region: '🇧🇷 Brazil', + languages: ['Portuguese', 'English'], + }, + { + id: 'demo-team-11', + name: 'Rookie Revolution', + description: 'First time racers welcome!', + memberCount: 5, + rating: 1650, + totalWins: 1, + totalRaces: 12, + performanceLevel: 'beginner', + isRecruiting: true, + createdAt: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000), + specialization: 'sprint', + region: '🇦🇺 Australia', + languages: ['English'], + }, + { + id: 'demo-team-12', + name: 'Pit Lane Pioneers', + description: 'Learning endurance racing from scratch.', + memberCount: 4, + rating: 1500, + totalWins: 0, + totalRaces: 8, + performanceLevel: 'beginner', + isRecruiting: true, + createdAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000), + specialization: 'endurance', + region: '🇯🇵 Japan', + languages: ['Japanese', 'English'], + }, + { + id: 'demo-team-13', + name: 'Shadow Squadron', + description: 'Elite drivers emerging from the shadows.', + memberCount: 6, + rating: 4100, + totalWins: 12, + totalRaces: 34, + performanceLevel: 'advanced', + isRecruiting: true, + createdAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000), + specialization: 'mixed', + region: '🇸🇪 Scandinavia', + languages: ['Swedish', 'Norwegian', 'English'], + }, + { + id: 'demo-team-14', + name: 'Turbo Collective', + description: 'Fast, furious, and friendly.', + memberCount: 4, + rating: 3200, + totalWins: 7, + totalRaces: 28, + performanceLevel: 'intermediate', + isRecruiting: true, + createdAt: new Date(Date.now() - 12 * 60 * 60 * 1000), + specialization: 'sprint', + region: '🇨🇦 Canada', + languages: ['English', 'French'], + }, +]; + +// ============================================================================ +// SKILL LEVEL CONFIG +// ============================================================================ + +const SKILL_LEVELS: { + id: SkillLevel; + label: string; + icon: React.ElementType; + color: string; + bgColor: string; + borderColor: string; +}[] = [ + { + id: 'pro', + label: 'Pro', + icon: Crown, + color: 'text-yellow-400', + bgColor: 'bg-yellow-400/10', + borderColor: 'border-yellow-400/30', + }, + { + id: 'advanced', + label: 'Advanced', + icon: Star, + color: 'text-purple-400', + bgColor: 'bg-purple-400/10', + borderColor: 'border-purple-400/30', + }, + { + id: 'intermediate', + label: 'Intermediate', + icon: TrendingUp, + color: 'text-primary-blue', + bgColor: 'bg-primary-blue/10', + borderColor: 'border-primary-blue/30', + }, + { + id: 'beginner', + label: 'Beginner', + icon: Shield, + color: 'text-green-400', + bgColor: 'bg-green-400/10', + borderColor: 'border-green-400/30', + }, +]; + +// ============================================================================ +// SORT OPTIONS +// ============================================================================ + +const SORT_OPTIONS: { id: SortBy; label: string; icon: React.ElementType }[] = [ + { id: 'rating', label: 'Rating', icon: Star }, + { id: 'wins', label: 'Total Wins', icon: Trophy }, + { id: 'winRate', label: 'Win Rate', icon: Percent }, + { id: 'races', label: 'Races', icon: Hash }, +]; + +// ============================================================================ +// TOP THREE PODIUM COMPONENT +// ============================================================================ + +interface TopThreePodiumProps { + teams: TeamDisplayData[]; + onTeamClick: (teamId: string) => void; +} + +function TopThreePodium({ teams, onTeamClick }: TopThreePodiumProps) { + const top3 = teams.slice(0, 3); + if (top3.length < 3) return null; + + // Display order: 2nd, 1st, 3rd + const podiumOrder = [top3[1], top3[0], top3[2]]; + const podiumHeights = ['h-28', 'h-36', 'h-20']; + const podiumPositions = [2, 1, 3]; + + const getPositionColor = (position: number) => { + switch (position) { + case 1: + return 'text-yellow-400'; + case 2: + return 'text-gray-300'; + case 3: + return 'text-amber-600'; + default: + return 'text-gray-500'; + } + }; + + const getGradient = (position: number) => { + switch (position) { + case 1: + return 'from-yellow-400/30 via-yellow-500/20 to-yellow-600/10'; + case 2: + return 'from-gray-300/30 via-gray-400/20 to-gray-500/10'; + case 3: + return 'from-amber-500/30 via-amber-600/20 to-amber-700/10'; + default: + return 'from-gray-600/30 to-gray-700/10'; + } + }; + + const getBorderColor = (position: number) => { + switch (position) { + case 1: + return 'border-yellow-400/50'; + case 2: + return 'border-gray-300/50'; + case 3: + return 'border-amber-600/50'; + default: + return 'border-charcoal-outline'; + } + }; + + return ( +
+
+ +

Top 3 Teams

+
+ +
+ {podiumOrder.map((team, index) => { + const position = podiumPositions[index]; + const levelConfig = SKILL_LEVELS.find((l) => l.id === team.performanceLevel); + const LevelIcon = levelConfig?.icon || Shield; + + return ( + + ); + })} +
+
+ ); +} + +// ============================================================================ +// MAIN PAGE COMPONENT +// ============================================================================ + +export default function TeamLeaderboardPage() { + const router = useRouter(); + const [realTeams, setRealTeams] = useState([]); + const [loading, setLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(''); + const [sortBy, setSortBy] = useState('rating'); + const [filterLevel, setFilterLevel] = useState('all'); + + useEffect(() => { + loadTeams(); + }, []); + + const loadTeams = async () => { + try { + const allTeamsQuery = getGetAllTeamsQuery(); + const teamMembersQuery = getGetTeamMembersQuery(); + + const allTeams = await allTeamsQuery.execute(); + const teamData: TeamDisplayData[] = []; + + await Promise.all( + allTeams.map(async (team: Team) => { + const memberships = await teamMembersQuery.execute({ teamId: team.id }); + const memberCount = memberships.length; + + let ratingSum = 0; + let ratingCount = 0; + let totalWins = 0; + let totalRaces = 0; + + for (const membership of memberships) { + const stats = getDriverStats(membership.driverId); + if (!stats) continue; + + if (typeof stats.rating === 'number') { + ratingSum += stats.rating; + ratingCount += 1; + } + + totalWins += stats.wins ?? 0; + totalRaces += stats.totalRaces ?? 0; + } + + const averageRating = ratingCount > 0 ? ratingSum / ratingCount : null; + + let performanceLevel: TeamDisplayData['performanceLevel'] = 'beginner'; + if (averageRating !== null) { + if (averageRating >= 4500) performanceLevel = 'pro'; + else if (averageRating >= 3000) performanceLevel = 'advanced'; + else if (averageRating >= 2000) performanceLevel = 'intermediate'; + } + + teamData.push({ + id: team.id, + name: team.name, + memberCount, + rating: averageRating, + totalWins, + totalRaces, + performanceLevel, + isRecruiting: true, + createdAt: new Date(), + }); + }), + ); + + setRealTeams(teamData); + } catch (error) { + console.error('Failed to load teams:', error); + } finally { + setLoading(false); + } + }; + + const teams = [...realTeams, ...DEMO_TEAMS]; + + const handleTeamClick = (teamId: string) => { + if (teamId.startsWith('demo-team-')) { + return; + } + router.push(`/teams/${teamId}`); + }; + + // Filter and sort teams + const filteredAndSortedTeams = teams + .filter((team) => { + // Search filter + if (searchQuery) { + const query = searchQuery.toLowerCase(); + if (!team.name.toLowerCase().includes(query) && !(team.description ?? '').toLowerCase().includes(query)) { + return false; + } + } + // Level filter + if (filterLevel !== 'all' && team.performanceLevel !== filterLevel) { + return false; + } + // Must have rating for leaderboard + return team.rating !== null; + }) + .sort((a, b) => { + switch (sortBy) { + case 'rating': + return (b.rating ?? 0) - (a.rating ?? 0); + case 'wins': + return b.totalWins - a.totalWins; + case 'winRate': { + const aRate = a.totalRaces > 0 ? a.totalWins / a.totalRaces : 0; + const bRate = b.totalRaces > 0 ? b.totalWins / b.totalRaces : 0; + return bRate - aRate; + } + case 'races': + return b.totalRaces - a.totalRaces; + default: + return 0; + } + }); + + const getMedalColor = (position: number) => { + switch (position) { + case 0: + return 'text-yellow-400'; + case 1: + return 'text-gray-300'; + case 2: + return 'text-amber-600'; + default: + return 'text-gray-500'; + } + }; + + const getMedalBg = (position: number) => { + switch (position) { + case 0: + return 'bg-gradient-to-br from-yellow-400/20 to-yellow-600/10 border-yellow-400/40'; + case 1: + return 'bg-gradient-to-br from-gray-300/20 to-gray-400/10 border-gray-300/40'; + case 2: + return 'bg-gradient-to-br from-amber-600/20 to-amber-700/10 border-amber-600/40'; + default: + return 'bg-iron-gray/50 border-charcoal-outline'; + } + }; + + if (loading) { + return ( +
+
+
+
+

Loading leaderboard...

+
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+ + +
+
+ +
+
+ + Team Leaderboard + +

Rankings of all teams by performance metrics

+
+
+
+ + {/* Filters and Search */} +
+ {/* Search and Level Filter Row */} +
+
+ + setSearchQuery(e.target.value)} + className="pl-11" + /> +
+ + {/* Level Filter */} +
+ + {SKILL_LEVELS.map((level) => { + const LevelIcon = level.icon; + return ( + + ); + })} +
+
+ + {/* Sort Options */} +
+ Sort by: +
+ {SORT_OPTIONS.map((option) => ( + + ))} +
+
+
+ + {/* Podium for Top 3 - only show when viewing by rating without filters */} + {sortBy === 'rating' && filterLevel === 'all' && !searchQuery && filteredAndSortedTeams.length >= 3 && ( + + )} + + {/* Stats Summary */} +
+
+
+ + Total Teams +
+

{filteredAndSortedTeams.length}

+
+
+
+ + Pro Teams +
+

+ {filteredAndSortedTeams.filter((t) => t.performanceLevel === 'pro').length} +

+
+
+
+ + Total Wins +
+

+ {filteredAndSortedTeams.reduce((sum, t) => sum + t.totalWins, 0)} +

+
+
+
+ + Total Races +
+

+ {filteredAndSortedTeams.reduce((sum, t) => sum + t.totalRaces, 0)} +

+
+
+ + {/* Leaderboard Table */} +
+ {/* Table Header */} +
+
Rank
+
Team
+
Members
+
Rating
+
Wins
+
Win Rate
+
+ + {/* Table Body */} +
+ {filteredAndSortedTeams.map((team, index) => { + const levelConfig = SKILL_LEVELS.find((l) => l.id === team.performanceLevel); + const LevelIcon = levelConfig?.icon || Shield; + const winRate = team.totalRaces > 0 ? ((team.totalWins / team.totalRaces) * 100).toFixed(1) : '0.0'; + + return ( + + ); + })} +
+ + {/* Empty State */} + {filteredAndSortedTeams.length === 0 && ( +
+ +

No teams found

+

Try adjusting your filters or search query

+ +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/apps/website/app/teams/page.tsx b/apps/website/app/teams/page.tsx index e82febd9d..4915a3c33 100644 --- a/apps/website/app/teams/page.tsx +++ b/apps/website/app/teams/page.tsx @@ -1,25 +1,26 @@ 'use client'; -import { useState, useEffect, useRef, useCallback } from 'react'; +import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { Users, Trophy, Search, Plus, - ChevronLeft, - ChevronRight, Sparkles, - Filter, - Flame, - Target, + Crown, Star, TrendingUp, Shield, Zap, - Award, - Crown, UserPlus, + ChevronRight, + Timer, + Target, + Award, + Handshake, + MessageCircle, + Calendar, } from 'lucide-react'; import TeamCard from '@/components/teams/TeamCard'; import Button from '@/components/ui/Button'; @@ -34,26 +35,7 @@ import type { Team } from '@gridpilot/racing'; // TYPES // ============================================================================ -type CategoryId = - | 'all' - | 'recruiting' - | 'popular' - | 'new' - | 'pro' - | 'advanced' - | 'intermediate' - | 'beginner' - | 'endurance' - | 'sprint'; - -interface Category { - id: CategoryId; - label: string; - icon: React.ElementType; - description: string; - filter: (team: TeamDisplayData) => boolean; - color?: string; -} +type SkillLevel = 'beginner' | 'intermediate' | 'advanced' | 'pro'; interface TeamDisplayData { id: string; @@ -62,11 +44,13 @@ interface TeamDisplayData { rating: number | null; totalWins: number; totalRaces: number; - performanceLevel: 'beginner' | 'intermediate' | 'advanced' | 'pro'; + performanceLevel: SkillLevel; isRecruiting: boolean; createdAt: Date; description?: string; specialization?: 'endurance' | 'sprint' | 'mixed'; + region?: string; + languages?: string[]; } // ============================================================================ @@ -87,6 +71,8 @@ const DEMO_TEAMS: TeamDisplayData[] = [ isRecruiting: true, createdAt: new Date(Date.now() - 180 * 24 * 60 * 60 * 1000), specialization: 'mixed', + region: '🇺🇸 North America', + languages: ['English'], }, { id: 'demo-team-2', @@ -100,6 +86,8 @@ const DEMO_TEAMS: TeamDisplayData[] = [ isRecruiting: false, createdAt: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000), specialization: 'endurance', + region: '🇬🇧 Europe', + languages: ['English', 'German'], }, { id: 'demo-team-3', @@ -113,6 +101,8 @@ const DEMO_TEAMS: TeamDisplayData[] = [ isRecruiting: true, createdAt: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000), specialization: 'sprint', + region: '🇩🇪 Germany', + languages: ['German', 'English'], }, // Advanced Teams { @@ -127,6 +117,8 @@ const DEMO_TEAMS: TeamDisplayData[] = [ isRecruiting: true, createdAt: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000), specialization: 'mixed', + region: '🇳🇱 Netherlands', + languages: ['Dutch', 'English'], }, { id: 'demo-team-5', @@ -140,6 +132,8 @@ const DEMO_TEAMS: TeamDisplayData[] = [ isRecruiting: true, createdAt: new Date(Date.now() - 45 * 24 * 60 * 60 * 1000), specialization: 'endurance', + region: '🇫🇷 France', + languages: ['French', 'English'], }, { id: 'demo-team-6', @@ -153,6 +147,8 @@ const DEMO_TEAMS: TeamDisplayData[] = [ isRecruiting: false, createdAt: new Date(Date.now() - 120 * 24 * 60 * 60 * 1000), specialization: 'sprint', + region: '🇮🇹 Italy', + languages: ['Italian', 'English'], }, // Intermediate Teams { @@ -167,6 +163,8 @@ const DEMO_TEAMS: TeamDisplayData[] = [ isRecruiting: true, createdAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), specialization: 'mixed', + region: '🇪🇸 Spain', + languages: ['Spanish', 'English'], }, { id: 'demo-team-8', @@ -180,6 +178,8 @@ const DEMO_TEAMS: TeamDisplayData[] = [ isRecruiting: true, createdAt: new Date(Date.now() - 15 * 24 * 60 * 60 * 1000), specialization: 'endurance', + region: '🌍 International', + languages: ['English'], }, { id: 'demo-team-9', @@ -193,6 +193,8 @@ const DEMO_TEAMS: TeamDisplayData[] = [ isRecruiting: true, createdAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), specialization: 'sprint', + region: '🇵🇱 Poland', + languages: ['Polish', 'English'], }, // Beginner Teams { @@ -207,6 +209,8 @@ const DEMO_TEAMS: TeamDisplayData[] = [ isRecruiting: true, createdAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000), specialization: 'mixed', + region: '🇧🇷 Brazil', + languages: ['Portuguese', 'English'], }, { id: 'demo-team-11', @@ -220,6 +224,8 @@ const DEMO_TEAMS: TeamDisplayData[] = [ isRecruiting: true, createdAt: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000), specialization: 'sprint', + region: '🇦🇺 Australia', + languages: ['English'], }, { id: 'demo-team-12', @@ -233,6 +239,8 @@ const DEMO_TEAMS: TeamDisplayData[] = [ isRecruiting: true, createdAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000), specialization: 'endurance', + region: '🇯🇵 Japan', + languages: ['Japanese', 'English'], }, // Recently Added { @@ -247,6 +255,8 @@ const DEMO_TEAMS: TeamDisplayData[] = [ isRecruiting: true, createdAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000), specialization: 'mixed', + region: '🇸🇪 Scandinavia', + languages: ['Swedish', 'Norwegian', 'English'], }, { id: 'demo-team-14', @@ -260,297 +270,398 @@ const DEMO_TEAMS: TeamDisplayData[] = [ isRecruiting: true, createdAt: new Date(Date.now() - 12 * 60 * 60 * 1000), specialization: 'sprint', + region: '🇨🇦 Canada', + languages: ['English', 'French'], }, ]; // ============================================================================ -// CATEGORIES +// SKILL LEVEL CONFIG // ============================================================================ -const CATEGORIES: Category[] = [ - { - id: 'all', - label: 'All', - icon: Users, - description: 'Browse all teams', - filter: () => true, - }, - { - id: 'recruiting', - label: 'Recruiting', - icon: UserPlus, - description: 'Teams looking for drivers', - filter: (team) => team.isRecruiting, - color: 'text-performance-green', - }, - { - id: 'popular', - label: 'Popular', - icon: Flame, - description: 'Most active teams', - filter: (team) => team.totalRaces >= 50, - color: 'text-orange-400', - }, - { - id: 'new', - label: 'New', - icon: Sparkles, - description: 'Recently formed teams', - filter: (team) => { - const oneWeekAgo = new Date(); - oneWeekAgo.setDate(oneWeekAgo.getDate() - 7); - return team.createdAt > oneWeekAgo; - }, - color: 'text-neon-aqua', - }, +const SKILL_LEVELS: { + id: SkillLevel; + label: string; + icon: React.ElementType; + color: string; + bgColor: string; + borderColor: string; + description: string; +}[] = [ { id: 'pro', label: 'Pro', icon: Crown, - description: 'Professional-level teams', - filter: (team) => team.performanceLevel === 'pro', color: 'text-yellow-400', + bgColor: 'bg-yellow-400/10', + borderColor: 'border-yellow-400/30', + description: 'Elite competition, sponsored teams', }, { id: 'advanced', label: 'Advanced', icon: Star, - description: 'High-skill teams', - filter: (team) => team.performanceLevel === 'advanced', color: 'text-purple-400', + bgColor: 'bg-purple-400/10', + borderColor: 'border-purple-400/30', + description: 'Competitive racing, high consistency', }, { id: 'intermediate', label: 'Intermediate', icon: TrendingUp, - description: 'Growing teams', - filter: (team) => team.performanceLevel === 'intermediate', color: 'text-primary-blue', + bgColor: 'bg-primary-blue/10', + borderColor: 'border-primary-blue/30', + description: 'Growing skills, regular practice', }, { id: 'beginner', label: 'Beginner', icon: Shield, - description: 'New racer friendly', - filter: (team) => team.performanceLevel === 'beginner', color: 'text-green-400', - }, - { - id: 'endurance', - label: 'Endurance', - icon: Trophy, - description: 'Long-distance specialists', - filter: (team) => team.specialization === 'endurance', - }, - { - id: 'sprint', - label: 'Sprint', - icon: Zap, - description: 'Short race experts', - filter: (team) => team.specialization === 'sprint', + bgColor: 'bg-green-400/10', + borderColor: 'border-green-400/30', + description: 'Learning the basics, friendly environment', }, ]; // ============================================================================ -// TEAM SLIDER COMPONENT +// WHY JOIN A TEAM SECTION // ============================================================================ -interface TeamSliderProps { - title: string; - icon: React.ElementType; - description: string; - teams: TeamDisplayData[]; - onTeamClick: (id: string) => void; - autoScroll?: boolean; - iconColor?: string; - scrollSpeedMultiplier?: number; - scrollDirection?: 'left' | 'right'; +function WhyJoinTeamSection() { + const benefits = [ + { + icon: Handshake, + title: 'Shared Strategy', + description: 'Develop setups together, share telemetry, and coordinate pit strategies for endurance races.', + }, + { + icon: MessageCircle, + title: 'Team Communication', + description: 'Discord integration, voice chat during races, and dedicated team channels.', + }, + { + icon: Calendar, + title: 'Coordinated Schedule', + description: 'Team calendars, practice sessions, and organized race attendance.', + }, + { + icon: Trophy, + title: 'Team Championships', + description: 'Compete in team-based leagues and build your collective reputation.', + }, + ]; + + return ( +
+
+

Why Join a Team?

+

Racing is better when you have teammates to share the journey

+
+ +
+ {benefits.map((benefit) => ( +
+
+ +
+

{benefit.title}

+

{benefit.description}

+
+ ))} +
+
+ ); } -function TeamSlider({ - title, - icon: Icon, - description, - teams, - onTeamClick, - autoScroll = true, - iconColor = 'text-primary-blue', - scrollSpeedMultiplier = 1, - scrollDirection = 'right', -}: TeamSliderProps) { - const scrollRef = useRef(null); - const [canScrollLeft, setCanScrollLeft] = useState(false); - const [canScrollRight, setCanScrollRight] = useState(true); - const [isHovering, setIsHovering] = useState(false); - const animationRef = useRef(null); - const scrollPositionRef = useRef(0); +// ============================================================================ +// SKILL LEVEL SECTION COMPONENT +// ============================================================================ - const checkScrollButtons = useCallback(() => { - if (scrollRef.current) { - const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current; - setCanScrollLeft(scrollLeft > 0); - setCanScrollRight(scrollLeft < scrollWidth - clientWidth - 10); - } - }, []); +interface SkillLevelSectionProps { + level: typeof SKILL_LEVELS[0]; + teams: TeamDisplayData[]; + onTeamClick: (id: string) => void; + defaultExpanded?: boolean; +} - const scroll = useCallback((direction: 'left' | 'right') => { - if (scrollRef.current) { - const cardWidth = 340; - const scrollAmount = direction === 'left' ? -cardWidth : cardWidth; - scrollPositionRef.current = scrollRef.current.scrollLeft + scrollAmount; - scrollRef.current.scrollBy({ left: scrollAmount, behavior: 'smooth' }); - } - }, []); - - // Initialize scroll position for left-scrolling sliders - useEffect(() => { - if (scrollDirection === 'left' && scrollRef.current) { - const { scrollWidth, clientWidth } = scrollRef.current; - scrollPositionRef.current = scrollWidth - clientWidth; - scrollRef.current.scrollLeft = scrollPositionRef.current; - } - }, [scrollDirection, teams.length]); - - // Smooth continuous auto-scroll - useEffect(() => { - if (!autoScroll || teams.length <= 1) return; - - const scrollContainer = scrollRef.current; - if (!scrollContainer) return; - - let lastTimestamp = 0; - const baseSpeed = 0.025; - const scrollSpeed = baseSpeed * scrollSpeedMultiplier; - const directionMultiplier = scrollDirection === 'left' ? -1 : 1; - - const animate = (timestamp: number) => { - if (!isHovering && scrollContainer) { - const delta = lastTimestamp ? timestamp - lastTimestamp : 0; - lastTimestamp = timestamp; - - scrollPositionRef.current += scrollSpeed * delta * directionMultiplier; - - const { scrollWidth, clientWidth } = scrollContainer; - const maxScroll = scrollWidth - clientWidth; - - if (scrollDirection === 'right' && scrollPositionRef.current >= maxScroll) { - scrollPositionRef.current = 0; - } else if (scrollDirection === 'left' && scrollPositionRef.current <= 0) { - scrollPositionRef.current = maxScroll; - } - - scrollContainer.scrollLeft = scrollPositionRef.current; - } else { - lastTimestamp = timestamp; - } - - animationRef.current = requestAnimationFrame(animate); - }; - - animationRef.current = requestAnimationFrame(animate); - - return () => { - if (animationRef.current) { - cancelAnimationFrame(animationRef.current); - } - }; - }, [autoScroll, teams.length, isHovering, scrollSpeedMultiplier, scrollDirection]); - - useEffect(() => { - const scrollContainer = scrollRef.current; - if (!scrollContainer) return; - - const handleScroll = () => { - scrollPositionRef.current = scrollContainer.scrollLeft; - checkScrollButtons(); - }; - - scrollContainer.addEventListener('scroll', handleScroll); - return () => scrollContainer.removeEventListener('scroll', handleScroll); - }, [checkScrollButtons]); +function SkillLevelSection({ level, teams, onTeamClick, defaultExpanded = false }: SkillLevelSectionProps) { + const [isExpanded, setIsExpanded] = useState(defaultExpanded); + const recruitingTeams = teams.filter((t) => t.isRecruiting); + const displayedTeams = isExpanded ? teams : teams.slice(0, 3); + const Icon = level.icon; if (teams.length === 0) return null; return ( -
+
+ {/* Section Header */}
-
- +
+
-

{title}

-

{description}

+
+

{level.label}

+ + {teams.length} {teams.length === 1 ? 'team' : 'teams'} + + {recruitingTeams.length > 0 && ( + + + {recruitingTeams.length} recruiting + + )} +
+

{level.description}

- - {teams.length} -
-
+ {teams.length > 3 && ( - + )} +
+ + {/* Teams Grid */} +
+ {displayedTeams.map((team) => ( + onTeamClick(team.id)} + /> + ))} +
+
+ ); +} + +// ============================================================================ +// FEATURED RECRUITING TEAMS +// ============================================================================ + +interface FeaturedRecruitingProps { + teams: TeamDisplayData[]; + onTeamClick: (id: string) => void; +} + +function FeaturedRecruiting({ teams, onTeamClick }: FeaturedRecruitingProps) { + const recruitingTeams = teams.filter((t) => t.isRecruiting).slice(0, 4); + + if (recruitingTeams.length === 0) return null; + + return ( +
+
+
+ +
+
+

Looking for Drivers

+

Teams actively recruiting new members

-
-
-
+
+ {recruitingTeams.map((team) => { + const levelConfig = SKILL_LEVELS.find((l) => l.id === team.performanceLevel); + const LevelIcon = levelConfig?.icon || Shield; -
setIsHovering(true)} - onMouseLeave={() => setIsHovering(false)} - className="flex gap-4 overflow-x-auto pb-4 px-4" - style={{ - scrollbarWidth: 'none', - msOverflowStyle: 'none', - }} + return ( + + ); + })} +
+
+ ); +} + +// ============================================================================ +// TEAM LEADERBOARD PREVIEW COMPONENT (Top 5 + Link) +// ============================================================================ + +interface TeamLeaderboardPreviewProps { + teams: TeamDisplayData[]; + onTeamClick: (id: string) => void; +} + +function TeamLeaderboardPreview({ teams, onTeamClick }: TeamLeaderboardPreviewProps) { + const router = useRouter(); + + // Sort teams by rating and get top 5 + const topTeams = [...teams] + .filter((t) => t.rating !== null) + .sort((a, b) => (b.rating ?? 0) - (a.rating ?? 0)) + .slice(0, 5); + + const getMedalColor = (position: number) => { + switch (position) { + case 0: + return 'text-yellow-400'; + case 1: + return 'text-gray-300'; + case 2: + return 'text-amber-600'; + default: + return 'text-gray-500'; + } + }; + + const getMedalBg = (position: number) => { + switch (position) { + case 0: + return 'bg-yellow-400/10 border-yellow-400/30'; + case 1: + return 'bg-gray-300/10 border-gray-300/30'; + case 2: + return 'bg-amber-600/10 border-amber-600/30'; + default: + return 'bg-iron-gray/50 border-charcoal-outline'; + } + }; + + if (topTeams.length === 0) return null; + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

Top Teams

+

Highest rated racing teams

+
+
+ + +
+ + {/* Compact Leaderboard */} +
+
+ {topTeams.map((team, index) => { + const levelConfig = SKILL_LEVELS.find((l) => l.id === team.performanceLevel); + const LevelIcon = levelConfig?.icon || Shield; + + return ( +
- ))} + className="flex items-center gap-4 px-4 py-3 w-full text-left hover:bg-iron-gray/30 transition-colors group" + > + {/* Position */} +
+ {index < 3 ? ( + + ) : ( + index + 1 + )} +
+ + {/* Team Info */} +
+ +
+
+

+ {team.name} +

+
+ + + {team.memberCount} + + + + {team.totalWins} wins + + {team.isRecruiting && ( + +
+ Recruiting + + )} +
+
+ + {/* Rating */} +
+

+ {team.rating?.toLocaleString()} +

+

Rating

+
+ + ); + })}
@@ -566,8 +677,6 @@ export default function TeamsPage() { const [realTeams, setRealTeams] = useState([]); const [loading, setLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(''); - const [activeCategory, setActiveCategory] = useState('all'); - const [showFilters, setShowFilters] = useState(false); const [showCreateForm, setShowCreateForm] = useState(false); useEffect(() => { @@ -606,7 +715,7 @@ export default function TeamsPage() { } const averageRating = ratingCount > 0 ? ratingSum / ratingCount : null; - + let performanceLevel: TeamDisplayData['performanceLevel'] = 'beginner'; if (averageRating !== null) { if (averageRating >= 4500) performanceLevel = 'pro'; @@ -622,8 +731,8 @@ export default function TeamsPage() { totalWins, totalRaces, performanceLevel, - isRecruiting: true, // Default for now - createdAt: new Date(), // Would need to be stored in Team entity + isRecruiting: true, + createdAt: new Date(), }); }), ); @@ -636,7 +745,6 @@ export default function TeamsPage() { } }; - // Combine real teams with demo teams const teams = [...realTeams, ...DEMO_TEAMS]; const handleTeamClick = (teamId: string) => { @@ -653,58 +761,40 @@ export default function TeamsPage() { }; // Filter by search query - const searchFilteredTeams = teams.filter((team) => { + const filteredTeams = teams.filter((team) => { if (!searchQuery) return true; const query = searchQuery.toLowerCase(); return ( team.name.toLowerCase().includes(query) || - (team.description ?? '').toLowerCase().includes(query) + (team.description ?? '').toLowerCase().includes(query) || + (team.region ?? '').toLowerCase().includes(query) || + (team.languages ?? []).some((lang) => lang.toLowerCase().includes(query)) ); }); - // Get teams for active category - const activeCategoryData = CATEGORIES.find((c) => c.id === activeCategory); - const categoryFilteredTeams = activeCategoryData - ? searchFilteredTeams.filter(activeCategoryData.filter) - : searchFilteredTeams; - - // Group teams by category for slider view - const teamsByCategory = CATEGORIES.reduce( - (acc, category) => { - acc[category.id] = searchFilteredTeams.filter(category.filter); + // Group teams by skill level + const teamsByLevel = SKILL_LEVELS.reduce( + (acc, level) => { + acc[level.id] = filteredTeams.filter((t) => t.performanceLevel === level.id); return acc; }, - {} as Record, + {} as Record, ); - // Featured categories with different scroll speeds and directions - const featuredCategoriesWithSpeed: { id: CategoryId; speed: number; direction: 'left' | 'right' }[] = [ - { id: 'recruiting', speed: 1.0, direction: 'right' }, - { id: 'pro', speed: 0.8, direction: 'left' }, - { id: 'advanced', speed: 1.1, direction: 'right' }, - { id: 'intermediate', speed: 0.9, direction: 'left' }, - { id: 'beginner', speed: 1.2, direction: 'right' }, - { id: 'new', speed: 1.0, direction: 'left' }, - ]; + const recruitingCount = teams.filter((t) => t.isRecruiting).length; if (showCreateForm) { return (
-

Create New Team

- setShowCreateForm(false)} - onSuccess={handleCreateSuccess} - /> + setShowCreateForm(false)} onSuccess={handleCreateSuccess} />
); @@ -715,7 +805,7 @@ export default function TeamsPage() {
-
+

Loading teams...

@@ -725,122 +815,131 @@ export default function TeamsPage() { return (
- {/* Hero Section */} -
-
-
+ {/* Hero Section - Different from Leagues */} +
+ {/* Main Hero Card */} +
+ {/* Background decorations */} +
+
+
-
-
-
-
- -
- - Join a Team - -
-

- Racing is better together. Find your crew, share strategies, and compete in endurance events as a team. -

+
+
+
+ {/* Badge */} +
+ + Team Racing +
- {/* Stats */} -
-
-
- - {teams.length} active teams - + + Find Your + Crew + + +

+ Solo racing is great. Team racing is unforgettable. Join a team that matches your skill level and ambitions. +

+ + {/* Quick Stats */} +
+
+ + {teams.length} + Teams +
+
+ + {recruitingCount} + Recruiting +
+
+ + {/* CTA Buttons */} +
+ + +
-
-
- - {teamsByCategory.recruiting.length} recruiting - -
-
-
- - {teamsByCategory.pro.length} pro teams - + + {/* Skill Level Quick Nav */} +
+

Find Your Level

+
+ {SKILL_LEVELS.map((level) => { + const LevelIcon = level.icon; + const count = teamsByLevel[level.id]?.length || 0; + + return ( + + ); + })} +
- - {/* CTA */} -
- -

Start your own racing team

-
- {/* Search and Filter Bar */} -
+ {/* Search and Filter Bar - Same style as Leagues */} +
+ {/* Search */}
setSearchQuery(e.target.value)} className="pl-11" />
- - -
- - {/* Category Tabs */} -
-
- {CATEGORIES.map((category) => { - const Icon = category.icon; - const count = teamsByCategory[category.id].length; - const isActive = activeCategory === category.id; - - return ( - - ); - })} -
- {/* Content */} + {/* Why Join Section */} + {!searchQuery && } + + {/* Team Leaderboard Preview */} + {!searchQuery && } + + {/* Featured Recruiting */} + {!searchQuery && } + + {/* Teams by Skill Level */} {teams.length === 0 ? (
@@ -856,92 +955,35 @@ export default function TeamsPage() {
- ) : activeCategory === 'all' && !searchQuery ? ( - /* Slider View */ -
- {featuredCategoriesWithSpeed - .map(({ id, speed, direction }) => { - const category = CATEGORIES.find((c) => c.id === id)!; - return { category, speed, direction }; - }) - .filter(({ category }) => teamsByCategory[category.id].length > 0) - .map(({ category, speed, direction }) => ( - - ))} -
+ ) : filteredTeams.length === 0 ? ( + +
+ +

No teams found matching "{searchQuery}"

+ +
+
) : ( - /* Grid View */
- {categoryFilteredTeams.length > 0 ? ( - <> -
-

- Showing {categoryFilteredTeams.length}{' '} - {categoryFilteredTeams.length === 1 ? 'team' : 'teams'} - {searchQuery && ( - - {' '} - for "{searchQuery}" - - )} -

-
-
- {categoryFilteredTeams.map((team) => ( - handleTeamClick(team.id)} - /> - ))} -
- - ) : ( - -
- -

- No teams found{searchQuery ? ` matching "${searchQuery}"` : ' in this category'} -

- -
-
- )} + {SKILL_LEVELS.map((level, index) => ( +
+ +
+ ))}
)}
diff --git a/apps/website/components/alpha/AlphaNav.tsx b/apps/website/components/alpha/AlphaNav.tsx index 6e850c4ba..c5f108608 100644 --- a/apps/website/components/alpha/AlphaNav.tsx +++ b/apps/website/components/alpha/AlphaNav.tsx @@ -11,6 +11,7 @@ const nonHomeLinks = [ { href: '/leagues', label: 'Leagues' }, { href: '/teams', label: 'Teams' }, { href: '/drivers', label: 'Drivers' }, + { href: '/leaderboards', label: 'Leaderboards' }, ] as const; export function AlphaNav({}: AlphaNavProps) { diff --git a/apps/website/components/onboarding/OnboardingWizard.tsx b/apps/website/components/onboarding/OnboardingWizard.tsx new file mode 100644 index 000000000..128b8d57e --- /dev/null +++ b/apps/website/components/onboarding/OnboardingWizard.tsx @@ -0,0 +1,971 @@ +'use client'; + +import { useState, FormEvent } from 'react'; +import { useRouter } from 'next/navigation'; +import Image from 'next/image'; +import { + User, + Globe, + Flag, + Car, + Heart, + Clock, + Check, + ChevronRight, + ChevronLeft, + Gamepad2, + Target, + Zap, + Trophy, + Users, + MapPin, + Mail, + Calendar, + AlertCircle, +} 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'; + +// ============================================================================ +// TYPES +// ============================================================================ + +type OnboardingStep = 1 | 2 | 3 | 4; + +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 FormErrors { + firstName?: string; + lastName?: string; + displayName?: string; + email?: string; + country?: string; + iracingId?: string; + submit?: string; +} + +// ============================================================================ +// 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)' }, + { 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 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)' }, +]; + +// ============================================================================ +// HELPER COMPONENTS +// ============================================================================ + +function StepIndicator({ currentStep, totalSteps }: { currentStep: number; totalSteps: 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 }, + ]; + + 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 && ( +
+ )} +
+ ); + })} +
+ ); +} + +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 [step, setStep] = useState(1); + const [loading, setLoading] = useState(false); + const [errors, setErrors] = useState({}); + + // Form state + const [personalInfo, setPersonalInfo] = useState({ + 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: '', + }); + + // Validation + const validateStep = async (currentStep: OnboardingStep): Promise => { + 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 (!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'; + } + } + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleNext = async () => { + const isValid = await validateStep(step); + if (isValid && step < 4) { + setStep((step + 1) as OnboardingStep); + } + }; + + const handleBack = () => { + if (step > 1) { + setStep((step - 1) as OnboardingStep); + } + }; + + 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; + } + } + + setLoading(true); + setErrors({}); + + try { + const driverRepo = getDriverRepository(); + + // 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, + }); + + await driverRepo.create(driver); + + 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 in just a few steps +

+
+ + {/* 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="Max" + disabled={loading} + /> +
+ +
+ + + setPersonalInfo({ ...personalInfo, lastName: e.target.value }) + } + error={!!errors.lastName} + errorMessage={errors.lastName} + placeholder="Verstappen" + disabled={loading} + /> +
+
+ +
+ + + setPersonalInfo({ ...personalInfo, displayName: e.target.value }) + } + error={!!errors.displayName} + errorMessage={errors.displayName} + placeholder="SuperMax33" + disabled={loading} + /> +
+ +
+ +
+ + + setPersonalInfo({ ...personalInfo, email: e.target.value }) + } + placeholder="max@racing.com" + disabled={loading} + className="pl-10" + /> +
+
+ +
+
+ +
+ + + +
+ {errors.country && ( +

{errors.country}

+ )} +
+ +
+ +
+ + + +
+
+
+
+ )} + + {/* Step 2: Racing Information */} + {step === 2 && ( +
+
+ + + Racing Background + +

+ 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 +

+
+ +
+ +
+ {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 +

+
+ +
+ +