wip
This commit is contained in:
26
apps/website/app/api/auth/login/route.ts
Normal file
26
apps/website/app/api/auth/login/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
26
apps/website/app/api/auth/signup/route.ts
Normal file
26
apps/website/app/api/auth/signup/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
263
apps/website/app/auth/login/page.tsx
Normal file
263
apps/website/app/auth/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
394
apps/website/app/auth/signup/page.tsx
Normal file
394
apps/website/app/auth/signup/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
545
apps/website/app/leaderboards/drivers/page.tsx
Normal file
545
apps/website/app/leaderboards/drivers/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
515
apps/website/app/leaderboards/page.tsx
Normal file
515
apps/website/app/leaderboards/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
apps/website/app/onboarding/page.tsx
Normal file
32
apps/website/app/onboarding/page.tsx
Normal 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
891
apps/website/app/teams/leaderboard/page.tsx
Normal file
891
apps/website/app/teams/leaderboard/page.tsx
Normal 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
@@ -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) {
|
||||
|
||||
971
apps/website/components/onboarding/OnboardingWizard.tsx
Normal file
971
apps/website/components/onboarding/OnboardingWizard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 }> {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
50
packages/identity/domain/repositories/IUserRepository.ts
Normal file
50
packages/identity/domain/repositories/IUserRepository.ts
Normal 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>;
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user