This commit is contained in:
2025-12-07 18:38:03 +01:00
parent 5ca2454853
commit 2d0860d66c
23 changed files with 7713 additions and 779 deletions

View File

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

View File

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

View File

@@ -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<FormErrors>({});
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 (
<main className="min-h-screen bg-deep-graphite flex items-center justify-center px-4 py-12">
{/* Background Pattern */}
<div className="absolute inset-0 bg-gradient-to-br from-primary-blue/5 via-transparent to-purple-600/5" />
<div className="absolute inset-0 opacity-5">
<div className="absolute inset-0" style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.4'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
}} />
</div>
<div className="relative w-full max-w-md">
{/* Logo/Header */}
<div className="text-center mb-8">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-primary-blue/20 to-purple-600/10 border border-primary-blue/30 mx-auto mb-4">
<Flag className="w-8 h-8 text-primary-blue" />
</div>
<Heading level={1} className="mb-2">Welcome Back</Heading>
<p className="text-gray-400">
Sign in to continue to GridPilot
</p>
</div>
<Card className="relative overflow-hidden">
{/* Background accent */}
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-bl from-primary-blue/10 to-transparent rounded-bl-full" />
<form onSubmit={handleSubmit} className="relative space-y-5">
{/* Email */}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-300 mb-2">
Email Address
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
error={!!errors.email}
errorMessage={errors.email}
placeholder="you@example.com"
disabled={loading}
className="pl-10"
autoComplete="email"
/>
</div>
</div>
{/* Password */}
<div>
<div className="flex items-center justify-between mb-2">
<label htmlFor="password" className="block text-sm font-medium text-gray-300">
Password
</label>
<Link href="/auth/forgot-password" className="text-xs text-primary-blue hover:underline">
Forgot password?
</Link>
</div>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<Input
id="password"
type={showPassword ? 'text' : 'password'}
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
error={!!errors.password}
errorMessage={errors.password}
placeholder="••••••••"
disabled={loading}
className="pl-10 pr-10"
autoComplete="current-password"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
{/* Error Message */}
{errors.submit && (
<div className="flex items-start gap-3 p-3 rounded-lg bg-red-500/10 border border-red-500/30">
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-400">{errors.submit}</p>
</div>
)}
{/* Submit Button */}
<Button
type="submit"
variant="primary"
disabled={loading}
className="w-full flex items-center justify-center gap-2"
>
{loading ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Signing in...
</>
) : (
<>
<LogIn className="w-4 h-4" />
Sign In
</>
)}
</Button>
</form>
{/* Divider */}
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-charcoal-outline" />
</div>
<div className="relative flex justify-center text-xs">
<span className="px-4 bg-iron-gray text-gray-500">or continue with</span>
</div>
</div>
{/* Demo Login */}
<button
type="button"
onClick={handleDemoLogin}
disabled={loading}
className="w-full flex items-center justify-center gap-3 px-4 py-3 rounded-lg bg-deep-graphite border border-charcoal-outline text-gray-300 hover:bg-iron-gray hover:border-primary-blue/30 transition-all disabled:opacity-50"
>
<Gamepad2 className="w-5 h-5 text-primary-blue" />
<span>Demo Login (iRacing)</span>
</button>
{/* Sign Up Link */}
<p className="mt-6 text-center text-sm text-gray-400">
Don't have an account?{' '}
<Link
href={`/auth/signup${returnTo !== '/dashboard' ? `?returnTo=${encodeURIComponent(returnTo)}` : ''}`}
className="text-primary-blue hover:underline font-medium"
>
Create one
</Link>
</p>
</Card>
{/* Footer */}
<p className="mt-6 text-center text-xs text-gray-500">
By signing in, you agree to our{' '}
<Link href="/terms" className="text-gray-400 hover:underline">Terms of Service</Link>
{' '}and{' '}
<Link href="/privacy" className="text-gray-400 hover:underline">Privacy Policy</Link>
</p>
</div>
</main>
);
}

View File

@@ -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<FormErrors>({});
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 (
<main className="min-h-screen bg-deep-graphite flex items-center justify-center px-4 py-12">
{/* Background Pattern */}
<div className="absolute inset-0 bg-gradient-to-br from-primary-blue/5 via-transparent to-purple-600/5" />
<div className="absolute inset-0 opacity-5">
<div className="absolute inset-0" style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.4'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
}} />
</div>
<div className="relative w-full max-w-md">
{/* Logo/Header */}
<div className="text-center mb-8">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-primary-blue/20 to-purple-600/10 border border-primary-blue/30 mx-auto mb-4">
<Flag className="w-8 h-8 text-primary-blue" />
</div>
<Heading level={1} className="mb-2">Join GridPilot</Heading>
<p className="text-gray-400">
Create your account and start racing
</p>
</div>
<Card className="relative overflow-hidden">
{/* Background accent */}
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-bl from-primary-blue/10 to-transparent rounded-bl-full" />
<form onSubmit={handleSubmit} className="relative space-y-5">
{/* Display Name */}
<div>
<label htmlFor="displayName" className="block text-sm font-medium text-gray-300 mb-2">
Display Name
</label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<Input
id="displayName"
type="text"
value={formData.displayName}
onChange={(e) => setFormData({ ...formData, displayName: e.target.value })}
error={!!errors.displayName}
errorMessage={errors.displayName}
placeholder="SuperMax33"
disabled={loading}
className="pl-10"
autoComplete="username"
/>
</div>
<p className="mt-1 text-xs text-gray-500">This is how other drivers will see you</p>
</div>
{/* Email */}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-300 mb-2">
Email Address
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
error={!!errors.email}
errorMessage={errors.email}
placeholder="you@example.com"
disabled={loading}
className="pl-10"
autoComplete="email"
/>
</div>
</div>
{/* Password */}
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-300 mb-2">
Password
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<Input
id="password"
type={showPassword ? 'text' : 'password'}
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
error={!!errors.password}
errorMessage={errors.password}
placeholder="••••••••"
disabled={loading}
className="pl-10 pr-10"
autoComplete="new-password"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
{/* Password Strength */}
{formData.password && (
<div className="mt-3 space-y-2">
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 rounded-full bg-charcoal-outline overflow-hidden">
<div
className={`h-full transition-all duration-300 ${passwordStrength.color}`}
style={{ width: `${(passwordStrength.score / 5) * 100}%` }}
/>
</div>
<span className={`text-xs font-medium ${
passwordStrength.score <= 1 ? 'text-red-400' :
passwordStrength.score <= 2 ? 'text-warning-amber' :
passwordStrength.score <= 3 ? 'text-primary-blue' :
'text-performance-green'
}`}>
{passwordStrength.label}
</span>
</div>
<div className="grid grid-cols-2 gap-1">
{passwordRequirements.map((req, index) => (
<div key={index} className="flex items-center gap-1.5 text-xs">
{req.met ? (
<Check className="w-3 h-3 text-performance-green" />
) : (
<X className="w-3 h-3 text-gray-500" />
)}
<span className={req.met ? 'text-gray-300' : 'text-gray-500'}>
{req.label}
</span>
</div>
))}
</div>
</div>
)}
</div>
{/* Confirm Password */}
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-300 mb-2">
Confirm Password
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<Input
id="confirmPassword"
type={showConfirmPassword ? 'text' : 'password'}
value={formData.confirmPassword}
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
error={!!errors.confirmPassword}
errorMessage={errors.confirmPassword}
placeholder="••••••••"
disabled={loading}
className="pl-10 pr-10"
autoComplete="new-password"
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
>
{showConfirmPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
{formData.confirmPassword && formData.password === formData.confirmPassword && (
<p className="mt-1 text-xs text-performance-green flex items-center gap-1">
<Check className="w-3 h-3" /> Passwords match
</p>
)}
</div>
{/* Error Message */}
{errors.submit && (
<div className="flex items-start gap-3 p-3 rounded-lg bg-red-500/10 border border-red-500/30">
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-400">{errors.submit}</p>
</div>
)}
{/* Submit Button */}
<Button
type="submit"
variant="primary"
disabled={loading}
className="w-full flex items-center justify-center gap-2"
>
{loading ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Creating account...
</>
) : (
<>
<UserPlus className="w-4 h-4" />
Create Account
</>
)}
</Button>
</form>
{/* Divider */}
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-charcoal-outline" />
</div>
<div className="relative flex justify-center text-xs">
<span className="px-4 bg-iron-gray text-gray-500">or continue with</span>
</div>
</div>
{/* Demo Login */}
<button
type="button"
onClick={handleDemoLogin}
disabled={loading}
className="w-full flex items-center justify-center gap-3 px-4 py-3 rounded-lg bg-deep-graphite border border-charcoal-outline text-gray-300 hover:bg-iron-gray hover:border-primary-blue/30 transition-all disabled:opacity-50"
>
<Gamepad2 className="w-5 h-5 text-primary-blue" />
<span>Demo Login (iRacing)</span>
</button>
{/* Login Link */}
<p className="mt-6 text-center text-sm text-gray-400">
Already have an account?{' '}
<Link
href={`/auth/login${returnTo !== '/onboarding' ? `?returnTo=${encodeURIComponent(returnTo)}` : ''}`}
className="text-primary-blue hover:underline font-medium"
>
Sign in
</Link>
</p>
</Card>
{/* Footer */}
<p className="mt-6 text-center text-xs text-gray-500">
By creating an account, you agree to our{' '}
<Link href="/terms" className="text-gray-400 hover:underline">Terms of Service</Link>
{' '}and{' '}
<Link href="/privacy" className="text-gray-400 hover:underline">Privacy Policy</Link>
</p>
</div>
</main>
);
}

View File

