363 lines
14 KiB
TypeScript
363 lines
14 KiB
TypeScript
'use client';
|
|
|
|
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
|
import Button from '@/components/ui/Button';
|
|
import Card from '@/components/ui/Card';
|
|
import { ArrowLeft, Calendar, Trophy, Users, Zap } from 'lucide-react';
|
|
|
|
export interface ResultEntry {
|
|
position: number;
|
|
driverId: string;
|
|
driverName: string;
|
|
driverAvatar: string;
|
|
country: string;
|
|
car: string;
|
|
laps: number;
|
|
time: string;
|
|
fastestLap: string;
|
|
points: number;
|
|
incidents: number;
|
|
isCurrentUser: boolean;
|
|
}
|
|
|
|
export interface PenaltyEntry {
|
|
driverId: string;
|
|
driverName: string;
|
|
type: 'time_penalty' | 'grid_penalty' | 'points_deduction' | 'disqualification' | 'warning' | 'license_points';
|
|
value: number;
|
|
reason: string;
|
|
notes?: string;
|
|
}
|
|
|
|
export interface RaceResultsTemplateProps {
|
|
raceTrack?: string;
|
|
raceScheduledAt?: string;
|
|
totalDrivers?: number;
|
|
leagueName?: string;
|
|
raceSOF?: number | null;
|
|
results: ResultEntry[];
|
|
penalties: PenaltyEntry[];
|
|
pointsSystem: Record<string, number>;
|
|
fastestLapTime: number;
|
|
currentDriverId: string;
|
|
isAdmin: boolean;
|
|
isLoading: boolean;
|
|
error?: Error | null;
|
|
// Actions
|
|
onBack: () => void;
|
|
onImportResults: (results: any[]) => void;
|
|
onPenaltyClick: (driver: { id: string; name: string }) => void;
|
|
// UI State
|
|
importing: boolean;
|
|
importSuccess: boolean;
|
|
importError: string | null;
|
|
showImportForm: boolean;
|
|
setShowImportForm: (show: boolean) => void;
|
|
}
|
|
|
|
export function RaceResultsTemplate({
|
|
raceTrack,
|
|
raceScheduledAt,
|
|
totalDrivers,
|
|
leagueName,
|
|
raceSOF,
|
|
results,
|
|
penalties,
|
|
pointsSystem,
|
|
fastestLapTime,
|
|
currentDriverId,
|
|
isAdmin,
|
|
isLoading,
|
|
error,
|
|
onBack,
|
|
onImportResults,
|
|
onPenaltyClick,
|
|
importing,
|
|
importSuccess,
|
|
importError,
|
|
showImportForm,
|
|
setShowImportForm,
|
|
}: RaceResultsTemplateProps) {
|
|
const formatDate = (date: string) => {
|
|
return new Date(date).toLocaleDateString('en-US', {
|
|
weekday: 'long',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
});
|
|
};
|
|
|
|
const formatTime = (ms: number) => {
|
|
const minutes = Math.floor(ms / 60000);
|
|
const seconds = Math.floor((ms % 60000) / 1000);
|
|
const milliseconds = Math.floor((ms % 1000) / 10);
|
|
return `${minutes}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(2, '0')}`;
|
|
};
|
|
|
|
const getCountryFlag = (countryCode: string): string => {
|
|
const codePoints = countryCode
|
|
.toUpperCase()
|
|
.split('')
|
|
.map(char => 127397 + char.charCodeAt(0));
|
|
return String.fromCodePoint(...codePoints);
|
|
};
|
|
|
|
const breadcrumbItems = [
|
|
{ label: 'Races', href: '/races' },
|
|
...(leagueName ? [{ label: leagueName, href: `/leagues/${leagueName}` }] : []),
|
|
...(raceTrack ? [{ label: raceTrack, href: `/races/${raceTrack}` }] : []),
|
|
{ label: 'Results' },
|
|
];
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
|
|
<div className="max-w-6xl mx-auto">
|
|
<div className="text-center text-gray-400">Loading results...</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error && !raceTrack) {
|
|
return (
|
|
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
|
|
<div className="max-w-6xl mx-auto">
|
|
<Card className="text-center py-12">
|
|
<div className="text-warning-amber mb-4">
|
|
{error?.message || 'Race not found'}
|
|
</div>
|
|
<Button
|
|
variant="secondary"
|
|
onClick={onBack}
|
|
>
|
|
Back to Races
|
|
</Button>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const hasResults = results.length > 0;
|
|
|
|
return (
|
|
<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 space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<Breadcrumbs items={breadcrumbItems} className="text-sm text-gray-400" />
|
|
<Button
|
|
variant="secondary"
|
|
onClick={onBack}
|
|
className="flex items-center gap-2 text-sm"
|
|
>
|
|
<ArrowLeft className="w-4 h-4" />
|
|
Back
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Header */}
|
|
<Card className="bg-gradient-to-r from-iron-gray/50 to-iron-gray/30">
|
|
<div className="flex items-center gap-4 mb-4">
|
|
<div className="w-12 h-12 rounded-xl bg-primary-blue/20 flex items-center justify-center">
|
|
<Trophy className="w-6 h-6 text-primary-blue" />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-white">Race Results</h1>
|
|
<p className="text-sm text-gray-400">
|
|
{raceTrack} • {raceScheduledAt ? formatDate(raceScheduledAt) : ''}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats */}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
<div className="p-3 bg-deep-graphite/60 rounded-lg">
|
|
<p className="text-xs text-gray-400 mb-1">Drivers</p>
|
|
<p className="text-lg font-bold text-white">{totalDrivers ?? 0}</p>
|
|
</div>
|
|
<div className="p-3 bg-deep-graphite/60 rounded-lg">
|
|
<p className="text-xs text-gray-400 mb-1">League</p>
|
|
<p className="text-sm font-medium text-white truncate">{leagueName ?? '—'}</p>
|
|
</div>
|
|
<div className="p-3 bg-deep-graphite/60 rounded-lg">
|
|
<p className="text-xs text-gray-400 mb-1">SOF</p>
|
|
<p className="text-lg font-bold text-warning-amber flex items-center gap-1">
|
|
<Zap className="w-4 h-4" />
|
|
{raceSOF ?? '—'}
|
|
</p>
|
|
</div>
|
|
<div className="p-3 bg-deep-graphite/60 rounded-lg">
|
|
<p className="text-xs text-gray-400 mb-1">Fastest Lap</p>
|
|
<p className="text-lg font-bold text-performance-green">
|
|
{fastestLapTime ? formatTime(fastestLapTime) : '—'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
{importSuccess && (
|
|
<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.
|
|
</div>
|
|
)}
|
|
|
|
{importError && (
|
|
<div className="p-4 bg-warning-amber/10 border border-warning-amber/30 rounded-lg text-warning-amber">
|
|
<strong>Error:</strong> {importError}
|
|
</div>
|
|
)}
|
|
|
|
<Card>
|
|
{hasResults ? (
|
|
<div className="space-y-4">
|
|
{/* Results Table */}
|
|
<div className="space-y-2">
|
|
{results.map((result) => {
|
|
const isCurrentUser = result.driverId === currentDriverId;
|
|
const countryFlag = getCountryFlag(result.country);
|
|
const points = pointsSystem[result.position.toString()] ?? 0;
|
|
|
|
return (
|
|
<div
|
|
key={result.driverId}
|
|
className={`
|
|
flex items-center gap-3 p-3 rounded-xl
|
|
${isCurrentUser ? 'bg-gradient-to-r from-primary-blue/20 via-primary-blue/10 to-transparent border border-primary-blue/40' : 'bg-deep-graphite'}
|
|
`}
|
|
>
|
|
{/* Position */}
|
|
<div className={`
|
|
flex items-center justify-center w-10 h-10 rounded-lg font-bold
|
|
${result.position === 1 ? 'bg-yellow-500/20 text-yellow-400' :
|
|
result.position === 2 ? 'bg-gray-400/20 text-gray-300' :
|
|
result.position === 3 ? 'bg-amber-600/20 text-amber-500' :
|
|
'bg-iron-gray text-gray-500'}
|
|
`}>
|
|
{result.position}
|
|
</div>
|
|
|
|
{/* Avatar */}
|
|
<div className="relative flex-shrink-0">
|
|
<img
|
|
src={result.driverAvatar}
|
|
alt={result.driverName}
|
|
className={`w-10 h-10 rounded-full object-cover ${isCurrentUser ? 'ring-2 ring-primary-blue/50' : ''}`}
|
|
/>
|
|
<div className="absolute -bottom-0.5 -right-0.5 w-5 h-5 rounded-full bg-deep-graphite border-2 border-deep-graphite flex items-center justify-center text-xs shadow-sm">
|
|
{countryFlag}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Driver Info */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<p className={`text-sm font-semibold truncate ${isCurrentUser ? 'text-primary-blue' : 'text-white'}`}>
|
|
{result.driverName}
|
|
</p>
|
|
{isCurrentUser && (
|
|
<span className="px-2 py-0.5 text-[10px] font-bold bg-primary-blue text-white rounded-full uppercase tracking-wide">
|
|
You
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-3 text-xs text-gray-400 mt-0.5">
|
|
<span>{result.car}</span>
|
|
<span>•</span>
|
|
<span>Laps: {result.laps}</span>
|
|
<span>•</span>
|
|
<span>Incidents: {result.incidents}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Times */}
|
|
<div className="text-right min-w-[100px]">
|
|
<p className="text-sm font-mono text-white">{result.time}</p>
|
|
<p className="text-xs text-performance-green">FL: {result.fastestLap}</p>
|
|
</div>
|
|
|
|
{/* Points */}
|
|
<div className="flex-shrink-0">
|
|
<div className="flex flex-col items-center px-3 py-1 rounded-lg bg-warning-amber/10 border border-warning-amber/20">
|
|
<span className="text-xs text-gray-400">PTS</span>
|
|
<span className="text-sm font-bold text-warning-amber">{points}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Penalties Section */}
|
|
{penalties.length > 0 && (
|
|
<div className="mt-6 pt-6 border-t border-charcoal-outline">
|
|
<h3 className="text-lg font-semibold text-white mb-4">Penalties</h3>
|
|
<div className="space-y-2">
|
|
{penalties.map((penalty, index) => (
|
|
<div key={index} className="flex items-center gap-3 p-3 bg-deep-graphite rounded-lg">
|
|
<div className="w-10 h-10 rounded-full bg-red-500/20 flex items-center justify-center flex-shrink-0">
|
|
<span className="text-red-400 font-bold text-sm">!</span>
|
|
</div>
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span className="font-medium text-white">{penalty.driverName}</span>
|
|
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">
|
|
{penalty.type.replace('_', ' ')}
|
|
</span>
|
|
</div>
|
|
<p className="text-sm text-gray-400">{penalty.reason}</p>
|
|
{penalty.notes && (
|
|
<p className="text-sm text-gray-500 mt-1 italic">{penalty.notes}</p>
|
|
)}
|
|
</div>
|
|
<div className="text-right">
|
|
<span className="text-2xl font-bold text-red-400">
|
|
{penalty.type === 'time_penalty' && `+${penalty.value}s`}
|
|
{penalty.type === 'grid_penalty' && `+${penalty.value} grid`}
|
|
{penalty.type === 'points_deduction' && `-${penalty.value} pts`}
|
|
{penalty.type === 'disqualification' && 'DSQ'}
|
|
{penalty.type === 'warning' && 'Warning'}
|
|
{penalty.type === 'license_points' && `${penalty.value} LP`}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<>
|
|
<h2 className="text-xl font-semibold text-white mb-6">Import Results</h2>
|
|
<p className="text-gray-400 text-sm mb-6">
|
|
No results imported. Upload CSV to test the standings system.
|
|
</p>
|
|
{importing ? (
|
|
<div className="text-center py-8 text-gray-400">
|
|
Importing results and updating standings...
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
<p className="text-sm text-gray-400">
|
|
This is a placeholder for the import form. In the actual implementation,
|
|
this would render the ImportResultsForm component.
|
|
</p>
|
|
<Button
|
|
variant="primary"
|
|
onClick={() => {
|
|
// Mock import for demo
|
|
onImportResults([]);
|
|
}}
|
|
disabled={importing}
|
|
>
|
|
Import Results (Demo)
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
);
|
|
} |