wip
This commit is contained in:
90
apps/website/app/api/auth/complete-onboarding/route.ts
Normal file
90
apps/website/app/api/auth/complete-onboarding/route.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getAuthService } from '@/lib/auth';
|
||||||
|
import { getDriverRepository } from '@/lib/di-container';
|
||||||
|
import { Driver } from '@gridpilot/racing';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const authService = getAuthService();
|
||||||
|
const session = await authService.getCurrentSession();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Not authenticated' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { firstName, lastName, displayName, country, timezone, bio } = body;
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!firstName || !firstName.trim()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'First name is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lastName || !lastName.trim()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Last name is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!displayName || displayName.trim().length < 3) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Display name must be at least 3 characters' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!country) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Country is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const driverRepo = getDriverRepository();
|
||||||
|
|
||||||
|
// Check if user already has a driver profile
|
||||||
|
if (session.user.primaryDriverId) {
|
||||||
|
const existingDriver = await driverRepo.findById(session.user.primaryDriverId);
|
||||||
|
if (existingDriver) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Driver profile already exists' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the driver profile
|
||||||
|
const driverId = crypto.randomUUID();
|
||||||
|
const driver = Driver.create({
|
||||||
|
id: driverId,
|
||||||
|
iracingId: '', // Will be set later via OAuth
|
||||||
|
name: displayName.trim(),
|
||||||
|
country: country,
|
||||||
|
bio: bio || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
await driverRepo.create(driver);
|
||||||
|
|
||||||
|
// Update user's primary driver ID in session
|
||||||
|
// Note: This would typically update the user record and refresh the session
|
||||||
|
// For now we'll just return success and let the client handle navigation
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
driverId: driverId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Onboarding error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error instanceof Error ? error.message : 'Failed to complete onboarding' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
84
apps/website/app/api/avatar/generate/route.ts
Normal file
84
apps/website/app/api/avatar/generate/route.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getAuthService } from '@/lib/auth';
|
||||||
|
import {
|
||||||
|
DemoFaceValidationAdapter,
|
||||||
|
DemoAvatarGenerationAdapter,
|
||||||
|
InMemoryAvatarGenerationRepository
|
||||||
|
} from '@gridpilot/demo-infrastructure';
|
||||||
|
import { RequestAvatarGenerationUseCase } from '@gridpilot/media';
|
||||||
|
|
||||||
|
// Create singleton instances
|
||||||
|
const faceValidation = new DemoFaceValidationAdapter();
|
||||||
|
const avatarGeneration = new DemoAvatarGenerationAdapter();
|
||||||
|
const avatarRepository = new InMemoryAvatarGenerationRepository();
|
||||||
|
|
||||||
|
const requestAvatarGenerationUseCase = new RequestAvatarGenerationUseCase(
|
||||||
|
avatarRepository,
|
||||||
|
faceValidation,
|
||||||
|
avatarGeneration
|
||||||
|
);
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const authService = getAuthService();
|
||||||
|
const session = await authService.getCurrentSession();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, errorMessage: 'Not authenticated' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { facePhotoData, suitColor } = body;
|
||||||
|
|
||||||
|
if (!facePhotoData) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, errorMessage: 'No face photo provided' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!suitColor) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, errorMessage: 'No suit color selected' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract base64 data if it's a data URL
|
||||||
|
let base64Data = facePhotoData;
|
||||||
|
if (facePhotoData.startsWith('data:')) {
|
||||||
|
base64Data = facePhotoData.split(',')[1] || facePhotoData;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await requestAvatarGenerationUseCase.execute({
|
||||||
|
userId: session.user.id,
|
||||||
|
facePhotoData: base64Data,
|
||||||
|
suitColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.status === 'failed') {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
errorMessage: result.errorMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
requestId: result.requestId,
|
||||||
|
avatarUrls: result.avatarUrls,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Avatar generation error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
errorMessage: error instanceof Error ? error.message : 'Failed to generate avatars'
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
apps/website/app/api/avatar/validate-face/route.ts
Normal file
40
apps/website/app/api/avatar/validate-face/route.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { DemoFaceValidationAdapter } from '@gridpilot/demo-infrastructure';
|
||||||
|
|
||||||
|
const faceValidation = new DemoFaceValidationAdapter();
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { imageData } = body;
|
||||||
|
|
||||||
|
if (!imageData) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ isValid: false, errorMessage: 'No image data provided' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract base64 data if it's a data URL
|
||||||
|
let base64Data = imageData;
|
||||||
|
if (imageData.startsWith('data:')) {
|
||||||
|
base64Data = imageData.split(',')[1] || imageData;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await faceValidation.validateFacePhoto(base64Data);
|
||||||
|
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Face validation error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
isValid: false,
|
||||||
|
hasFace: false,
|
||||||
|
faceCount: 0,
|
||||||
|
confidence: 0,
|
||||||
|
errorMessage: 'Failed to validate photo'
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import Card from '@/components/ui/Card';
|
|||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
import Heading from '@/components/ui/Heading';
|
import Heading from '@/components/ui/Heading';
|
||||||
|
import { useAuth } from '@/lib/auth/AuthContext';
|
||||||
|
|
||||||
interface FormErrors {
|
interface FormErrors {
|
||||||
email?: string;
|
email?: string;
|
||||||
@@ -29,6 +30,7 @@ interface FormErrors {
|
|||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
const { refreshSession } = useAuth();
|
||||||
const returnTo = searchParams.get('returnTo') ?? '/dashboard';
|
const returnTo = searchParams.get('returnTo') ?? '/dashboard';
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -81,8 +83,9 @@ export default function LoginPage() {
|
|||||||
throw new Error(data.error || 'Login failed');
|
throw new Error(data.error || 'Login failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Refresh session in context so header updates immediately
|
||||||
|
await refreshSession();
|
||||||
router.push(returnTo);
|
router.push(returnTo);
|
||||||
router.refresh();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setErrors({
|
setErrors({
|
||||||
submit: error instanceof Error ? error.message : 'Login failed. Please try again.',
|
submit: error instanceof Error ? error.message : 'Login failed. Please try again.',
|
||||||
@@ -94,9 +97,8 @@ export default function LoginPage() {
|
|||||||
const handleDemoLogin = async () => {
|
const handleDemoLogin = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const authService = getAuthService();
|
// Redirect to iRacing auth start route
|
||||||
const { redirectUrl } = await authService.startIracingAuthRedirect(returnTo);
|
router.push(`/auth/iracing/start?returnTo=${encodeURIComponent(returnTo)}`);
|
||||||
router.push(redirectUrl);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setErrors({
|
setErrors({
|
||||||
submit: 'Demo login failed. Please try again.',
|
submit: 'Demo login failed. Please try again.',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, FormEvent } from 'react';
|
import { useState, useEffect, FormEvent } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {
|
import {
|
||||||
@@ -15,13 +15,14 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
X,
|
X,
|
||||||
Gamepad2,
|
Gamepad2,
|
||||||
|
Loader2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
import Heading from '@/components/ui/Heading';
|
import Heading from '@/components/ui/Heading';
|
||||||
import { getAuthService } from '@/lib/auth';
|
import { useAuth } from '@/lib/auth/AuthContext';
|
||||||
|
|
||||||
interface FormErrors {
|
interface FormErrors {
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
@@ -54,9 +55,11 @@ function checkPasswordStrength(password: string): PasswordStrength {
|
|||||||
export default function SignupPage() {
|
export default function SignupPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
const { refreshSession } = useAuth();
|
||||||
const returnTo = searchParams.get('returnTo') ?? '/onboarding';
|
const returnTo = searchParams.get('returnTo') ?? '/onboarding';
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [checkingAuth, setCheckingAuth] = useState(true);
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
const [errors, setErrors] = useState<FormErrors>({});
|
const [errors, setErrors] = useState<FormErrors>({});
|
||||||
@@ -67,6 +70,25 @@ export default function SignupPage() {
|
|||||||
confirmPassword: '',
|
confirmPassword: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check if already authenticated
|
||||||
|
useEffect(() => {
|
||||||
|
async function checkAuth() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/session');
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.authenticated) {
|
||||||
|
// Already logged in, redirect to dashboard or return URL
|
||||||
|
router.replace(returnTo === '/onboarding' ? '/dashboard' : returnTo);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not authenticated, continue showing signup page
|
||||||
|
} finally {
|
||||||
|
setCheckingAuth(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checkAuth();
|
||||||
|
}, [router, returnTo]);
|
||||||
|
|
||||||
const passwordStrength = checkPasswordStrength(formData.password);
|
const passwordStrength = checkPasswordStrength(formData.password);
|
||||||
|
|
||||||
const passwordRequirements = [
|
const passwordRequirements = [
|
||||||
@@ -117,15 +139,25 @@ export default function SignupPage() {
|
|||||||
setErrors({});
|
setErrors({});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const authService = getAuthService();
|
const response = await fetch('/api/auth/signup', {
|
||||||
await authService.signupWithEmail({
|
method: 'POST',
|
||||||
email: formData.email,
|
headers: { 'Content-Type': 'application/json' },
|
||||||
password: formData.password,
|
body: JSON.stringify({
|
||||||
displayName: formData.displayName,
|
email: formData.email,
|
||||||
|
password: formData.password,
|
||||||
|
displayName: formData.displayName,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Signup failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh session in context so header updates immediately
|
||||||
|
await refreshSession();
|
||||||
router.push(returnTo);
|
router.push(returnTo);
|
||||||
router.refresh();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setErrors({
|
setErrors({
|
||||||
submit: error instanceof Error ? error.message : 'Signup failed. Please try again.',
|
submit: error instanceof Error ? error.message : 'Signup failed. Please try again.',
|
||||||
@@ -137,10 +169,9 @@ export default function SignupPage() {
|
|||||||
const handleDemoLogin = async () => {
|
const handleDemoLogin = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const authService = getAuthService();
|
// Redirect to iRacing auth start route
|
||||||
const { redirectUrl } = await authService.startIracingAuthRedirect(returnTo);
|
router.push(`/auth/iracing/start?returnTo=${encodeURIComponent(returnTo)}`);
|
||||||
router.push(redirectUrl);
|
} catch {
|
||||||
} catch (error) {
|
|
||||||
setErrors({
|
setErrors({
|
||||||
submit: 'Demo login failed. Please try again.',
|
submit: 'Demo login failed. Please try again.',
|
||||||
});
|
});
|
||||||
@@ -148,6 +179,15 @@ export default function SignupPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Show loading while checking auth
|
||||||
|
if (checkingAuth) {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-deep-graphite flex items-center justify-center">
|
||||||
|
<Loader2 className="w-8 h-8 text-primary-blue animate-spin" />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-deep-graphite flex items-center justify-center px-4 py-12">
|
<main className="min-h-screen bg-deep-graphite flex items-center justify-center px-4 py-12">
|
||||||
{/* Background Pattern */}
|
{/* Background Pattern */}
|
||||||
@@ -189,7 +229,7 @@ export default function SignupPage() {
|
|||||||
onChange={(e) => setFormData({ ...formData, displayName: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, displayName: e.target.value })}
|
||||||
error={!!errors.displayName}
|
error={!!errors.displayName}
|
||||||
errorMessage={errors.displayName}
|
errorMessage={errors.displayName}
|
||||||
placeholder="SuperMax33"
|
placeholder="SpeedyRacer42"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="pl-10"
|
className="pl-10"
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export default async function DashboardPage() {
|
|||||||
return {
|
return {
|
||||||
league,
|
league,
|
||||||
position: driverStanding?.position ?? 0,
|
position: driverStanding?.position ?? 0,
|
||||||
points: driverStanding?.totalPoints ?? 0,
|
points: driverStanding?.points ?? 0,
|
||||||
totalDrivers: standings.length,
|
totalDrivers: standings.length,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ import {
|
|||||||
getGetLeagueScoringConfigQuery,
|
getGetLeagueScoringConfigQuery,
|
||||||
getDriverStats,
|
getDriverStats,
|
||||||
getAllDriverRankings,
|
getAllDriverRankings,
|
||||||
|
getGetLeagueStatsQuery,
|
||||||
} from '@/lib/di-container';
|
} from '@/lib/di-container';
|
||||||
|
import { Zap, Users, Trophy, Calendar } from 'lucide-react';
|
||||||
import { getMembership, getLeagueMembers, isOwnerOrAdmin } from '@/lib/leagueMembership';
|
import { getMembership, getLeagueMembers, isOwnerOrAdmin } from '@/lib/leagueMembership';
|
||||||
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
||||||
|
|
||||||
@@ -42,6 +44,8 @@ export default function LeagueDetailPage() {
|
|||||||
const [standings, setStandings] = useState<LeagueDriverSeasonStatsDTO[]>([]);
|
const [standings, setStandings] = useState<LeagueDriverSeasonStatsDTO[]>([]);
|
||||||
const [drivers, setDrivers] = useState<DriverDTO[]>([]);
|
const [drivers, setDrivers] = useState<DriverDTO[]>([]);
|
||||||
const [scoringConfig, setScoringConfig] = useState<LeagueScoringConfigDTO | null>(null);
|
const [scoringConfig, setScoringConfig] = useState<LeagueScoringConfigDTO | null>(null);
|
||||||
|
const [averageSOF, setAverageSOF] = useState<number | null>(null);
|
||||||
|
const [completedRacesCount, setCompletedRacesCount] = useState<number>(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState<
|
const [activeTab, setActiveTab] = useState<
|
||||||
@@ -59,6 +63,7 @@ export default function LeagueDetailPage() {
|
|||||||
const leagueRepo = getLeagueRepository();
|
const leagueRepo = getLeagueRepository();
|
||||||
const raceRepo = getRaceRepository();
|
const raceRepo = getRaceRepository();
|
||||||
const driverRepo = getDriverRepository();
|
const driverRepo = getDriverRepository();
|
||||||
|
const leagueStatsQuery = getGetLeagueStatsQuery();
|
||||||
|
|
||||||
const leagueData = await leagueRepo.findById(leagueId);
|
const leagueData = await leagueRepo.findById(leagueId);
|
||||||
|
|
||||||
@@ -91,6 +96,18 @@ export default function LeagueDetailPage() {
|
|||||||
.filter((dto): dto is DriverDTO => dto !== null);
|
.filter((dto): dto is DriverDTO => dto !== null);
|
||||||
|
|
||||||
setDrivers(driverDtos);
|
setDrivers(driverDtos);
|
||||||
|
|
||||||
|
// Load league stats including average SOF from application query
|
||||||
|
const leagueStats = await leagueStatsQuery.execute({ leagueId });
|
||||||
|
if (leagueStats) {
|
||||||
|
setAverageSOF(leagueStats.averageSOF);
|
||||||
|
setCompletedRacesCount(leagueStats.completedRaces);
|
||||||
|
} else {
|
||||||
|
// Fallback: count completed races manually
|
||||||
|
const leagueRaces = await raceRepo.findByLeagueId(leagueId);
|
||||||
|
const completedRaces = leagueRaces.filter(r => r.status === 'completed');
|
||||||
|
setCompletedRacesCount(completedRaces.length);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load league');
|
setError(err instanceof Error ? err.message : 'Failed to load league');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -292,12 +309,13 @@ export default function LeagueDetailPage() {
|
|||||||
<div className="pt-4 border-t border-charcoal-outline">
|
<div className="pt-4 border-t border-charcoal-outline">
|
||||||
<h3 className="text-white font-medium mb-3">At a glance</h3>
|
<h3 className="text-white font-medium mb-3">At a glance</h3>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-1">
|
<h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-1">
|
||||||
Structure
|
Structure
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-gray-200">
|
<p className="text-gray-200 flex items-center gap-1.5">
|
||||||
|
<Users className="w-4 h-4 text-gray-500" />
|
||||||
Solo • {league.settings.maxDrivers ?? 32} drivers
|
Solo • {league.settings.maxDrivers ?? 32} drivers
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -305,22 +323,29 @@ export default function LeagueDetailPage() {
|
|||||||
<h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-1">
|
<h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-1">
|
||||||
Schedule
|
Schedule
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-gray-200">
|
<p className="text-gray-200 flex items-center gap-1.5">
|
||||||
{`? rounds • 30 min Qualifying • ${
|
<Calendar className="w-4 h-4 text-gray-500" />
|
||||||
typeof league.settings.sessionDuration === 'number'
|
{completedRacesCount > 0 ? `${completedRacesCount} races completed` : 'Season upcoming'}
|
||||||
? league.settings.sessionDuration
|
|
||||||
: 40
|
|
||||||
} min Races`}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-1">
|
<h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-1">
|
||||||
Scoring & drops
|
Scoring & drops
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-gray-200">
|
<p className="text-gray-200 flex items-center gap-1.5">
|
||||||
|
<Trophy className="w-4 h-4 text-gray-500" />
|
||||||
{league.settings.pointsSystem.toUpperCase()}
|
{league.settings.pointsSystem.toUpperCase()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-1">
|
||||||
|
Avg. Strength of Field
|
||||||
|
</h4>
|
||||||
|
<p className="text-warning-amber font-medium flex items-center gap-1.5">
|
||||||
|
<Zap className="w-4 h-4" />
|
||||||
|
{averageSOF ? averageSOF : '—'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useRouter, useParams } from 'next/navigation';
|
import { useRouter, useParams } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import FeatureLimitationTooltip from '@/components/alpha/FeatureLimitationTooltip';
|
import Heading from '@/components/ui/Heading';
|
||||||
|
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
||||||
import type { Race } from '@gridpilot/racing/domain/entities/Race';
|
import type { Race } from '@gridpilot/racing/domain/entities/Race';
|
||||||
import type { League } from '@gridpilot/racing/domain/entities/League';
|
import type { League } from '@gridpilot/racing/domain/entities/League';
|
||||||
import type { Driver } from '@gridpilot/racing/domain/entities/Driver';
|
import type { Driver } from '@gridpilot/racing/domain/entities/Driver';
|
||||||
@@ -16,12 +18,35 @@ import {
|
|||||||
getIsDriverRegisteredForRaceQuery,
|
getIsDriverRegisteredForRaceQuery,
|
||||||
getRegisterForRaceUseCase,
|
getRegisterForRaceUseCase,
|
||||||
getWithdrawFromRaceUseCase,
|
getWithdrawFromRaceUseCase,
|
||||||
|
getTrackRepository,
|
||||||
|
getCarRepository,
|
||||||
|
getGetRaceWithSOFQuery,
|
||||||
} from '@/lib/di-container';
|
} from '@/lib/di-container';
|
||||||
import { getMembership } from '@/lib/leagueMembership';
|
import { getMembership } from '@/lib/leagueMembership';
|
||||||
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
||||||
import CompanionStatus from '@/components/alpha/CompanionStatus';
|
import {
|
||||||
import CompanionInstructions from '@/components/alpha/CompanionInstructions';
|
Calendar,
|
||||||
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
Clock,
|
||||||
|
MapPin,
|
||||||
|
Car,
|
||||||
|
Trophy,
|
||||||
|
Users,
|
||||||
|
Zap,
|
||||||
|
PlayCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
ChevronRight,
|
||||||
|
Flag,
|
||||||
|
Timer,
|
||||||
|
UserPlus,
|
||||||
|
UserMinus,
|
||||||
|
AlertTriangle,
|
||||||
|
ArrowRight,
|
||||||
|
ArrowLeft,
|
||||||
|
ExternalLink,
|
||||||
|
Award,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { getDriverStats, getAllDriverRankings } from '@/lib/di-container';
|
||||||
|
|
||||||
export default function RaceDetailPage() {
|
export default function RaceDetailPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -37,6 +62,7 @@ export default function RaceDetailPage() {
|
|||||||
const [entryList, setEntryList] = useState<Driver[]>([]);
|
const [entryList, setEntryList] = useState<Driver[]>([]);
|
||||||
const [isUserRegistered, setIsUserRegistered] = useState(false);
|
const [isUserRegistered, setIsUserRegistered] = useState(false);
|
||||||
const [canRegister, setCanRegister] = useState(false);
|
const [canRegister, setCanRegister] = useState(false);
|
||||||
|
const [raceSOF, setRaceSOF] = useState<number | null>(null);
|
||||||
|
|
||||||
const currentDriverId = useEffectiveDriverId();
|
const currentDriverId = useEffectiveDriverId();
|
||||||
|
|
||||||
@@ -44,6 +70,7 @@ export default function RaceDetailPage() {
|
|||||||
try {
|
try {
|
||||||
const raceRepo = getRaceRepository();
|
const raceRepo = getRaceRepository();
|
||||||
const leagueRepo = getLeagueRepository();
|
const leagueRepo = getLeagueRepository();
|
||||||
|
const raceWithSOFQuery = getGetRaceWithSOFQuery();
|
||||||
|
|
||||||
const raceData = await raceRepo.findById(raceId);
|
const raceData = await raceRepo.findById(raceId);
|
||||||
|
|
||||||
@@ -55,6 +82,12 @@ export default function RaceDetailPage() {
|
|||||||
|
|
||||||
setRace(raceData);
|
setRace(raceData);
|
||||||
|
|
||||||
|
// Load race with SOF from application query
|
||||||
|
const raceWithSOF = await raceWithSOFQuery.execute({ raceId });
|
||||||
|
if (raceWithSOF) {
|
||||||
|
setRaceSOF(raceWithSOF.strengthOfField);
|
||||||
|
}
|
||||||
|
|
||||||
// Load league data
|
// Load league data
|
||||||
const leagueData = await leagueRepo.findById(raceData.leagueId);
|
const leagueData = await leagueRepo.findById(raceData.leagueId);
|
||||||
setLeague(leagueData);
|
setLeague(leagueData);
|
||||||
@@ -78,7 +111,8 @@ export default function RaceDetailPage() {
|
|||||||
const drivers = await Promise.all(
|
const drivers = await Promise.all(
|
||||||
registeredDriverIds.map((id: string) => driverRepo.findById(id)),
|
registeredDriverIds.map((id: string) => driverRepo.findById(id)),
|
||||||
);
|
);
|
||||||
setEntryList(drivers.filter((d: Driver | null): d is Driver => d !== null));
|
const validDrivers = drivers.filter((d: Driver | null): d is Driver => d !== null);
|
||||||
|
setEntryList(validDrivers);
|
||||||
|
|
||||||
const isRegisteredQuery = getIsDriverRegisteredForRaceQuery();
|
const isRegisteredQuery = getIsDriverRegisteredForRaceQuery();
|
||||||
const userIsRegistered = await isRegisteredQuery.execute({
|
const userIsRegistered = await isRegisteredQuery.execute({
|
||||||
@@ -173,7 +207,8 @@ export default function RaceDetailPage() {
|
|||||||
|
|
||||||
const formatDate = (date: Date) => {
|
const formatDate = (date: Date) => {
|
||||||
return new Date(date).toLocaleDateString('en-US', {
|
return new Date(date).toLocaleDateString('en-US', {
|
||||||
month: 'short',
|
weekday: 'long',
|
||||||
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
});
|
});
|
||||||
@@ -187,28 +222,69 @@ export default function RaceDetailPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDateTime = (date: Date) => {
|
const getTimeUntil = (date: Date) => {
|
||||||
return new Date(date).toLocaleDateString('en-US', {
|
const now = new Date();
|
||||||
month: 'short',
|
const target = new Date(date);
|
||||||
day: 'numeric',
|
const diffMs = target.getTime() - now.getTime();
|
||||||
year: 'numeric',
|
|
||||||
hour: '2-digit',
|
if (diffMs < 0) return null;
|
||||||
minute: '2-digit',
|
|
||||||
timeZoneName: 'short',
|
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
});
|
const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||||
|
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||||
|
|
||||||
|
if (days > 0) return `${days}d ${hours}h`;
|
||||||
|
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||||
|
return `${minutes}m`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusColors = {
|
const statusConfig = {
|
||||||
scheduled: 'bg-primary-blue/20 text-primary-blue border-primary-blue/30',
|
scheduled: {
|
||||||
completed: 'bg-green-500/20 text-green-400 border-green-500/30',
|
icon: Clock,
|
||||||
cancelled: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
|
color: 'text-primary-blue',
|
||||||
} as const;
|
bg: 'bg-primary-blue/10',
|
||||||
|
border: 'border-primary-blue/30',
|
||||||
|
label: 'Scheduled',
|
||||||
|
description: 'This race is scheduled and waiting to start',
|
||||||
|
},
|
||||||
|
running: {
|
||||||
|
icon: PlayCircle,
|
||||||
|
color: 'text-performance-green',
|
||||||
|
bg: 'bg-performance-green/10',
|
||||||
|
border: 'border-performance-green/30',
|
||||||
|
label: 'LIVE NOW',
|
||||||
|
description: 'This race is currently in progress',
|
||||||
|
},
|
||||||
|
completed: {
|
||||||
|
icon: CheckCircle2,
|
||||||
|
color: 'text-gray-400',
|
||||||
|
bg: 'bg-gray-500/10',
|
||||||
|
border: 'border-gray-500/30',
|
||||||
|
label: 'Completed',
|
||||||
|
description: 'This race has finished',
|
||||||
|
},
|
||||||
|
cancelled: {
|
||||||
|
icon: XCircle,
|
||||||
|
color: 'text-warning-amber',
|
||||||
|
bg: 'bg-warning-amber/10',
|
||||||
|
border: 'border-warning-amber/30',
|
||||||
|
label: 'Cancelled',
|
||||||
|
description: 'This race has been cancelled',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
|
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8">
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
<div className="text-center text-gray-400">Loading race details...</div>
|
<div className="animate-pulse space-y-6">
|
||||||
|
<div className="h-6 bg-iron-gray rounded w-1/4" />
|
||||||
|
<div className="h-48 bg-iron-gray rounded-xl" />
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div className="lg:col-span-2 h-64 bg-iron-gray rounded-xl" />
|
||||||
|
<div className="h-64 bg-iron-gray rounded-xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -216,237 +292,371 @@ export default function RaceDetailPage() {
|
|||||||
|
|
||||||
if (error || !race) {
|
if (error || !race) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
|
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8">
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
<Card className="text-center py-12">
|
<Breadcrumbs items={[{ label: 'Races', href: '/races' }, { label: 'Error' }]} />
|
||||||
<div className="text-warning-amber mb-4">
|
|
||||||
{error || 'Race not found'}
|
<Card className="text-center py-12 mt-6">
|
||||||
</div>
|
<div className="flex flex-col items-center gap-4">
|
||||||
<Button
|
<div className="p-4 bg-warning-amber/10 rounded-full">
|
||||||
variant="secondary"
|
<AlertTriangle className="w-8 h-8 text-warning-amber" />
|
||||||
onClick={() => router.push('/races')}
|
</div>
|
||||||
>
|
|
||||||
Back to Races
|
|
||||||
</Button>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
|
|
||||||
<div className="max-w-4xl mx-auto">
|
|
||||||
{/* Breadcrumb */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<button
|
|
||||||
onClick={() => router.push('/races')}
|
|
||||||
className="text-gray-400 hover:text-primary-blue transition-colors text-sm flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
||||||
</svg>
|
|
||||||
Back to Races
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Companion Status */}
|
|
||||||
<FeatureLimitationTooltip message="Companion automation available in production">
|
|
||||||
<div className="mb-6">
|
|
||||||
<CompanionStatus />
|
|
||||||
</div>
|
|
||||||
</FeatureLimitationTooltip>
|
|
||||||
|
|
||||||
{/* Race Header */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<div className="flex items-start justify-between mb-4">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold text-white mb-2">{race.track}</h1>
|
|
||||||
{league && (
|
|
||||||
<p className="text-gray-400">{league.name}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={`px-3 py-1 text-sm font-medium rounded border ${
|
|
||||||
statusColors[race.status as keyof typeof statusColors]
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{race.status.charAt(0).toUpperCase() + race.status.slice(1)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Companion Instructions for Scheduled Races */}
|
|
||||||
{race.status === 'scheduled' && (
|
|
||||||
<div className="mb-6">
|
|
||||||
<CompanionInstructions race={race} leagueName={league?.name} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
||||||
{/* Race Details */}
|
|
||||||
<Card className="lg:col-span-2">
|
|
||||||
<h2 className="text-xl font-semibold text-white mb-6">Race Details</h2>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Date & Time */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm text-gray-500 block mb-1">Scheduled Date & Time</label>
|
<p className="text-white font-medium mb-1">{error || 'Race not found'}</p>
|
||||||
<p className="text-white text-lg font-medium">
|
<p className="text-sm text-gray-500">The race you're looking for doesn't exist or has been removed.</p>
|
||||||
{formatDateTime(race.scheduledAt)}
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-4 mt-2 text-sm">
|
|
||||||
<span className="text-gray-400">{formatDate(race.scheduledAt)}</span>
|
|
||||||
<span className="text-gray-400">{formatTime(race.scheduledAt)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Track */}
|
|
||||||
<div className="pt-4 border-t border-charcoal-outline">
|
|
||||||
<label className="text-sm text-gray-500 block mb-1">Track</label>
|
|
||||||
<p className="text-white">{race.track}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Car */}
|
|
||||||
<div>
|
|
||||||
<label className="text-sm text-gray-500 block mb-1">Car</label>
|
|
||||||
<p className="text-white">{race.car}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Session Type */}
|
|
||||||
<div>
|
|
||||||
<label className="text-sm text-gray-500 block mb-1">Session Type</label>
|
|
||||||
<p className="text-white capitalize">{race.sessionType}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* League */}
|
|
||||||
<div className="pt-4 border-t border-charcoal-outline">
|
|
||||||
<label className="text-sm text-gray-500 block mb-1">League</label>
|
|
||||||
{league ? (
|
|
||||||
<button
|
|
||||||
onClick={() => router.push(`/leagues/${league.id}`)}
|
|
||||||
className="text-primary-blue hover:underline"
|
|
||||||
>
|
|
||||||
{league.name}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<p className="text-white">ID: {race.leagueId.slice(0, 8)}...</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<Card>
|
|
||||||
<h2 className="text-xl font-semibold text-white mb-4">Actions</h2>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
{/* Registration Actions */}
|
|
||||||
{race.status === 'scheduled' && canRegister && !isUserRegistered && (
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
className="w-full"
|
|
||||||
onClick={handleRegister}
|
|
||||||
disabled={registering}
|
|
||||||
>
|
|
||||||
{registering ? 'Registering...' : 'Register for Race'}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{race.status === 'scheduled' && isUserRegistered && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="px-3 py-2 bg-green-500/10 border border-green-500/30 rounded text-green-400 text-sm text-center">
|
|
||||||
✓ Registered
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
className="w-full"
|
|
||||||
onClick={handleWithdraw}
|
|
||||||
disabled={registering}
|
|
||||||
>
|
|
||||||
{registering ? 'Withdrawing...' : 'Withdraw'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{race.status === 'completed' && (
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
className="w-full"
|
|
||||||
onClick={() => router.push(`/races/${race.id}/results`)}
|
|
||||||
>
|
|
||||||
View Results
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{race.status === 'scheduled' && (
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
className="w-full"
|
|
||||||
onClick={handleCancelRace}
|
|
||||||
disabled={cancelling}
|
|
||||||
>
|
|
||||||
{cancelling ? 'Cancelling...' : 'Cancel Race'}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="w-full"
|
|
||||||
onClick={() => router.push('/races')}
|
onClick={() => router.push('/races')}
|
||||||
|
className="mt-4"
|
||||||
>
|
>
|
||||||
Back to Races
|
Back to Races
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{/* Entry List */}
|
const config = statusConfig[race.status];
|
||||||
{race.status === 'scheduled' && (
|
const StatusIcon = config.icon;
|
||||||
<Card className="mt-6">
|
const timeUntil = race.status === 'scheduled' ? getTimeUntil(race.scheduledAt) : null;
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h2 className="text-xl font-semibold text-white">Entry List</h2>
|
const breadcrumbItems = [
|
||||||
<span className="text-sm text-gray-400">
|
{ label: 'Races', href: '/races' },
|
||||||
{entryList.length} {entryList.length === 1 ? 'driver' : 'drivers'} registered
|
...(league ? [{ label: league.name, href: `/leagues/${league.id}` }] : []),
|
||||||
</span>
|
{ label: race.track },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Build driver rankings for entry list display
|
||||||
|
const getDriverRank = (driverId: string): { rating: number | null; rank: number | null } => {
|
||||||
|
const stats = getDriverStats(driverId);
|
||||||
|
if (!stats) return { rating: null, rank: null };
|
||||||
|
|
||||||
|
const allRankings = getAllDriverRankings();
|
||||||
|
let rank = stats.overallRank;
|
||||||
|
if (!rank || rank <= 0) {
|
||||||
|
const indexInGlobal = allRankings.findIndex(s => s.driverId === driverId);
|
||||||
|
if (indexInGlobal !== -1) {
|
||||||
|
rank = indexInGlobal + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rating: stats.rating, rank };
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
|
{/* Navigation Row: Breadcrumbs left, Back button right */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Breadcrumbs items={breadcrumbItems} className="text-sm text-gray-400" />
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
className="flex items-center gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hero Header */}
|
||||||
|
<div className={`relative overflow-hidden rounded-2xl ${config.bg} border ${config.border} p-6 sm:p-8`}>
|
||||||
|
{/* Live indicator */}
|
||||||
|
{race.status === 'running' && (
|
||||||
|
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-performance-green via-performance-green/50 to-performance-green animate-pulse" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="absolute top-0 right-0 w-64 h-64 bg-white/5 rounded-full blur-3xl" />
|
||||||
|
|
||||||
|
<div className="relative z-10">
|
||||||
|
{/* Status Badge */}
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-full ${config.bg} border ${config.border}`}>
|
||||||
|
{race.status === 'running' && (
|
||||||
|
<span className="w-2 h-2 bg-performance-green rounded-full animate-pulse" />
|
||||||
|
)}
|
||||||
|
<StatusIcon className={`w-4 h-4 ${config.color}`} />
|
||||||
|
<span className={`text-sm font-semibold ${config.color}`}>{config.label}</span>
|
||||||
|
</div>
|
||||||
|
{timeUntil && (
|
||||||
|
<span className="text-sm text-gray-400">
|
||||||
|
Starts in <span className="text-white font-medium">{timeUntil}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{entryList.length === 0 ? (
|
{/* Title */}
|
||||||
<div className="text-center py-8 text-gray-400">
|
<Heading level={1} className="text-2xl sm:text-3xl font-bold text-white mb-2">
|
||||||
<p className="mb-2">No drivers registered yet</p>
|
{race.track}
|
||||||
<p className="text-sm text-gray-500">Be the first to register!</p>
|
</Heading>
|
||||||
</div>
|
|
||||||
) : (
|
{/* Meta */}
|
||||||
<div className="space-y-2">
|
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-gray-400">
|
||||||
{entryList.map((driver, index) => (
|
<span className="flex items-center gap-2">
|
||||||
<div
|
<Calendar className="w-4 h-4" />
|
||||||
key={driver.id}
|
{formatDate(race.scheduledAt)}
|
||||||
className="flex items-center gap-4 p-3 bg-iron-gray/50 rounded-lg border border-charcoal-outline hover:border-primary-blue/50 transition-colors cursor-pointer"
|
</span>
|
||||||
onClick={() => router.push(`/drivers/${driver.id}`)}
|
<span className="flex items-center gap-2">
|
||||||
>
|
<Clock className="w-4 h-4" />
|
||||||
<div className="w-8 text-center text-gray-400 font-mono text-sm">
|
{formatTime(race.scheduledAt)}
|
||||||
#{index + 1}
|
</span>
|
||||||
</div>
|
<span className="flex items-center gap-2">
|
||||||
<div className="w-10 h-10 bg-charcoal-outline rounded-full flex items-center justify-center flex-shrink-0">
|
<Car className="w-4 h-4" />
|
||||||
<span className="text-lg font-bold text-gray-500">
|
{race.car}
|
||||||
{driver.name.charAt(0)}
|
</span>
|
||||||
</span>
|
{raceSOF && (
|
||||||
</div>
|
<span className="flex items-center gap-2 text-warning-amber">
|
||||||
<div className="flex-1">
|
<Zap className="w-4 h-4" />
|
||||||
<p className="text-white font-medium">{driver.name}</p>
|
SOF {raceSOF}
|
||||||
<p className="text-sm text-gray-400">{driver.country}</p>
|
</span>
|
||||||
</div>
|
)}
|
||||||
{driver.id === currentDriverId && (
|
</div>
|
||||||
<span className="px-2 py-1 text-xs font-medium bg-primary-blue/20 text-primary-blue rounded">
|
</div>
|
||||||
You
|
</div>
|
||||||
</span>
|
|
||||||
)}
|
{/* League Banner */}
|
||||||
|
{league && (
|
||||||
|
<Link
|
||||||
|
href={`/leagues/${league.id}`}
|
||||||
|
className="block group"
|
||||||
|
>
|
||||||
|
<div className="relative overflow-hidden rounded-xl bg-gradient-to-r from-primary-blue/10 via-primary-blue/5 to-transparent border border-primary-blue/20 p-4 transition-all hover:border-primary-blue/40">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="p-3 bg-primary-blue/20 rounded-xl">
|
||||||
|
<Trophy className="w-6 h-6 text-primary-blue" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div>
|
||||||
|
<p className="text-xs text-gray-400 uppercase tracking-wide">Part of</p>
|
||||||
|
<p className="text-lg font-semibold text-white group-hover:text-primary-blue transition-colors">
|
||||||
|
{league.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-primary-blue">
|
||||||
|
<span className="text-sm hidden sm:block">View League</span>
|
||||||
|
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</Card>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{/* Race Details */}
|
||||||
|
<Card>
|
||||||
|
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||||
|
<Flag className="w-5 h-5 text-primary-blue" />
|
||||||
|
Race Details
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div className="p-4 bg-deep-graphite rounded-lg">
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Track</p>
|
||||||
|
<p className="text-white font-medium">{race.track}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-deep-graphite rounded-lg">
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Car</p>
|
||||||
|
<p className="text-white font-medium">{race.car}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-deep-graphite rounded-lg">
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Session Type</p>
|
||||||
|
<p className="text-white font-medium capitalize">{race.sessionType}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-deep-graphite rounded-lg">
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Status</p>
|
||||||
|
<p className={`font-medium ${config.color}`}>{config.label}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-deep-graphite rounded-lg">
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Strength of Field</p>
|
||||||
|
<p className="text-warning-amber font-medium flex items-center gap-1.5">
|
||||||
|
<Zap className="w-4 h-4" />
|
||||||
|
{raceSOF ?? '—'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{race.registeredCount !== undefined && (
|
||||||
|
<div className="p-4 bg-deep-graphite rounded-lg">
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Registered</p>
|
||||||
|
<p className="text-white font-medium">
|
||||||
|
{race.registeredCount}
|
||||||
|
{race.maxParticipants && ` / ${race.maxParticipants}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Entry List */}
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||||
|
<Users className="w-5 h-5 text-primary-blue" />
|
||||||
|
Entry List
|
||||||
|
</h2>
|
||||||
|
<span className="text-sm text-gray-400">
|
||||||
|
{entryList.length} driver{entryList.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{entryList.length === 0 ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="p-4 bg-iron-gray rounded-full inline-block mb-3">
|
||||||
|
<Users className="w-6 h-6 text-gray-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-400">No drivers registered yet</p>
|
||||||
|
<p className="text-sm text-gray-500">Be the first to sign up!</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
{entryList.map((driver, index) => {
|
||||||
|
const driverRankInfo = getDriverRank(driver.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={driver.id}
|
||||||
|
onClick={() => router.push(`/drivers/${driver.id}`)}
|
||||||
|
className="flex items-center gap-2 p-2 bg-deep-graphite rounded-lg hover:bg-charcoal-outline/50 cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<span className="w-6 text-xs text-gray-500 font-mono">#{index + 1}</span>
|
||||||
|
<div className="w-7 h-7 bg-iron-gray rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="text-sm font-bold text-gray-400">
|
||||||
|
{driver.name.charAt(0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-white text-sm font-medium truncate">{driver.name}</p>
|
||||||
|
</div>
|
||||||
|
{driverRankInfo.rating && (
|
||||||
|
<span className="text-xs text-warning-amber font-medium">
|
||||||
|
{driverRankInfo.rating}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{driver.id === currentDriverId && (
|
||||||
|
<span className="px-1.5 py-0.5 text-xs font-medium bg-primary-blue/20 text-primary-blue rounded">
|
||||||
|
You
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar - Actions */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Quick Actions Card */}
|
||||||
|
<Card>
|
||||||
|
<h2 className="text-lg font-semibold text-white mb-4">Actions</h2>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Registration Actions */}
|
||||||
|
{race.status === 'scheduled' && canRegister && !isUserRegistered && (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
className="w-full flex items-center justify-center gap-2"
|
||||||
|
onClick={handleRegister}
|
||||||
|
disabled={registering}
|
||||||
|
>
|
||||||
|
<UserPlus className="w-4 h-4" />
|
||||||
|
{registering ? 'Registering...' : 'Register for Race'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{race.status === 'scheduled' && isUserRegistered && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2 px-4 py-3 bg-performance-green/10 border border-performance-green/30 rounded-lg text-performance-green">
|
||||||
|
<CheckCircle2 className="w-5 h-5" />
|
||||||
|
<span className="font-medium">You're Registered</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
className="w-full flex items-center justify-center gap-2"
|
||||||
|
onClick={handleWithdraw}
|
||||||
|
disabled={registering}
|
||||||
|
>
|
||||||
|
<UserMinus className="w-4 h-4" />
|
||||||
|
{registering ? 'Withdrawing...' : 'Withdraw'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{race.status === 'completed' && (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
className="w-full flex items-center justify-center gap-2"
|
||||||
|
onClick={() => router.push(`/races/${race.id}/results`)}
|
||||||
|
>
|
||||||
|
<Trophy className="w-4 h-4" />
|
||||||
|
View Results
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{race.status === 'scheduled' && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
className="w-full flex items-center justify-center gap-2"
|
||||||
|
onClick={handleCancelRace}
|
||||||
|
disabled={cancelling}
|
||||||
|
>
|
||||||
|
<XCircle className="w-4 h-4" />
|
||||||
|
{cancelling ? 'Cancelling...' : 'Cancel Race'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Status Info */}
|
||||||
|
<Card className={`${config.bg} border ${config.border}`}>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className={`p-2 rounded-lg ${config.bg}`}>
|
||||||
|
<StatusIcon className={`w-5 h-5 ${config.color}`} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className={`font-medium ${config.color}`}>{config.label}</p>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">{config.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Quick Links */}
|
||||||
|
<Card>
|
||||||
|
<h3 className="text-sm font-semibold text-white mb-3">Quick Links</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Link
|
||||||
|
href="/races"
|
||||||
|
className="flex items-center gap-3 p-2 rounded-lg hover:bg-deep-graphite transition-colors"
|
||||||
|
>
|
||||||
|
<Flag className="w-4 h-4 text-gray-400" />
|
||||||
|
<span className="text-sm text-gray-300">All Races</span>
|
||||||
|
</Link>
|
||||||
|
{league && (
|
||||||
|
<Link
|
||||||
|
href={`/leagues/${league.id}`}
|
||||||
|
className="flex items-center gap-3 p-2 rounded-lg hover:bg-deep-graphite transition-colors"
|
||||||
|
>
|
||||||
|
<Trophy className="w-4 h-4 text-gray-400" />
|
||||||
|
<span className="text-sm text-gray-300">{league.name}</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{league && (
|
||||||
|
<Link
|
||||||
|
href={`/leagues/${league.id}/standings`}
|
||||||
|
className="flex items-center gap-3 p-2 rounded-lg hover:bg-deep-graphite transition-colors"
|
||||||
|
>
|
||||||
|
<Users className="w-4 h-4 text-gray-400" />
|
||||||
|
<span className="text-sm text-gray-300">League Standings</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useRouter, useParams } from 'next/navigation';
|
import { useRouter, useParams } from 'next/navigation';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
|
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
||||||
import ResultsTable from '@/components/races/ResultsTable';
|
import ResultsTable from '@/components/races/ResultsTable';
|
||||||
import ImportResultsForm from '@/components/races/ImportResultsForm';
|
import ImportResultsForm from '@/components/races/ImportResultsForm';
|
||||||
import { Race } from '@gridpilot/racing/domain/entities/Race';
|
import { Race } from '@gridpilot/racing/domain/entities/Race';
|
||||||
@@ -15,8 +16,10 @@ import {
|
|||||||
getLeagueRepository,
|
getLeagueRepository,
|
||||||
getResultRepository,
|
getResultRepository,
|
||||||
getStandingRepository,
|
getStandingRepository,
|
||||||
getDriverRepository
|
getDriverRepository,
|
||||||
|
getGetRaceWithSOFQuery,
|
||||||
} from '@/lib/di-container';
|
} from '@/lib/di-container';
|
||||||
|
import { ArrowLeft, Zap, Trophy, Users, Clock, Calendar } from 'lucide-react';
|
||||||
|
|
||||||
export default function RaceResultsPage() {
|
export default function RaceResultsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -27,6 +30,7 @@ export default function RaceResultsPage() {
|
|||||||
const [league, setLeague] = useState<League | null>(null);
|
const [league, setLeague] = useState<League | null>(null);
|
||||||
const [results, setResults] = useState<Result[]>([]);
|
const [results, setResults] = useState<Result[]>([]);
|
||||||
const [drivers, setDrivers] = useState<Driver[]>([]);
|
const [drivers, setDrivers] = useState<Driver[]>([]);
|
||||||
|
const [raceSOF, setRaceSOF] = useState<number | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [importing, setImporting] = useState(false);
|
const [importing, setImporting] = useState(false);
|
||||||
@@ -38,6 +42,7 @@ export default function RaceResultsPage() {
|
|||||||
const leagueRepo = getLeagueRepository();
|
const leagueRepo = getLeagueRepository();
|
||||||
const resultRepo = getResultRepository();
|
const resultRepo = getResultRepository();
|
||||||
const driverRepo = getDriverRepository();
|
const driverRepo = getDriverRepository();
|
||||||
|
const raceWithSOFQuery = getGetRaceWithSOFQuery();
|
||||||
|
|
||||||
const raceData = await raceRepo.findById(raceId);
|
const raceData = await raceRepo.findById(raceId);
|
||||||
|
|
||||||
@@ -49,6 +54,12 @@ export default function RaceResultsPage() {
|
|||||||
|
|
||||||
setRace(raceData);
|
setRace(raceData);
|
||||||
|
|
||||||
|
// Load race with SOF from application query
|
||||||
|
const raceWithSOF = await raceWithSOFQuery.execute({ raceId });
|
||||||
|
if (raceWithSOF) {
|
||||||
|
setRaceSOF(raceWithSOF.strengthOfField);
|
||||||
|
}
|
||||||
|
|
||||||
// Load league data
|
// Load league data
|
||||||
const leagueData = await leagueRepo.findById(raceData.leagueId);
|
const leagueData = await leagueRepo.findById(raceData.leagueId);
|
||||||
setLeague(leagueData);
|
setLeague(leagueData);
|
||||||
@@ -166,45 +177,85 @@ export default function RaceResultsPage() {
|
|||||||
|
|
||||||
const hasResults = results.length > 0;
|
const hasResults = results.length > 0;
|
||||||
|
|
||||||
|
const breadcrumbItems = [
|
||||||
|
{ label: 'Races', href: '/races' },
|
||||||
|
...(league ? [{ label: league.name, href: `/leagues/${league.id}` }] : []),
|
||||||
|
...(race ? [{ label: race.track, href: `/races/${raceId}` }] : []),
|
||||||
|
{ label: 'Results' },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
|
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8">
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto space-y-6">
|
||||||
{/* Breadcrumb */}
|
{/* Navigation Row: Breadcrumbs left, Back button right */}
|
||||||
<div className="mb-6">
|
<div className="flex items-center justify-between">
|
||||||
<button
|
<Breadcrumbs items={breadcrumbItems} className="text-sm text-gray-400" />
|
||||||
onClick={() => router.push(`/races/${raceId}`)}
|
<Button
|
||||||
className="text-gray-400 hover:text-primary-blue transition-colors text-sm flex items-center gap-2"
|
variant="secondary"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
className="flex items-center gap-2 text-sm"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<ArrowLeft className="w-4 h-4" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
Back
|
||||||
</svg>
|
</Button>
|
||||||
Back to Race Details
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Page Header */}
|
{/* Hero Header */}
|
||||||
<div className="mb-8">
|
<div className="relative overflow-hidden rounded-2xl bg-gray-500/10 border border-gray-500/30 p-6 sm:p-8">
|
||||||
<h1 className="text-3xl font-bold text-white mb-2">Race Results</h1>
|
<div className="absolute top-0 right-0 w-64 h-64 bg-white/5 rounded-full blur-3xl" />
|
||||||
{race && (
|
|
||||||
<div>
|
<div className="relative z-10">
|
||||||
<p className="text-gray-400">{race.track}</p>
|
<div className="flex items-center gap-3 mb-4">
|
||||||
{league && (
|
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-performance-green/10 border border-performance-green/30">
|
||||||
<p className="text-sm text-gray-500">{league.name}</p>
|
<Trophy className="w-4 h-4 text-performance-green" />
|
||||||
|
<span className="text-sm font-semibold text-performance-green">Final Results</span>
|
||||||
|
</div>
|
||||||
|
{raceSOF && (
|
||||||
|
<span className="flex items-center gap-1.5 text-warning-amber text-sm">
|
||||||
|
<Zap className="w-4 h-4" />
|
||||||
|
SOF {raceSOF}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold text-white mb-2">
|
||||||
|
{race?.track ?? 'Race'} Results
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-gray-400">
|
||||||
|
{race && (
|
||||||
|
<>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Calendar className="w-4 h-4" />
|
||||||
|
{new Date(race.scheduledAt).toLocaleDateString('en-US', {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
{results.length} drivers classified
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{league && (
|
||||||
|
<span className="text-primary-blue">{league.name}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Success Message */}
|
{/* Success Message */}
|
||||||
{importSuccess && (
|
{importSuccess && (
|
||||||
<div className="mb-6 p-4 bg-performance-green/10 border border-performance-green/30 rounded text-performance-green">
|
<div className="p-4 bg-performance-green/10 border border-performance-green/30 rounded-lg text-performance-green">
|
||||||
<strong>Success!</strong> Results imported and standings updated.
|
<strong>Success!</strong> Results imported and standings updated.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Error Message */}
|
{/* Error Message */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-6 p-4 bg-warning-amber/10 border border-warning-amber/30 rounded text-warning-amber">
|
<div className="p-4 bg-warning-amber/10 border border-warning-amber/30 rounded-lg text-warning-amber">
|
||||||
<strong>Error:</strong> {error}
|
<strong>Error:</strong> {error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -212,15 +263,12 @@ export default function RaceResultsPage() {
|
|||||||
{/* Content */}
|
{/* Content */}
|
||||||
<Card>
|
<Card>
|
||||||
{hasResults ? (
|
{hasResults ? (
|
||||||
<>
|
<ResultsTable
|
||||||
<h2 className="text-xl font-semibold text-white mb-6">Results</h2>
|
results={results}
|
||||||
<ResultsTable
|
drivers={drivers}
|
||||||
results={results}
|
pointsSystem={getPointsSystem()}
|
||||||
drivers={drivers}
|
fastestLapTime={getFastestLapTime()}
|
||||||
pointsSystem={getPointsSystem()}
|
/>
|
||||||
fastestLapTime={getFastestLapTime()}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<h2 className="text-xl font-semibold text-white mb-6">Import Results</h2>
|
<h2 className="text-xl font-semibold text-white mb-6">Import Results</h2>
|
||||||
|
|||||||
439
apps/website/app/races/all/page.tsx
Normal file
439
apps/website/app/races/all/page.tsx
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import Heading from '@/components/ui/Heading';
|
||||||
|
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
||||||
|
import { Race, RaceStatus } from '@gridpilot/racing/domain/entities/Race';
|
||||||
|
import { League } from '@gridpilot/racing/domain/entities/League';
|
||||||
|
import { getRaceRepository, getLeagueRepository } from '@/lib/di-container';
|
||||||
|
import {
|
||||||
|
Calendar,
|
||||||
|
Clock,
|
||||||
|
Flag,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronLeft,
|
||||||
|
Filter,
|
||||||
|
Car,
|
||||||
|
Trophy,
|
||||||
|
Zap,
|
||||||
|
PlayCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
ArrowRight,
|
||||||
|
Search,
|
||||||
|
SlidersHorizontal,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 10;
|
||||||
|
|
||||||
|
export default function AllRacesPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const [races, setRaces] = useState<Race[]>([]);
|
||||||
|
const [leagues, setLeagues] = useState<Map<string, League>>(new Map());
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
const [statusFilter, setStatusFilter] = useState<RaceStatus | 'all'>('all');
|
||||||
|
const [leagueFilter, setLeagueFilter] = useState<string>('all');
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
|
|
||||||
|
const loadRaces = async () => {
|
||||||
|
try {
|
||||||
|
const raceRepo = getRaceRepository();
|
||||||
|
const leagueRepo = getLeagueRepository();
|
||||||
|
|
||||||
|
const [allRaces, allLeagues] = await Promise.all([
|
||||||
|
raceRepo.findAll(),
|
||||||
|
leagueRepo.findAll()
|
||||||
|
]);
|
||||||
|
|
||||||
|
setRaces(allRaces.sort((a, b) => b.scheduledAt.getTime() - a.scheduledAt.getTime()));
|
||||||
|
|
||||||
|
const leagueMap = new Map<string, League>();
|
||||||
|
allLeagues.forEach(league => leagueMap.set(league.id, league));
|
||||||
|
setLeagues(leagueMap);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load races:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadRaces();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Filter races
|
||||||
|
const filteredRaces = useMemo(() => {
|
||||||
|
return races.filter(race => {
|
||||||
|
// Status filter
|
||||||
|
if (statusFilter !== 'all' && race.status !== statusFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// League filter
|
||||||
|
if (leagueFilter !== 'all' && race.leagueId !== leagueFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search filter
|
||||||
|
if (searchQuery) {
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
const league = leagues.get(race.leagueId);
|
||||||
|
const matchesTrack = race.track.toLowerCase().includes(query);
|
||||||
|
const matchesCar = race.car.toLowerCase().includes(query);
|
||||||
|
const matchesLeague = league?.name.toLowerCase().includes(query);
|
||||||
|
if (!matchesTrack && !matchesCar && !matchesLeague) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [races, statusFilter, leagueFilter, searchQuery, leagues]);
|
||||||
|
|
||||||
|
// Paginate
|
||||||
|
const totalPages = Math.ceil(filteredRaces.length / ITEMS_PER_PAGE);
|
||||||
|
const paginatedRaces = useMemo(() => {
|
||||||
|
const start = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||||
|
return filteredRaces.slice(start, start + ITEMS_PER_PAGE);
|
||||||
|
}, [filteredRaces, currentPage]);
|
||||||
|
|
||||||
|
// Reset page when filters change
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, [statusFilter, leagueFilter, searchQuery]);
|
||||||
|
|
||||||
|
const formatDate = (date: Date) => {
|
||||||
|
return new Date(date).toLocaleDateString('en-US', {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (date: Date) => {
|
||||||
|
return new Date(date).toLocaleTimeString('en-US', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
scheduled: {
|
||||||
|
icon: Clock,
|
||||||
|
color: 'text-primary-blue',
|
||||||
|
bg: 'bg-primary-blue/10',
|
||||||
|
border: 'border-primary-blue/30',
|
||||||
|
label: 'Scheduled',
|
||||||
|
},
|
||||||
|
running: {
|
||||||
|
icon: PlayCircle,
|
||||||
|
color: 'text-performance-green',
|
||||||
|
bg: 'bg-performance-green/10',
|
||||||
|
border: 'border-performance-green/30',
|
||||||
|
label: 'LIVE',
|
||||||
|
},
|
||||||
|
completed: {
|
||||||
|
icon: CheckCircle2,
|
||||||
|
color: 'text-gray-400',
|
||||||
|
bg: 'bg-gray-500/10',
|
||||||
|
border: 'border-gray-500/30',
|
||||||
|
label: 'Completed',
|
||||||
|
},
|
||||||
|
cancelled: {
|
||||||
|
icon: XCircle,
|
||||||
|
color: 'text-warning-amber',
|
||||||
|
bg: 'bg-warning-amber/10',
|
||||||
|
border: 'border-warning-amber/30',
|
||||||
|
label: 'Cancelled',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const breadcrumbItems = [
|
||||||
|
{ label: 'Races', href: '/races' },
|
||||||
|
{ label: 'All Races' },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="max-w-5xl mx-auto">
|
||||||
|
<div className="animate-pulse space-y-6">
|
||||||
|
<div className="h-6 bg-iron-gray rounded w-1/4" />
|
||||||
|
<div className="h-10 bg-iron-gray rounded w-1/3" />
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[1, 2, 3, 4, 5].map(i => (
|
||||||
|
<div key={i} className="h-24 bg-iron-gray rounded-lg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="max-w-5xl mx-auto space-y-6">
|
||||||
|
{/* Breadcrumbs */}
|
||||||
|
<Breadcrumbs items={breadcrumbItems} />
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<Heading level={1} className="text-2xl font-bold text-white flex items-center gap-3">
|
||||||
|
<Flag className="w-6 h-6 text-primary-blue" />
|
||||||
|
All Races
|
||||||
|
</Heading>
|
||||||
|
<p className="text-gray-400 text-sm mt-1">
|
||||||
|
{filteredRaces.length} race{filteredRaces.length !== 1 ? 's' : ''} found
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setShowFilters(!showFilters)}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<SlidersHorizontal className="w-4 h-4" />
|
||||||
|
Filters
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search & Filters */}
|
||||||
|
<Card className={`!p-4 ${showFilters ? '' : 'hidden sm:block'}`}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Search by track, car, or league..."
|
||||||
|
className="w-full pl-10 pr-4 py-2.5 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-blue"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter Row */}
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
{/* Status Filter */}
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value as RaceStatus | 'all')}
|
||||||
|
className="px-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue"
|
||||||
|
>
|
||||||
|
<option value="all">All Statuses</option>
|
||||||
|
<option value="scheduled">Scheduled</option>
|
||||||
|
<option value="running">Live</option>
|
||||||
|
<option value="completed">Completed</option>
|
||||||
|
<option value="cancelled">Cancelled</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* League Filter */}
|
||||||
|
<select
|
||||||
|
value={leagueFilter}
|
||||||
|
onChange={(e) => setLeagueFilter(e.target.value)}
|
||||||
|
className="px-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue"
|
||||||
|
>
|
||||||
|
<option value="all">All Leagues</option>
|
||||||
|
{Array.from(leagues.values()).map(league => (
|
||||||
|
<option key={league.id} value={league.id}>
|
||||||
|
{league.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Clear Filters */}
|
||||||
|
{(statusFilter !== 'all' || leagueFilter !== 'all' || searchQuery) && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setStatusFilter('all');
|
||||||
|
setLeagueFilter('all');
|
||||||
|
setSearchQuery('');
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 text-sm text-primary-blue hover:underline"
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Race List */}
|
||||||
|
{paginatedRaces.length === 0 ? (
|
||||||
|
<Card className="text-center py-12">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="p-4 bg-iron-gray rounded-full">
|
||||||
|
<Calendar className="w-8 h-8 text-gray-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-white font-medium mb-1">No races found</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{races.length === 0
|
||||||
|
? 'No races have been scheduled yet'
|
||||||
|
: 'Try adjusting your search or filters'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{paginatedRaces.map(race => {
|
||||||
|
const config = statusConfig[race.status];
|
||||||
|
const StatusIcon = config.icon;
|
||||||
|
const league = leagues.get(race.leagueId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={race.id}
|
||||||
|
onClick={() => router.push(`/races/${race.id}`)}
|
||||||
|
className={`group relative overflow-hidden rounded-xl bg-iron-gray border ${config.border} p-4 cursor-pointer transition-all duration-200 hover:scale-[1.01] hover:border-primary-blue`}
|
||||||
|
>
|
||||||
|
{/* Live indicator */}
|
||||||
|
{race.status === 'running' && (
|
||||||
|
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-performance-green via-performance-green/50 to-performance-green animate-pulse" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Date Column */}
|
||||||
|
<div className="hidden sm:flex flex-col items-center min-w-[80px] text-center">
|
||||||
|
<p className="text-xs text-gray-500 uppercase">
|
||||||
|
{new Date(race.scheduledAt).toLocaleDateString('en-US', { month: 'short' })}
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold text-white">
|
||||||
|
{new Date(race.scheduledAt).getDate()}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{formatTime(race.scheduledAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="hidden sm:block w-px h-16 bg-charcoal-outline" />
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="font-semibold text-white truncate group-hover:text-primary-blue transition-colors">
|
||||||
|
{race.track}
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 mt-1">
|
||||||
|
<span className="flex items-center gap-1.5 text-sm text-gray-400">
|
||||||
|
<Car className="w-3.5 h-3.5" />
|
||||||
|
{race.car}
|
||||||
|
</span>
|
||||||
|
{race.strengthOfField && (
|
||||||
|
<span className="flex items-center gap-1.5 text-sm text-warning-amber">
|
||||||
|
<Zap className="w-3.5 h-3.5" />
|
||||||
|
SOF {race.strengthOfField}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="sm:hidden text-sm text-gray-500">
|
||||||
|
{formatDate(race.scheduledAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{league && (
|
||||||
|
<Link
|
||||||
|
href={`/leagues/${league.id}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="inline-flex items-center gap-1.5 mt-2 text-sm text-primary-blue hover:underline"
|
||||||
|
>
|
||||||
|
<Trophy className="w-3.5 h-3.5" />
|
||||||
|
{league.name}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Badge */}
|
||||||
|
<div className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full ${config.bg} ${config.border} border flex-shrink-0`}>
|
||||||
|
<StatusIcon className={`w-3.5 h-3.5 ${config.color}`} />
|
||||||
|
<span className={`text-xs font-medium ${config.color}`}>
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Arrow */}
|
||||||
|
<ChevronRight className="w-5 h-5 text-gray-500 group-hover:text-primary-blue transition-colors flex-shrink-0" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between pt-4">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Showing {((currentPage - 1) * ITEMS_PER_PAGE) + 1}–{Math.min(currentPage * ITEMS_PER_PAGE, filteredRaces.length)} of {filteredRaces.length}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="p-2 rounded-lg border border-charcoal-outline text-gray-400 hover:text-white hover:border-primary-blue disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||||
|
let pageNum: number;
|
||||||
|
if (totalPages <= 5) {
|
||||||
|
pageNum = i + 1;
|
||||||
|
} else if (currentPage <= 3) {
|
||||||
|
pageNum = i + 1;
|
||||||
|
} else if (currentPage >= totalPages - 2) {
|
||||||
|
pageNum = totalPages - 4 + i;
|
||||||
|
} else {
|
||||||
|
pageNum = currentPage - 2 + i;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={pageNum}
|
||||||
|
onClick={() => setCurrentPage(pageNum)}
|
||||||
|
className={`w-10 h-10 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
currentPage === pageNum
|
||||||
|
? 'bg-primary-blue text-white'
|
||||||
|
: 'text-gray-400 hover:text-white hover:bg-iron-gray'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{pageNum}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="p-2 rounded-lg border border-charcoal-outline text-gray-400 hover:text-white hover:border-primary-blue disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,33 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import RaceCard from '@/components/races/RaceCard';
|
import Button from '@/components/ui/Button';
|
||||||
|
import Heading from '@/components/ui/Heading';
|
||||||
import { Race, RaceStatus } from '@gridpilot/racing/domain/entities/Race';
|
import { Race, RaceStatus } from '@gridpilot/racing/domain/entities/Race';
|
||||||
import { League } from '@gridpilot/racing/domain/entities/League';
|
import { League } from '@gridpilot/racing/domain/entities/League';
|
||||||
import { getRaceRepository, getLeagueRepository } from '@/lib/di-container';
|
import { getRaceRepository, getLeagueRepository } from '@/lib/di-container';
|
||||||
|
import {
|
||||||
|
Calendar,
|
||||||
|
Clock,
|
||||||
|
Flag,
|
||||||
|
ChevronRight,
|
||||||
|
Filter,
|
||||||
|
MapPin,
|
||||||
|
Car,
|
||||||
|
Trophy,
|
||||||
|
Users,
|
||||||
|
Zap,
|
||||||
|
PlayCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
CalendarDays,
|
||||||
|
ArrowRight,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
type TimeFilter = 'all' | 'upcoming' | 'live' | 'past';
|
||||||
|
|
||||||
export default function RacesPage() {
|
export default function RacesPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -18,7 +39,7 @@ export default function RacesPage() {
|
|||||||
// Filters
|
// Filters
|
||||||
const [statusFilter, setStatusFilter] = useState<RaceStatus | 'all'>('all');
|
const [statusFilter, setStatusFilter] = useState<RaceStatus | 'all'>('all');
|
||||||
const [leagueFilter, setLeagueFilter] = useState<string>('all');
|
const [leagueFilter, setLeagueFilter] = useState<string>('all');
|
||||||
const [timeFilter, setTimeFilter] = useState<'all' | 'upcoming' | 'past'>('all');
|
const [timeFilter, setTimeFilter] = useState<TimeFilter>('upcoming');
|
||||||
|
|
||||||
const loadRaces = async () => {
|
const loadRaces = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -30,7 +51,7 @@ export default function RacesPage() {
|
|||||||
leagueRepo.findAll()
|
leagueRepo.findAll()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setRaces(allRaces.sort((a, b) => b.scheduledAt.getTime() - a.scheduledAt.getTime()));
|
setRaces(allRaces.sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime()));
|
||||||
|
|
||||||
const leagueMap = new Map<string, League>();
|
const leagueMap = new Map<string, League>();
|
||||||
allLeagues.forEach(league => leagueMap.set(league.id, league));
|
allLeagues.forEach(league => leagueMap.set(league.id, league));
|
||||||
@@ -46,137 +67,539 @@ export default function RacesPage() {
|
|||||||
loadRaces();
|
loadRaces();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const filteredRaces = races.filter(race => {
|
// Filter races
|
||||||
// Status filter
|
const filteredRaces = useMemo(() => {
|
||||||
if (statusFilter !== 'all' && race.status !== statusFilter) {
|
return races.filter(race => {
|
||||||
return false;
|
// Status filter
|
||||||
}
|
if (statusFilter !== 'all' && race.status !== statusFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// League filter
|
// League filter
|
||||||
if (leagueFilter !== 'all' && race.leagueId !== leagueFilter) {
|
if (leagueFilter !== 'all' && race.leagueId !== leagueFilter) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Time filter
|
// Time filter
|
||||||
if (timeFilter === 'upcoming' && !race.isUpcoming()) {
|
if (timeFilter === 'upcoming' && !race.isUpcoming()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (timeFilter === 'past' && !race.isPast()) {
|
if (timeFilter === 'live' && !race.isLive()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (timeFilter === 'past' && !race.isPast()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
}, [races, statusFilter, leagueFilter, timeFilter]);
|
||||||
|
|
||||||
|
// Group races by date for calendar view
|
||||||
|
const racesByDate = useMemo(() => {
|
||||||
|
const grouped = new Map<string, Race[]>();
|
||||||
|
filteredRaces.forEach(race => {
|
||||||
|
const dateKey = race.scheduledAt.toISOString().split('T')[0];
|
||||||
|
if (!grouped.has(dateKey)) {
|
||||||
|
grouped.set(dateKey, []);
|
||||||
|
}
|
||||||
|
grouped.get(dateKey)!.push(race);
|
||||||
|
});
|
||||||
|
return grouped;
|
||||||
|
}, [filteredRaces]);
|
||||||
|
|
||||||
|
// Get upcoming races (next 7 days)
|
||||||
|
const upcomingRaces = useMemo(() => {
|
||||||
|
const now = new Date();
|
||||||
|
const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
|
||||||
|
return races.filter(race =>
|
||||||
|
race.isUpcoming() &&
|
||||||
|
race.scheduledAt >= now &&
|
||||||
|
race.scheduledAt <= nextWeek
|
||||||
|
).slice(0, 5);
|
||||||
|
}, [races]);
|
||||||
|
|
||||||
|
// Get live races
|
||||||
|
const liveRaces = useMemo(() => {
|
||||||
|
return races.filter(race => race.isLive());
|
||||||
|
}, [races]);
|
||||||
|
|
||||||
|
// Get recent results
|
||||||
|
const recentResults = useMemo(() => {
|
||||||
|
return races
|
||||||
|
.filter(race => race.status === 'completed')
|
||||||
|
.sort((a, b) => b.scheduledAt.getTime() - a.scheduledAt.getTime())
|
||||||
|
.slice(0, 3);
|
||||||
|
}, [races]);
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const total = races.length;
|
||||||
|
const scheduled = races.filter(r => r.status === 'scheduled').length;
|
||||||
|
const running = races.filter(r => r.status === 'running').length;
|
||||||
|
const completed = races.filter(r => r.status === 'completed').length;
|
||||||
|
return { total, scheduled, running, completed };
|
||||||
|
}, [races]);
|
||||||
|
|
||||||
|
const formatDate = (date: Date) => {
|
||||||
|
return new Date(date).toLocaleDateString('en-US', {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (date: Date) => {
|
||||||
|
return new Date(date).toLocaleTimeString('en-US', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFullDate = (date: Date) => {
|
||||||
|
return new Date(date).toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRelativeTime = (date: Date) => {
|
||||||
|
const now = new Date();
|
||||||
|
const targetDate = new Date(date);
|
||||||
|
const diffMs = targetDate.getTime() - now.getTime();
|
||||||
|
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||||
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (diffMs < 0) return 'Past';
|
||||||
|
if (diffHours < 1) return 'Starting soon';
|
||||||
|
if (diffHours < 24) return `In ${diffHours}h`;
|
||||||
|
if (diffDays === 1) return 'Tomorrow';
|
||||||
|
if (diffDays < 7) return `In ${diffDays} days`;
|
||||||
|
return formatDate(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
scheduled: {
|
||||||
|
icon: Clock,
|
||||||
|
color: 'text-primary-blue',
|
||||||
|
bg: 'bg-primary-blue/10',
|
||||||
|
border: 'border-primary-blue/30',
|
||||||
|
label: 'Scheduled',
|
||||||
|
},
|
||||||
|
running: {
|
||||||
|
icon: PlayCircle,
|
||||||
|
color: 'text-performance-green',
|
||||||
|
bg: 'bg-performance-green/10',
|
||||||
|
border: 'border-performance-green/30',
|
||||||
|
label: 'LIVE',
|
||||||
|
},
|
||||||
|
completed: {
|
||||||
|
icon: CheckCircle2,
|
||||||
|
color: 'text-gray-400',
|
||||||
|
bg: 'bg-gray-500/10',
|
||||||
|
border: 'border-gray-500/30',
|
||||||
|
label: 'Completed',
|
||||||
|
},
|
||||||
|
cancelled: {
|
||||||
|
icon: XCircle,
|
||||||
|
color: 'text-warning-amber',
|
||||||
|
bg: 'bg-warning-amber/10',
|
||||||
|
border: 'border-warning-amber/30',
|
||||||
|
label: 'Cancelled',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
|
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8">
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<div className="text-center text-gray-400">Loading races...</div>
|
<div className="animate-pulse space-y-6">
|
||||||
|
<div className="h-10 bg-iron-gray rounded w-1/4" />
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
{[1, 2, 3, 4].map(i => (
|
||||||
|
<div key={i} className="h-24 bg-iron-gray rounded-lg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="h-64 bg-iron-gray rounded-lg" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
|
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8">
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-7xl mx-auto space-y-8">
|
||||||
{/* Header */}
|
{/* Hero Header */}
|
||||||
<div className="mb-8">
|
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-iron-gray via-iron-gray to-charcoal-outline border border-charcoal-outline p-8">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="absolute top-0 right-0 w-64 h-64 bg-primary-blue/5 rounded-full blur-3xl" />
|
||||||
<h1 className="text-3xl font-bold text-white">Races</h1>
|
<div className="absolute bottom-0 left-0 w-48 h-48 bg-performance-green/5 rounded-full blur-3xl" />
|
||||||
|
|
||||||
|
<div className="relative z-10">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="p-2 bg-primary-blue/10 rounded-lg">
|
||||||
|
<Flag className="w-6 h-6 text-primary-blue" />
|
||||||
|
</div>
|
||||||
|
<Heading level={1} className="text-3xl font-bold text-white">
|
||||||
|
Race Calendar
|
||||||
|
</Heading>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-400 max-w-2xl">
|
||||||
|
Track upcoming races, view live events, and explore results across all your leagues.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Stats */}
|
||||||
|
<div className="relative z-10 grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
|
||||||
|
<div className="bg-deep-graphite/60 backdrop-blur rounded-xl p-4 border border-charcoal-outline/50">
|
||||||
|
<div className="flex items-center gap-2 text-gray-400 text-sm mb-1">
|
||||||
|
<CalendarDays className="w-4 h-4" />
|
||||||
|
<span>Total</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-white">{stats.total}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-deep-graphite/60 backdrop-blur rounded-xl p-4 border border-charcoal-outline/50">
|
||||||
|
<div className="flex items-center gap-2 text-primary-blue text-sm mb-1">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
<span>Scheduled</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-white">{stats.scheduled}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-deep-graphite/60 backdrop-blur rounded-xl p-4 border border-charcoal-outline/50">
|
||||||
|
<div className="flex items-center gap-2 text-performance-green text-sm mb-1">
|
||||||
|
<Zap className="w-4 h-4" />
|
||||||
|
<span>Live Now</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-white">{stats.running}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-deep-graphite/60 backdrop-blur rounded-xl p-4 border border-charcoal-outline/50">
|
||||||
|
<div className="flex items-center gap-2 text-gray-400 text-sm mb-1">
|
||||||
|
<Trophy className="w-4 h-4" />
|
||||||
|
<span>Completed</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-white">{stats.completed}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-400">
|
|
||||||
Manage and view all scheduled races across your leagues
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Live Races Banner */}
|
||||||
<Card className="mb-6">
|
{liveRaces.length > 0 && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="relative overflow-hidden rounded-xl bg-gradient-to-r from-performance-green/20 via-performance-green/10 to-transparent border border-performance-green/30 p-6">
|
||||||
{/* Time Filter */}
|
<div className="absolute top-0 right-0 w-32 h-32 bg-performance-green/20 rounded-full blur-2xl animate-pulse" />
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
<div className="relative z-10">
|
||||||
Time
|
<div className="flex items-center gap-2 mb-4">
|
||||||
</label>
|
<div className="flex items-center gap-2 px-3 py-1 bg-performance-green/20 rounded-full">
|
||||||
<select
|
<span className="w-2 h-2 bg-performance-green rounded-full animate-pulse" />
|
||||||
value={timeFilter}
|
<span className="text-performance-green font-semibold text-sm">LIVE NOW</span>
|
||||||
onChange={(e) => setTimeFilter(e.target.value as typeof timeFilter)}
|
</div>
|
||||||
className="w-full px-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-blue"
|
</div>
|
||||||
>
|
|
||||||
<option value="all">All Races</option>
|
<div className="space-y-3">
|
||||||
<option value="upcoming">Upcoming</option>
|
{liveRaces.map(race => (
|
||||||
<option value="past">Past</option>
|
<div
|
||||||
</select>
|
key={race.id}
|
||||||
</div>
|
onClick={() => router.push(`/races/${race.id}`)}
|
||||||
|
className="flex items-center justify-between p-4 bg-deep-graphite/80 rounded-lg border border-performance-green/20 cursor-pointer hover:border-performance-green/40 transition-all"
|
||||||
{/* Status Filter */}
|
>
|
||||||
<div>
|
<div className="flex items-center gap-4">
|
||||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
<div className="p-2 bg-performance-green/20 rounded-lg">
|
||||||
Status
|
<PlayCircle className="w-5 h-5 text-performance-green" />
|
||||||
</label>
|
</div>
|
||||||
<select
|
<div>
|
||||||
value={statusFilter}
|
<h3 className="font-semibold text-white">{race.track}</h3>
|
||||||
onChange={(e) => setStatusFilter(e.target.value as typeof statusFilter)}
|
<p className="text-sm text-gray-400">{leagues.get(race.leagueId)?.name}</p>
|
||||||
className="w-full px-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-blue"
|
</div>
|
||||||
>
|
</div>
|
||||||
<option value="all">All Statuses</option>
|
<ChevronRight className="w-5 h-5 text-gray-400" />
|
||||||
<option value="scheduled">Scheduled</option>
|
</div>
|
||||||
<option value="completed">Completed</option>
|
|
||||||
<option value="cancelled">Cancelled</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* League Filter */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
|
||||||
League
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={leagueFilter}
|
|
||||||
onChange={(e) => setLeagueFilter(e.target.value)}
|
|
||||||
className="w-full px-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-blue"
|
|
||||||
>
|
|
||||||
<option value="all">All Leagues</option>
|
|
||||||
{Array.from(leagues.values()).map(league => (
|
|
||||||
<option key={league.id} value={league.id}>
|
|
||||||
{league.name}
|
|
||||||
</option>
|
|
||||||
))}
|
))}
|
||||||
</select>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Race List */}
|
|
||||||
{filteredRaces.length === 0 ? (
|
|
||||||
<Card className="text-center py-12">
|
|
||||||
<div className="text-gray-400 mb-4">
|
|
||||||
{races.length === 0 ? (
|
|
||||||
<>
|
|
||||||
<p className="mb-2">No races scheduled</p>
|
|
||||||
<p className="text-sm text-gray-500">Try the full workflow in alpha mode</p>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<p className="mb-2">No races match your filters</p>
|
|
||||||
<p className="text-sm text-gray-500">Try adjusting your filter criteria</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{filteredRaces.map(race => (
|
|
||||||
<RaceCard
|
|
||||||
key={race.id}
|
|
||||||
race={race}
|
|
||||||
leagueName={leagues.get(race.leagueId)?.name}
|
|
||||||
onClick={() => router.push(`/races/${race.id}`)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Main Content - Race List */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{/* Filters */}
|
||||||
|
<Card className="!p-4">
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
{/* Time Filter Tabs */}
|
||||||
|
<div className="flex items-center gap-1 p-1 bg-deep-graphite rounded-lg">
|
||||||
|
{(['upcoming', 'live', 'past', 'all'] as TimeFilter[]).map(filter => (
|
||||||
|
<button
|
||||||
|
key={filter}
|
||||||
|
onClick={() => setTimeFilter(filter)}
|
||||||
|
className={`px-4 py-2 rounded-md text-sm font-medium transition-all ${
|
||||||
|
timeFilter === filter
|
||||||
|
? 'bg-primary-blue text-white'
|
||||||
|
: 'text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{filter === 'live' && <span className="inline-block w-2 h-2 bg-performance-green rounded-full mr-2 animate-pulse" />}
|
||||||
|
{filter.charAt(0).toUpperCase() + filter.slice(1)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* League Filter */}
|
||||||
|
<select
|
||||||
|
value={leagueFilter}
|
||||||
|
onChange={(e) => setLeagueFilter(e.target.value)}
|
||||||
|
className="px-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue"
|
||||||
|
>
|
||||||
|
<option value="all">All Leagues</option>
|
||||||
|
{Array.from(leagues.values()).map(league => (
|
||||||
|
<option key={league.id} value={league.id}>
|
||||||
|
{league.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Race List by Date */}
|
||||||
|
{filteredRaces.length === 0 ? (
|
||||||
|
<Card className="text-center py-12">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="p-4 bg-iron-gray rounded-full">
|
||||||
|
<Calendar className="w-8 h-8 text-gray-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-white font-medium mb-1">No races found</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{races.length === 0
|
||||||
|
? 'No races have been scheduled yet'
|
||||||
|
: 'Try adjusting your filters'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Array.from(racesByDate.entries()).map(([dateKey, dayRaces]) => (
|
||||||
|
<div key={dateKey} className="space-y-3">
|
||||||
|
{/* Date Header */}
|
||||||
|
<div className="flex items-center gap-3 px-2">
|
||||||
|
<div className="p-2 bg-primary-blue/10 rounded-lg">
|
||||||
|
<Calendar className="w-4 h-4 text-primary-blue" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-semibold text-white">
|
||||||
|
{formatFullDate(new Date(dateKey))}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{dayRaces.length} race{dayRaces.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Races for this date */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{dayRaces.map(race => {
|
||||||
|
const config = statusConfig[race.status];
|
||||||
|
const StatusIcon = config.icon;
|
||||||
|
const league = leagues.get(race.leagueId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={race.id}
|
||||||
|
onClick={() => router.push(`/races/${race.id}`)}
|
||||||
|
className={`group relative overflow-hidden rounded-xl bg-iron-gray border ${config.border} p-4 cursor-pointer transition-all duration-200 hover:scale-[1.01] hover:border-primary-blue`}
|
||||||
|
>
|
||||||
|
{/* Live indicator */}
|
||||||
|
{race.status === 'running' && (
|
||||||
|
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-performance-green via-performance-green/50 to-performance-green animate-pulse" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
{/* Time Column */}
|
||||||
|
<div className="flex-shrink-0 text-center min-w-[60px]">
|
||||||
|
<p className="text-lg font-bold text-white">{formatTime(race.scheduledAt)}</p>
|
||||||
|
<p className={`text-xs ${config.color}`}>
|
||||||
|
{race.status === 'running' ? 'LIVE' : getRelativeTime(race.scheduledAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className={`w-px self-stretch ${config.bg}`} />
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="font-semibold text-white truncate group-hover:text-primary-blue transition-colors">
|
||||||
|
{race.track}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-3 mt-1">
|
||||||
|
<span className="flex items-center gap-1 text-sm text-gray-400">
|
||||||
|
<Car className="w-3.5 h-3.5" />
|
||||||
|
{race.car}
|
||||||
|
</span>
|
||||||
|
{race.strengthOfField && (
|
||||||
|
<span className="flex items-center gap-1 text-sm text-gray-400">
|
||||||
|
<Zap className="w-3.5 h-3.5 text-warning-amber" />
|
||||||
|
SOF {race.strengthOfField}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Badge */}
|
||||||
|
<div className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full ${config.bg} ${config.border} border`}>
|
||||||
|
<StatusIcon className={`w-3.5 h-3.5 ${config.color}`} />
|
||||||
|
<span className={`text-xs font-medium ${config.color}`}>
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* League Link */}
|
||||||
|
{league && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-charcoal-outline/50">
|
||||||
|
<Link
|
||||||
|
href={`/leagues/${league.id}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="inline-flex items-center gap-2 text-sm text-primary-blue hover:underline"
|
||||||
|
>
|
||||||
|
<Trophy className="w-3.5 h-3.5" />
|
||||||
|
{league.name}
|
||||||
|
<ArrowRight className="w-3 h-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Arrow */}
|
||||||
|
<ChevronRight className="w-5 h-5 text-gray-500 group-hover:text-primary-blue transition-colors flex-shrink-0" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* View All Link */}
|
||||||
|
{filteredRaces.length > 0 && (
|
||||||
|
<div className="text-center">
|
||||||
|
<Link
|
||||||
|
href="/races/all"
|
||||||
|
className="inline-flex items-center gap-2 px-6 py-3 bg-iron-gray border border-charcoal-outline rounded-lg text-white hover:border-primary-blue transition-colors"
|
||||||
|
>
|
||||||
|
View All Races
|
||||||
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Upcoming This Week */}
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="font-semibold text-white flex items-center gap-2">
|
||||||
|
<Clock className="w-4 h-4 text-primary-blue" />
|
||||||
|
Next Up
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs text-gray-500">This week</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{upcomingRaces.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500 text-center py-4">
|
||||||
|
No races scheduled this week
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{upcomingRaces.map((race, index) => (
|
||||||
|
<div
|
||||||
|
key={race.id}
|
||||||
|
onClick={() => router.push(`/races/${race.id}`)}
|
||||||
|
className="flex items-center gap-3 p-2 rounded-lg hover:bg-deep-graphite cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0 w-10 h-10 bg-primary-blue/10 rounded-lg flex items-center justify-center">
|
||||||
|
<span className="text-sm font-bold text-primary-blue">
|
||||||
|
{new Date(race.scheduledAt).getDate()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-white truncate">{race.track}</p>
|
||||||
|
<p className="text-xs text-gray-500">{formatTime(race.scheduledAt)}</p>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="w-4 h-4 text-gray-500" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Recent Results */}
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="font-semibold text-white flex items-center gap-2">
|
||||||
|
<Trophy className="w-4 h-4 text-warning-amber" />
|
||||||
|
Recent Results
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{recentResults.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500 text-center py-4">
|
||||||
|
No completed races yet
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{recentResults.map(race => (
|
||||||
|
<div
|
||||||
|
key={race.id}
|
||||||
|
onClick={() => router.push(`/races/${race.id}/results`)}
|
||||||
|
className="flex items-center gap-3 p-2 rounded-lg hover:bg-deep-graphite cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0 w-10 h-10 bg-gray-500/10 rounded-lg flex items-center justify-center">
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-white truncate">{race.track}</p>
|
||||||
|
<p className="text-xs text-gray-500">{formatDate(race.scheduledAt)}</p>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="w-4 h-4 text-gray-500" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<Card>
|
||||||
|
<h3 className="font-semibold text-white mb-4">Quick Actions</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Link
|
||||||
|
href="/leagues"
|
||||||
|
className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite hover:bg-charcoal-outline/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="p-2 bg-primary-blue/10 rounded-lg">
|
||||||
|
<Users className="w-4 h-4 text-primary-blue" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-white">Browse Leagues</span>
|
||||||
|
<ChevronRight className="w-4 h-4 text-gray-500 ml-auto" />
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/leaderboards"
|
||||||
|
className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite hover:bg-charcoal-outline/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="p-2 bg-warning-amber/10 rounded-lg">
|
||||||
|
<Trophy className="w-4 h-4 text-warning-amber" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-white">View Leaderboards</span>
|
||||||
|
<ChevronRight className="w-4 h-4 text-gray-500 ml-auto" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useAuth } from '@/lib/auth/AuthContext';
|
|||||||
type AlphaNavProps = Record<string, never>;
|
type AlphaNavProps = Record<string, never>;
|
||||||
const nonHomeLinks = [
|
const nonHomeLinks = [
|
||||||
{ href: '/leagues', label: 'Leagues' },
|
{ href: '/leagues', label: 'Leagues' },
|
||||||
|
{ href: '/races', label: 'Races' },
|
||||||
{ href: '/teams', label: 'Teams' },
|
{ href: '/teams', label: 'Teams' },
|
||||||
{ href: '/drivers', label: 'Drivers' },
|
{ href: '/drivers', label: 'Drivers' },
|
||||||
{ href: '/leaderboards', label: 'Leaderboards' },
|
{ href: '/leaderboards', label: 'Leaderboards' },
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,50 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Race } from '@gridpilot/racing/domain/entities/Race';
|
import { Race } from '@gridpilot/racing/domain/entities/Race';
|
||||||
|
import { Clock, PlayCircle, CheckCircle2, XCircle, Zap, Car, Trophy } from 'lucide-react';
|
||||||
|
|
||||||
interface RaceCardProps {
|
interface RaceCardProps {
|
||||||
race: Race;
|
race: Race;
|
||||||
leagueName?: string;
|
leagueName?: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
compact?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RaceCard({ race, leagueName, onClick }: RaceCardProps) {
|
export default function RaceCard({ race, leagueName, onClick, compact = false }: RaceCardProps) {
|
||||||
const statusColors = {
|
const statusConfig = {
|
||||||
scheduled: 'bg-primary-blue/20 text-primary-blue border-primary-blue/30',
|
scheduled: {
|
||||||
completed: 'bg-green-500/20 text-green-400 border-green-500/30',
|
icon: Clock,
|
||||||
cancelled: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
|
color: 'text-primary-blue',
|
||||||
|
bg: 'bg-primary-blue/10',
|
||||||
|
border: 'border-primary-blue/30',
|
||||||
|
label: 'Scheduled',
|
||||||
|
},
|
||||||
|
running: {
|
||||||
|
icon: PlayCircle,
|
||||||
|
color: 'text-performance-green',
|
||||||
|
bg: 'bg-performance-green/10',
|
||||||
|
border: 'border-performance-green/30',
|
||||||
|
label: 'LIVE',
|
||||||
|
},
|
||||||
|
completed: {
|
||||||
|
icon: CheckCircle2,
|
||||||
|
color: 'text-gray-400',
|
||||||
|
bg: 'bg-gray-500/10',
|
||||||
|
border: 'border-gray-500/30',
|
||||||
|
label: 'Completed',
|
||||||
|
},
|
||||||
|
cancelled: {
|
||||||
|
icon: XCircle,
|
||||||
|
color: 'text-warning-amber',
|
||||||
|
bg: 'bg-warning-amber/10',
|
||||||
|
border: 'border-warning-amber/30',
|
||||||
|
label: 'Cancelled',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const config = statusConfig[race.status];
|
||||||
|
const StatusIcon = config.icon;
|
||||||
|
|
||||||
const formatDate = (date: Date) => {
|
const formatDate = (date: Date) => {
|
||||||
return new Date(date).toLocaleDateString('en-US', {
|
return new Date(date).toLocaleDateString('en-US', {
|
||||||
month: 'short',
|
month: 'short',
|
||||||
@@ -35,52 +65,121 @@ export default function RaceCard({ race, leagueName, onClick }: RaceCardProps) {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const targetDate = new Date(date);
|
const targetDate = new Date(date);
|
||||||
const diffMs = targetDate.getTime() - now.getTime();
|
const diffMs = targetDate.getTime() - now.getTime();
|
||||||
|
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
if (diffDays < 0) return null;
|
if (diffMs < 0) return null;
|
||||||
if (diffDays === 0) return 'Today';
|
if (diffHours < 1) return 'Starting soon';
|
||||||
|
if (diffHours < 24) return `In ${diffHours}h`;
|
||||||
if (diffDays === 1) return 'Tomorrow';
|
if (diffDays === 1) return 'Tomorrow';
|
||||||
if (diffDays < 7) return `in ${diffDays} days`;
|
if (diffDays < 7) return `In ${diffDays} days`;
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const relativeTime = race.status === 'scheduled' ? getRelativeTime(race.scheduledAt) : null;
|
const relativeTime = race.status === 'scheduled' ? getRelativeTime(race.scheduledAt) : null;
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
className={`
|
||||||
|
relative overflow-hidden p-4 rounded-lg bg-iron-gray border ${config.border}
|
||||||
|
transition-all duration-200
|
||||||
|
${onClick ? 'cursor-pointer hover:scale-[1.02] hover:border-primary-blue' : ''}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{race.status === 'running' && (
|
||||||
|
<div className="absolute top-0 left-0 right-0 h-0.5 bg-performance-green animate-pulse" />
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<StatusIcon className={`w-5 h-5 ${config.color}`} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-sm font-medium text-white truncate">{race.track}</h3>
|
||||||
|
<p className="text-xs text-gray-500">{formatTime(race.scheduledAt)}</p>
|
||||||
|
</div>
|
||||||
|
{relativeTime && (
|
||||||
|
<span className={`text-xs ${config.color}`}>{relativeTime}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`
|
className={`
|
||||||
p-6 rounded-lg bg-iron-gray border border-charcoal-outline
|
relative overflow-hidden p-5 rounded-xl bg-iron-gray border ${config.border}
|
||||||
transition-all duration-200
|
transition-all duration-200
|
||||||
${onClick ? 'cursor-pointer hover:scale-[1.03] hover:border-primary-blue' : ''}
|
${onClick ? 'cursor-pointer hover:scale-[1.02] hover:border-primary-blue' : ''}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between mb-4">
|
{/* Live indicator bar */}
|
||||||
<div className="flex-1">
|
{race.status === 'running' && (
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-performance-green via-performance-green/50 to-performance-green animate-pulse" />
|
||||||
<h3 className="text-lg font-semibold text-white">{race.track}</h3>
|
)}
|
||||||
<span className={`px-2 py-1 text-xs font-medium rounded border ${statusColors[race.status]}`}>
|
|
||||||
{race.status.charAt(0).toUpperCase() + race.status.slice(1)}
|
<div className="flex items-start justify-between gap-4">
|
||||||
</span>
|
{/* Left side - Race info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<h3 className="text-lg font-semibold text-white truncate">{race.track}</h3>
|
||||||
|
{/* Status badge */}
|
||||||
|
<div className={`flex items-center gap-1.5 px-2 py-0.5 rounded-full ${config.bg} border ${config.border} flex-shrink-0`}>
|
||||||
|
{race.status === 'running' && (
|
||||||
|
<span className="w-1.5 h-1.5 bg-performance-green rounded-full animate-pulse" />
|
||||||
|
)}
|
||||||
|
<StatusIcon className={`w-3.5 h-3.5 ${config.color}`} />
|
||||||
|
<span className={`text-xs font-medium ${config.color}`}>
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Meta info */}
|
||||||
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-gray-400">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Car className="w-3.5 h-3.5" />
|
||||||
|
{race.car}
|
||||||
|
</span>
|
||||||
|
{race.strengthOfField && (
|
||||||
|
<span className="flex items-center gap-1.5 text-warning-amber">
|
||||||
|
<Zap className="w-3.5 h-3.5" />
|
||||||
|
SOF {race.strengthOfField}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{leagueName && (
|
||||||
|
<span className="flex items-center gap-1.5 text-primary-blue">
|
||||||
|
<Trophy className="w-3.5 h-3.5" />
|
||||||
|
{leagueName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-400 text-sm">{race.car}</p>
|
|
||||||
{leagueName && (
|
|
||||||
<p className="text-gray-500 text-xs mt-1">{leagueName}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
|
||||||
<p className="text-white font-medium text-sm">{formatDate(race.scheduledAt)}</p>
|
{/* Right side - Date/Time */}
|
||||||
<p className="text-gray-400 text-xs">{formatTime(race.scheduledAt)}</p>
|
<div className="text-right flex-shrink-0">
|
||||||
|
<p className="text-white font-medium">{formatDate(race.scheduledAt)}</p>
|
||||||
|
<p className="text-gray-500 text-sm">{formatTime(race.scheduledAt)}</p>
|
||||||
{relativeTime && (
|
{relativeTime && (
|
||||||
<p className="text-primary-blue text-xs mt-1">{relativeTime}</p>
|
<p className={`text-sm mt-1 ${config.color}`}>{relativeTime}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
{/* Bottom row */}
|
||||||
|
<div className="flex items-center justify-between mt-3 pt-3 border-t border-charcoal-outline/50">
|
||||||
<span className="text-xs text-gray-500 uppercase tracking-wide">
|
<span className="text-xs text-gray-500 uppercase tracking-wide">
|
||||||
{race.sessionType}
|
{race.sessionType}
|
||||||
</span>
|
</span>
|
||||||
|
{race.registeredCount !== undefined && (
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{race.registeredCount} registered
|
||||||
|
{race.maxParticipants && ` / ${race.maxParticipants}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
199
apps/website/components/ui/CountrySelect.tsx
Normal file
199
apps/website/components/ui/CountrySelect.tsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { Globe, Search, ChevronDown, Check } from 'lucide-react';
|
||||||
|
|
||||||
|
export interface Country {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COUNTRIES: Country[] = [
|
||||||
|
{ 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' },
|
||||||
|
{ code: 'CZ', name: 'Czech Republic' },
|
||||||
|
{ code: 'HU', name: 'Hungary' },
|
||||||
|
{ code: 'RU', name: 'Russia' },
|
||||||
|
{ code: 'MX', name: 'Mexico' },
|
||||||
|
{ code: 'AR', name: 'Argentina' },
|
||||||
|
{ code: 'CL', name: 'Chile' },
|
||||||
|
{ code: 'NZ', name: 'New Zealand' },
|
||||||
|
{ code: 'ZA', name: 'South Africa' },
|
||||||
|
{ code: 'IN', name: 'India' },
|
||||||
|
{ code: 'KR', name: 'South Korea' },
|
||||||
|
{ code: 'SG', name: 'Singapore' },
|
||||||
|
{ code: 'MY', name: 'Malaysia' },
|
||||||
|
{ code: 'TH', name: 'Thailand' },
|
||||||
|
{ code: 'AE', name: 'United Arab Emirates' },
|
||||||
|
{ code: 'SA', name: 'Saudi Arabia' },
|
||||||
|
{ code: 'IE', name: 'Ireland' },
|
||||||
|
{ code: 'GR', name: 'Greece' },
|
||||||
|
{ code: 'TR', name: 'Turkey' },
|
||||||
|
{ code: 'RO', name: 'Romania' },
|
||||||
|
{ code: 'UA', name: 'Ukraine' },
|
||||||
|
];
|
||||||
|
|
||||||
|
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 '🏁';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CountrySelectProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
error?: boolean;
|
||||||
|
errorMessage?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CountrySelect({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
error,
|
||||||
|
errorMessage,
|
||||||
|
disabled,
|
||||||
|
placeholder = 'Select country',
|
||||||
|
}: CountrySelectProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const selectedCountry = COUNTRIES.find(c => c.code === value);
|
||||||
|
|
||||||
|
const filteredCountries = COUNTRIES.filter(country =>
|
||||||
|
country.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
country.code.toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
setSearch('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && inputRef.current) {
|
||||||
|
inputRef.current.focus();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleSelect = (code: string) => {
|
||||||
|
onChange(code);
|
||||||
|
setIsOpen(false);
|
||||||
|
setSearch('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative">
|
||||||
|
{/* Trigger Button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`flex items-center justify-between w-full rounded-md border-0 px-4 py-3 bg-iron-gray text-white shadow-sm ring-1 ring-inset transition-all duration-150 sm:text-sm ${
|
||||||
|
error
|
||||||
|
? 'ring-warning-amber focus:ring-warning-amber'
|
||||||
|
: 'ring-charcoal-outline focus:ring-primary-blue'
|
||||||
|
} ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:ring-gray-500'}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Globe className="w-4 h-4 text-gray-500" />
|
||||||
|
{selectedCountry ? (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="text-lg">{getCountryFlag(selectedCountry.code)}</span>
|
||||||
|
<span>{selectedCountry.name}</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-500">{placeholder}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ChevronDown className={`w-4 h-4 text-gray-500 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute z-50 mt-2 w-full rounded-lg bg-iron-gray border border-charcoal-outline shadow-xl max-h-80 overflow-hidden">
|
||||||
|
{/* Search Input */}
|
||||||
|
<div className="p-2 border-b border-charcoal-outline">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search countries..."
|
||||||
|
className="w-full rounded-md border-0 px-4 py-2 pl-9 bg-deep-graphite text-white text-sm placeholder:text-gray-500 focus:outline-none focus:ring-1 focus:ring-primary-blue"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Country List */}
|
||||||
|
<div className="overflow-y-auto max-h-60">
|
||||||
|
{filteredCountries.length > 0 ? (
|
||||||
|
filteredCountries.map((country) => (
|
||||||
|
<button
|
||||||
|
key={country.code}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelect(country.code)}
|
||||||
|
className={`flex items-center justify-between w-full px-4 py-2.5 text-left text-sm transition-colors ${
|
||||||
|
value === country.code
|
||||||
|
? 'bg-primary-blue/20 text-white'
|
||||||
|
: 'text-gray-300 hover:bg-deep-graphite'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-3">
|
||||||
|
<span className="text-lg">{getCountryFlag(country.code)}</span>
|
||||||
|
<span>{country.name}</span>
|
||||||
|
</span>
|
||||||
|
{value === country.code && (
|
||||||
|
<Check className="w-4 h-4 text-primary-blue" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="px-4 py-6 text-center text-gray-500 text-sm">
|
||||||
|
No countries found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{error && errorMessage && (
|
||||||
|
<p className="mt-2 text-sm text-warning-amber">{errorMessage}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ type AuthContextValue = {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
login: (returnTo?: string) => void;
|
login: (returnTo?: string) => void;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
|
refreshSession: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||||
@@ -32,41 +33,34 @@ export function AuthProvider({ initialSession = null, children }: AuthProviderPr
|
|||||||
const [session, setSession] = useState<AuthSession | null>(initialSession);
|
const [session, setSession] = useState<AuthSession | null>(initialSession);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const fetchSession = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/session', {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
setSession(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as { session: AuthSession | null };
|
||||||
|
setSession(data.session ?? null);
|
||||||
|
} catch {
|
||||||
|
setSession(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshSession = useCallback(async () => {
|
||||||
|
await fetchSession();
|
||||||
|
}, [fetchSession]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialSession) return;
|
if (initialSession) return;
|
||||||
|
|
||||||
let cancelled = false;
|
fetchSession();
|
||||||
|
}, [initialSession, fetchSession]);
|
||||||
async function loadSession() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/auth/session', {
|
|
||||||
method: 'GET',
|
|
||||||
credentials: 'include',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
if (!cancelled) setSession(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await res.json()) as { session: AuthSession | null };
|
|
||||||
|
|
||||||
if (!cancelled) {
|
|
||||||
setSession(data.session ?? null);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
if (!cancelled) {
|
|
||||||
setSession(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadSession();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [initialSession]);
|
|
||||||
|
|
||||||
const login = useCallback(
|
const login = useCallback(
|
||||||
(returnTo?: string) => {
|
(returnTo?: string) => {
|
||||||
@@ -105,8 +99,9 @@ export function AuthProvider({ initialSession = null, children }: AuthProviderPr
|
|||||||
loading,
|
loading,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
|
refreshSession,
|
||||||
}),
|
}),
|
||||||
[session, loading, login, logout],
|
[session, loading, login, logout, refreshSession],
|
||||||
);
|
);
|
||||||
|
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { Result } from '@gridpilot/racing/domain/entities/Result';
|
|||||||
import { Standing } from '@gridpilot/racing/domain/entities/Standing';
|
import { Standing } from '@gridpilot/racing/domain/entities/Standing';
|
||||||
import { Game } from '@gridpilot/racing/domain/entities/Game';
|
import { Game } from '@gridpilot/racing/domain/entities/Game';
|
||||||
import { Season } from '@gridpilot/racing/domain/entities/Season';
|
import { Season } from '@gridpilot/racing/domain/entities/Season';
|
||||||
|
import { Track } from '@gridpilot/racing/domain/entities/Track';
|
||||||
|
import { Car } from '@gridpilot/racing/domain/entities/Car';
|
||||||
|
|
||||||
import type { IDriverRepository } from '@gridpilot/racing/domain/repositories/IDriverRepository';
|
import type { IDriverRepository } from '@gridpilot/racing/domain/repositories/IDriverRepository';
|
||||||
import type { ILeagueRepository } from '@gridpilot/racing/domain/repositories/ILeagueRepository';
|
import type { ILeagueRepository } from '@gridpilot/racing/domain/repositories/ILeagueRepository';
|
||||||
@@ -22,6 +24,8 @@ import type { IPenaltyRepository } from '@gridpilot/racing/domain/repositories/I
|
|||||||
import type { IGameRepository } from '@gridpilot/racing/domain/repositories/IGameRepository';
|
import type { IGameRepository } from '@gridpilot/racing/domain/repositories/IGameRepository';
|
||||||
import type { ISeasonRepository } from '@gridpilot/racing/domain/repositories/ISeasonRepository';
|
import type { ISeasonRepository } from '@gridpilot/racing/domain/repositories/ISeasonRepository';
|
||||||
import type { ILeagueScoringConfigRepository } from '@gridpilot/racing/domain/repositories/ILeagueScoringConfigRepository';
|
import type { ILeagueScoringConfigRepository } from '@gridpilot/racing/domain/repositories/ILeagueScoringConfigRepository';
|
||||||
|
import type { ITrackRepository } from '@gridpilot/racing/domain/repositories/ITrackRepository';
|
||||||
|
import type { ICarRepository } from '@gridpilot/racing/domain/repositories/ICarRepository';
|
||||||
import type {
|
import type {
|
||||||
ITeamRepository,
|
ITeamRepository,
|
||||||
ITeamMembershipRepository,
|
ITeamMembershipRepository,
|
||||||
@@ -39,6 +43,8 @@ import { InMemoryRaceRepository } from '@gridpilot/racing/infrastructure/reposit
|
|||||||
import { InMemoryResultRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryResultRepository';
|
import { InMemoryResultRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryResultRepository';
|
||||||
import { InMemoryStandingRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryStandingRepository';
|
import { InMemoryStandingRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryStandingRepository';
|
||||||
import { InMemoryPenaltyRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryPenaltyRepository';
|
import { InMemoryPenaltyRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryPenaltyRepository';
|
||||||
|
import { InMemoryTrackRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryTrackRepository';
|
||||||
|
import { InMemoryCarRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryCarRepository';
|
||||||
import {
|
import {
|
||||||
InMemoryGameRepository,
|
InMemoryGameRepository,
|
||||||
InMemorySeasonRepository,
|
InMemorySeasonRepository,
|
||||||
@@ -75,7 +81,10 @@ import {
|
|||||||
GetLeagueScoringConfigQuery,
|
GetLeagueScoringConfigQuery,
|
||||||
CreateLeagueWithSeasonAndScoringUseCase,
|
CreateLeagueWithSeasonAndScoringUseCase,
|
||||||
GetLeagueFullConfigQuery,
|
GetLeagueFullConfigQuery,
|
||||||
|
GetRaceWithSOFQuery,
|
||||||
|
GetLeagueStatsQuery,
|
||||||
} from '@gridpilot/racing/application';
|
} from '@gridpilot/racing/application';
|
||||||
|
import type { DriverRatingProvider } from '@gridpilot/racing/application';
|
||||||
import {
|
import {
|
||||||
createStaticRacingSeed,
|
createStaticRacingSeed,
|
||||||
type RacingSeedData,
|
type RacingSeedData,
|
||||||
@@ -172,6 +181,8 @@ class DIContainer {
|
|||||||
private _feedRepository: IFeedRepository;
|
private _feedRepository: IFeedRepository;
|
||||||
private _socialRepository: ISocialGraphRepository;
|
private _socialRepository: ISocialGraphRepository;
|
||||||
private _imageService: ImageServicePort;
|
private _imageService: ImageServicePort;
|
||||||
|
private _trackRepository: ITrackRepository;
|
||||||
|
private _carRepository: ICarRepository;
|
||||||
|
|
||||||
// Racing application use-cases / queries
|
// Racing application use-cases / queries
|
||||||
private _joinLeagueUseCase: JoinLeagueUseCase;
|
private _joinLeagueUseCase: JoinLeagueUseCase;
|
||||||
@@ -189,6 +200,9 @@ class DIContainer {
|
|||||||
private _getLeagueFullConfigQuery: GetLeagueFullConfigQuery;
|
private _getLeagueFullConfigQuery: GetLeagueFullConfigQuery;
|
||||||
// Placeholder for future schedule preview wiring
|
// Placeholder for future schedule preview wiring
|
||||||
private _previewLeagueScheduleQuery: PreviewLeagueScheduleQuery;
|
private _previewLeagueScheduleQuery: PreviewLeagueScheduleQuery;
|
||||||
|
private _getRaceWithSOFQuery: GetRaceWithSOFQuery;
|
||||||
|
private _getLeagueStatsQuery: GetLeagueStatsQuery;
|
||||||
|
private _driverRatingProvider: DriverRatingProvider;
|
||||||
|
|
||||||
private _createTeamUseCase: CreateTeamUseCase;
|
private _createTeamUseCase: CreateTeamUseCase;
|
||||||
private _joinTeamUseCase: JoinTeamUseCase;
|
private _joinTeamUseCase: JoinTeamUseCase;
|
||||||
@@ -226,8 +240,33 @@ class DIContainer {
|
|||||||
this._leagueRepository
|
this._leagueRepository
|
||||||
);
|
);
|
||||||
|
|
||||||
// Race registrations (start empty; populated via use-cases)
|
// Race registrations - seed from results for completed races, plus some upcoming races
|
||||||
this._raceRegistrationRepository = new InMemoryRaceRegistrationRepository();
|
const seedRaceRegistrations: Array<{ raceId: string; driverId: string; registeredAt: Date }> = [];
|
||||||
|
|
||||||
|
// For completed races, extract driver registrations from results
|
||||||
|
for (const result of seedData.results) {
|
||||||
|
seedRaceRegistrations.push({
|
||||||
|
raceId: result.raceId,
|
||||||
|
driverId: result.driverId,
|
||||||
|
registeredAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), // 7 days ago
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// For some upcoming races, add random registrations
|
||||||
|
const upcomingRaces = seedData.races.filter(r => r.status === 'scheduled').slice(0, 10);
|
||||||
|
for (const race of upcomingRaces) {
|
||||||
|
const participantCount = Math.floor(Math.random() * 12) + 8; // 8-20 participants
|
||||||
|
const shuffledDrivers = [...seedData.drivers].sort(() => Math.random() - 0.5).slice(0, participantCount);
|
||||||
|
for (const driver of shuffledDrivers) {
|
||||||
|
seedRaceRegistrations.push({
|
||||||
|
raceId: race.id,
|
||||||
|
driverId: driver.id,
|
||||||
|
registeredAt: new Date(Date.now() - Math.floor(Math.random() * 5) * 24 * 60 * 60 * 1000),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._raceRegistrationRepository = new InMemoryRaceRegistrationRepository(seedRaceRegistrations);
|
||||||
|
|
||||||
// Penalties (seeded in-memory adapter)
|
// Penalties (seeded in-memory adapter)
|
||||||
this._penaltyRepository = new InMemoryPenaltyRepository();
|
this._penaltyRepository = new InMemoryPenaltyRepository();
|
||||||
@@ -490,6 +529,39 @@ class DIContainer {
|
|||||||
// Schedule preview query (used by league creation wizard step 3)
|
// Schedule preview query (used by league creation wizard step 3)
|
||||||
this._previewLeagueScheduleQuery = new PreviewLeagueScheduleQuery();
|
this._previewLeagueScheduleQuery = new PreviewLeagueScheduleQuery();
|
||||||
|
|
||||||
|
// DriverRatingProvider adapter using driverStats
|
||||||
|
this._driverRatingProvider = {
|
||||||
|
getRating: (driverId: string): number | null => {
|
||||||
|
const stats = driverStats[driverId];
|
||||||
|
return stats?.rating ?? null;
|
||||||
|
},
|
||||||
|
getRatings: (driverIds: string[]): Map<string, number> => {
|
||||||
|
const result = new Map<string, number>();
|
||||||
|
for (const id of driverIds) {
|
||||||
|
const stats = driverStats[id];
|
||||||
|
if (stats?.rating) {
|
||||||
|
result.set(id, stats.rating);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// SOF queries
|
||||||
|
this._getRaceWithSOFQuery = new GetRaceWithSOFQuery(
|
||||||
|
this._raceRepository,
|
||||||
|
this._raceRegistrationRepository,
|
||||||
|
this._resultRepository,
|
||||||
|
this._driverRatingProvider,
|
||||||
|
);
|
||||||
|
|
||||||
|
this._getLeagueStatsQuery = new GetLeagueStatsQuery(
|
||||||
|
this._leagueRepository,
|
||||||
|
this._raceRepository,
|
||||||
|
this._resultRepository,
|
||||||
|
this._driverRatingProvider,
|
||||||
|
);
|
||||||
|
|
||||||
this._createTeamUseCase = new CreateTeamUseCase(
|
this._createTeamUseCase = new CreateTeamUseCase(
|
||||||
this._teamRepository,
|
this._teamRepository,
|
||||||
this._teamMembershipRepository,
|
this._teamMembershipRepository,
|
||||||
@@ -529,6 +601,184 @@ class DIContainer {
|
|||||||
|
|
||||||
// Image service backed by demo adapter
|
// Image service backed by demo adapter
|
||||||
this._imageService = new DemoImageServiceAdapter();
|
this._imageService = new DemoImageServiceAdapter();
|
||||||
|
|
||||||
|
// Seed Track and Car data for demo
|
||||||
|
const seedTracks = [
|
||||||
|
Track.create({
|
||||||
|
id: 'track-spa',
|
||||||
|
name: 'Spa-Francorchamps',
|
||||||
|
shortName: 'SPA',
|
||||||
|
country: 'Belgium',
|
||||||
|
category: 'road',
|
||||||
|
difficulty: 'advanced',
|
||||||
|
lengthKm: 7.004,
|
||||||
|
turns: 19,
|
||||||
|
imageUrl: '/images/tracks/spa.jpg',
|
||||||
|
gameId: 'iracing',
|
||||||
|
}),
|
||||||
|
Track.create({
|
||||||
|
id: 'track-monza',
|
||||||
|
name: 'Autodromo Nazionale Monza',
|
||||||
|
shortName: 'MON',
|
||||||
|
country: 'Italy',
|
||||||
|
category: 'road',
|
||||||
|
difficulty: 'intermediate',
|
||||||
|
lengthKm: 5.793,
|
||||||
|
turns: 11,
|
||||||
|
imageUrl: '/images/tracks/monza.jpg',
|
||||||
|
gameId: 'iracing',
|
||||||
|
}),
|
||||||
|
Track.create({
|
||||||
|
id: 'track-nurburgring',
|
||||||
|
name: 'Nürburgring Grand Prix',
|
||||||
|
shortName: 'NUR',
|
||||||
|
country: 'Germany',
|
||||||
|
category: 'road',
|
||||||
|
difficulty: 'advanced',
|
||||||
|
lengthKm: 5.148,
|
||||||
|
turns: 15,
|
||||||
|
imageUrl: '/images/tracks/nurburgring.jpg',
|
||||||
|
gameId: 'iracing',
|
||||||
|
}),
|
||||||
|
Track.create({
|
||||||
|
id: 'track-silverstone',
|
||||||
|
name: 'Silverstone Circuit',
|
||||||
|
shortName: 'SIL',
|
||||||
|
country: 'United Kingdom',
|
||||||
|
category: 'road',
|
||||||
|
difficulty: 'intermediate',
|
||||||
|
lengthKm: 5.891,
|
||||||
|
turns: 18,
|
||||||
|
imageUrl: '/images/tracks/silverstone.jpg',
|
||||||
|
gameId: 'iracing',
|
||||||
|
}),
|
||||||
|
Track.create({
|
||||||
|
id: 'track-suzuka',
|
||||||
|
name: 'Suzuka International Racing Course',
|
||||||
|
shortName: 'SUZ',
|
||||||
|
country: 'Japan',
|
||||||
|
category: 'road',
|
||||||
|
difficulty: 'expert',
|
||||||
|
lengthKm: 5.807,
|
||||||
|
turns: 18,
|
||||||
|
imageUrl: '/images/tracks/suzuka.jpg',
|
||||||
|
gameId: 'iracing',
|
||||||
|
}),
|
||||||
|
Track.create({
|
||||||
|
id: 'track-daytona',
|
||||||
|
name: 'Daytona International Speedway',
|
||||||
|
shortName: 'DAY',
|
||||||
|
country: 'United States',
|
||||||
|
category: 'oval',
|
||||||
|
difficulty: 'intermediate',
|
||||||
|
lengthKm: 4.023,
|
||||||
|
turns: 4,
|
||||||
|
imageUrl: '/images/tracks/daytona.jpg',
|
||||||
|
gameId: 'iracing',
|
||||||
|
}),
|
||||||
|
Track.create({
|
||||||
|
id: 'track-laguna',
|
||||||
|
name: 'WeatherTech Raceway Laguna Seca',
|
||||||
|
shortName: 'LAG',
|
||||||
|
country: 'United States',
|
||||||
|
category: 'road',
|
||||||
|
difficulty: 'advanced',
|
||||||
|
lengthKm: 3.602,
|
||||||
|
turns: 11,
|
||||||
|
imageUrl: '/images/tracks/laguna.jpg',
|
||||||
|
gameId: 'iracing',
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const seedCars = [
|
||||||
|
Car.create({
|
||||||
|
id: 'car-porsche-992',
|
||||||
|
name: '911 GT3 R',
|
||||||
|
shortName: '992 GT3R',
|
||||||
|
manufacturer: 'Porsche',
|
||||||
|
carClass: 'gt',
|
||||||
|
license: 'B',
|
||||||
|
year: 2023,
|
||||||
|
horsepower: 565,
|
||||||
|
weight: 1300,
|
||||||
|
gameId: 'iracing',
|
||||||
|
}),
|
||||||
|
Car.create({
|
||||||
|
id: 'car-ferrari-296',
|
||||||
|
name: '296 GT3',
|
||||||
|
shortName: '296 GT3',
|
||||||
|
manufacturer: 'Ferrari',
|
||||||
|
carClass: 'gt',
|
||||||
|
license: 'B',
|
||||||
|
year: 2023,
|
||||||
|
horsepower: 600,
|
||||||
|
weight: 1270,
|
||||||
|
gameId: 'iracing',
|
||||||
|
}),
|
||||||
|
Car.create({
|
||||||
|
id: 'car-mclaren-720s',
|
||||||
|
name: '720S GT3 Evo',
|
||||||
|
shortName: '720S',
|
||||||
|
manufacturer: 'McLaren',
|
||||||
|
carClass: 'gt',
|
||||||
|
license: 'B',
|
||||||
|
year: 2023,
|
||||||
|
horsepower: 552,
|
||||||
|
weight: 1290,
|
||||||
|
gameId: 'iracing',
|
||||||
|
}),
|
||||||
|
Car.create({
|
||||||
|
id: 'car-mercedes-gt3',
|
||||||
|
name: 'AMG GT3 2020',
|
||||||
|
shortName: 'AMG GT3',
|
||||||
|
manufacturer: 'Mercedes',
|
||||||
|
carClass: 'gt',
|
||||||
|
license: 'B',
|
||||||
|
year: 2020,
|
||||||
|
horsepower: 550,
|
||||||
|
weight: 1285,
|
||||||
|
gameId: 'iracing',
|
||||||
|
}),
|
||||||
|
Car.create({
|
||||||
|
id: 'car-lmp2',
|
||||||
|
name: 'Dallara P217 LMP2',
|
||||||
|
shortName: 'LMP2',
|
||||||
|
manufacturer: 'Dallara',
|
||||||
|
carClass: 'prototype',
|
||||||
|
license: 'A',
|
||||||
|
year: 2021,
|
||||||
|
horsepower: 600,
|
||||||
|
weight: 930,
|
||||||
|
gameId: 'iracing',
|
||||||
|
}),
|
||||||
|
Car.create({
|
||||||
|
id: 'car-f4',
|
||||||
|
name: 'Formula 4',
|
||||||
|
shortName: 'F4',
|
||||||
|
manufacturer: 'Tatuus',
|
||||||
|
carClass: 'formula',
|
||||||
|
license: 'D',
|
||||||
|
year: 2022,
|
||||||
|
horsepower: 160,
|
||||||
|
weight: 570,
|
||||||
|
gameId: 'iracing',
|
||||||
|
}),
|
||||||
|
Car.create({
|
||||||
|
id: 'car-mx5',
|
||||||
|
name: 'MX-5 Cup',
|
||||||
|
shortName: 'MX5',
|
||||||
|
manufacturer: 'Mazda',
|
||||||
|
carClass: 'sports',
|
||||||
|
license: 'D',
|
||||||
|
year: 2023,
|
||||||
|
horsepower: 181,
|
||||||
|
weight: 1128,
|
||||||
|
gameId: 'iracing',
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
this._trackRepository = new InMemoryTrackRepository(seedTracks);
|
||||||
|
this._carRepository = new InMemoryCarRepository(seedCars);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -652,6 +902,18 @@ class DIContainer {
|
|||||||
return this._previewLeagueScheduleQuery;
|
return this._previewLeagueScheduleQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get getRaceWithSOFQuery(): GetRaceWithSOFQuery {
|
||||||
|
return this._getRaceWithSOFQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
get getLeagueStatsQuery(): GetLeagueStatsQuery {
|
||||||
|
return this._getLeagueStatsQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
get driverRatingProvider(): DriverRatingProvider {
|
||||||
|
return this._driverRatingProvider;
|
||||||
|
}
|
||||||
|
|
||||||
get createLeagueWithSeasonAndScoringUseCase(): CreateLeagueWithSeasonAndScoringUseCase {
|
get createLeagueWithSeasonAndScoringUseCase(): CreateLeagueWithSeasonAndScoringUseCase {
|
||||||
return this._createLeagueWithSeasonAndScoringUseCase;
|
return this._createLeagueWithSeasonAndScoringUseCase;
|
||||||
}
|
}
|
||||||
@@ -719,6 +981,14 @@ class DIContainer {
|
|||||||
get imageService(): ImageServicePort {
|
get imageService(): ImageServicePort {
|
||||||
return this._imageService;
|
return this._imageService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get trackRepository(): ITrackRepository {
|
||||||
|
return this._trackRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
get carRepository(): ICarRepository {
|
||||||
|
return this._carRepository;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -813,6 +1083,18 @@ export function getCreateLeagueWithSeasonAndScoringUseCase(): CreateLeagueWithSe
|
|||||||
return DIContainer.getInstance().createLeagueWithSeasonAndScoringUseCase;
|
return DIContainer.getInstance().createLeagueWithSeasonAndScoringUseCase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getGetRaceWithSOFQuery(): GetRaceWithSOFQuery {
|
||||||
|
return DIContainer.getInstance().getRaceWithSOFQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGetLeagueStatsQuery(): GetLeagueStatsQuery {
|
||||||
|
return DIContainer.getInstance().getLeagueStatsQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDriverRatingProvider(): DriverRatingProvider {
|
||||||
|
return DIContainer.getInstance().driverRatingProvider;
|
||||||
|
}
|
||||||
|
|
||||||
export function getTeamRepository(): ITeamRepository {
|
export function getTeamRepository(): ITeamRepository {
|
||||||
return DIContainer.getInstance().teamRepository;
|
return DIContainer.getInstance().teamRepository;
|
||||||
}
|
}
|
||||||
@@ -877,6 +1159,14 @@ export function getImageService(): ImageServicePort {
|
|||||||
return DIContainer.getInstance().imageService;
|
return DIContainer.getInstance().imageService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getTrackRepository(): ITrackRepository {
|
||||||
|
return DIContainer.getInstance().trackRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCarRepository(): ICarRepository {
|
||||||
|
return DIContainer.getInstance().carRepository;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reset function for testing
|
* Reset function for testing
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1 +1,4 @@
|
|||||||
export * from './media/DemoImageServiceAdapter';
|
export * from './media/DemoImageServiceAdapter';
|
||||||
|
export * from './media/DemoFaceValidationAdapter';
|
||||||
|
export * from './media/DemoAvatarGenerationAdapter';
|
||||||
|
export * from './media/InMemoryAvatarGenerationRepository';
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import type {
|
||||||
|
AvatarGenerationPort,
|
||||||
|
AvatarGenerationOptions,
|
||||||
|
AvatarGenerationResult
|
||||||
|
} from '@gridpilot/media';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Demo implementation of AvatarGenerationPort.
|
||||||
|
*
|
||||||
|
* In production, this would use a real AI image generation API like:
|
||||||
|
* - OpenAI DALL-E
|
||||||
|
* - Midjourney API
|
||||||
|
* - Stable Diffusion
|
||||||
|
* - RunwayML
|
||||||
|
*
|
||||||
|
* For demo purposes, this returns placeholder avatar images.
|
||||||
|
*/
|
||||||
|
export class DemoAvatarGenerationAdapter implements AvatarGenerationPort {
|
||||||
|
private readonly placeholderAvatars: Record<string, string[]> = {
|
||||||
|
red: [
|
||||||
|
'/images/avatars/generated/red-1.png',
|
||||||
|
'/images/avatars/generated/red-2.png',
|
||||||
|
'/images/avatars/generated/red-3.png',
|
||||||
|
],
|
||||||
|
blue: [
|
||||||
|
'/images/avatars/generated/blue-1.png',
|
||||||
|
'/images/avatars/generated/blue-2.png',
|
||||||
|
'/images/avatars/generated/blue-3.png',
|
||||||
|
],
|
||||||
|
green: [
|
||||||
|
'/images/avatars/generated/green-1.png',
|
||||||
|
'/images/avatars/generated/green-2.png',
|
||||||
|
'/images/avatars/generated/green-3.png',
|
||||||
|
],
|
||||||
|
yellow: [
|
||||||
|
'/images/avatars/generated/yellow-1.png',
|
||||||
|
'/images/avatars/generated/yellow-2.png',
|
||||||
|
'/images/avatars/generated/yellow-3.png',
|
||||||
|
],
|
||||||
|
orange: [
|
||||||
|
'/images/avatars/generated/orange-1.png',
|
||||||
|
'/images/avatars/generated/orange-2.png',
|
||||||
|
'/images/avatars/generated/orange-3.png',
|
||||||
|
],
|
||||||
|
purple: [
|
||||||
|
'/images/avatars/generated/purple-1.png',
|
||||||
|
'/images/avatars/generated/purple-2.png',
|
||||||
|
'/images/avatars/generated/purple-3.png',
|
||||||
|
],
|
||||||
|
black: [
|
||||||
|
'/images/avatars/generated/black-1.png',
|
||||||
|
'/images/avatars/generated/black-2.png',
|
||||||
|
'/images/avatars/generated/black-3.png',
|
||||||
|
],
|
||||||
|
white: [
|
||||||
|
'/images/avatars/generated/white-1.png',
|
||||||
|
'/images/avatars/generated/white-2.png',
|
||||||
|
'/images/avatars/generated/white-3.png',
|
||||||
|
],
|
||||||
|
pink: [
|
||||||
|
'/images/avatars/generated/pink-1.png',
|
||||||
|
'/images/avatars/generated/pink-2.png',
|
||||||
|
'/images/avatars/generated/pink-3.png',
|
||||||
|
],
|
||||||
|
cyan: [
|
||||||
|
'/images/avatars/generated/cyan-1.png',
|
||||||
|
'/images/avatars/generated/cyan-2.png',
|
||||||
|
'/images/avatars/generated/cyan-3.png',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
async generateAvatars(options: AvatarGenerationOptions): Promise<AvatarGenerationResult> {
|
||||||
|
// Simulate AI processing time (1-3 seconds)
|
||||||
|
await this.delay(1500 + Math.random() * 1500);
|
||||||
|
|
||||||
|
// Log what would be sent to the AI (for debugging)
|
||||||
|
console.log('[DemoAvatarGeneration] Would generate with prompt:', options.prompt);
|
||||||
|
console.log('[DemoAvatarGeneration] Suit color:', options.suitColor);
|
||||||
|
console.log('[DemoAvatarGeneration] Style:', options.style);
|
||||||
|
console.log('[DemoAvatarGeneration] Count:', options.count);
|
||||||
|
|
||||||
|
// For demo, return placeholder URLs based on suit color
|
||||||
|
// In production, these would be actual AI-generated images
|
||||||
|
const colorAvatars = this.placeholderAvatars[options.suitColor] || this.placeholderAvatars.blue;
|
||||||
|
|
||||||
|
// Generate unique URLs with a hash to simulate different generations
|
||||||
|
const hash = this.generateHash(options.facePhotoUrl + Date.now());
|
||||||
|
const avatars = colorAvatars.slice(0, options.count).map((baseUrl, index) => {
|
||||||
|
// In demo mode, use dicebear or similar for generating varied avatars
|
||||||
|
const seed = `${hash}-${options.suitColor}-${index}`;
|
||||||
|
return {
|
||||||
|
url: `https://api.dicebear.com/7.x/personas/svg?seed=${seed}&backgroundColor=transparent`,
|
||||||
|
thumbnailUrl: `https://api.dicebear.com/7.x/personas/svg?seed=${seed}&backgroundColor=transparent&size=64`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
avatars,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private delay(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateHash(input: string): string {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < input.length; i++) {
|
||||||
|
const char = input.charCodeAt(i);
|
||||||
|
hash = ((hash << 5) - hash) + char;
|
||||||
|
hash = hash & hash;
|
||||||
|
}
|
||||||
|
return Math.abs(hash).toString(36);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { FaceValidationPort, FaceValidationResult } from '@gridpilot/media';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Demo implementation of FaceValidationPort.
|
||||||
|
*
|
||||||
|
* In production, this would use a real face detection API like:
|
||||||
|
* - AWS Rekognition
|
||||||
|
* - Google Cloud Vision
|
||||||
|
* - Azure Face API
|
||||||
|
* - OpenCV / face-api.js
|
||||||
|
*
|
||||||
|
* For demo purposes, this always returns a valid face if the image data is provided.
|
||||||
|
*/
|
||||||
|
export class DemoFaceValidationAdapter implements FaceValidationPort {
|
||||||
|
async validateFacePhoto(imageData: string | Buffer): Promise<FaceValidationResult> {
|
||||||
|
// Simulate some processing time
|
||||||
|
await this.delay(500);
|
||||||
|
|
||||||
|
// Check if we have any image data
|
||||||
|
const dataString = typeof imageData === 'string' ? imageData : imageData.toString();
|
||||||
|
|
||||||
|
if (!dataString || dataString.length < 100) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
hasFace: false,
|
||||||
|
faceCount: 0,
|
||||||
|
confidence: 0,
|
||||||
|
errorMessage: 'Invalid or empty image data',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for valid base64 image data or data URL
|
||||||
|
const isValidImage =
|
||||||
|
dataString.startsWith('data:image/') ||
|
||||||
|
dataString.startsWith('/9j/') || // JPEG magic bytes in base64
|
||||||
|
dataString.startsWith('iVBOR') || // PNG magic bytes in base64
|
||||||
|
dataString.length > 1000; // Assume long strings are valid image data
|
||||||
|
|
||||||
|
if (!isValidImage) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
hasFace: false,
|
||||||
|
faceCount: 0,
|
||||||
|
confidence: 0,
|
||||||
|
errorMessage: 'Please upload a valid image file (JPEG or PNG)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// For demo: always return success with high confidence
|
||||||
|
// In production, this would actually analyze the image
|
||||||
|
return {
|
||||||
|
isValid: true,
|
||||||
|
hasFace: true,
|
||||||
|
faceCount: 1,
|
||||||
|
confidence: 0.95,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private delay(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type {
|
||||||
|
IAvatarGenerationRepository
|
||||||
|
} from '@gridpilot/media';
|
||||||
|
import {
|
||||||
|
AvatarGenerationRequest,
|
||||||
|
type AvatarGenerationRequestProps
|
||||||
|
} from '@gridpilot/media';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory implementation of IAvatarGenerationRepository.
|
||||||
|
*
|
||||||
|
* For demo/development purposes. In production, this would use a database.
|
||||||
|
*/
|
||||||
|
export class InMemoryAvatarGenerationRepository implements IAvatarGenerationRepository {
|
||||||
|
private readonly requests = new Map<string, AvatarGenerationRequestProps>();
|
||||||
|
|
||||||
|
async save(request: AvatarGenerationRequest): Promise<void> {
|
||||||
|
this.requests.set(request.id, request.toProps());
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<AvatarGenerationRequest | null> {
|
||||||
|
const props = this.requests.get(id);
|
||||||
|
if (!props) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return AvatarGenerationRequest.reconstitute(props);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByUserId(userId: string): Promise<AvatarGenerationRequest[]> {
|
||||||
|
const results: AvatarGenerationRequest[] = [];
|
||||||
|
for (const props of this.requests.values()) {
|
||||||
|
if (props.userId === userId) {
|
||||||
|
results.push(AvatarGenerationRequest.reconstitute(props));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
async findLatestByUserId(userId: string): Promise<AvatarGenerationRequest | null> {
|
||||||
|
const userRequests = await this.findByUserId(userId);
|
||||||
|
return userRequests.length > 0 ? userRequests[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
this.requests.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
35
packages/media/application/ports/AvatarGenerationPort.ts
Normal file
35
packages/media/application/ports/AvatarGenerationPort.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Port: AvatarGenerationPort
|
||||||
|
*
|
||||||
|
* Defines the contract for AI-powered avatar generation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RacingSuitColor, AvatarStyle } from '../../domain/entities/AvatarGenerationRequest';
|
||||||
|
|
||||||
|
export interface AvatarGenerationOptions {
|
||||||
|
facePhotoUrl: string;
|
||||||
|
prompt: string;
|
||||||
|
suitColor: RacingSuitColor;
|
||||||
|
style: AvatarStyle;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeneratedAvatar {
|
||||||
|
url: string;
|
||||||
|
thumbnailUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AvatarGenerationResult {
|
||||||
|
success: boolean;
|
||||||
|
avatars: GeneratedAvatar[];
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AvatarGenerationPort {
|
||||||
|
/**
|
||||||
|
* Generate racing avatars from a face photo
|
||||||
|
* @param options Generation options including face photo and styling preferences
|
||||||
|
* @returns Generated avatar URLs
|
||||||
|
*/
|
||||||
|
generateAvatars(options: AvatarGenerationOptions): Promise<AvatarGenerationResult>;
|
||||||
|
}
|
||||||
20
packages/media/application/ports/FaceValidationPort.ts
Normal file
20
packages/media/application/ports/FaceValidationPort.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Port: FaceValidationPort
|
||||||
|
*
|
||||||
|
* Defines the contract for validating face photos.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface FaceValidationResult {
|
||||||
|
isValid: boolean;
|
||||||
|
hasFace: boolean;
|
||||||
|
faceCount: number;
|
||||||
|
confidence: number;
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FaceValidationPort {
|
||||||
|
/**
|
||||||
|
* Validate that an image contains exactly one valid face
|
||||||
|
*/
|
||||||
|
validateFacePhoto(imageData: string | Buffer): Promise<FaceValidationResult>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* Use Case: RequestAvatarGenerationUseCase
|
||||||
|
*
|
||||||
|
* Initiates the avatar generation process by validating the face photo
|
||||||
|
* and creating a generation request.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IAvatarGenerationRepository } from '../../domain/repositories/IAvatarGenerationRepository';
|
||||||
|
import type { FaceValidationPort } from '../ports/FaceValidationPort';
|
||||||
|
import type { AvatarGenerationPort } from '../ports/AvatarGenerationPort';
|
||||||
|
import { AvatarGenerationRequest, type RacingSuitColor, type AvatarStyle } from '../../domain/entities/AvatarGenerationRequest';
|
||||||
|
|
||||||
|
export interface RequestAvatarGenerationCommand {
|
||||||
|
userId: string;
|
||||||
|
facePhotoData: string; // Base64 encoded image data
|
||||||
|
suitColor: RacingSuitColor;
|
||||||
|
style?: AvatarStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestAvatarGenerationResult {
|
||||||
|
requestId: string;
|
||||||
|
status: 'validating' | 'generating' | 'completed' | 'failed';
|
||||||
|
avatarUrls?: string[];
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RequestAvatarGenerationUseCase {
|
||||||
|
constructor(
|
||||||
|
private readonly avatarRepository: IAvatarGenerationRepository,
|
||||||
|
private readonly faceValidation: FaceValidationPort,
|
||||||
|
private readonly avatarGeneration: AvatarGenerationPort,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(command: RequestAvatarGenerationCommand): Promise<RequestAvatarGenerationResult> {
|
||||||
|
// Create the generation request
|
||||||
|
const requestId = this.generateId();
|
||||||
|
const request = AvatarGenerationRequest.create({
|
||||||
|
id: requestId,
|
||||||
|
userId: command.userId,
|
||||||
|
facePhotoUrl: `data:image/jpeg;base64,${command.facePhotoData}`,
|
||||||
|
suitColor: command.suitColor,
|
||||||
|
style: command.style,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark as validating
|
||||||
|
request.markAsValidating();
|
||||||
|
await this.avatarRepository.save(request);
|
||||||
|
|
||||||
|
// Validate the face photo
|
||||||
|
const validationResult = await this.faceValidation.validateFacePhoto(command.facePhotoData);
|
||||||
|
|
||||||
|
if (!validationResult.isValid) {
|
||||||
|
request.fail(validationResult.errorMessage || 'Face validation failed');
|
||||||
|
await this.avatarRepository.save(request);
|
||||||
|
return {
|
||||||
|
requestId,
|
||||||
|
status: 'failed',
|
||||||
|
errorMessage: validationResult.errorMessage || 'Please upload a clear photo of your face',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validationResult.hasFace) {
|
||||||
|
request.fail('No face detected in the image');
|
||||||
|
await this.avatarRepository.save(request);
|
||||||
|
return {
|
||||||
|
requestId,
|
||||||
|
status: 'failed',
|
||||||
|
errorMessage: 'No face detected. Please upload a photo that clearly shows your face.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validationResult.faceCount > 1) {
|
||||||
|
request.fail('Multiple faces detected');
|
||||||
|
await this.avatarRepository.save(request);
|
||||||
|
return {
|
||||||
|
requestId,
|
||||||
|
status: 'failed',
|
||||||
|
errorMessage: 'Multiple faces detected. Please upload a photo with only your face.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as generating
|
||||||
|
request.markAsGenerating();
|
||||||
|
await this.avatarRepository.save(request);
|
||||||
|
|
||||||
|
// Generate avatars
|
||||||
|
const generationResult = await this.avatarGeneration.generateAvatars({
|
||||||
|
facePhotoUrl: request.facePhotoUrl,
|
||||||
|
prompt: request.buildPrompt(),
|
||||||
|
suitColor: request.suitColor,
|
||||||
|
style: request.style,
|
||||||
|
count: 3, // Generate 3 options
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!generationResult.success) {
|
||||||
|
request.fail(generationResult.errorMessage || 'Avatar generation failed');
|
||||||
|
await this.avatarRepository.save(request);
|
||||||
|
return {
|
||||||
|
requestId,
|
||||||
|
status: 'failed',
|
||||||
|
errorMessage: generationResult.errorMessage || 'Failed to generate avatars. Please try again.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete with generated avatars
|
||||||
|
const avatarUrls = generationResult.avatars.map(a => a.url);
|
||||||
|
request.completeWithAvatars(avatarUrls);
|
||||||
|
await this.avatarRepository.save(request);
|
||||||
|
|
||||||
|
return {
|
||||||
|
requestId,
|
||||||
|
status: 'completed',
|
||||||
|
avatarUrls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateId(): string {
|
||||||
|
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return 'avatar-' + Date.now().toString(36) + Math.random().toString(36).substr(2, 9);
|
||||||
|
}
|
||||||
|
}
|
||||||
65
packages/media/application/use-cases/SelectAvatarUseCase.ts
Normal file
65
packages/media/application/use-cases/SelectAvatarUseCase.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Use Case: SelectAvatarUseCase
|
||||||
|
*
|
||||||
|
* Allows a user to select one of the generated avatars as their profile avatar.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IAvatarGenerationRepository } from '../../domain/repositories/IAvatarGenerationRepository';
|
||||||
|
|
||||||
|
export interface SelectAvatarCommand {
|
||||||
|
requestId: string;
|
||||||
|
userId: string;
|
||||||
|
avatarIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectAvatarResult {
|
||||||
|
success: boolean;
|
||||||
|
selectedAvatarUrl?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SelectAvatarUseCase {
|
||||||
|
constructor(
|
||||||
|
private readonly avatarRepository: IAvatarGenerationRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(command: SelectAvatarCommand): Promise<SelectAvatarResult> {
|
||||||
|
const request = await this.avatarRepository.findById(command.requestId);
|
||||||
|
|
||||||
|
if (!request) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errorMessage: 'Avatar generation request not found',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.userId !== command.userId) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errorMessage: 'You do not have permission to select this avatar',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.status !== 'completed') {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errorMessage: 'Avatar generation is not yet complete',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
request.selectAvatar(command.avatarIndex);
|
||||||
|
await this.avatarRepository.save(request);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
selectedAvatarUrl: request.selectedAvatarUrl,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errorMessage: error instanceof Error ? error.message : 'Failed to select avatar',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
218
packages/media/domain/entities/AvatarGenerationRequest.ts
Normal file
218
packages/media/domain/entities/AvatarGenerationRequest.ts
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
/**
|
||||||
|
* Domain Entity: AvatarGenerationRequest
|
||||||
|
*
|
||||||
|
* Represents a request to generate a racing avatar from a face photo.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type RacingSuitColor =
|
||||||
|
| 'red'
|
||||||
|
| 'blue'
|
||||||
|
| 'green'
|
||||||
|
| 'yellow'
|
||||||
|
| 'orange'
|
||||||
|
| 'purple'
|
||||||
|
| 'black'
|
||||||
|
| 'white'
|
||||||
|
| 'pink'
|
||||||
|
| 'cyan';
|
||||||
|
|
||||||
|
export type AvatarStyle =
|
||||||
|
| 'realistic'
|
||||||
|
| 'cartoon'
|
||||||
|
| 'pixel-art';
|
||||||
|
|
||||||
|
export type AvatarGenerationStatus =
|
||||||
|
| 'pending'
|
||||||
|
| 'validating'
|
||||||
|
| 'generating'
|
||||||
|
| 'completed'
|
||||||
|
| 'failed';
|
||||||
|
|
||||||
|
export interface AvatarGenerationRequestProps {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
facePhotoUrl: string;
|
||||||
|
suitColor: RacingSuitColor;
|
||||||
|
style: AvatarStyle;
|
||||||
|
status: AvatarGenerationStatus;
|
||||||
|
generatedAvatarUrls: string[];
|
||||||
|
selectedAvatarIndex?: number;
|
||||||
|
errorMessage?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AvatarGenerationRequest {
|
||||||
|
readonly id: string;
|
||||||
|
readonly userId: string;
|
||||||
|
readonly facePhotoUrl: string;
|
||||||
|
readonly suitColor: RacingSuitColor;
|
||||||
|
readonly style: AvatarStyle;
|
||||||
|
private _status: AvatarGenerationStatus;
|
||||||
|
private _generatedAvatarUrls: string[];
|
||||||
|
private _selectedAvatarIndex?: number;
|
||||||
|
private _errorMessage?: string;
|
||||||
|
readonly createdAt: Date;
|
||||||
|
private _updatedAt: Date;
|
||||||
|
|
||||||
|
private constructor(props: AvatarGenerationRequestProps) {
|
||||||
|
this.id = props.id;
|
||||||
|
this.userId = props.userId;
|
||||||
|
this.facePhotoUrl = props.facePhotoUrl;
|
||||||
|
this.suitColor = props.suitColor;
|
||||||
|
this.style = props.style;
|
||||||
|
this._status = props.status;
|
||||||
|
this._generatedAvatarUrls = [...props.generatedAvatarUrls];
|
||||||
|
this._selectedAvatarIndex = props.selectedAvatarIndex;
|
||||||
|
this._errorMessage = props.errorMessage;
|
||||||
|
this.createdAt = props.createdAt;
|
||||||
|
this._updatedAt = props.updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
static create(props: {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
facePhotoUrl: string;
|
||||||
|
suitColor: RacingSuitColor;
|
||||||
|
style?: AvatarStyle;
|
||||||
|
}): AvatarGenerationRequest {
|
||||||
|
if (!props.userId) {
|
||||||
|
throw new Error('User ID is required');
|
||||||
|
}
|
||||||
|
if (!props.facePhotoUrl) {
|
||||||
|
throw new Error('Face photo URL is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
return new AvatarGenerationRequest({
|
||||||
|
id: props.id,
|
||||||
|
userId: props.userId,
|
||||||
|
facePhotoUrl: props.facePhotoUrl,
|
||||||
|
suitColor: props.suitColor,
|
||||||
|
style: props.style ?? 'realistic',
|
||||||
|
status: 'pending',
|
||||||
|
generatedAvatarUrls: [],
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static reconstitute(props: AvatarGenerationRequestProps): AvatarGenerationRequest {
|
||||||
|
return new AvatarGenerationRequest(props);
|
||||||
|
}
|
||||||
|
|
||||||
|
get status(): AvatarGenerationStatus {
|
||||||
|
return this._status;
|
||||||
|
}
|
||||||
|
|
||||||
|
get generatedAvatarUrls(): string[] {
|
||||||
|
return [...this._generatedAvatarUrls];
|
||||||
|
}
|
||||||
|
|
||||||
|
get selectedAvatarIndex(): number | undefined {
|
||||||
|
return this._selectedAvatarIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
get selectedAvatarUrl(): string | undefined {
|
||||||
|
if (this._selectedAvatarIndex !== undefined && this._generatedAvatarUrls[this._selectedAvatarIndex]) {
|
||||||
|
return this._generatedAvatarUrls[this._selectedAvatarIndex];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
get errorMessage(): string | undefined {
|
||||||
|
return this._errorMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
get updatedAt(): Date {
|
||||||
|
return this._updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
markAsValidating(): void {
|
||||||
|
if (this._status !== 'pending') {
|
||||||
|
throw new Error('Can only start validation from pending status');
|
||||||
|
}
|
||||||
|
this._status = 'validating';
|
||||||
|
this._updatedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
markAsGenerating(): void {
|
||||||
|
if (this._status !== 'validating') {
|
||||||
|
throw new Error('Can only start generation from validating status');
|
||||||
|
}
|
||||||
|
this._status = 'generating';
|
||||||
|
this._updatedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
completeWithAvatars(avatarUrls: string[]): void {
|
||||||
|
if (avatarUrls.length === 0) {
|
||||||
|
throw new Error('At least one avatar URL is required');
|
||||||
|
}
|
||||||
|
this._status = 'completed';
|
||||||
|
this._generatedAvatarUrls = [...avatarUrls];
|
||||||
|
this._updatedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
fail(errorMessage: string): void {
|
||||||
|
this._status = 'failed';
|
||||||
|
this._errorMessage = errorMessage;
|
||||||
|
this._updatedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
selectAvatar(index: number): void {
|
||||||
|
if (this._status !== 'completed') {
|
||||||
|
throw new Error('Can only select avatar when generation is completed');
|
||||||
|
}
|
||||||
|
if (index < 0 || index >= this._generatedAvatarUrls.length) {
|
||||||
|
throw new Error('Invalid avatar index');
|
||||||
|
}
|
||||||
|
this._selectedAvatarIndex = index;
|
||||||
|
this._updatedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the AI prompt for avatar generation.
|
||||||
|
* We control the prompt completely - users cannot enter free text.
|
||||||
|
*/
|
||||||
|
buildPrompt(): string {
|
||||||
|
const colorDescriptions: Record<RacingSuitColor, string> = {
|
||||||
|
red: 'vibrant racing red',
|
||||||
|
blue: 'deep motorsport blue',
|
||||||
|
green: 'racing green',
|
||||||
|
yellow: 'bright championship yellow',
|
||||||
|
orange: 'McLaren-style papaya orange',
|
||||||
|
purple: 'royal purple',
|
||||||
|
black: 'stealth black',
|
||||||
|
white: 'clean white',
|
||||||
|
pink: 'hot pink',
|
||||||
|
cyan: 'electric cyan',
|
||||||
|
};
|
||||||
|
|
||||||
|
const styleDescriptions: Record<AvatarStyle, string> = {
|
||||||
|
realistic: 'photorealistic, professional motorsport portrait',
|
||||||
|
cartoon: 'stylized cartoon racing character',
|
||||||
|
'pixel-art': '8-bit pixel art retro racing avatar',
|
||||||
|
};
|
||||||
|
|
||||||
|
const suitColorDesc = colorDescriptions[this.suitColor];
|
||||||
|
const styleDesc = styleDescriptions[this.style];
|
||||||
|
|
||||||
|
return `Create a ${styleDesc} of a racing driver wearing a ${suitColorDesc} racing suit with matching helmet. The driver should look professional and confident, as if posing for a team photo. Background should be a subtle racing paddock or garage setting. High quality, well-lit, professional motorsport photography style.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
toProps(): AvatarGenerationRequestProps {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
userId: this.userId,
|
||||||
|
facePhotoUrl: this.facePhotoUrl,
|
||||||
|
suitColor: this.suitColor,
|
||||||
|
style: this.style,
|
||||||
|
status: this._status,
|
||||||
|
generatedAvatarUrls: [...this._generatedAvatarUrls],
|
||||||
|
selectedAvatarIndex: this._selectedAvatarIndex,
|
||||||
|
errorMessage: this._errorMessage,
|
||||||
|
createdAt: this.createdAt,
|
||||||
|
updatedAt: this._updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Repository Interface: IAvatarGenerationRepository
|
||||||
|
*
|
||||||
|
* Defines the contract for avatar generation request persistence.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AvatarGenerationRequest, AvatarGenerationRequestProps } from '../entities/AvatarGenerationRequest';
|
||||||
|
|
||||||
|
export interface IAvatarGenerationRepository {
|
||||||
|
/**
|
||||||
|
* Save an avatar generation request
|
||||||
|
*/
|
||||||
|
save(request: AvatarGenerationRequest): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find an avatar generation request by ID
|
||||||
|
*/
|
||||||
|
findById(id: string): Promise<AvatarGenerationRequest | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all avatar generation requests for a user
|
||||||
|
*/
|
||||||
|
findByUserId(userId: string): Promise<AvatarGenerationRequest[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the latest avatar generation request for a user
|
||||||
|
*/
|
||||||
|
findLatestByUserId(userId: string): Promise<AvatarGenerationRequest | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an avatar generation request
|
||||||
|
*/
|
||||||
|
delete(id: string): Promise<void>;
|
||||||
|
}
|
||||||
@@ -1 +1,12 @@
|
|||||||
export * from './application/ports/ImageServicePort';
|
// Ports
|
||||||
|
export * from './application/ports/ImageServicePort';
|
||||||
|
export * from './application/ports/FaceValidationPort';
|
||||||
|
export * from './application/ports/AvatarGenerationPort';
|
||||||
|
|
||||||
|
// Use Cases
|
||||||
|
export * from './application/use-cases/RequestAvatarGenerationUseCase';
|
||||||
|
export * from './application/use-cases/SelectAvatarUseCase';
|
||||||
|
|
||||||
|
// Domain
|
||||||
|
export * from './domain/entities/AvatarGenerationRequest';
|
||||||
|
export * from './domain/repositories/IAvatarGenerationRepository';
|
||||||
@@ -3,7 +3,12 @@ export type RaceDTO = {
|
|||||||
leagueId: string;
|
leagueId: string;
|
||||||
scheduledAt: string;
|
scheduledAt: string;
|
||||||
track: string;
|
track: string;
|
||||||
|
trackId?: string;
|
||||||
car: string;
|
car: string;
|
||||||
|
carId?: string;
|
||||||
sessionType: 'practice' | 'qualifying' | 'race';
|
sessionType: 'practice' | 'qualifying' | 'race';
|
||||||
status: 'scheduled' | 'completed' | 'cancelled';
|
status: 'scheduled' | 'running' | 'completed' | 'cancelled';
|
||||||
|
strengthOfField?: number;
|
||||||
|
registeredCount?: number;
|
||||||
|
maxParticipants?: number;
|
||||||
};
|
};
|
||||||
@@ -24,6 +24,11 @@ export * from './use-cases/RecalculateChampionshipStandingsUseCase';
|
|||||||
export * from './use-cases/CreateLeagueWithSeasonAndScoringUseCase';
|
export * from './use-cases/CreateLeagueWithSeasonAndScoringUseCase';
|
||||||
export * from './use-cases/GetLeagueFullConfigQuery';
|
export * from './use-cases/GetLeagueFullConfigQuery';
|
||||||
export * from './use-cases/PreviewLeagueScheduleQuery';
|
export * from './use-cases/PreviewLeagueScheduleQuery';
|
||||||
|
export * from './use-cases/GetRaceWithSOFQuery';
|
||||||
|
export * from './use-cases/GetLeagueStatsQuery';
|
||||||
|
|
||||||
|
// Export ports
|
||||||
|
export * from './ports/DriverRatingProvider';
|
||||||
|
|
||||||
// Re-export domain types for legacy callers (type-only)
|
// Re-export domain types for legacy callers (type-only)
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@@ -76,9 +76,14 @@ export class EntityMappers {
|
|||||||
leagueId: race.leagueId,
|
leagueId: race.leagueId,
|
||||||
scheduledAt: race.scheduledAt.toISOString(),
|
scheduledAt: race.scheduledAt.toISOString(),
|
||||||
track: race.track,
|
track: race.track,
|
||||||
|
trackId: race.trackId,
|
||||||
car: race.car,
|
car: race.car,
|
||||||
|
carId: race.carId,
|
||||||
sessionType: race.sessionType,
|
sessionType: race.sessionType,
|
||||||
status: race.status,
|
status: race.status,
|
||||||
|
strengthOfField: race.strengthOfField,
|
||||||
|
registeredCount: race.registeredCount,
|
||||||
|
maxParticipants: race.maxParticipants,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,9 +93,14 @@ export class EntityMappers {
|
|||||||
leagueId: race.leagueId,
|
leagueId: race.leagueId,
|
||||||
scheduledAt: race.scheduledAt.toISOString(),
|
scheduledAt: race.scheduledAt.toISOString(),
|
||||||
track: race.track,
|
track: race.track,
|
||||||
|
trackId: race.trackId,
|
||||||
car: race.car,
|
car: race.car,
|
||||||
|
carId: race.carId,
|
||||||
sessionType: race.sessionType,
|
sessionType: race.sessionType,
|
||||||
status: race.status,
|
status: race.status,
|
||||||
|
strengthOfField: race.strengthOfField,
|
||||||
|
registeredCount: race.registeredCount,
|
||||||
|
maxParticipants: race.maxParticipants,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
20
packages/racing/application/ports/DriverRatingProvider.ts
Normal file
20
packages/racing/application/ports/DriverRatingProvider.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Application Port: DriverRatingProvider
|
||||||
|
*
|
||||||
|
* Port for looking up driver ratings.
|
||||||
|
* Implemented by infrastructure adapters that connect to rating systems.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface DriverRatingProvider {
|
||||||
|
/**
|
||||||
|
* Get the rating for a single driver
|
||||||
|
* Returns null if driver has no rating
|
||||||
|
*/
|
||||||
|
getRating(driverId: string): number | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get ratings for multiple drivers
|
||||||
|
* Returns a map of driverId -> rating
|
||||||
|
*/
|
||||||
|
getRatings(driverIds: string[]): Map<string, number>;
|
||||||
|
}
|
||||||
@@ -114,7 +114,7 @@ export class GetLeagueScoringConfigQuery {
|
|||||||
|
|
||||||
for (const [sessionType, table] of Object.entries(tables)) {
|
for (const [sessionType, table] of Object.entries(tables)) {
|
||||||
for (let pos = 1; pos <= maxPositions; pos++) {
|
for (let pos = 1; pos <= maxPositions; pos++) {
|
||||||
const points = table.getPoints(pos);
|
const points = table.getPointsForPosition(pos);
|
||||||
if (points && points !== 0) {
|
if (points && points !== 0) {
|
||||||
preview.push({
|
preview.push({
|
||||||
sessionType,
|
sessionType,
|
||||||
|
|||||||
99
packages/racing/application/use-cases/GetLeagueStatsQuery.ts
Normal file
99
packages/racing/application/use-cases/GetLeagueStatsQuery.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* Application Query: GetLeagueStatsQuery
|
||||||
|
*
|
||||||
|
* Returns league statistics including average SOF across completed races.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
|
||||||
|
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
|
||||||
|
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
|
||||||
|
import type { DriverRatingProvider } from '../ports/DriverRatingProvider';
|
||||||
|
import {
|
||||||
|
AverageStrengthOfFieldCalculator,
|
||||||
|
type StrengthOfFieldCalculator,
|
||||||
|
} from '../../domain/services/StrengthOfFieldCalculator';
|
||||||
|
|
||||||
|
export interface GetLeagueStatsQueryParams {
|
||||||
|
leagueId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeagueStatsDTO {
|
||||||
|
leagueId: string;
|
||||||
|
totalRaces: number;
|
||||||
|
completedRaces: number;
|
||||||
|
scheduledRaces: number;
|
||||||
|
averageSOF: number | null;
|
||||||
|
highestSOF: number | null;
|
||||||
|
lowestSOF: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GetLeagueStatsQuery {
|
||||||
|
private readonly sofCalculator: StrengthOfFieldCalculator;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly leagueRepository: ILeagueRepository,
|
||||||
|
private readonly raceRepository: IRaceRepository,
|
||||||
|
private readonly resultRepository: IResultRepository,
|
||||||
|
private readonly driverRatingProvider: DriverRatingProvider,
|
||||||
|
sofCalculator?: StrengthOfFieldCalculator,
|
||||||
|
) {
|
||||||
|
this.sofCalculator = sofCalculator ?? new AverageStrengthOfFieldCalculator();
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(params: GetLeagueStatsQueryParams): Promise<LeagueStatsDTO | null> {
|
||||||
|
const { leagueId } = params;
|
||||||
|
|
||||||
|
const league = await this.leagueRepository.findById(leagueId);
|
||||||
|
if (!league) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const races = await this.raceRepository.findByLeagueId(leagueId);
|
||||||
|
const completedRaces = races.filter(r => r.status === 'completed');
|
||||||
|
const scheduledRaces = races.filter(r => r.status === 'scheduled');
|
||||||
|
|
||||||
|
// Calculate SOF for each completed race
|
||||||
|
const sofValues: number[] = [];
|
||||||
|
|
||||||
|
for (const race of completedRaces) {
|
||||||
|
// Use stored SOF if available
|
||||||
|
if (race.strengthOfField) {
|
||||||
|
sofValues.push(race.strengthOfField);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise calculate from results
|
||||||
|
const results = await this.resultRepository.findByRaceId(race.id);
|
||||||
|
if (results.length === 0) continue;
|
||||||
|
|
||||||
|
const driverIds = results.map(r => r.driverId);
|
||||||
|
const ratings = this.driverRatingProvider.getRatings(driverIds);
|
||||||
|
const driverRatings = driverIds
|
||||||
|
.filter(id => ratings.has(id))
|
||||||
|
.map(id => ({ driverId: id, rating: ratings.get(id)! }));
|
||||||
|
|
||||||
|
const sof = this.sofCalculator.calculate(driverRatings);
|
||||||
|
if (sof !== null) {
|
||||||
|
sofValues.push(sof);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate aggregate stats
|
||||||
|
const averageSOF = sofValues.length > 0
|
||||||
|
? Math.round(sofValues.reduce((a, b) => a + b, 0) / sofValues.length)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const highestSOF = sofValues.length > 0 ? Math.max(...sofValues) : null;
|
||||||
|
const lowestSOF = sofValues.length > 0 ? Math.min(...sofValues) : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
leagueId,
|
||||||
|
totalRaces: races.length,
|
||||||
|
completedRaces: completedRaces.length,
|
||||||
|
scheduledRaces: scheduledRaces.length,
|
||||||
|
averageSOF,
|
||||||
|
highestSOF,
|
||||||
|
lowestSOF,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
88
packages/racing/application/use-cases/GetRaceWithSOFQuery.ts
Normal file
88
packages/racing/application/use-cases/GetRaceWithSOFQuery.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* Application Query: GetRaceWithSOFQuery
|
||||||
|
*
|
||||||
|
* Returns race details enriched with calculated Strength of Field (SOF).
|
||||||
|
* SOF is calculated from participant ratings if not already stored on the race.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
|
||||||
|
import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository';
|
||||||
|
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
|
||||||
|
import type { DriverRatingProvider } from '../ports/DriverRatingProvider';
|
||||||
|
import {
|
||||||
|
AverageStrengthOfFieldCalculator,
|
||||||
|
type StrengthOfFieldCalculator,
|
||||||
|
} from '../../domain/services/StrengthOfFieldCalculator';
|
||||||
|
import type { RaceDTO } from '../dto/RaceDTO';
|
||||||
|
|
||||||
|
export interface GetRaceWithSOFQueryParams {
|
||||||
|
raceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RaceWithSOFDTO extends Omit<RaceDTO, 'strengthOfField'> {
|
||||||
|
strengthOfField: number | null;
|
||||||
|
participantCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GetRaceWithSOFQuery {
|
||||||
|
private readonly sofCalculator: StrengthOfFieldCalculator;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly raceRepository: IRaceRepository,
|
||||||
|
private readonly registrationRepository: IRaceRegistrationRepository,
|
||||||
|
private readonly resultRepository: IResultRepository,
|
||||||
|
private readonly driverRatingProvider: DriverRatingProvider,
|
||||||
|
sofCalculator?: StrengthOfFieldCalculator,
|
||||||
|
) {
|
||||||
|
this.sofCalculator = sofCalculator ?? new AverageStrengthOfFieldCalculator();
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(params: GetRaceWithSOFQueryParams): Promise<RaceWithSOFDTO | null> {
|
||||||
|
const { raceId } = params;
|
||||||
|
|
||||||
|
const race = await this.raceRepository.findById(raceId);
|
||||||
|
if (!race) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get participant IDs based on race status
|
||||||
|
let participantIds: string[] = [];
|
||||||
|
|
||||||
|
if (race.status === 'completed') {
|
||||||
|
// For completed races, use results
|
||||||
|
const results = await this.resultRepository.findByRaceId(raceId);
|
||||||
|
participantIds = results.map(r => r.driverId);
|
||||||
|
} else {
|
||||||
|
// For upcoming/running races, use registrations
|
||||||
|
participantIds = await this.registrationRepository.getRegisteredDrivers(raceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use stored SOF if available, otherwise calculate
|
||||||
|
let strengthOfField = race.strengthOfField ?? null;
|
||||||
|
|
||||||
|
if (strengthOfField === null && participantIds.length > 0) {
|
||||||
|
const ratings = this.driverRatingProvider.getRatings(participantIds);
|
||||||
|
const driverRatings = participantIds
|
||||||
|
.filter(id => ratings.has(id))
|
||||||
|
.map(id => ({ driverId: id, rating: ratings.get(id)! }));
|
||||||
|
|
||||||
|
strengthOfField = this.sofCalculator.calculate(driverRatings);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: race.id,
|
||||||
|
leagueId: race.leagueId,
|
||||||
|
scheduledAt: race.scheduledAt.toISOString(),
|
||||||
|
track: race.track,
|
||||||
|
trackId: race.trackId,
|
||||||
|
car: race.car,
|
||||||
|
carId: race.carId,
|
||||||
|
sessionType: race.sessionType,
|
||||||
|
status: race.status,
|
||||||
|
strengthOfField,
|
||||||
|
registeredCount: race.registeredCount ?? participantIds.length,
|
||||||
|
maxParticipants: race.maxParticipants,
|
||||||
|
participantCount: participantIds.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
130
packages/racing/domain/entities/Car.ts
Normal file
130
packages/racing/domain/entities/Car.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* Domain Entity: Car
|
||||||
|
*
|
||||||
|
* Represents a racing car/vehicle in the GridPilot platform.
|
||||||
|
* Immutable entity with factory methods and domain validation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type CarClass = 'formula' | 'gt' | 'prototype' | 'touring' | 'sports' | 'oval' | 'dirt';
|
||||||
|
export type CarLicense = 'R' | 'D' | 'C' | 'B' | 'A' | 'Pro';
|
||||||
|
|
||||||
|
export class Car {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly shortName: string;
|
||||||
|
readonly manufacturer: string;
|
||||||
|
readonly carClass: CarClass;
|
||||||
|
readonly license: CarLicense;
|
||||||
|
readonly year: number;
|
||||||
|
readonly horsepower?: number;
|
||||||
|
readonly weight?: number;
|
||||||
|
readonly imageUrl?: string;
|
||||||
|
readonly gameId: string;
|
||||||
|
|
||||||
|
private constructor(props: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
shortName: string;
|
||||||
|
manufacturer: string;
|
||||||
|
carClass: CarClass;
|
||||||
|
license: CarLicense;
|
||||||
|
year: number;
|
||||||
|
horsepower?: number;
|
||||||
|
weight?: number;
|
||||||
|
imageUrl?: string;
|
||||||
|
gameId: string;
|
||||||
|
}) {
|
||||||
|
this.id = props.id;
|
||||||
|
this.name = props.name;
|
||||||
|
this.shortName = props.shortName;
|
||||||
|
this.manufacturer = props.manufacturer;
|
||||||
|
this.carClass = props.carClass;
|
||||||
|
this.license = props.license;
|
||||||
|
this.year = props.year;
|
||||||
|
this.horsepower = props.horsepower;
|
||||||
|
this.weight = props.weight;
|
||||||
|
this.imageUrl = props.imageUrl;
|
||||||
|
this.gameId = props.gameId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory method to create a new Car entity
|
||||||
|
*/
|
||||||
|
static create(props: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
shortName?: string;
|
||||||
|
manufacturer: string;
|
||||||
|
carClass?: CarClass;
|
||||||
|
license?: CarLicense;
|
||||||
|
year?: number;
|
||||||
|
horsepower?: number;
|
||||||
|
weight?: number;
|
||||||
|
imageUrl?: string;
|
||||||
|
gameId: string;
|
||||||
|
}): Car {
|
||||||
|
this.validate(props);
|
||||||
|
|
||||||
|
return new Car({
|
||||||
|
id: props.id,
|
||||||
|
name: props.name,
|
||||||
|
shortName: props.shortName ?? props.name.slice(0, 10),
|
||||||
|
manufacturer: props.manufacturer,
|
||||||
|
carClass: props.carClass ?? 'gt',
|
||||||
|
license: props.license ?? 'D',
|
||||||
|
year: props.year ?? new Date().getFullYear(),
|
||||||
|
horsepower: props.horsepower,
|
||||||
|
weight: props.weight,
|
||||||
|
imageUrl: props.imageUrl,
|
||||||
|
gameId: props.gameId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain validation logic
|
||||||
|
*/
|
||||||
|
private static validate(props: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
manufacturer: string;
|
||||||
|
gameId: string;
|
||||||
|
}): void {
|
||||||
|
if (!props.id || props.id.trim().length === 0) {
|
||||||
|
throw new Error('Car ID is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.name || props.name.trim().length === 0) {
|
||||||
|
throw new Error('Car name is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.manufacturer || props.manufacturer.trim().length === 0) {
|
||||||
|
throw new Error('Car manufacturer is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.gameId || props.gameId.trim().length === 0) {
|
||||||
|
throw new Error('Game ID is required');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get formatted car display name
|
||||||
|
*/
|
||||||
|
getDisplayName(): string {
|
||||||
|
return `${this.manufacturer} ${this.name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get license badge color
|
||||||
|
*/
|
||||||
|
getLicenseColor(): string {
|
||||||
|
const colors: Record<CarLicense, string> = {
|
||||||
|
'R': '#FF6B6B',
|
||||||
|
'D': '#FFB347',
|
||||||
|
'C': '#FFD700',
|
||||||
|
'B': '#7FFF00',
|
||||||
|
'A': '#00BFFF',
|
||||||
|
'Pro': '#9370DB',
|
||||||
|
};
|
||||||
|
return colors[this.license];
|
||||||
|
}
|
||||||
|
}
|
||||||
146
packages/racing/domain/entities/Protest.ts
Normal file
146
packages/racing/domain/entities/Protest.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* Domain Entity: Protest
|
||||||
|
*
|
||||||
|
* Represents a protest filed by a driver against another driver for an incident during a race.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ProtestStatus = 'pending' | 'under_review' | 'upheld' | 'dismissed' | 'withdrawn';
|
||||||
|
|
||||||
|
export interface ProtestIncident {
|
||||||
|
/** Lap number where the incident occurred */
|
||||||
|
lap: number;
|
||||||
|
/** Time in the race (seconds from start, or timestamp) */
|
||||||
|
timeInRace?: number;
|
||||||
|
/** Brief description of the incident */
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProtestProps {
|
||||||
|
id: string;
|
||||||
|
raceId: string;
|
||||||
|
/** The driver filing the protest */
|
||||||
|
protestingDriverId: string;
|
||||||
|
/** The driver being protested against */
|
||||||
|
accusedDriverId: string;
|
||||||
|
/** Details of the incident */
|
||||||
|
incident: ProtestIncident;
|
||||||
|
/** Optional comment/statement from the protesting driver */
|
||||||
|
comment?: string;
|
||||||
|
/** URL to proof video clip */
|
||||||
|
proofVideoUrl?: string;
|
||||||
|
/** Current status of the protest */
|
||||||
|
status: ProtestStatus;
|
||||||
|
/** ID of the steward/admin who reviewed (if any) */
|
||||||
|
reviewedBy?: string;
|
||||||
|
/** Decision notes from the steward */
|
||||||
|
decisionNotes?: string;
|
||||||
|
/** Timestamp when the protest was filed */
|
||||||
|
filedAt: Date;
|
||||||
|
/** Timestamp when the protest was reviewed */
|
||||||
|
reviewedAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Protest {
|
||||||
|
private constructor(private readonly props: ProtestProps) {}
|
||||||
|
|
||||||
|
static create(props: ProtestProps): Protest {
|
||||||
|
if (!props.id) throw new Error('Protest ID is required');
|
||||||
|
if (!props.raceId) throw new Error('Race ID is required');
|
||||||
|
if (!props.protestingDriverId) throw new Error('Protesting driver ID is required');
|
||||||
|
if (!props.accusedDriverId) throw new Error('Accused driver ID is required');
|
||||||
|
if (!props.incident) throw new Error('Incident details are required');
|
||||||
|
if (props.incident.lap < 0) throw new Error('Lap number must be non-negative');
|
||||||
|
if (!props.incident.description?.trim()) throw new Error('Incident description is required');
|
||||||
|
|
||||||
|
return new Protest({
|
||||||
|
...props,
|
||||||
|
status: props.status || 'pending',
|
||||||
|
filedAt: props.filedAt || new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get id(): string { return this.props.id; }
|
||||||
|
get raceId(): string { return this.props.raceId; }
|
||||||
|
get protestingDriverId(): string { return this.props.protestingDriverId; }
|
||||||
|
get accusedDriverId(): string { return this.props.accusedDriverId; }
|
||||||
|
get incident(): ProtestIncident { return { ...this.props.incident }; }
|
||||||
|
get comment(): string | undefined { return this.props.comment; }
|
||||||
|
get proofVideoUrl(): string | undefined { return this.props.proofVideoUrl; }
|
||||||
|
get status(): ProtestStatus { return this.props.status; }
|
||||||
|
get reviewedBy(): string | undefined { return this.props.reviewedBy; }
|
||||||
|
get decisionNotes(): string | undefined { return this.props.decisionNotes; }
|
||||||
|
get filedAt(): Date { return this.props.filedAt; }
|
||||||
|
get reviewedAt(): Date | undefined { return this.props.reviewedAt; }
|
||||||
|
|
||||||
|
isPending(): boolean {
|
||||||
|
return this.props.status === 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
isUnderReview(): boolean {
|
||||||
|
return this.props.status === 'under_review';
|
||||||
|
}
|
||||||
|
|
||||||
|
isResolved(): boolean {
|
||||||
|
return ['upheld', 'dismissed', 'withdrawn'].includes(this.props.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start reviewing the protest
|
||||||
|
*/
|
||||||
|
startReview(stewardId: string): Protest {
|
||||||
|
if (!this.isPending()) {
|
||||||
|
throw new Error('Only pending protests can be put under review');
|
||||||
|
}
|
||||||
|
return new Protest({
|
||||||
|
...this.props,
|
||||||
|
status: 'under_review',
|
||||||
|
reviewedBy: stewardId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uphold the protest (finding the accused guilty)
|
||||||
|
*/
|
||||||
|
uphold(stewardId: string, decisionNotes: string): Protest {
|
||||||
|
if (!this.isPending() && !this.isUnderReview()) {
|
||||||
|
throw new Error('Only pending or under-review protests can be upheld');
|
||||||
|
}
|
||||||
|
return new Protest({
|
||||||
|
...this.props,
|
||||||
|
status: 'upheld',
|
||||||
|
reviewedBy: stewardId,
|
||||||
|
decisionNotes,
|
||||||
|
reviewedAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dismiss the protest (finding no fault)
|
||||||
|
*/
|
||||||
|
dismiss(stewardId: string, decisionNotes: string): Protest {
|
||||||
|
if (!this.isPending() && !this.isUnderReview()) {
|
||||||
|
throw new Error('Only pending or under-review protests can be dismissed');
|
||||||
|
}
|
||||||
|
return new Protest({
|
||||||
|
...this.props,
|
||||||
|
status: 'dismissed',
|
||||||
|
reviewedBy: stewardId,
|
||||||
|
decisionNotes,
|
||||||
|
reviewedAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Withdraw the protest (by the protesting driver)
|
||||||
|
*/
|
||||||
|
withdraw(): Protest {
|
||||||
|
if (this.isResolved()) {
|
||||||
|
throw new Error('Cannot withdraw a resolved protest');
|
||||||
|
}
|
||||||
|
return new Protest({
|
||||||
|
...this.props,
|
||||||
|
status: 'withdrawn',
|
||||||
|
reviewedAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,38 +1,53 @@
|
|||||||
/**
|
/**
|
||||||
* Domain Entity: Race
|
* Domain Entity: Race
|
||||||
*
|
*
|
||||||
* Represents a race/session in the GridPilot platform.
|
* Represents a race/session in the GridPilot platform.
|
||||||
* Immutable entity with factory methods and domain validation.
|
* Immutable entity with factory methods and domain validation.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type SessionType = 'practice' | 'qualifying' | 'race';
|
export type SessionType = 'practice' | 'qualifying' | 'race';
|
||||||
export type RaceStatus = 'scheduled' | 'completed' | 'cancelled';
|
export type RaceStatus = 'scheduled' | 'running' | 'completed' | 'cancelled';
|
||||||
|
|
||||||
export class Race {
|
export class Race {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly leagueId: string;
|
readonly leagueId: string;
|
||||||
readonly scheduledAt: Date;
|
readonly scheduledAt: Date;
|
||||||
readonly track: string;
|
readonly track: string;
|
||||||
|
readonly trackId?: string;
|
||||||
readonly car: string;
|
readonly car: string;
|
||||||
|
readonly carId?: string;
|
||||||
readonly sessionType: SessionType;
|
readonly sessionType: SessionType;
|
||||||
readonly status: RaceStatus;
|
readonly status: RaceStatus;
|
||||||
|
readonly strengthOfField?: number;
|
||||||
|
readonly registeredCount?: number;
|
||||||
|
readonly maxParticipants?: number;
|
||||||
|
|
||||||
private constructor(props: {
|
private constructor(props: {
|
||||||
id: string;
|
id: string;
|
||||||
leagueId: string;
|
leagueId: string;
|
||||||
scheduledAt: Date;
|
scheduledAt: Date;
|
||||||
track: string;
|
track: string;
|
||||||
|
trackId?: string;
|
||||||
car: string;
|
car: string;
|
||||||
|
carId?: string;
|
||||||
sessionType: SessionType;
|
sessionType: SessionType;
|
||||||
status: RaceStatus;
|
status: RaceStatus;
|
||||||
|
strengthOfField?: number;
|
||||||
|
registeredCount?: number;
|
||||||
|
maxParticipants?: number;
|
||||||
}) {
|
}) {
|
||||||
this.id = props.id;
|
this.id = props.id;
|
||||||
this.leagueId = props.leagueId;
|
this.leagueId = props.leagueId;
|
||||||
this.scheduledAt = props.scheduledAt;
|
this.scheduledAt = props.scheduledAt;
|
||||||
this.track = props.track;
|
this.track = props.track;
|
||||||
|
this.trackId = props.trackId;
|
||||||
this.car = props.car;
|
this.car = props.car;
|
||||||
|
this.carId = props.carId;
|
||||||
this.sessionType = props.sessionType;
|
this.sessionType = props.sessionType;
|
||||||
this.status = props.status;
|
this.status = props.status;
|
||||||
|
this.strengthOfField = props.strengthOfField;
|
||||||
|
this.registeredCount = props.registeredCount;
|
||||||
|
this.maxParticipants = props.maxParticipants;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,9 +58,14 @@ export class Race {
|
|||||||
leagueId: string;
|
leagueId: string;
|
||||||
scheduledAt: Date;
|
scheduledAt: Date;
|
||||||
track: string;
|
track: string;
|
||||||
|
trackId?: string;
|
||||||
car: string;
|
car: string;
|
||||||
|
carId?: string;
|
||||||
sessionType?: SessionType;
|
sessionType?: SessionType;
|
||||||
status?: RaceStatus;
|
status?: RaceStatus;
|
||||||
|
strengthOfField?: number;
|
||||||
|
registeredCount?: number;
|
||||||
|
maxParticipants?: number;
|
||||||
}): Race {
|
}): Race {
|
||||||
this.validate(props);
|
this.validate(props);
|
||||||
|
|
||||||
@@ -54,9 +74,14 @@ export class Race {
|
|||||||
leagueId: props.leagueId,
|
leagueId: props.leagueId,
|
||||||
scheduledAt: props.scheduledAt,
|
scheduledAt: props.scheduledAt,
|
||||||
track: props.track,
|
track: props.track,
|
||||||
|
trackId: props.trackId,
|
||||||
car: props.car,
|
car: props.car,
|
||||||
|
carId: props.carId,
|
||||||
sessionType: props.sessionType ?? 'race',
|
sessionType: props.sessionType ?? 'race',
|
||||||
status: props.status ?? 'scheduled',
|
status: props.status ?? 'scheduled',
|
||||||
|
strengthOfField: props.strengthOfField,
|
||||||
|
registeredCount: props.registeredCount,
|
||||||
|
maxParticipants: props.maxParticipants,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +116,20 @@ export class Race {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the race (move from scheduled to running)
|
||||||
|
*/
|
||||||
|
start(): Race {
|
||||||
|
if (this.status !== 'scheduled') {
|
||||||
|
throw new Error('Only scheduled races can be started');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Race({
|
||||||
|
...this,
|
||||||
|
status: 'running',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark race as completed
|
* Mark race as completed
|
||||||
*/
|
*/
|
||||||
@@ -127,6 +166,17 @@ export class Race {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update SOF and participant count
|
||||||
|
*/
|
||||||
|
updateField(strengthOfField: number, registeredCount: number): Race {
|
||||||
|
return new Race({
|
||||||
|
...this,
|
||||||
|
strengthOfField,
|
||||||
|
registeredCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if race is in the past
|
* Check if race is in the past
|
||||||
*/
|
*/
|
||||||
@@ -140,4 +190,11 @@ export class Race {
|
|||||||
isUpcoming(): boolean {
|
isUpcoming(): boolean {
|
||||||
return this.status === 'scheduled' && !this.isPast();
|
return this.status === 'scheduled' && !this.isPast();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if race is live/running
|
||||||
|
*/
|
||||||
|
isLive(): boolean {
|
||||||
|
return this.status === 'running';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
120
packages/racing/domain/entities/Track.ts
Normal file
120
packages/racing/domain/entities/Track.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* Domain Entity: Track
|
||||||
|
*
|
||||||
|
* Represents a racing track/circuit in the GridPilot platform.
|
||||||
|
* Immutable entity with factory methods and domain validation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type TrackCategory = 'oval' | 'road' | 'street' | 'dirt';
|
||||||
|
export type TrackDifficulty = 'beginner' | 'intermediate' | 'advanced' | 'expert';
|
||||||
|
|
||||||
|
export class Track {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly shortName: string;
|
||||||
|
readonly country: string;
|
||||||
|
readonly category: TrackCategory;
|
||||||
|
readonly difficulty: TrackDifficulty;
|
||||||
|
readonly lengthKm: number;
|
||||||
|
readonly turns: number;
|
||||||
|
readonly imageUrl?: string;
|
||||||
|
readonly gameId: string;
|
||||||
|
|
||||||
|
private constructor(props: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
shortName: string;
|
||||||
|
country: string;
|
||||||
|
category: TrackCategory;
|
||||||
|
difficulty: TrackDifficulty;
|
||||||
|
lengthKm: number;
|
||||||
|
turns: number;
|
||||||
|
imageUrl?: string;
|
||||||
|
gameId: string;
|
||||||
|
}) {
|
||||||
|
this.id = props.id;
|
||||||
|
this.name = props.name;
|
||||||
|
this.shortName = props.shortName;
|
||||||
|
this.country = props.country;
|
||||||
|
this.category = props.category;
|
||||||
|
this.difficulty = props.difficulty;
|
||||||
|
this.lengthKm = props.lengthKm;
|
||||||
|
this.turns = props.turns;
|
||||||
|
this.imageUrl = props.imageUrl;
|
||||||
|
this.gameId = props.gameId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory method to create a new Track entity
|
||||||
|
*/
|
||||||
|
static create(props: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
shortName?: string;
|
||||||
|
country: string;
|
||||||
|
category?: TrackCategory;
|
||||||
|
difficulty?: TrackDifficulty;
|
||||||
|
lengthKm: number;
|
||||||
|
turns: number;
|
||||||
|
imageUrl?: string;
|
||||||
|
gameId: string;
|
||||||
|
}): Track {
|
||||||
|
this.validate(props);
|
||||||
|
|
||||||
|
return new Track({
|
||||||
|
id: props.id,
|
||||||
|
name: props.name,
|
||||||
|
shortName: props.shortName ?? props.name.slice(0, 3).toUpperCase(),
|
||||||
|
country: props.country,
|
||||||
|
category: props.category ?? 'road',
|
||||||
|
difficulty: props.difficulty ?? 'intermediate',
|
||||||
|
lengthKm: props.lengthKm,
|
||||||
|
turns: props.turns,
|
||||||
|
imageUrl: props.imageUrl,
|
||||||
|
gameId: props.gameId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain validation logic
|
||||||
|
*/
|
||||||
|
private static validate(props: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
country: string;
|
||||||
|
lengthKm: number;
|
||||||
|
turns: number;
|
||||||
|
gameId: string;
|
||||||
|
}): void {
|
||||||
|
if (!props.id || props.id.trim().length === 0) {
|
||||||
|
throw new Error('Track ID is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.name || props.name.trim().length === 0) {
|
||||||
|
throw new Error('Track name is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.country || props.country.trim().length === 0) {
|
||||||
|
throw new Error('Track country is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.lengthKm <= 0) {
|
||||||
|
throw new Error('Track length must be positive');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.turns < 0) {
|
||||||
|
throw new Error('Track turns cannot be negative');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.gameId || props.gameId.trim().length === 0) {
|
||||||
|
throw new Error('Game ID is required');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get formatted length string
|
||||||
|
*/
|
||||||
|
getFormattedLength(): string {
|
||||||
|
return `${this.lengthKm.toFixed(2)} km`;
|
||||||
|
}
|
||||||
|
}
|
||||||
65
packages/racing/domain/repositories/ICarRepository.ts
Normal file
65
packages/racing/domain/repositories/ICarRepository.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Application Port: ICarRepository
|
||||||
|
*
|
||||||
|
* Repository interface for Car entity CRUD operations.
|
||||||
|
* Defines async methods using domain entities as types.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Car, CarClass, CarLicense } from '../entities/Car';
|
||||||
|
|
||||||
|
export interface ICarRepository {
|
||||||
|
/**
|
||||||
|
* Find a car by ID
|
||||||
|
*/
|
||||||
|
findById(id: string): Promise<Car | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all cars
|
||||||
|
*/
|
||||||
|
findAll(): Promise<Car[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find cars by game ID
|
||||||
|
*/
|
||||||
|
findByGameId(gameId: string): Promise<Car[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find cars by class
|
||||||
|
*/
|
||||||
|
findByClass(carClass: CarClass): Promise<Car[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find cars by license level
|
||||||
|
*/
|
||||||
|
findByLicense(license: CarLicense): Promise<Car[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find cars by manufacturer
|
||||||
|
*/
|
||||||
|
findByManufacturer(manufacturer: string): Promise<Car[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search cars by name
|
||||||
|
*/
|
||||||
|
searchByName(query: string): Promise<Car[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new car
|
||||||
|
*/
|
||||||
|
create(car: Car): Promise<Car>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing car
|
||||||
|
*/
|
||||||
|
update(car: Car): Promise<Car>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a car by ID
|
||||||
|
*/
|
||||||
|
delete(id: string): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a car exists by ID
|
||||||
|
*/
|
||||||
|
exists(id: string): Promise<boolean>;
|
||||||
|
}
|
||||||
60
packages/racing/domain/repositories/ITrackRepository.ts
Normal file
60
packages/racing/domain/repositories/ITrackRepository.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* Application Port: ITrackRepository
|
||||||
|
*
|
||||||
|
* Repository interface for Track entity CRUD operations.
|
||||||
|
* Defines async methods using domain entities as types.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Track, TrackCategory } from '../entities/Track';
|
||||||
|
|
||||||
|
export interface ITrackRepository {
|
||||||
|
/**
|
||||||
|
* Find a track by ID
|
||||||
|
*/
|
||||||
|
findById(id: string): Promise<Track | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all tracks
|
||||||
|
*/
|
||||||
|
findAll(): Promise<Track[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find tracks by game ID
|
||||||
|
*/
|
||||||
|
findByGameId(gameId: string): Promise<Track[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find tracks by category
|
||||||
|
*/
|
||||||
|
findByCategory(category: TrackCategory): Promise<Track[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find tracks by country
|
||||||
|
*/
|
||||||
|
findByCountry(country: string): Promise<Track[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search tracks by name
|
||||||
|
*/
|
||||||
|
searchByName(query: string): Promise<Track[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new track
|
||||||
|
*/
|
||||||
|
create(track: Track): Promise<Track>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing track
|
||||||
|
*/
|
||||||
|
update(track: Track): Promise<Track>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a track by ID
|
||||||
|
*/
|
||||||
|
delete(id: string): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a track exists by ID
|
||||||
|
*/
|
||||||
|
exists(id: string): Promise<boolean>;
|
||||||
|
}
|
||||||
39
packages/racing/domain/services/StrengthOfFieldCalculator.ts
Normal file
39
packages/racing/domain/services/StrengthOfFieldCalculator.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Domain Service: StrengthOfFieldCalculator
|
||||||
|
*
|
||||||
|
* Calculates the Strength of Field (SOF) for a race based on participant ratings.
|
||||||
|
* SOF is the average rating of all participants in a race.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface DriverRating {
|
||||||
|
driverId: string;
|
||||||
|
rating: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StrengthOfFieldCalculator {
|
||||||
|
/**
|
||||||
|
* Calculate SOF from a list of driver ratings
|
||||||
|
* Returns null if no valid ratings are provided
|
||||||
|
*/
|
||||||
|
calculate(driverRatings: DriverRating[]): number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default implementation using simple average
|
||||||
|
*/
|
||||||
|
export class AverageStrengthOfFieldCalculator implements StrengthOfFieldCalculator {
|
||||||
|
calculate(driverRatings: DriverRating[]): number | null {
|
||||||
|
if (driverRatings.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validRatings = driverRatings.filter(dr => dr.rating > 0);
|
||||||
|
|
||||||
|
if (validRatings.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sum = validRatings.reduce((acc, dr) => acc + dr.rating, 0);
|
||||||
|
return Math.round(sum / validRatings.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ export * from './domain/entities/Standing';
|
|||||||
export * from './domain/entities/LeagueMembership';
|
export * from './domain/entities/LeagueMembership';
|
||||||
export * from './domain/entities/RaceRegistration';
|
export * from './domain/entities/RaceRegistration';
|
||||||
export * from './domain/entities/Team';
|
export * from './domain/entities/Team';
|
||||||
|
export * from './domain/entities/Track';
|
||||||
|
export * from './domain/entities/Car';
|
||||||
|
|
||||||
export * from './domain/repositories/IDriverRepository';
|
export * from './domain/repositories/IDriverRepository';
|
||||||
export * from './domain/repositories/ILeagueRepository';
|
export * from './domain/repositories/ILeagueRepository';
|
||||||
@@ -16,6 +18,10 @@ export * from './domain/repositories/ILeagueMembershipRepository';
|
|||||||
export * from './domain/repositories/IRaceRegistrationRepository';
|
export * from './domain/repositories/IRaceRegistrationRepository';
|
||||||
export * from './domain/repositories/ITeamRepository';
|
export * from './domain/repositories/ITeamRepository';
|
||||||
export * from './domain/repositories/ITeamMembershipRepository';
|
export * from './domain/repositories/ITeamMembershipRepository';
|
||||||
|
export * from './domain/repositories/ITrackRepository';
|
||||||
|
export * from './domain/repositories/ICarRepository';
|
||||||
|
|
||||||
|
export * from './domain/services/StrengthOfFieldCalculator';
|
||||||
|
|
||||||
export * from './application/mappers/EntityMappers';
|
export * from './application/mappers/EntityMappers';
|
||||||
export * from './application/dto/DriverDTO';
|
export * from './application/dto/DriverDTO';
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* Infrastructure Adapter: InMemoryCarRepository
|
||||||
|
*
|
||||||
|
* In-memory implementation of ICarRepository.
|
||||||
|
* Stores data in Map structure with UUID generation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { Car, CarClass, CarLicense } from '@gridpilot/racing/domain/entities/Car';
|
||||||
|
import type { ICarRepository } from '@gridpilot/racing/domain/repositories/ICarRepository';
|
||||||
|
|
||||||
|
export class InMemoryCarRepository implements ICarRepository {
|
||||||
|
private cars: Map<string, Car>;
|
||||||
|
|
||||||
|
constructor(seedData?: Car[]) {
|
||||||
|
this.cars = new Map();
|
||||||
|
|
||||||
|
if (seedData) {
|
||||||
|
seedData.forEach(car => {
|
||||||
|
this.cars.set(car.id, car);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<Car | null> {
|
||||||
|
return this.cars.get(id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(): Promise<Car[]> {
|
||||||
|
return Array.from(this.cars.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByGameId(gameId: string): Promise<Car[]> {
|
||||||
|
return Array.from(this.cars.values())
|
||||||
|
.filter(car => car.gameId === gameId)
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByClass(carClass: CarClass): Promise<Car[]> {
|
||||||
|
return Array.from(this.cars.values())
|
||||||
|
.filter(car => car.carClass === carClass)
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByLicense(license: CarLicense): Promise<Car[]> {
|
||||||
|
return Array.from(this.cars.values())
|
||||||
|
.filter(car => car.license === license)
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByManufacturer(manufacturer: string): Promise<Car[]> {
|
||||||
|
const lowerManufacturer = manufacturer.toLowerCase();
|
||||||
|
return Array.from(this.cars.values())
|
||||||
|
.filter(car => car.manufacturer.toLowerCase() === lowerManufacturer)
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchByName(query: string): Promise<Car[]> {
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
return Array.from(this.cars.values())
|
||||||
|
.filter(car =>
|
||||||
|
car.name.toLowerCase().includes(lowerQuery) ||
|
||||||
|
car.shortName.toLowerCase().includes(lowerQuery) ||
|
||||||
|
car.manufacturer.toLowerCase().includes(lowerQuery)
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(car: Car): Promise<Car> {
|
||||||
|
if (await this.exists(car.id)) {
|
||||||
|
throw new Error(`Car with ID ${car.id} already exists`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cars.set(car.id, car);
|
||||||
|
return car;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(car: Car): Promise<Car> {
|
||||||
|
if (!await this.exists(car.id)) {
|
||||||
|
throw new Error(`Car with ID ${car.id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cars.set(car.id, car);
|
||||||
|
return car;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
if (!await this.exists(id)) {
|
||||||
|
throw new Error(`Car with ID ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cars.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async exists(id: string): Promise<boolean> {
|
||||||
|
return this.cars.has(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility method to generate a new UUID
|
||||||
|
*/
|
||||||
|
static generateId(): string {
|
||||||
|
return uuidv4();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* Infrastructure Adapter: InMemoryTrackRepository
|
||||||
|
*
|
||||||
|
* In-memory implementation of ITrackRepository.
|
||||||
|
* Stores data in Map structure with UUID generation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { Track, TrackCategory } from '@gridpilot/racing/domain/entities/Track';
|
||||||
|
import type { ITrackRepository } from '@gridpilot/racing/domain/repositories/ITrackRepository';
|
||||||
|
|
||||||
|
export class InMemoryTrackRepository implements ITrackRepository {
|
||||||
|
private tracks: Map<string, Track>;
|
||||||
|
|
||||||
|
constructor(seedData?: Track[]) {
|
||||||
|
this.tracks = new Map();
|
||||||
|
|
||||||
|
if (seedData) {
|
||||||
|
seedData.forEach(track => {
|
||||||
|
this.tracks.set(track.id, track);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<Track | null> {
|
||||||
|
return this.tracks.get(id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(): Promise<Track[]> {
|
||||||
|
return Array.from(this.tracks.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByGameId(gameId: string): Promise<Track[]> {
|
||||||
|
return Array.from(this.tracks.values())
|
||||||
|
.filter(track => track.gameId === gameId)
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByCategory(category: TrackCategory): Promise<Track[]> {
|
||||||
|
return Array.from(this.tracks.values())
|
||||||
|
.filter(track => track.category === category)
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByCountry(country: string): Promise<Track[]> {
|
||||||
|
return Array.from(this.tracks.values())
|
||||||
|
.filter(track => track.country.toLowerCase() === country.toLowerCase())
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchByName(query: string): Promise<Track[]> {
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
return Array.from(this.tracks.values())
|
||||||
|
.filter(track =>
|
||||||
|
track.name.toLowerCase().includes(lowerQuery) ||
|
||||||
|
track.shortName.toLowerCase().includes(lowerQuery)
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(track: Track): Promise<Track> {
|
||||||
|
if (await this.exists(track.id)) {
|
||||||
|
throw new Error(`Track with ID ${track.id} already exists`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.tracks.set(track.id, track);
|
||||||
|
return track;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(track: Track): Promise<Track> {
|
||||||
|
if (!await this.exists(track.id)) {
|
||||||
|
throw new Error(`Track with ID ${track.id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.tracks.set(track.id, track);
|
||||||
|
return track;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
if (!await this.exists(id)) {
|
||||||
|
throw new Error(`Track with ID ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.tracks.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async exists(id: string): Promise<boolean> {
|
||||||
|
return this.tracks.has(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility method to generate a new UUID
|
||||||
|
*/
|
||||||
|
static generateId(): string {
|
||||||
|
return uuidv4();
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
Reference in New Issue
Block a user