@@ -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 leagueRepository = getLeagueRepository();
const standingRepository = getStandingRepository();
const socialRepository = getSocialRepository();
const leagueMembershipRepository = getLeagueMembershipRepository();
const imageService = getImageService();
const [feedItems, upcomingRaces, allResults] = await Promise.all([
feedRepository.getFeedForDriver(session.user.primaryDriverId ?? ''),
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 completedRaces = upcomingRaces.filter((race) => race.status === 'completed');
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 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;
// Recent results for driver
const driverResults = allResults.filter(r => r.driverId === currentDriverId);
const recentResults = driverResults.slice(0, 5);
// 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 (
<main className="min-h-screen bg-deep-graphite">
<section className="max-w-7xl mx-auto px-6 pt-10 pb-4">
<div className="flex items-baseline justify-between gap-4 mb-4">
{/* Hero Section */}
<section className="relative overflow-hidden">
{/* Background Pattern */}
<div className="absolute inset-0 bg-gradient-to-br from-primary-blue/10 via-deep-graphite to-purple-600/5" />
<div className="absolute inset-0 opacity-5">
<div className="absolute inset-0" style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.4'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
}} />
</div>
<div className="relative max-w-7xl mx-auto px-6 py-10">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-8">
{/* Welcome Message */}
<div className="flex items-start gap-5">
{currentDriver && (
<div className="relative">
<div className="w-20 h-20 rounded-2xl bg-gradient-to-br from-primary-blue to-purple-600 p-0.5 shadow-xl shadow-primary-blue/20">
<div className="w-full h-full rounded-xl overflow-hidden bg-iron-gray">
<Image
src={imageService.getDriverAvatar(currentDriverId)}
alt={currentDriver.name}
width={80}
height={80}
className="w-full h-full object-cover"
/>
</div>
</div>
<div className="absolute -bottom-1 -right-1 w-5 h-5 rounded-full bg-performance-green border-3 border-deep-graphite" />
</div>
)}
<div>
<h1 className="text-3xl font-bold text-white">Dashboard</h1>
<p className="text-sm text-gray-400">
Personalized activity from your friends, leagues, and teams.
</p>
<p className="text-gray-400 text-sm mb-1">{getGreeting()},</p>
<h1 className="text-3xl md:text-4xl font-bold text-white mb-2">
{currentDriver?.name ?? 'Racer'}
<span className="ml-3 text-2xl">{currentDriver ? getCountryFlag(currentDriver.country) : '🏁'}</span>
</h1>
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-1.5 px-3 py-1 rounded-full bg-primary-blue/10 border border-primary-blue/30">
<Star className="w-3.5 h-3.5 text-primary-blue" />
<span className="text-sm font-semibold text-primary-blue">{rating}</span>
</div>
<div className="flex items-center gap-1.5 px-3 py-1 rounded-full bg-yellow-400/10 border border-yellow-400/30">
<Trophy className="w-3.5 h-3.5 text-yellow-400" />
<span className="text-sm font-semibold text-yellow-400">#{globalRank}</span>
</div>
<span className="text-xs text-gray-500">{totalRaces} races completed</span>
</div>
</div>
</div>
{/* Quick Actions */}
<div className="flex flex-wrap gap-3">
<Link href="/leagues">
<Button variant="secondary" className="flex items-center gap-2">
<Flag className="w-4 h-4" />
Browse Leagues
</Button>
</Link>
<Link href="/profile">
<Button variant="primary" className="flex items-center gap-2">
<Activity className="w-4 h-4" />
View Profile
</Button>
</Link>
</div>
</div>
{/* Quick Stats Row */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-8">
<div className="p-4 rounded-xl bg-iron-gray/50 border border-charcoal-outline backdrop-blur-sm">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-performance-green/20">
<Trophy className="w-5 h-5 text-performance-green" />
</div>
<div>
<p className="text-2xl font-bold text-white">{wins}</p>
<p className="text-xs text-gray-500">Wins</p>
</div>
</div>
</div>
<div className="p-4 rounded-xl bg-iron-gray/50 border border-charcoal-outline backdrop-blur-sm">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-warning-amber/20">
<Medal className="w-5 h-5 text-warning-amber" />
</div>
<div>
<p className="text-2xl font-bold text-white">{podiums}</p>
<p className="text-xs text-gray-500">Podiums</p>
</div>
</div>
</div>
<div className="p-4 rounded-xl bg-iron-gray/50 border border-charcoal-outline backdrop-blur-sm">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-blue/20">
<Target className="w-5 h-5 text-primary-blue" />
</div>
<div>
<p className="text-2xl font-bold text-white">{driverStats?.consistency ?? 0}%</p>
<p className="text-xs text-gray-500">Consistency</p>
</div>
</div>
</div>
<div className="p-4 rounded-xl bg-iron-gray/50 border border-charcoal-outline backdrop-blur-sm">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-500/20">
<Users className="w-5 h-5 text-purple-400" />
</div>
<div>
<p className="text-2xl font-bold text-white">{driverLeagues.length}</p>
<p className="text-xs text-gray-500">Active Leagues</p>
</div>
</div>
</div>
</div>
</div>
</section>
<FeedLayout
feedItems={feedItems}
upcomingRaces={upcoming}
latestResults={latestResults}
{/* Main Content */}
<section className="max-w-7xl mx-auto px-6 py-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column - Main Content */}
<div className="lg:col-span-2 space-y-6">
{/* Next Race Card */}
{nextRace && (
<Card className="relative overflow-hidden bg-gradient-to-br from-iron-gray to-iron-gray/80 border-primary-blue/30">
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-bl from-primary-blue/20 to-transparent rounded-bl-full" />
<div className="relative">
<div className="flex items-center gap-2 mb-4">
<div className="flex items-center gap-2 px-3 py-1 rounded-full bg-primary-blue/20 border border-primary-blue/30">
<Play className="w-3.5 h-3.5 text-primary-blue" />
<span className="text-xs font-semibold text-primary-blue uppercase tracking-wider">Next Race</span>
</div>
{myUpcomingRaces.includes(nextRace) && (
<span className="px-2 py-0.5 rounded-full bg-performance-green/20 text-performance-green text-xs font-medium">
Your League
</span>
)}
</div>
<div className="flex flex-col md:flex-row md:items-end md:justify-between gap-4">
<div>
<h2 className="text-2xl font-bold text-white mb-2">{nextRace.track}</h2>
<p className="text-gray-400 mb-3">{nextRace.car}</p>
<div className="flex flex-wrap items-center gap-4 text-sm">
<span className="flex items-center gap-1.5 text-gray-400">
<Calendar className="w-4 h-4" />
{nextRace.scheduledAt.toLocaleDateString('en-US', {
weekday: 'long',
month: 'short',
day: 'numeric',
})}
</span>
<span className="flex items-center gap-1.5 text-gray-400">
<Clock className="w-4 h-4" />
{nextRace.scheduledAt.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
</div>
<div className="flex flex-col items-end gap-3">
<div className="text-right">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Starts in</p>
<p className="text-3xl font-bold text-primary-blue font-mono">{timeUntil(nextRace.scheduledAt)}</p>
</div>
<Link href={`/races/${nextRace.id}`}>
<Button variant="primary" className="flex items-center gap-2">
View Details
<ChevronRight className="w-4 h-4" />
</Button>
</Link>
</div>
</div>
</div>
</Card>
)}
{/* League Standings Preview */}
{leagueStandings.length > 0 && (
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<Award className="w-5 h-5 text-yellow-400" />
Your Championship Standings
</h2>
<Link href="/profile/leagues" className="text-sm text-primary-blue hover:underline flex items-center gap-1">
View all <ChevronRight className="w-4 h-4" />
</Link>
</div>
<div className="space-y-3">
{leagueStandings.map(({ league, position, points, totalDrivers }) => (
<Link
key={league.id}
href={`/leagues/${league.id}/standings`}
className="flex items-center gap-4 p-4 rounded-xl bg-deep-graphite border border-charcoal-outline hover:border-primary-blue/30 transition-colors group"
>
<div className={`flex h-12 w-12 items-center justify-center rounded-xl font-bold text-xl ${
position === 1 ? 'bg-yellow-400/20 text-yellow-400' :
position === 2 ? 'bg-gray-300/20 text-gray-300' :
position === 3 ? 'bg-orange-400/20 text-orange-400' :
'bg-iron-gray text-gray-400'
}`}>
{position > 0 ? `P${position}` : '-'}
</div>
<div className="flex-1 min-w-0">
<p className="text-white font-semibold truncate group-hover:text-primary-blue transition-colors">
{league.name}
</p>
<p className="text-sm text-gray-500">
{points} points {totalDrivers} drivers
</p>
</div>
<div className="flex items-center gap-2">
{position <= 3 && position > 0 && (
<Crown className={`w-5 h-5 ${
position === 1 ? 'text-yellow-400' :
position === 2 ? 'text-gray-300' :
'text-orange-400'
}`} />
)}
<ChevronRight className="w-5 h-5 text-gray-500 group-hover:text-primary-blue transition-colors" />
</div>
</Link>
))}
</div>
</Card>
)}
{/* Activity Feed */}
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<Activity className="w-5 h-5 text-neon-aqua" />
Recent Activity
</h2>
</div>
{feedItems.length > 0 ? (
<div className="space-y-4">
{feedItems.slice(0, 5).map((item) => (
<FeedItemRow key={item.id} item={item} imageService={imageService} />
))}
</div>
) : (
<div className="text-center py-8">
<Activity className="w-12 h-12 text-gray-600 mx-auto mb-3" />
<p className="text-gray-400 mb-2">No activity yet</p>
<p className="text-sm text-gray-500">Join leagues and add friends to see activity here</p>
</div>
)}
</Card>
</div>
{/* Right Column - Sidebar */}
<div className="space-y-6">
{/* Upcoming Races */}
<Card>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Calendar className="w-5 h-5 text-red-400" />
Upcoming Races
</h3>
<Link href="/races" className="text-xs text-primary-blue hover:underline">
View all
</Link>
</div>
{upcomingRaces.length > 0 ? (
<div className="space-y-3">
{upcomingRaces.slice(0, 5).map((race) => {
const isMyRace = driverLeagueIds.includes(race.leagueId);
return (
<Link
key={race.id}
href={`/races/${race.id}`}
className="block p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-primary-blue/30 transition-colors"
>
<div className="flex items-start justify-between gap-2 mb-2">
<p className="text-white font-medium text-sm truncate">{race.track}</p>
{isMyRace && (
<span className="flex-shrink-0 w-2 h-2 rounded-full bg-performance-green" title="Your league" />
)}
</div>
<p className="text-xs text-gray-500 truncate mb-2">{race.car}</p>
<div className="flex items-center justify-between text-xs">
<span className="text-gray-400">
{race.scheduledAt.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</span>
<span className="text-primary-blue font-medium">{timeUntil(race.scheduledAt)}</span>
</div>
</Link>
);
})}
</div>
) : (
<p className="text-gray-500 text-sm text-center py-4">No upcoming races</p>
)}
</Card>
{/* Friends */}
<Card>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Users className="w-5 h-5 text-purple-400" />
Friends
</h3>
<span className="text-xs text-gray-500">{friends.length} friends</span>
</div>
{friends.length > 0 ? (
<div className="space-y-2">
{friends.slice(0, 6).map((friend) => (
<Link
key={friend.id}
href={`/drivers/${friend.id}`}
className="flex items-center gap-3 p-2 rounded-lg hover:bg-deep-graphite transition-colors"
>
<div className="w-9 h-9 rounded-full overflow-hidden bg-gradient-to-br from-primary-blue to-purple-600">
<Image
src={imageService.getDriverAvatar(friend.id)}
alt={friend.name}
width={36}
height={36}
className="w-full h-full object-cover"
/>
</div>
<div className="flex-1 min-w-0">
<p className="text-white text-sm font-medium truncate">{friend.name}</p>
<p className="text-xs text-gray-500">{getCountryFlag(friend.country)}</p>
</div>
</Link>
))}
{friends.length > 6 && (
<Link
href="/profile"
className="block text-center py-2 text-sm text-primary-blue hover:underline"
>
+{friends.length - 6} more
</Link>
)}
</div>
) : (
<div className="text-center py-6">
<UserPlus className="w-10 h-10 text-gray-600 mx-auto mb-2" />
<p className="text-sm text-gray-400 mb-2">No friends yet</p>
<Link href="/drivers">
<Button variant="secondary" className="text-xs">
Find Drivers
</Button>
</Link>
</div>
)}
</Card>
{/* Quick Links */}
<Card className="bg-gradient-to-br from-iron-gray to-iron-gray/80">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Zap className="w-5 h-5 text-warning-amber" />
Quick Links
</h3>
<div className="space-y-2">
<Link
href="/leaderboards"
className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite/50 border border-charcoal-outline hover:border-yellow-400/30 transition-colors"
>
<Trophy className="w-5 h-5 text-yellow-400" />
<span className="text-white text-sm">Leaderboards</span>
<ChevronRight className="w-4 h-4 text-gray-500 ml-auto" />
</Link>
<Link
href="/teams"
className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite/50 border border-charcoal-outline hover:border-purple-400/30 transition-colors"
>
<Users className="w-5 h-5 text-purple-400" />
<span className="text-white text-sm">Teams</span>
<ChevronRight className="w-4 h-4 text-gray-500 ml-auto" />
</Link>
<Link
href="/leagues/create"
className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite/50 border border-charcoal-outline hover:border-primary-blue/30 transition-colors"
>
<Flag className="w-5 h-5 text-primary-blue" />
<span className="text-white text-sm">Create League</span>
<ChevronRight className="w-4 h-4 text-gray-500 ml-auto" />
</Link>
</div>
</Card>
</div>
</div>
</section>
</main>
);
}
// 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 (
<div className="flex gap-3 p-3 rounded-lg bg-deep-graphite/50 border border-charcoal-outline">
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${color} flex-shrink-0`}>
<Icon className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-white">{item.headline}</p>
{item.body && (
<p className="text-xs text-gray-500 mt-1 line-clamp-2">{item.body}</p>
)}
<p className="text-xs text-gray-500 mt-1">{timeAgo(item.timestamp)}</p>
</div>
{item.ctaHref && (
<Link href={item.ctaHref} className="flex-shrink-0">
<Button variant="secondary" className="text-xs px-3 py-1.5">
{item.ctaLabel || 'View'}
</Button>
</Link>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,15 +2,41 @@
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 = {
interface DriverListItem {
id: string;
name: string;
rating: number;
@@ -21,17 +47,373 @@ type DriverListItem = {
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 (
<button
type="button"
onClick={onClick}
className={`p-5 rounded-xl bg-iron-gray/60 border-2 ${getBorderColor(position)} transition-all duration-200 text-left group hover:scale-[1.02]`}
>
{/* Header with Position */}
<div className="flex items-start justify-between mb-4">
<div className={`flex h-10 w-10 items-center justify-center rounded-full ${position <= 3 ? 'bg-gradient-to-br from-yellow-400/20 to-amber-600/10' : 'bg-iron-gray'}`}>
{position <= 3 ? (
<Crown className={`w-5 h-5 ${getMedalColor(position)}`} />
) : (
<span className="text-lg font-bold text-gray-400">#{position}</span>
)}
</div>
<span className={`px-2 py-1 rounded-full text-[10px] font-medium ${levelConfig?.bgColor} ${levelConfig?.color} border ${levelConfig?.borderColor}`}>
{levelConfig?.label}
</span>
</div>
{/* Avatar & Name */}
<div className="flex items-center gap-4 mb-4">
<div className="relative w-16 h-16 rounded-full overflow-hidden border-2 border-charcoal-outline group-hover:border-primary-blue transition-colors">
<Image src={imageService.getDriverAvatar(driver.id)} alt={driver.name} fill className="object-cover" />
</div>
<div>
<h3 className="text-lg font-semibold text-white group-hover:text-primary-blue transition-colors">
{driver.name}
</h3>
<div className="flex items-center gap-2 text-sm text-gray-500">
<Flag className="w-3.5 h-3.5" />
{driver.nationality}
</div>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-3">
<div className="text-center p-2 rounded-lg bg-charcoal-outline/30">
<p className="text-lg font-bold text-primary-blue">{driver.rating.toLocaleString()}</p>
<p className="text-[10px] text-gray-500">Rating</p>
</div>
<div className="text-center p-2 rounded-lg bg-charcoal-outline/30">
<p className="text-lg font-bold text-performance-green">{driver.wins}</p>
<p className="text-[10px] text-gray-500">Wins</p>
</div>
<div className="text-center p-2 rounded-lg bg-charcoal-outline/30">
<p className="text-lg font-bold text-warning-amber">{driver.podiums}</p>
<p className="text-[10px] text-gray-500">Podiums</p>
</div>
</div>
</button>
);
}
// ============================================================================
// 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 (
<div className="mb-10">
<div className="flex items-center gap-3 mb-4">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-neon-aqua/10 border border-neon-aqua/20">
<BarChart3 className="w-5 h-5 text-neon-aqua" />
</div>
<div>
<h2 className="text-lg font-semibold text-white">Skill Distribution</h2>
<p className="text-xs text-gray-500">Driver population by skill level</p>
</div>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{distribution.map((level) => {
const Icon = level.icon;
return (
<div
key={level.id}
className={`p-4 rounded-xl ${level.bgColor} border ${level.borderColor}`}
>
<div className="flex items-center justify-between mb-3">
<Icon className={`w-5 h-5 ${level.color}`} />
<span className={`text-2xl font-bold ${level.color}`}>{level.count}</span>
</div>
<p className="text-white font-medium mb-1">{level.label}</p>
<div className="w-full h-2 rounded-full bg-deep-graphite/50 overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
level.id === 'pro' ? 'bg-yellow-400' :
level.id === 'advanced' ? 'bg-purple-400' :
level.id === 'intermediate' ? 'bg-primary-blue' :
'bg-green-400'
}`}
style={{ width: `${level.percentage}%` }}
/>
</div>
<p className="text-xs text-gray-500 mt-1">{level.percentage}% of drivers</p>
</div>
);
})}
</div>
</div>
);
}
// ============================================================================
// 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 (
<div className="mb-10">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-yellow-400/20 to-amber-600/10 border border-yellow-400/30">
<Award className="w-5 h-5 text-yellow-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-white">Top Drivers</h2>
<p className="text-xs text-gray-500">Highest rated competitors</p>
</div>
</div>
<Button
variant="secondary"
onClick={() => router.push('/leaderboards/drivers')}
className="flex items-center gap-2 text-sm"
>
Full Rankings
<ChevronRight className="w-4 h-4" />
</Button>
</div>
<div className="rounded-xl bg-iron-gray/30 border border-charcoal-outline overflow-hidden">
<div className="divide-y divide-charcoal-outline/50">
{top5.map((driver, index) => {
const levelConfig = SKILL_LEVELS.find((l) => l.id === driver.skillLevel);
const position = index + 1;
return (
<button
key={driver.id}
type="button"
onClick={() => onDriverClick(driver.id)}
className="flex items-center gap-4 px-4 py-3 w-full text-left hover:bg-iron-gray/30 transition-colors group"
>
{/* Position */}
<div className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold border ${getMedalBg(position)} ${getMedalColor(position)}`}>
{position <= 3 ? <Crown className="w-3.5 h-3.5" /> : position}
</div>
{/* Avatar */}
<div className="relative w-9 h-9 rounded-full overflow-hidden border-2 border-charcoal-outline">
<Image src={imageService.getDriverAvatar(driver.id)} alt={driver.name} fill className="object-cover" />
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<p className="text-white font-medium truncate group-hover:text-primary-blue transition-colors">
{driver.name}
</p>
<div className="flex items-center gap-2 text-xs text-gray-500">
<Flag className="w-3 h-3" />
{driver.nationality}
<span className={levelConfig?.color}>{levelConfig?.label}</span>
</div>
</div>
{/* Stats */}
<div className="flex items-center gap-4 text-sm">
<div className="text-center">
<p className="text-primary-blue font-mono font-semibold">{driver.rating.toLocaleString()}</p>
<p className="text-[10px] text-gray-500">Rating</p>
</div>
<div className="text-center">
<p className="text-performance-green font-mono font-semibold">{driver.wins}</p>
<p className="text-[10px] text-gray-500">Wins</p>
</div>
</div>
</button>
);
})}
</div>
</div>
</div>
);
}
// ============================================================================
// 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 (
<div className="mb-10">
<div className="flex items-center gap-3 mb-4">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-performance-green/10 border border-performance-green/20">
<Activity className="w-5 h-5 text-performance-green" />
</div>
<div>
<h2 className="text-lg font-semibold text-white">Active Drivers</h2>
<p className="text-xs text-gray-500">Currently competing in leagues</p>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
{activeDrivers.map((driver) => {
const levelConfig = SKILL_LEVELS.find((l) => l.id === driver.skillLevel);
return (
<button
key={driver.id}
type="button"
onClick={() => onDriverClick(driver.id)}
className="p-3 rounded-xl bg-iron-gray/40 border border-charcoal-outline hover:border-performance-green/40 transition-all group text-center"
>
<div className="relative w-12 h-12 mx-auto rounded-full overflow-hidden border-2 border-charcoal-outline mb-2">
<Image src={imageService.getDriverAvatar(driver.id)} alt={driver.name} fill className="object-cover" />
<div className="absolute bottom-0 right-0 w-3 h-3 rounded-full bg-performance-green border-2 border-iron-gray" />
</div>
<p className="text-sm font-medium text-white truncate group-hover:text-performance-green transition-colors">
{driver.name}
</p>
<p className={`text-xs ${levelConfig?.color}`}>{levelConfig?.label}</p>
</button>
);
})}
</div>
</div>
);
}
// ============================================================================
// MAIN PAGE COMPONENT
// ============================================================================
export default function DriversPage() {
const router = useRouter();
const [drivers, setDrivers] = useState<DriverListItem[]>([]);
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 (
<div className="max-w-6xl mx-auto">
<div className="text-center text-gray-400">Loading drivers...</div>
<div className="max-w-7xl mx-auto px-4">
<div className="flex items-center justify-center min-h-[400px]">
<div className="flex flex-col items-center gap-4">
<div className="w-10 h-10 border-2 border-primary-blue border-t-transparent rounded-full animate-spin" />
<p className="text-gray-400">Loading drivers...</p>
</div>
</div>
</div>
);
}
return (
<div className="max-w-6xl mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold text-white mb-2">Drivers</h1>
<p className="text-gray-400">
Browse driver profiles and stats
<div className="max-w-7xl mx-auto px-4 pb-12">
{/* Hero Section */}
<div className="relative mb-10 py-10 px-8 rounded-2xl bg-gradient-to-br from-primary-blue/20 via-iron-gray/80 to-deep-graphite border border-primary-blue/30 overflow-hidden">
{/* Background decoration */}
<div className="absolute top-0 right-0 w-96 h-96 bg-primary-blue/10 rounded-full blur-3xl" />
<div className="absolute bottom-0 left-0 w-64 h-64 bg-yellow-400/5 rounded-full blur-3xl" />
<div className="absolute top-1/2 right-1/4 w-48 h-48 bg-performance-green/5 rounded-full blur-2xl" />
<div className="relative z-10 flex flex-col lg:flex-row lg:items-center lg:justify-between gap-8">
<div className="max-w-2xl">
<div className="flex items-center gap-3 mb-4">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-primary-blue/20 to-primary-blue/5 border border-primary-blue/20">
<Users className="w-6 h-6 text-primary-blue" />
</div>
<Heading level={1} className="text-3xl lg:text-4xl">
Drivers
</Heading>
</div>
<p className="text-gray-400 text-lg leading-relaxed mb-6">
Meet the racers who make every lap count. From rookies to champions, track their journey and see who's dominating the grid.
</p>
{/* Quick Stats */}
<div className="flex flex-wrap gap-6">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-primary-blue" />
<span className="text-sm text-gray-400">
<span className="text-white font-semibold">{drivers.length}</span> drivers
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-performance-green animate-pulse" />
<span className="text-sm text-gray-400">
<span className="text-white font-semibold">{activeCount}</span> active
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-yellow-400" />
<span className="text-sm text-gray-400">
<span className="text-white font-semibold">{totalWins.toLocaleString()}</span> total wins
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-neon-aqua" />
<span className="text-sm text-gray-400">
<span className="text-white font-semibold">{totalRaces.toLocaleString()}</span> races
</span>
</div>
</div>
</div>
<Card className="mb-8">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-gray-400 mb-2">
Search Drivers
</label>
{/* CTA */}
<div className="flex flex-col gap-4">
<Button
variant="primary"
onClick={() => router.push('/leaderboards/drivers')}
className="flex items-center gap-2 px-6 py-3"
>
<Trophy className="w-5 h-5" />
View Leaderboard
</Button>
<p className="text-xs text-gray-500 text-center">See full driver rankings</p>
</div>
</div>
</div>
{/* Search */}
<div className="mb-8">
<div className="relative max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
<Input
type="text"
placeholder="Search by name..."
placeholder="Search drivers by name or nationality..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-11"
/>
</div>
</div>
{/* Featured Drivers */}
{!searchQuery && (
<div className="mb-10">
<div className="flex items-center gap-3 mb-4">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-yellow-400/10 border border-yellow-400/20">
<Crown className="w-5 h-5 text-yellow-400" />
</div>
<div>
<label className="block text-sm font-medium text-gray-400 mb-2">
Skill Level
</label>
<select
className="w-full px-3 py-3 bg-iron-gray border-0 rounded-md text-white ring-1 ring-inset ring-charcoal-outline focus:ring-2 focus:ring-primary-blue transition-all duration-150 text-sm"
value={selectedSkill}
onChange={(e) => setSelectedSkill(e.target.value as SkillLevel | 'all')}
>
<option value="all">All Levels</option>
<option value="beginner">Beginner</option>
<option value="intermediate">Intermediate</option>
<option value="advanced">Advanced</option>
<option value="pro">Pro</option>
</select>
<h2 className="text-lg font-semibold text-white">Featured Drivers</h2>
<p className="text-xs text-gray-500">Top performers on the grid</p>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-400 mb-2">
Nationality
</label>
<select
className="w-full px-3 py-3 bg-iron-gray border-0 rounded-md text-white ring-1 ring-inset ring-charcoal-outline focus:ring-2 focus:ring-primary-blue transition-all duration-150 text-sm"
value={selectedNationality}
onChange={(e) => setSelectedNationality(e.target.value)}
>
<option value="all">All Countries</option>
{nationalities.map((nat) => (
<option key={nat} value={nat}>
{nat}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-400 mb-2">
Status
</label>
<label className="flex items-center pt-3">
<input
type="checkbox"
className="w-4 h-4 text-primary-blue bg-iron-gray border-charcoal-outline rounded focus:ring-primary-blue focus:ring-2"
checked={activeOnly}
onChange={(e) => setActiveOnly(e.target.checked)}
/>
<span className="ml-2 text-sm text-gray-400">Active only</span>
</label>
</div>
<div>
<label className="block text-sm font-medium text-gray-400 mb-2">
Sort By
</label>
<select
className="w-full px-3 py-3 bg-iron-gray border-0 rounded-md text-white ring-1 ring-inset ring-charcoal-outline focus:ring-2 focus:ring-primary-blue transition-all duration-150 text-sm"
value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)}
>
<option value="rank">Overall Rank</option>
<option value="rating">Rating</option>
<option value="wins">Wins</option>
<option value="podiums">Podiums</option>
</select>
</div>
</div>
</Card>
<div className="mb-4 flex items-center justify-between">
<p className="text-sm text-gray-400">
{sortedDrivers.length} {sortedDrivers.length === 1 ? 'driver' : 'drivers'} found
</p>
</div>
<div className="space-y-4">
{sortedDrivers.map((driver) => (
<DriverCard
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{featuredDrivers.map((driver, index) => (
<FeaturedDriverCard
key={driver.id}
id={driver.id}
name={driver.name}
rating={driver.rating}
skillLevel={driver.skillLevel}
nationality={driver.nationality}
racesCompleted={driver.racesCompleted}
wins={driver.wins}
podiums={driver.podiums}
rank={driver.rank}
driver={driver}
position={index + 1}
onClick={() => handleDriverClick(driver.id)}
/>
))}
</div>
{sortedDrivers.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-400">No drivers found matching your filters.</p>
</div>
)}
{/* Active Drivers */}
{!searchQuery && <RecentActivity drivers={drivers} onDriverClick={handleDriverClick} />}
{/* Skill Distribution */}
{!searchQuery && <SkillDistribution drivers={drivers} />}
{/* Leaderboard Preview */}
<LeaderboardPreview drivers={filteredDrivers} onDriverClick={handleDriverClick} />
{/* Empty State */}
{filteredDrivers.length === 0 && (
<Card className="text-center py-12">
<div className="flex flex-col items-center gap-4">
<Search className="w-10 h-10 text-gray-600" />
<p className="text-gray-400">No drivers found matching "{searchQuery}"</p>
<Button variant="secondary" onClick={() => setSearchQuery('')}>
Clear search
</Button>
</div>
</Card>
)}
</div>
);
}

View File

@@ -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 (
<div className="mb-10">
<div className="flex items-end justify-center gap-4 lg:gap-8">
{podiumOrder.map((driver, index) => {
const levelConfig = SKILL_LEVELS.find((l) => l.id === driver.skillLevel);
const position = positions[index];
return (
<button
key={driver.id}
type="button"
onClick={() => onDriverClick(driver.id)}
className="flex flex-col items-center group"
>
{/* Driver Avatar & Info */}
<div className="relative mb-4">
{/* Crown for 1st place */}
{position === 1 && (
<div className="absolute -top-6 left-1/2 -translate-x-1/2 animate-bounce">
<Crown className="w-8 h-8 text-yellow-400 drop-shadow-[0_0_10px_rgba(250,204,21,0.5)]" />
</div>
)}
{/* Avatar */}
<div className={`relative ${position === 1 ? 'w-24 h-24 lg:w-28 lg:h-28' : 'w-20 h-20 lg:w-24 lg:h-24'} rounded-full overflow-hidden border-4 ${position === 1 ? 'border-yellow-400 shadow-[0_0_30px_rgba(250,204,21,0.3)]' : position === 2 ? 'border-gray-300' : 'border-amber-600'} group-hover:scale-105 transition-transform`}>
<Image
src={imageService.getDriverAvatar(driver.id)}
alt={driver.name}
fill
className="object-cover"
/>
</div>
{/* Position badge */}
<div className={`absolute -bottom-2 left-1/2 -translate-x-1/2 w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold bg-gradient-to-br ${podiumColors[index]} border-2 ${crownColors[index]}`}>
{position}
</div>
</div>
{/* Driver Name */}
<p className={`text-white font-semibold ${position === 1 ? 'text-lg' : 'text-base'} group-hover:text-primary-blue transition-colors mb-1`}>
{driver.name}
</p>
{/* Rating */}
<p className={`font-mono font-bold ${position === 1 ? 'text-xl text-yellow-400' : 'text-lg text-primary-blue'}`}>
{driver.rating.toLocaleString()}
</p>
{/* Stats */}
<div className="flex items-center gap-2 text-xs text-gray-500 mt-1">
<span className="flex items-center gap-1">
<Trophy className="w-3 h-3 text-performance-green" />
{driver.wins}
</span>
<span></span>
<span className="flex items-center gap-1">
<Medal className="w-3 h-3 text-warning-amber" />
{driver.podiums}
</span>
</div>
{/* Podium Stand */}
<div className={`mt-4 w-28 lg:w-36 ${podiumHeights[index]} rounded-t-lg bg-gradient-to-t ${podiumColors[index]} border-t border-x flex items-end justify-center pb-4`}>
<span className={`text-4xl lg:text-5xl font-black ${crownColors[index]}`}>
{position}
</span>
</div>
</button>
);
})}
</div>
</div>
);
}
// ============================================================================
// MAIN PAGE COMPONENT
// ============================================================================
export default function DriverLeaderboardPage() {
const router = useRouter();
const imageService = getImageService();
const [drivers, setDrivers] = useState<DriverListItem[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [selectedSkill, setSelectedSkill] = useState<'all' | SkillLevel>('all');
const [sortBy, setSortBy] = useState<SortBy>('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 (
<div className="max-w-7xl mx-auto px-4">
<div className="flex items-center justify-center min-h-[400px]">
<div className="flex flex-col items-center gap-4">
<div className="w-10 h-10 border-2 border-primary-blue border-t-transparent rounded-full animate-spin" />
<p className="text-gray-400">Loading driver rankings...</p>
</div>
</div>
</div>
);
}
return (
<div className="max-w-7xl mx-auto px-4 pb-12">
{/* Header */}
<div className="mb-8">
<Button
variant="secondary"
onClick={() => router.push('/leaderboards')}
className="flex items-center gap-2 mb-6"
>
<ArrowLeft className="w-4 h-4" />
Back to Leaderboards
</Button>
<div className="flex items-center gap-4 mb-2">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-primary-blue/20 to-primary-blue/5 border border-primary-blue/20">
<Trophy className="w-7 h-7 text-primary-blue" />
</div>
<div>
<Heading level={1} className="text-3xl lg:text-4xl">
Driver Leaderboard
</Heading>
<p className="text-gray-400">Full rankings of all drivers by performance metrics</p>
</div>
</div>
</div>
{/* Top 3 Podium */}
{!searchQuery && sortBy === 'rank' && <TopThreePodium drivers={sortedDrivers} onDriverClick={handleDriverClick} />}
{/* Filters */}
<div className="mb-6 space-y-4">
<div className="flex flex-col lg:flex-row gap-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
<Input
type="text"
placeholder="Search drivers by name or nationality..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-11"
/>
</div>
<Button
type="button"
variant="secondary"
onClick={() => setShowFilters(!showFilters)}
className="lg:hidden flex items-center gap-2"
>
<Filter className="w-4 h-4" />
Filters
</Button>
</div>
<div className={`flex flex-wrap gap-2 ${showFilters ? 'block' : 'hidden lg:flex'}`}>
<button
type="button"
onClick={() => setSelectedSkill('all')}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-all ${
selectedSkill === 'all'
? 'bg-primary-blue text-white'
: 'bg-iron-gray/50 text-gray-400 border border-charcoal-outline hover:text-white'
}`}
>
All Levels
</button>
{SKILL_LEVELS.map((level) => {
const LevelIcon = level.icon;
return (
<button
key={level.id}
type="button"
onClick={() => setSelectedSkill(level.id)}
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-all ${
selectedSkill === level.id
? `${level.bgColor} ${level.color} border ${level.borderColor}`
: 'bg-iron-gray/50 text-gray-400 border border-charcoal-outline hover:text-white'
}`}
>
<LevelIcon className="w-4 h-4" />
{level.label}
</button>
);
})}
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">Sort by:</span>
<div className="flex items-center gap-1 p-1 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
{SORT_OPTIONS.map((option) => (
<button
key={option.id}
type="button"
onClick={() => setSortBy(option.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
sortBy === option.id
? 'bg-primary-blue text-white'
: 'text-gray-400 hover:text-white hover:bg-iron-gray'
}`}
>
<option.icon className="w-3.5 h-3.5" />
{option.label}
</button>
))}
</div>
</div>
</div>
{/* Leaderboard Table */}
<div className="rounded-xl bg-iron-gray/30 border border-charcoal-outline overflow-hidden">
{/* Table Header */}
<div className="grid grid-cols-12 gap-4 px-4 py-3 bg-iron-gray/50 border-b border-charcoal-outline text-xs font-medium text-gray-500 uppercase tracking-wider">
<div className="col-span-1 text-center">Rank</div>
<div className="col-span-5 lg:col-span-4">Driver</div>
<div className="col-span-2 text-center hidden md:block">Races</div>
<div className="col-span-2 lg:col-span-1 text-center">Rating</div>
<div className="col-span-2 lg:col-span-1 text-center">Wins</div>
<div className="col-span-1 text-center hidden lg:block">Podiums</div>
<div className="col-span-2 text-center">Win Rate</div>
</div>
{/* Table Body */}
<div className="divide-y divide-charcoal-outline/50">
{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 (
<button
key={driver.id}
type="button"
onClick={() => handleDriverClick(driver.id)}
className="grid grid-cols-12 gap-4 px-4 py-4 w-full text-left hover:bg-iron-gray/30 transition-colors group"
>
{/* Position */}
<div className="col-span-1 flex items-center justify-center">
<div className={`flex h-9 w-9 items-center justify-center rounded-full text-sm font-bold border ${getMedalBg(position)} ${getMedalColor(position)}`}>
{position <= 3 ? <Medal className="w-4 h-4" /> : position}
</div>
</div>
{/* Driver Info */}
<div className="col-span-5 lg:col-span-4 flex items-center gap-3">
<div className="relative w-10 h-10 rounded-full overflow-hidden border-2 border-charcoal-outline">
<Image src={imageService.getDriverAvatar(driver.id)} alt={driver.name} fill className="object-cover" />
</div>
<div className="min-w-0">
<p className="text-white font-semibold truncate group-hover:text-primary-blue transition-colors">
{driver.name}
</p>
<div className="flex items-center gap-2 text-xs text-gray-500">
<span className="flex items-center gap-1">
<Flag className="w-3 h-3" />
{driver.nationality}
</span>
<span className={`flex items-center gap-1 ${levelConfig?.color}`}>
<LevelIcon className="w-3 h-3" />
{levelConfig?.label}
</span>
</div>
</div>
</div>
{/* Races */}
<div className="col-span-2 items-center justify-center hidden md:flex">
<span className="text-gray-400">{driver.racesCompleted}</span>
</div>
{/* Rating */}
<div className="col-span-2 lg:col-span-1 flex items-center justify-center">
<span className={`font-mono font-semibold ${sortBy === 'rating' ? 'text-primary-blue' : 'text-white'}`}>
{driver.rating.toLocaleString()}
</span>
</div>
{/* Wins */}
<div className="col-span-2 lg:col-span-1 flex items-center justify-center">
<span className={`font-mono font-semibold ${sortBy === 'wins' ? 'text-primary-blue' : 'text-performance-green'}`}>
{driver.wins}
</span>
</div>
{/* Podiums */}
<div className="col-span-1 items-center justify-center hidden lg:flex">
<span className={`font-mono font-semibold ${sortBy === 'podiums' ? 'text-primary-blue' : 'text-warning-amber'}`}>
{driver.podiums}
</span>
</div>
{/* Win Rate */}
<div className="col-span-2 flex items-center justify-center">
<span className={`font-mono font-semibold ${sortBy === 'winRate' ? 'text-primary-blue' : 'text-white'}`}>
{winRate}%
</span>
</div>
</button>
);
})}
</div>
{/* Empty State */}
{sortedDrivers.length === 0 && (
<div className="py-16 text-center">
<Search className="w-12 h-12 text-gray-600 mx-auto mb-4" />
<p className="text-gray-400 mb-2">No drivers found</p>
<p className="text-sm text-gray-500">Try adjusting your filters or search query</p>
<Button
variant="secondary"
onClick={() => {
setSearchQuery('');
setSelectedSkill('all');
}}
className="mt-4"
>
Clear Filters
</Button>
</div>
)}
</div>
</div>
);
}

View File

@@ -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 (
<div className="rounded-xl bg-iron-gray/30 border border-charcoal-outline overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-charcoal-outline bg-iron-gray/20">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-primary-blue/20 to-primary-blue/5 border border-primary-blue/20">
<Trophy className="w-5 h-5 text-primary-blue" />
</div>
<div>
<h3 className="text-lg font-semibold text-white">Driver Rankings</h3>
<p className="text-xs text-gray-500">Top performers across all leagues</p>
</div>
</div>
<Button
variant="secondary"
onClick={() => router.push('/leaderboards/drivers')}
className="flex items-center gap-2 text-sm"
>
View All
<ChevronRight className="w-4 h-4" />
</Button>
</div>
{/* Leaderboard Rows */}
<div className="divide-y divide-charcoal-outline/50">
{top10.map((driver, index) => {
const levelConfig = SKILL_LEVELS.find((l) => l.id === driver.skillLevel);
const position = index + 1;
return (
<button
key={driver.id}
type="button"
onClick={() => onDriverClick(driver.id)}
className="flex items-center gap-4 px-5 py-3 w-full text-left hover:bg-iron-gray/30 transition-colors group"
>
{/* Position */}
<div className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold border ${getMedalBg(position)} ${getMedalColor(position)}`}>
{position <= 3 ? <Crown className="w-3.5 h-3.5" /> : position}
</div>
{/* Avatar */}
<div className="relative w-9 h-9 rounded-full overflow-hidden border-2 border-charcoal-outline">
<Image src={imageService.getDriverAvatar(driver.id)} alt={driver.name} fill className="object-cover" />
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<p className="text-white font-medium truncate group-hover:text-primary-blue transition-colors">
{driver.name}
</p>
<div className="flex items-center gap-2 text-xs text-gray-500">
<Flag className="w-3 h-3" />
{driver.nationality}
<span className={levelConfig?.color}>{levelConfig?.label}</span>
</div>
</div>
{/* Stats */}
<div className="flex items-center gap-4 text-sm">
<div className="text-center">
<p className="text-primary-blue font-mono font-semibold">{driver.rating.toLocaleString()}</p>
<p className="text-[10px] text-gray-500">Rating</p>
</div>
<div className="text-center">
<p className="text-performance-green font-mono font-semibold">{driver.wins}</p>
<p className="text-[10px] text-gray-500">Wins</p>
</div>
</div>
</button>
);
})}
</div>
</div>
);
}
// ============================================================================
// 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 (
<div className="rounded-xl bg-iron-gray/30 border border-charcoal-outline overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-charcoal-outline bg-iron-gray/20">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-purple-500/20 to-purple-500/5 border border-purple-500/20">
<Users className="w-5 h-5 text-purple-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-white">Team Rankings</h3>
<p className="text-xs text-gray-500">Top performing racing teams</p>
</div>
</div>
<Button
variant="secondary"
onClick={() => router.push('/teams/leaderboard')}
className="flex items-center gap-2 text-sm"
>
View All
<ChevronRight className="w-4 h-4" />
</Button>
</div>
{/* Leaderboard Rows */}
<div className="divide-y divide-charcoal-outline/50">
{top5.map((team, index) => {
const levelConfig = SKILL_LEVELS.find((l) => l.id === team.performanceLevel);
const LevelIcon = levelConfig?.icon || Shield;
const position = index + 1;
return (
<button
key={team.id}
type="button"
onClick={() => onTeamClick(team.id)}
className="flex items-center gap-4 px-5 py-3 w-full text-left hover:bg-iron-gray/30 transition-colors group"
>
{/* Position */}
<div className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold border ${getMedalBg(position)} ${getMedalColor(position)}`}>
{position <= 3 ? <Crown className="w-3.5 h-3.5" /> : position}
</div>
{/* Team Icon */}
<div className={`flex h-9 w-9 items-center justify-center rounded-lg ${levelConfig?.bgColor} border ${levelConfig?.borderColor}`}>
<LevelIcon className={`w-4 h-4 ${levelConfig?.color}`} />
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<p className="text-white font-medium truncate group-hover:text-purple-400 transition-colors">
{team.name}
</p>
<div className="flex items-center gap-2 text-xs text-gray-500">
<Users className="w-3 h-3" />
{team.memberCount} members
<span className={levelConfig?.color}>{levelConfig?.label}</span>
</div>
</div>
{/* Stats */}
<div className="flex items-center gap-4 text-sm">
<div className="text-center">
<p className="text-purple-400 font-mono font-semibold">{team.rating?.toLocaleString()}</p>
<p className="text-[10px] text-gray-500">Rating</p>
</div>
<div className="text-center">
<p className="text-performance-green font-mono font-semibold">{team.totalWins}</p>
<p className="text-[10px] text-gray-500">Wins</p>
</div>
</div>
</button>
);
})}
</div>
</div>
);
}
// ============================================================================
// MAIN PAGE COMPONENT
// ============================================================================
export default function LeaderboardsPage() {
const router = useRouter();
const [drivers, setDrivers] = useState<DriverListItem[]>([]);
const [teams, setTeams] = useState<TeamDisplayData[]>([]);
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 (
<div className="max-w-7xl mx-auto px-4">
<div className="flex items-center justify-center min-h-[400px]">
<div className="flex flex-col items-center gap-4">
<div className="w-10 h-10 border-2 border-yellow-400 border-t-transparent rounded-full animate-spin" />
<p className="text-gray-400">Loading leaderboards...</p>
</div>
</div>
</div>
);
}
return (
<div className="max-w-7xl mx-auto px-4 pb-12">
{/* Hero Section */}
<div className="relative mb-10 py-10 px-8 rounded-2xl bg-gradient-to-br from-yellow-600/20 via-iron-gray/80 to-deep-graphite border border-yellow-500/20 overflow-hidden">
{/* Background decoration */}
<div className="absolute top-0 right-0 w-96 h-96 bg-yellow-400/10 rounded-full blur-3xl" />
<div className="absolute bottom-0 left-0 w-64 h-64 bg-amber-600/5 rounded-full blur-3xl" />
<div className="absolute top-1/2 right-1/4 w-48 h-48 bg-purple-500/5 rounded-full blur-2xl" />
<div className="relative z-10">
<div className="flex items-center gap-4 mb-4">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-yellow-400/20 to-amber-600/10 border border-yellow-400/30">
<Award className="w-7 h-7 text-yellow-400" />
</div>
<div>
<Heading level={1} className="text-3xl lg:text-4xl">
Leaderboards
</Heading>
<p className="text-gray-400">Where champions rise and legends are made</p>
</div>
</div>
<p className="text-gray-400 text-lg leading-relaxed max-w-2xl mb-6">
Track the best drivers and teams across all competitions. Every race counts. Every position matters. Who will claim the throne?
</p>
{/* Quick Nav */}
<div className="flex flex-wrap gap-3">
<Button
variant="secondary"
onClick={() => router.push('/leaderboards/drivers')}
className="flex items-center gap-2"
>
<Trophy className="w-4 h-4 text-primary-blue" />
Driver Rankings
</Button>
<Button
variant="secondary"
onClick={() => router.push('/teams/leaderboard')}
className="flex items-center gap-2"
>
<Users className="w-4 h-4 text-purple-400" />
Team Rankings
</Button>
</div>
</div>
</div>
{/* Leaderboard Grids */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<DriverLeaderboardPreview drivers={drivers} onDriverClick={handleDriverClick} />
<TeamLeaderboardPreview teams={teams} onTeamClick={handleTeamClick} />
</div>
</div>
);
}

View File

@@ -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 (
<main className="min-h-screen bg-deep-graphite">
<OnboardingWizard />
</main>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -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 (
<div className="mb-10 p-8 rounded-2xl bg-gradient-to-br from-iron-gray/60 to-iron-gray/30 border border-charcoal-outline">
<div className="flex items-center justify-center gap-2 mb-8">
<Trophy className="w-6 h-6 text-yellow-400" />
<h2 className="text-xl font-bold text-white">Top 3 Teams</h2>
</div>
<div className="flex items-end justify-center gap-4 md:gap-8">
{podiumOrder.map((team, index) => {
const position = podiumPositions[index];
const levelConfig = SKILL_LEVELS.find((l) => l.id === team.performanceLevel);
const LevelIcon = levelConfig?.icon || Shield;
return (
<button
key={team.id}
type="button"
onClick={() => onTeamClick(team.id)}
className="flex flex-col items-center group"
>
{/* Team card */}
<div
className={`relative mb-4 p-4 rounded-xl bg-gradient-to-br ${getGradient(position)} border ${getBorderColor(position)} transition-all group-hover:scale-105 group-hover:shadow-lg`}
>
{/* Crown for 1st place */}
{position === 1 && (
<div className="absolute -top-4 left-1/2 -translate-x-1/2">
<div className="relative">
<Crown className="w-8 h-8 text-yellow-400 animate-pulse" />
<div className="absolute inset-0 w-8 h-8 bg-yellow-400/30 blur-md rounded-full" />
</div>
</div>
)}
{/* Team icon */}
<div
className={`flex h-16 w-16 md:h-20 md:w-20 items-center justify-center rounded-xl ${levelConfig?.bgColor} border ${levelConfig?.borderColor} mb-3`}
>
<LevelIcon className={`w-8 h-8 md:w-10 md:h-10 ${levelConfig?.color}`} />
</div>
{/* Team name */}
<p className="text-white font-bold text-sm md:text-base text-center max-w-[120px] truncate group-hover:text-purple-400 transition-colors">
{team.name}
</p>
{/* Rating */}
<p className={`text-lg md:text-xl font-mono font-bold ${getPositionColor(position)} text-center`}>
{team.rating?.toLocaleString() ?? '—'}
</p>
{/* Stats row */}
<div className="flex items-center justify-center gap-3 mt-2 text-xs text-gray-400">
<span className="flex items-center gap-1">
<Trophy className="w-3 h-3 text-performance-green" />
{team.totalWins}
</span>
<span className="flex items-center gap-1">
<Users className="w-3 h-3 text-purple-400" />
{team.memberCount}
</span>
</div>
</div>
{/* Podium stand */}
<div
className={`${podiumHeights[index]} w-20 md:w-28 rounded-t-lg bg-gradient-to-t ${getGradient(position)} border-t border-x ${getBorderColor(position)} flex items-start justify-center pt-3`}
>
<span className={`text-2xl md:text-3xl font-bold ${getPositionColor(position)}`}>
{position}
</span>
</div>
</button>
);
})}
</div>
</div>
);
}
// ============================================================================
// MAIN PAGE COMPONENT
// ============================================================================
export default function TeamLeaderboardPage() {
const router = useRouter();
const [realTeams, setRealTeams] = useState<TeamDisplayData[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState<SortBy>('rating');
const [filterLevel, setFilterLevel] = useState<SkillLevel | 'all'>('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 (
<div className="max-w-7xl mx-auto px-4">
<div className="flex items-center justify-center min-h-[400px]">
<div className="flex flex-col items-center gap-4">
<div className="w-10 h-10 border-2 border-yellow-400 border-t-transparent rounded-full animate-spin" />
<p className="text-gray-400">Loading leaderboard...</p>
</div>
</div>
</div>
);
}
return (
<div className="max-w-7xl mx-auto px-4 pb-12">
{/* Header */}
<div className="mb-8">
<Button
variant="secondary"
onClick={() => router.push('/teams')}
className="flex items-center gap-2 mb-6"
>
<ArrowLeft className="w-4 h-4" />
Back to Teams
</Button>
<div className="flex items-center gap-4 mb-2">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-yellow-400/20 to-amber-600/10 border border-yellow-400/30">
<Award className="w-7 h-7 text-yellow-400" />
</div>
<div>
<Heading level={1} className="text-3xl lg:text-4xl">
Team Leaderboard
</Heading>
<p className="text-gray-400">Rankings of all teams by performance metrics</p>
</div>
</div>
</div>
{/* Filters and Search */}
<div className="mb-6 space-y-4">
{/* Search and Level Filter Row */}
<div className="flex flex-col lg:flex-row gap-4">
<div className="flex-1 relative max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
<Input
type="text"
placeholder="Search teams..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-11"
/>
</div>
{/* Level Filter */}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setFilterLevel('all')}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-all ${
filterLevel === 'all'
? 'bg-purple-600 text-white'
: 'bg-iron-gray/50 text-gray-400 border border-charcoal-outline hover:text-white'
}`}
>
All Levels
</button>
{SKILL_LEVELS.map((level) => {
const LevelIcon = level.icon;
return (
<button
key={level.id}
type="button"
onClick={() => setFilterLevel(level.id)}
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-all ${
filterLevel === level.id
? `${level.bgColor} ${level.color} border ${level.borderColor}`
: 'bg-iron-gray/50 text-gray-400 border border-charcoal-outline hover:text-white'
}`}
>
<LevelIcon className="w-4 h-4" />
{level.label}
</button>
);
})}
</div>
</div>
{/* Sort Options */}
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">Sort by:</span>
<div className="flex items-center gap-1 p-1 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
{SORT_OPTIONS.map((option) => (
<button
key={option.id}
type="button"
onClick={() => setSortBy(option.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
sortBy === option.id
? 'bg-purple-600 text-white'
: 'text-gray-400 hover:text-white hover:bg-iron-gray'
}`}
>
<option.icon className="w-3.5 h-3.5" />
{option.label}
</button>
))}
</div>
</div>
</div>
{/* Podium for Top 3 - only show when viewing by rating without filters */}
{sortBy === 'rating' && filterLevel === 'all' && !searchQuery && filteredAndSortedTeams.length >= 3 && (
<TopThreePodium teams={filteredAndSortedTeams} onTeamClick={handleTeamClick} />
)}
{/* Stats Summary */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div className="p-4 rounded-xl bg-iron-gray/30 border border-charcoal-outline">
<div className="flex items-center gap-2 mb-1">
<Users className="w-4 h-4 text-purple-400" />
<span className="text-xs text-gray-500">Total Teams</span>
</div>
<p className="text-2xl font-bold text-white">{filteredAndSortedTeams.length}</p>
</div>
<div className="p-4 rounded-xl bg-iron-gray/30 border border-charcoal-outline">
<div className="flex items-center gap-2 mb-1">
<Crown className="w-4 h-4 text-yellow-400" />
<span className="text-xs text-gray-500">Pro Teams</span>
</div>
<p className="text-2xl font-bold text-white">
{filteredAndSortedTeams.filter((t) => t.performanceLevel === 'pro').length}
</p>
</div>
<div className="p-4 rounded-xl bg-iron-gray/30 border border-charcoal-outline">
<div className="flex items-center gap-2 mb-1">
<Trophy className="w-4 h-4 text-performance-green" />
<span className="text-xs text-gray-500">Total Wins</span>
</div>
<p className="text-2xl font-bold text-white">
{filteredAndSortedTeams.reduce((sum, t) => sum + t.totalWins, 0)}
</p>
</div>
<div className="p-4 rounded-xl bg-iron-gray/30 border border-charcoal-outline">
<div className="flex items-center gap-2 mb-1">
<Target className="w-4 h-4 text-neon-aqua" />
<span className="text-xs text-gray-500">Total Races</span>
</div>
<p className="text-2xl font-bold text-white">
{filteredAndSortedTeams.reduce((sum, t) => sum + t.totalRaces, 0)}
</p>
</div>
</div>
{/* Leaderboard Table */}
<div className="rounded-xl bg-iron-gray/30 border border-charcoal-outline overflow-hidden">
{/* Table Header */}
<div className="grid grid-cols-12 gap-4 px-4 py-3 bg-iron-gray/50 border-b border-charcoal-outline text-xs font-medium text-gray-500 uppercase tracking-wider">
<div className="col-span-1 text-center">Rank</div>
<div className="col-span-4 lg:col-span-5">Team</div>
<div className="col-span-2 text-center hidden lg:block">Members</div>
<div className="col-span-2 lg:col-span-1 text-center">Rating</div>
<div className="col-span-2 lg:col-span-1 text-center">Wins</div>
<div className="col-span-2 text-center">Win Rate</div>
</div>
{/* Table Body */}
<div className="divide-y divide-charcoal-outline/50">
{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 (
<button
key={team.id}
type="button"
onClick={() => handleTeamClick(team.id)}
className="grid grid-cols-12 gap-4 px-4 py-4 w-full text-left hover:bg-iron-gray/30 transition-colors group"
>
{/* Position */}
<div className="col-span-1 flex items-center justify-center">
<div
className={`flex h-9 w-9 items-center justify-center rounded-full text-sm font-bold border ${getMedalBg(index)} ${getMedalColor(index)}`}
>
{index < 3 ? (
<Medal className="w-4 h-4" />
) : (
index + 1
)}
</div>
</div>
{/* Team Info */}
<div className="col-span-4 lg:col-span-5 flex items-center gap-3">
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${levelConfig?.bgColor} border ${levelConfig?.borderColor}`}>
<LevelIcon className={`w-5 h-5 ${levelConfig?.color}`} />
</div>
<div className="min-w-0 flex-1">
<p className="text-white font-semibold truncate group-hover:text-purple-400 transition-colors">
{team.name}
</p>
<div className="flex items-center gap-2 text-xs text-gray-500 flex-wrap">
<span className={`${levelConfig?.color}`}>{levelConfig?.label}</span>
{team.region && (
<span className="flex items-center gap-1 text-gray-400">
<Globe className="w-3 h-3 text-neon-aqua" />
{team.region}
</span>
)}
{team.languages && team.languages.length > 0 && (
<span className="flex items-center gap-1 text-gray-400">
<Languages className="w-3 h-3 text-purple-400" />
{team.languages.slice(0, 2).join(', ')}
{team.languages.length > 2 && ` +${team.languages.length - 2}`}
</span>
)}
{team.isRecruiting && (
<span className="flex items-center gap-1 text-performance-green">
<div className="w-1.5 h-1.5 rounded-full bg-performance-green animate-pulse" />
Recruiting
</span>
)}
</div>
</div>
</div>
{/* Members */}
<div className="col-span-2 items-center justify-center hidden lg:flex">
<span className="flex items-center gap-1 text-gray-400">
<Users className="w-4 h-4" />
{team.memberCount}
</span>
</div>
{/* Rating */}
<div className="col-span-2 lg:col-span-1 flex items-center justify-center">
<span className={`font-mono font-semibold ${sortBy === 'rating' ? 'text-purple-400' : 'text-white'}`}>
{team.rating?.toLocaleString() ?? '—'}
</span>
</div>
{/* Wins */}
<div className="col-span-2 lg:col-span-1 flex items-center justify-center">
<span className={`font-mono font-semibold ${sortBy === 'wins' ? 'text-purple-400' : 'text-white'}`}>
{team.totalWins}
</span>
</div>
{/* Win Rate */}
<div className="col-span-2 flex items-center justify-center">
<span className={`font-mono font-semibold ${sortBy === 'winRate' ? 'text-purple-400' : 'text-white'}`}>
{winRate}%
</span>
</div>
</button>
);
})}
</div>
{/* Empty State */}
{filteredAndSortedTeams.length === 0 && (
<div className="py-16 text-center">
<Trophy className="w-12 h-12 text-gray-600 mx-auto mb-4" />
<p className="text-gray-400 mb-2">No teams found</p>
<p className="text-sm text-gray-500">Try adjusting your filters or search query</p>
<Button
variant="secondary"
onClick={() => {
setSearchQuery('');
setFilterLevel('all');
}}
className="mt-4"
>
Clear Filters
</Button>
</div>
)}
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -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) {

View File

@@ -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 (
<div className="flex items-center justify-center gap-2 mb-8">
{steps.map((step, index) => {
const Icon = step.icon;
const isCompleted = step.id < currentStep;
const isCurrent = step.id === currentStep;
return (
<div key={step.id} className="flex items-center">
<div className="flex flex-col items-center">
<div
className={`flex h-12 w-12 items-center justify-center rounded-full transition-all duration-300 ${
isCurrent
? 'bg-primary-blue text-white shadow-lg shadow-primary-blue/30'
: isCompleted
? 'bg-performance-green text-white'
: 'bg-iron-gray border border-charcoal-outline text-gray-500'
}`}
>
{isCompleted ? (
<Check className="w-5 h-5" />
) : (
<Icon className="w-5 h-5" />
)}
</div>
<span
className={`mt-2 text-xs font-medium ${
isCurrent ? 'text-white' : isCompleted ? 'text-performance-green' : 'text-gray-500'
}`}
>
{step.label}
</span>
</div>
{index < steps.length - 1 && (
<div
className={`w-12 h-0.5 mx-2 mt-[-20px] ${
isCompleted ? 'bg-performance-green' : 'bg-charcoal-outline'
}`}
/>
)}
</div>
);
})}
</div>
);
}
function SelectableCard({
selected,
onClick,
icon,
label,
description,
className = '',
}: {
selected: boolean;
onClick: () => void;
icon?: string | React.ReactNode;
label: string;
description?: string;
className?: string;
}) {
return (
<button
type="button"
onClick={onClick}
className={`p-4 rounded-xl border text-left transition-all ${
selected
? 'bg-primary-blue/20 border-primary-blue text-white'
: 'bg-iron-gray/50 border-charcoal-outline text-gray-400 hover:border-gray-500 hover:bg-iron-gray'
} ${className}`}
>
<div className="flex items-center gap-3">
{icon && (
<span className="text-2xl">{typeof icon === 'string' ? icon : icon}</span>
)}
<div>
<p className={`font-medium ${selected ? 'text-white' : 'text-gray-300'}`}>{label}</p>
{description && (
<p className={`text-xs mt-0.5 ${selected ? 'text-primary-blue/80' : 'text-gray-500'}`}>
{description}
</p>
)}
</div>
</div>
</button>
);
}
// ============================================================================
// MAIN COMPONENT
// ============================================================================
export default function OnboardingWizard() {
const router = useRouter();
const [step, setStep] = useState<OnboardingStep>(1);
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<FormErrors>({});
// Form state
const [personalInfo, setPersonalInfo] = useState<PersonalInfo>({
firstName: '',
lastName: '',
displayName: '',
email: '',
country: '',
timezone: '',
});
const [racingInfo, setRacingInfo] = useState<RacingInfo>({
iracingId: '',
experienceLevel: 'intermediate',
preferredDiscipline: 'road',
yearsRacing: '',
});
const [preferencesInfo, setPreferencesInfo] = useState<PreferencesInfo>({
favoriteTrack: '',
favoriteCar: '',
racingStyle: 'consistent',
availability: 'weekday-evenings',
lookingForTeam: false,
openToRequests: true,
});
const [bioInfo, setBioInfo] = useState<BioInfo>({
bio: '',
goals: '',
});
// Validation
const validateStep = async (currentStep: OnboardingStep): Promise<boolean> => {
const newErrors: FormErrors = {};
if (currentStep === 1) {
if (!personalInfo.firstName.trim()) {
newErrors.firstName = 'First name is required';
}
if (!personalInfo.lastName.trim()) {
newErrors.lastName = 'Last name is required';
}
if (!personalInfo.displayName.trim()) {
newErrors.displayName = 'Display name is required';
} else if (personalInfo.displayName.length < 3) {
newErrors.displayName = 'Display name must be at least 3 characters';
}
if (!personalInfo.country) {
newErrors.country = 'Please select your country';
}
}
if (currentStep === 2) {
if (!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 (
<div className="max-w-3xl mx-auto px-4 py-10">
{/* Header */}
<div className="text-center mb-8">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-primary-blue/20 to-purple-600/10 border border-primary-blue/30 mx-auto mb-4">
<Flag className="w-8 h-8 text-primary-blue" />
</div>
<Heading level={1} className="mb-2">Welcome to GridPilot</Heading>
<p className="text-gray-400">
Let's set up your racing profile in just a few steps
</p>
</div>
{/* Progress Indicator */}
<StepIndicator currentStep={step} totalSteps={4} />
{/* Form Card */}
<Card className="relative overflow-hidden">
{/* Background accent */}
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-bl from-primary-blue/10 to-transparent rounded-bl-full" />
<form onSubmit={handleSubmit} className="relative">
{/* Step 1: Personal Information */}
{step === 1 && (
<div className="space-y-6">
<div>
<Heading level={2} className="text-xl mb-1 flex items-center gap-2">
<User className="w-5 h-5 text-primary-blue" />
Personal Information
</Heading>
<p className="text-sm text-gray-400">
Tell us a bit about yourself
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor="firstName" className="block text-sm font-medium text-gray-300 mb-2">
First Name *
</label>
<Input
id="firstName"
type="text"
value={personalInfo.firstName}
onChange={(e) =>
setPersonalInfo({ ...personalInfo, firstName: e.target.value })
}
error={!!errors.firstName}
errorMessage={errors.firstName}
placeholder="Max"
disabled={loading}
/>
</div>
<div>
<label htmlFor="lastName" className="block text-sm font-medium text-gray-300 mb-2">
Last Name *
</label>
<Input
id="lastName"
type="text"
value={personalInfo.lastName}
onChange={(e) =>
setPersonalInfo({ ...personalInfo, lastName: e.target.value })
}
error={!!errors.lastName}
errorMessage={errors.lastName}
placeholder="Verstappen"
disabled={loading}
/>
</div>
</div>
<div>
<label htmlFor="displayName" className="block text-sm font-medium text-gray-300 mb-2">
Display Name * <span className="text-gray-500 font-normal">(shown publicly)</span>
</label>
<Input
id="displayName"
type="text"
value={personalInfo.displayName}
onChange={(e) =>
setPersonalInfo({ ...personalInfo, displayName: e.target.value })
}
error={!!errors.displayName}
errorMessage={errors.displayName}
placeholder="SuperMax33"
disabled={loading}
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-300 mb-2">
Email <span className="text-gray-500 font-normal">(optional, for notifications)</span>
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<Input
id="email"
type="email"
value={personalInfo.email}
onChange={(e) =>
setPersonalInfo({ ...personalInfo, email: e.target.value })
}
placeholder="max@racing.com"
disabled={loading}
className="pl-10"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor="country" className="block text-sm font-medium text-gray-300 mb-2">
Country *
</label>
<div className="relative">
<Globe className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500 z-10" />
<select
id="country"
value={personalInfo.country}
onChange={(e) =>
setPersonalInfo({ ...personalInfo, country: e.target.value })
}
className="block w-full rounded-md border-0 px-4 py-3 pl-10 bg-iron-gray text-white shadow-sm ring-1 ring-inset ring-charcoal-outline placeholder:text-gray-500 focus:ring-2 focus:ring-inset focus:ring-primary-blue transition-all duration-150 sm:text-sm appearance-none cursor-pointer"
disabled={loading}
>
<option value="">Select country</option>
{COUNTRIES.map((country) => (
<option key={country.code} value={country.code}>
{getCountryFlag(country.code)} {country.name}
</option>
))}
</select>
<ChevronRight className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500 rotate-90" />
</div>
{errors.country && (
<p className="mt-2 text-sm text-warning-amber">{errors.country}</p>
)}
</div>
<div>
<label htmlFor="timezone" className="block text-sm font-medium text-gray-300 mb-2">
Timezone
</label>
<div className="relative">
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500 z-10" />
<select
id="timezone"
value={personalInfo.timezone}
onChange={(e) =>
setPersonalInfo({ ...personalInfo, timezone: e.target.value })
}
className="block w-full rounded-md border-0 px-4 py-3 pl-10 bg-iron-gray text-white shadow-sm ring-1 ring-inset ring-charcoal-outline placeholder:text-gray-500 focus:ring-2 focus:ring-inset focus:ring-primary-blue transition-all duration-150 sm:text-sm appearance-none cursor-pointer"
disabled={loading}
>
<option value="">Select timezone</option>
{TIMEZONES.map((tz) => (
<option key={tz.value} value={tz.value}>
{tz.label}
</option>
))}
</select>
<ChevronRight className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500 rotate-90" />
</div>
</div>
</div>
</div>
)}
{/* Step 2: Racing Information */}
{step === 2 && (
<div className="space-y-6">
<div>
<Heading level={2} className="text-xl mb-1 flex items-center gap-2">
<Gamepad2 className="w-5 h-5 text-primary-blue" />
Racing Background
</Heading>
<p className="text-sm text-gray-400">
Tell us about your racing experience
</p>
</div>
<div>
<label htmlFor="iracingId" className="block text-sm font-medium text-gray-300 mb-2">
iRacing Customer ID *
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 text-sm">#</span>
<Input
id="iracingId"
type="text"
value={racingInfo.iracingId}
onChange={(e) =>
setRacingInfo({ ...racingInfo, iracingId: e.target.value })
}
error={!!errors.iracingId}
errorMessage={errors.iracingId}
placeholder="123456"
disabled={loading}
className="pl-8"
/>
</div>
<p className="mt-1 text-xs text-gray-500">
Find this in your iRacing account settings
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-3">
Experience Level
</label>
<div className="grid grid-cols-2 gap-3">
{EXPERIENCE_LEVELS.map((level) => (
<SelectableCard
key={level.value}
selected={racingInfo.experienceLevel === level.value}
onClick={() =>
setRacingInfo({
...racingInfo,
experienceLevel: level.value as RacingInfo['experienceLevel'],
})
}
label={level.label}
description={level.description}
/>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-3">
Preferred Discipline
</label>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{DISCIPLINES.map((discipline) => (
<SelectableCard
key={discipline.value}
selected={racingInfo.preferredDiscipline === discipline.value}
onClick={() =>
setRacingInfo({
...racingInfo,
preferredDiscipline: discipline.value,
})
}
icon={discipline.icon}
label={discipline.label}
/>
))}
</div>
</div>
<div>
<label htmlFor="yearsRacing" className="block text-sm font-medium text-gray-300 mb-2">
Years Sim Racing
</label>
<div className="relative">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<Input
id="yearsRacing"
type="text"
value={racingInfo.yearsRacing}
onChange={(e) =>
setRacingInfo({ ...racingInfo, yearsRacing: e.target.value })
}
placeholder="e.g., 3 years"
disabled={loading}
className="pl-10"
/>
</div>
</div>
</div>
)}
{/* Step 3: Preferences */}
{step === 3 && (
<div className="space-y-6">
<div>
<Heading level={2} className="text-xl mb-1 flex items-center gap-2">
<Heart className="w-5 h-5 text-primary-blue" />
Racing Preferences
</Heading>
<p className="text-sm text-gray-400">
Customize your racing profile
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor="favoriteTrack" className="block text-sm font-medium text-gray-300 mb-2">
Favorite Track
</label>
<div className="relative">
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<Input
id="favoriteTrack"
type="text"
value={preferencesInfo.favoriteTrack}
onChange={(e) =>
setPreferencesInfo({ ...preferencesInfo, favoriteTrack: e.target.value })
}
placeholder="e.g., Spa-Francorchamps"
disabled={loading}
className="pl-10"
/>
</div>
</div>
<div>
<label htmlFor="favoriteCar" className="block text-sm font-medium text-gray-300 mb-2">
Favorite Car
</label>
<div className="relative">
<Car className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<Input
id="favoriteCar"
type="text"
value={preferencesInfo.favoriteCar}
onChange={(e) =>
setPreferencesInfo({ ...preferencesInfo, favoriteCar: e.target.value })
}
placeholder="e.g., Porsche 911 GT3 R"
disabled={loading}
className="pl-10"
/>
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-3">
Racing Style
</label>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{RACING_STYLES.map((style) => (
<SelectableCard
key={style.value}
selected={preferencesInfo.racingStyle === style.value}
onClick={() =>
setPreferencesInfo({
...preferencesInfo,
racingStyle: style.value,
})
}
icon={style.icon}
label={style.label}
/>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-3">
Availability
</label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{AVAILABILITY.map((avail) => (
<SelectableCard
key={avail.value}
selected={preferencesInfo.availability === avail.value}
onClick={() =>
setPreferencesInfo({
...preferencesInfo,
availability: avail.value,
})
}
icon={<Clock className="w-4 h-4" />}
label={avail.label}
/>
))}
</div>
</div>
<div className="space-y-3">
<label className="block text-sm font-medium text-gray-300">
Status Flags
</label>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={() =>
setPreferencesInfo({
...preferencesInfo,
lookingForTeam: !preferencesInfo.lookingForTeam,
})
}
className={`flex items-center gap-2 px-4 py-3 rounded-xl border transition-all ${
preferencesInfo.lookingForTeam
? 'bg-performance-green/20 border-performance-green text-performance-green'
: 'bg-iron-gray/50 border-charcoal-outline text-gray-400 hover:border-gray-500'
}`}
>
<Users className="w-4 h-4" />
<span className="text-sm font-medium">Looking for Team</span>
</button>
<button
type="button"
onClick={() =>
setPreferencesInfo({
...preferencesInfo,
openToRequests: !preferencesInfo.openToRequests,
})
}
className={`flex items-center gap-2 px-4 py-3 rounded-xl border transition-all ${
preferencesInfo.openToRequests
? 'bg-primary-blue/20 border-primary-blue text-primary-blue'
: 'bg-iron-gray/50 border-charcoal-outline text-gray-400 hover:border-gray-500'
}`}
>
<Heart className="w-4 h-4" />
<span className="text-sm font-medium">Open to Friend Requests</span>
</button>
</div>
</div>
</div>
)}
{/* Step 4: Bio & Goals */}
{step === 4 && (
<div className="space-y-6">
<div>
<Heading level={2} className="text-xl mb-1 flex items-center gap-2">
<Target className="w-5 h-5 text-primary-blue" />
Bio & Goals
</Heading>
<p className="text-sm text-gray-400">
Tell the community about yourself and your racing aspirations
</p>
</div>
<div>
<label htmlFor="bio" className="block text-sm font-medium text-gray-300 mb-2">
About You
</label>
<textarea
id="bio"
value={bioInfo.bio}
onChange={(e) => setBioInfo({ ...bioInfo, bio: e.target.value })}
placeholder="Tell us about yourself, your racing history, what got you into sim racing..."
maxLength={500}
rows={4}
disabled={loading}
className="block w-full rounded-md border-0 px-4 py-3 bg-iron-gray text-white shadow-sm ring-1 ring-inset ring-charcoal-outline placeholder:text-gray-500 focus:ring-2 focus:ring-inset focus:ring-primary-blue transition-all duration-150 sm:text-sm sm:leading-6 resize-none"
/>
<p className="mt-1 text-xs text-gray-500 text-right">
{bioInfo.bio.length}/500
</p>
</div>
<div>
<label htmlFor="goals" className="block text-sm font-medium text-gray-300 mb-2">
Racing Goals
</label>
<textarea
id="goals"
value={bioInfo.goals}
onChange={(e) => setBioInfo({ ...bioInfo, goals: e.target.value })}
placeholder="What are you hoping to achieve? Championship titles, improving lap times, joining a competitive team..."
maxLength={300}
rows={3}
disabled={loading}
className="block w-full rounded-md border-0 px-4 py-3 bg-iron-gray text-white shadow-sm ring-1 ring-inset ring-charcoal-outline placeholder:text-gray-500 focus:ring-2 focus:ring-inset focus:ring-primary-blue transition-all duration-150 sm:text-sm sm:leading-6 resize-none"
/>
<p className="mt-1 text-xs text-gray-500 text-right">
{bioInfo.goals.length}/300
</p>
</div>
{/* Preview */}
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-3">Profile Preview</p>
<div className="flex items-start gap-4">
<div className="w-16 h-16 rounded-xl bg-gradient-to-br from-primary-blue to-purple-600 flex items-center justify-center text-2xl font-bold text-white">
{personalInfo.displayName.charAt(0).toUpperCase() || '?'}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<p className="text-white font-semibold">{personalInfo.displayName || 'Your Name'}</p>
<span className="text-xl">{personalInfo.country ? getCountryFlag(personalInfo.country) : '🏁'}</span>
</div>
<div className="flex flex-wrap gap-2 text-xs">
<span className="px-2 py-0.5 rounded-full bg-primary-blue/20 text-primary-blue">
{EXPERIENCE_LEVELS.find(e => e.value === racingInfo.experienceLevel)?.label}
</span>
<span className="px-2 py-0.5 rounded-full bg-purple-500/20 text-purple-400">
{DISCIPLINES.find(d => d.value === racingInfo.preferredDiscipline)?.label}
</span>
{preferencesInfo.lookingForTeam && (
<span className="px-2 py-0.5 rounded-full bg-performance-green/20 text-performance-green">
Looking for Team
</span>
)}
</div>
{bioInfo.bio && (
<p className="text-gray-400 text-sm mt-2 line-clamp-2">{bioInfo.bio}</p>
)}
</div>
</div>
</div>
</div>
)}
{/* Error Message */}
{errors.submit && (
<div className="mt-6 flex items-start gap-3 p-4 rounded-xl bg-red-500/10 border border-red-500/30">
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-400">{errors.submit}</p>
</div>
)}
{/* Navigation Buttons */}
<div className="mt-8 flex items-center justify-between">
<Button
type="button"
variant="secondary"
onClick={handleBack}
disabled={step === 1 || loading}
className="flex items-center gap-2"
>
<ChevronLeft className="w-4 h-4" />
Back
</Button>
{step < 4 ? (
<Button
type="button"
variant="primary"
onClick={handleNext}
disabled={loading}
className="flex items-center gap-2"
>
Continue
<ChevronRight className="w-4 h-4" />
</Button>
) : (
<Button
type="submit"
variant="primary"
disabled={loading}
className="flex items-center gap-2"
>
{loading ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Creating Profile...
</>
) : (
<>
<Check className="w-4 h-4" />
Complete Setup
</>
)}
</Button>
)}
</div>
</form>
</Card>
{/* Help Text */}
<p className="text-center text-xs text-gray-500 mt-6">
You can always update your profile later in the settings
</p>
</div>
);
}

View File

@@ -104,18 +104,19 @@ export default function UserPill() {
}, [session, driver, primaryDriverId]);
if (!session) {
const loginHref = '/auth/iracing/start?returnTo=/dashboard';
return (
<div className="flex items-center">
<div className="flex items-center gap-2">
<Link
href={loginHref}
href="/auth/login"
className="inline-flex items-center gap-2 rounded-full bg-iron-gray border border-charcoal-outline px-4 py-1.5 text-xs font-medium text-gray-300 hover:text-white hover:border-gray-500 transition-all"
>
Sign In
</Link>
<Link
href="/auth/signup"
className="inline-flex items-center gap-2 rounded-full bg-primary-blue px-4 py-1.5 text-xs font-semibold text-white shadow-[0_0_12px_rgba(25,140,255,0.5)] hover:bg-primary-blue/90 hover:shadow-[0_0_18px_rgba(25,140,255,0.8)] transition-all"
>
<span className="inline-flex h-4 w-4 items-center justify-center rounded-full bg-white/10">
<Star className="h-3 w-3 text-amber-300" />
</span>
<span>Authenticate with iRacing</span>
Get Started
</Link>
</div>
);

View File

@@ -13,6 +13,8 @@ import {
UserPlus,
Zap,
Clock,
Globe,
Languages,
} from 'lucide-react';
import { getImageService } from '@/lib/di-container';
@@ -28,6 +30,8 @@ interface TeamCardProps {
performanceLevel?: 'beginner' | 'intermediate' | 'advanced' | 'pro';
isRecruiting?: boolean;
specialization?: 'endurance' | 'sprint' | 'mixed';
region?: string;
languages?: string[];
leagues?: string[];
onClick?: () => void;
}
@@ -70,6 +74,8 @@ export default function TeamCard({
performanceLevel,
isRecruiting,
specialization,
region,
languages,
onClick,
}: TeamCardProps) {
const imageService = getImageService();
@@ -134,10 +140,29 @@ export default function TeamCard({
{/* Content */}
<div className="p-4 flex flex-col flex-1">
{/* Description */}
<p className="text-xs text-gray-500 line-clamp-2 mb-4 h-8">
<p className="text-xs text-gray-500 line-clamp-2 mb-3">
{description || 'No description available'}
</p>
{/* Region & Languages */}
{(region || (languages && languages.length > 0)) && (
<div className="flex flex-wrap items-center gap-2 mb-3">
{region && (
<span className="flex items-center gap-1.5 px-2 py-1 rounded-md text-[10px] bg-charcoal-outline/50 text-gray-400 border border-charcoal-outline/30">
<Globe className="w-3 h-3 text-neon-aqua" />
{region}
</span>
)}
{languages && languages.length > 0 && (
<span className="flex items-center gap-1.5 px-2 py-1 rounded-md text-[10px] bg-charcoal-outline/50 text-gray-400 border border-charcoal-outline/30">
<Languages className="w-3 h-3 text-purple-400" />
{languages.slice(0, 2).join(', ')}
{languages.length > 2 && ` +${languages.length - 2}`}
</span>
)}
</div>
)}
{/* Stats Grid */}
<div className="grid grid-cols-3 gap-2 mb-4">
<div className="text-center p-2 rounded-lg bg-charcoal-outline/30">

View File

@@ -4,8 +4,25 @@ import type { AuthSessionDTO } from '@gridpilot/identity/application/dto/AuthSes
export type AuthUser = AuthenticatedUserDTO;
export type AuthSession = AuthSessionDTO;
export interface SignupParams {
email: string;
password: string;
displayName: string;
}
export interface LoginParams {
email: string;
password: string;
}
export interface AuthService {
getCurrentSession(): Promise<AuthSession | null>;
// Email/password authentication
signupWithEmail(params: SignupParams): Promise<AuthSession>;
loginWithEmail(params: LoginParams): Promise<AuthSession>;
// iRacing OAuth (demo)
startIracingAuthRedirect(
returnTo?: string,
): Promise<{ redirectUrl: string; state: string }>;
@@ -14,5 +31,6 @@ export interface AuthService {
state: string;
returnTo?: string;
}): Promise<AuthSession>;
logout(): Promise<void>;
}

View File

@@ -1,12 +1,26 @@
import type { AuthService, AuthSession } from './AuthService';
import type { AuthService, AuthSession, SignupParams, LoginParams } from './AuthService';
import type { AuthCallbackCommandDTO } from '@gridpilot/identity/application/dto/AuthCallbackCommandDTO';
import type { StartAuthCommandDTO } from '@gridpilot/identity/application/dto/StartAuthCommandDTO';
import { StartAuthUseCase } from '@gridpilot/identity/application/use-cases/StartAuthUseCase';
import { GetCurrentUserSessionUseCase } from '@gridpilot/identity/application/use-cases/GetCurrentUserSessionUseCase';
import { HandleAuthCallbackUseCase } from '@gridpilot/identity/application/use-cases/HandleAuthCallbackUseCase';
import { LogoutUseCase } from '@gridpilot/identity/application/use-cases/LogoutUseCase';
import { SignupWithEmailUseCase } from '@gridpilot/identity/application/use-cases/SignupWithEmailUseCase';
import { LoginWithEmailUseCase } from '@gridpilot/identity/application/use-cases/LoginWithEmailUseCase';
import { CookieIdentitySessionAdapter } from '@gridpilot/identity/infrastructure/session/CookieIdentitySessionAdapter';
import { IracingDemoIdentityProviderAdapter } from '@gridpilot/identity/infrastructure/providers/IracingDemoIdentityProviderAdapter';
import { InMemoryUserRepository } from '@gridpilot/identity/infrastructure/repositories/InMemoryUserRepository';
import type { IUserRepository } from '@gridpilot/identity/domain/repositories/IUserRepository';
// Singleton user repository to persist across requests (in-memory demo)
let userRepositoryInstance: IUserRepository | null = null;
function getUserRepository(): IUserRepository {
if (!userRepositoryInstance) {
userRepositoryInstance = new InMemoryUserRepository();
}
return userRepositoryInstance;
}
export class InMemoryAuthService implements AuthService {
async getCurrentSession(): Promise<AuthSession | null> {
@@ -15,6 +29,31 @@ export class InMemoryAuthService implements AuthService {
return useCase.execute();
}
async signupWithEmail(params: SignupParams): Promise<AuthSession> {
const userRepository = getUserRepository();
const sessionPort = new CookieIdentitySessionAdapter();
const useCase = new SignupWithEmailUseCase(userRepository, sessionPort);
const result = await useCase.execute({
email: params.email,
password: params.password,
displayName: params.displayName,
});
return result.session;
}
async loginWithEmail(params: LoginParams): Promise<AuthSession> {
const userRepository = getUserRepository();
const sessionPort = new CookieIdentitySessionAdapter();
const useCase = new LoginWithEmailUseCase(userRepository, sessionPort);
return useCase.execute({
email: params.email,
password: params.password,
});
}
async startIracingAuthRedirect(
returnTo?: string,
): Promise<{ redirectUrl: string; state: string }> {

View File

@@ -0,0 +1,71 @@
/**
* Login with Email Use Case
*
* Authenticates a user with email and password.
*/
import type { IUserRepository } from '../../domain/repositories/IUserRepository';
import type { AuthenticatedUserDTO } from '../dto/AuthenticatedUserDTO';
import type { AuthSessionDTO } from '../dto/AuthSessionDTO';
import type { IdentitySessionPort } from '../ports/IdentitySessionPort';
export interface LoginCommandDTO {
email: string;
password: string;
}
export class LoginWithEmailUseCase {
constructor(
private readonly userRepository: IUserRepository,
private readonly sessionPort: IdentitySessionPort,
) {}
async execute(command: LoginCommandDTO): Promise<AuthSessionDTO> {
// Validate inputs
if (!command.email || !command.password) {
throw new Error('Email and password are required');
}
// Find user by email
const user = await this.userRepository.findByEmail(command.email.toLowerCase().trim());
if (!user) {
throw new Error('Invalid email or password');
}
// Verify password
const passwordHash = await this.hashPassword(command.password, user.salt);
if (passwordHash !== user.passwordHash) {
throw new Error('Invalid email or password');
}
// Create session
const authenticatedUser: AuthenticatedUserDTO = {
id: user.id,
displayName: user.displayName,
email: user.email,
primaryDriverId: user.primaryDriverId,
};
return this.sessionPort.createSession(authenticatedUser);
}
private async hashPassword(password: string, salt: string): Promise<string> {
// Simple hash for demo - in production, use bcrypt or argon2
const data = password + salt;
if (typeof crypto !== 'undefined' && crypto.subtle) {
const encoder = new TextEncoder();
const dataBuffer = encoder.encode(data);
const hashBuffer = await crypto.subtle.digest('SHA-256', dataBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
// Fallback for environments without crypto.subtle
let hash = 0;
for (let i = 0; i < data.length; i++) {
const char = data.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return Math.abs(hash).toString(16).padStart(16, '0');
}
}

View File

@@ -0,0 +1,123 @@
/**
* Signup with Email Use Case
*
* Creates a new user account with email and password.
*/
import type { IUserRepository, StoredUser } from '../../domain/repositories/IUserRepository';
import type { AuthenticatedUserDTO } from '../dto/AuthenticatedUserDTO';
import type { AuthSessionDTO } from '../dto/AuthSessionDTO';
import type { IdentitySessionPort } from '../ports/IdentitySessionPort';
export interface SignupCommandDTO {
email: string;
password: string;
displayName: string;
}
export interface SignupResultDTO {
session: AuthSessionDTO;
isNewUser: boolean;
}
export class SignupWithEmailUseCase {
constructor(
private readonly userRepository: IUserRepository,
private readonly sessionPort: IdentitySessionPort,
) {}
async execute(command: SignupCommandDTO): Promise<SignupResultDTO> {
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(command.email)) {
throw new Error('Invalid email format');
}
// Validate password strength
if (command.password.length < 8) {
throw new Error('Password must be at least 8 characters');
}
// Validate display name
if (!command.displayName || command.displayName.trim().length < 2) {
throw new Error('Display name must be at least 2 characters');
}
// Check if email already exists
const existingUser = await this.userRepository.findByEmail(command.email);
if (existingUser) {
throw new Error('An account with this email already exists');
}
// Hash password (simple hash for demo - in production use bcrypt)
const salt = this.generateSalt();
const passwordHash = await this.hashPassword(command.password, salt);
// Create user
const userId = this.generateUserId();
const newUser: StoredUser = {
id: userId,
email: command.email.toLowerCase().trim(),
displayName: command.displayName.trim(),
passwordHash,
salt,
createdAt: new Date(),
};
await this.userRepository.create(newUser);
// Create session
const authenticatedUser: AuthenticatedUserDTO = {
id: newUser.id,
displayName: newUser.displayName,
email: newUser.email,
primaryDriverId: undefined, // Will be set during onboarding
};
const session = await this.sessionPort.createSession(authenticatedUser);
return {
session,
isNewUser: true,
};
}
private generateSalt(): string {
const array = new Uint8Array(16);
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
crypto.getRandomValues(array);
} else {
for (let i = 0; i < array.length; i++) {
array[i] = Math.floor(Math.random() * 256);
}
}
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
}
private async hashPassword(password: string, salt: string): Promise<string> {
// Simple hash for demo - in production, use bcrypt or argon2
const data = password + salt;
if (typeof crypto !== 'undefined' && crypto.subtle) {
const encoder = new TextEncoder();
const dataBuffer = encoder.encode(data);
const hashBuffer = await crypto.subtle.digest('SHA-256', dataBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
// Fallback for environments without crypto.subtle
let hash = 0;
for (let i = 0; i < data.length; i++) {
const char = data.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return Math.abs(hash).toString(16).padStart(16, '0');
}
private generateUserId(): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID();
}
return 'user-' + Date.now().toString(36) + Math.random().toString(36).substr(2, 9);
}
}

View File

@@ -0,0 +1,50 @@
/**
* Domain Repository: IUserRepository
*
* Repository interface for User entity operations.
*/
import type { AuthenticatedUserDTO } from '../../application/dto/AuthenticatedUserDTO';
export interface UserCredentials {
email: string;
passwordHash: string;
salt: string;
}
export interface StoredUser {
id: string;
email: string;
displayName: string;
passwordHash: string;
salt: string;
primaryDriverId?: string;
createdAt: Date;
}
export interface IUserRepository {
/**
* Find user by email
*/
findByEmail(email: string): Promise<StoredUser | null>;
/**
* Find user by ID
*/
findById(id: string): Promise<StoredUser | null>;
/**
* Create a new user
*/
create(user: StoredUser): Promise<StoredUser>;
/**
* Update user
*/
update(user: StoredUser): Promise<StoredUser>;
/**
* Check if email exists
*/
emailExists(email: string): Promise<boolean>;
}

View File

@@ -0,0 +1,56 @@
/**
* In-Memory User Repository
*
* Stores users in memory for demo/development purposes.
*/
import type { IUserRepository, StoredUser } from '../../domain/repositories/IUserRepository';
export class InMemoryUserRepository implements IUserRepository {
private users: Map<string, StoredUser> = new Map();
private emailIndex: Map<string, string> = new Map(); // email -> userId
constructor(initialUsers: StoredUser[] = []) {
for (const user of initialUsers) {
this.users.set(user.id, user);
this.emailIndex.set(user.email.toLowerCase(), user.id);
}
}
async findByEmail(email: string): Promise<StoredUser | null> {
const userId = this.emailIndex.get(email.toLowerCase());
if (!userId) return null;
return this.users.get(userId) ?? null;
}
async findById(id: string): Promise<StoredUser | null> {
return this.users.get(id) ?? null;
}
async create(user: StoredUser): Promise<StoredUser> {
if (this.emailIndex.has(user.email.toLowerCase())) {
throw new Error('Email already exists');
}
this.users.set(user.id, user);
this.emailIndex.set(user.email.toLowerCase(), user.id);
return user;
}
async update(user: StoredUser): Promise<StoredUser> {
const existing = this.users.get(user.id);
if (!existing) {
throw new Error('User not found');
}
// If email changed, update index
if (existing.email.toLowerCase() !== user.email.toLowerCase()) {
this.emailIndex.delete(existing.email.toLowerCase());
this.emailIndex.set(user.email.toLowerCase(), user.id);
}
this.users.set(user.id, user);
return user;
}
async emailExists(email: string): Promise<boolean> {
return this.emailIndex.has(email.toLowerCase());
}
}