This commit is contained in:
2025-12-09 10:32:59 +01:00
parent 35f988f885
commit a780139692
26 changed files with 2224 additions and 344 deletions

View File

@@ -0,0 +1,282 @@
'use client';
import { useState } from 'react';
import Modal from '@/components/ui/Modal';
import Button from '@/components/ui/Button';
import { getFileProtestUseCase, getDriverRepository } from '@/lib/di-container';
import type { Driver } from '@gridpilot/racing/domain/entities/Driver';
import type { ProtestIncident } from '@gridpilot/racing/domain/entities/Protest';
import {
AlertTriangle,
Video,
MessageSquare,
Hash,
Clock,
User,
FileText,
CheckCircle2,
} from 'lucide-react';
interface FileProtestModalProps {
isOpen: boolean;
onClose: () => void;
raceId: string;
leagueId?: string;
protestingDriverId: string;
participants: Driver[];
}
export default function FileProtestModal({
isOpen,
onClose,
raceId,
leagueId,
protestingDriverId,
participants,
}: FileProtestModalProps) {
const [step, setStep] = useState<'form' | 'submitting' | 'success' | 'error'>('form');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Form state
const [accusedDriverId, setAccusedDriverId] = useState<string>('');
const [lap, setLap] = useState<string>('');
const [timeInRace, setTimeInRace] = useState<string>('');
const [description, setDescription] = useState<string>('');
const [comment, setComment] = useState<string>('');
const [proofVideoUrl, setProofVideoUrl] = useState<string>('');
const otherParticipants = participants.filter(p => p.id !== protestingDriverId);
const handleSubmit = async () => {
// Validation
if (!accusedDriverId) {
setErrorMessage('Please select the driver you are protesting against.');
return;
}
if (!lap || parseInt(lap, 10) < 0) {
setErrorMessage('Please enter a valid lap number.');
return;
}
if (!description.trim()) {
setErrorMessage('Please describe what happened.');
return;
}
setStep('submitting');
setErrorMessage(null);
try {
const useCase = getFileProtestUseCase();
const incident: ProtestIncident = {
lap: parseInt(lap, 10),
timeInRace: timeInRace ? parseInt(timeInRace, 10) : undefined,
description: description.trim(),
};
await useCase.execute({
raceId,
protestingDriverId,
accusedDriverId,
incident,
comment: comment.trim() || undefined,
proofVideoUrl: proofVideoUrl.trim() || undefined,
});
setStep('success');
} catch (err) {
setStep('error');
setErrorMessage(err instanceof Error ? err.message : 'Failed to file protest');
}
};
const handleClose = () => {
// Reset form state
setStep('form');
setErrorMessage(null);
setAccusedDriverId('');
setLap('');
setTimeInRace('');
setDescription('');
setComment('');
setProofVideoUrl('');
onClose();
};
if (step === 'success') {
return (
<Modal
isOpen={isOpen}
onOpenChange={handleClose}
title="Protest Filed Successfully"
>
<div className="flex flex-col items-center py-6 text-center">
<div className="p-4 bg-performance-green/20 rounded-full mb-4">
<CheckCircle2 className="w-8 h-8 text-performance-green" />
</div>
<p className="text-white font-medium mb-2">Your protest has been submitted</p>
<p className="text-sm text-gray-400 mb-6">
The stewards will review your protest and make a decision.
You'll be notified of the outcome.
</p>
<Button variant="primary" onClick={handleClose}>
Close
</Button>
</div>
</Modal>
);
}
return (
<Modal
isOpen={isOpen}
onOpenChange={handleClose}
title="File a Protest"
description="Report an incident to the stewards for review. Please provide as much detail as possible."
>
<div className="space-y-5">
{errorMessage && (
<div className="flex items-start gap-3 p-3 bg-warning-amber/10 border border-warning-amber/30 rounded-lg">
<AlertTriangle className="w-5 h-5 text-warning-amber flex-shrink-0 mt-0.5" />
<p className="text-sm text-warning-amber">{errorMessage}</p>
</div>
)}
{/* Driver Selection */}
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-300 mb-2">
<User className="w-4 h-4 text-primary-blue" />
Driver involved *
</label>
<select
value={accusedDriverId}
onChange={(e) => setAccusedDriverId(e.target.value)}
disabled={step === 'submitting'}
className="w-full px-3 py-2.5 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue/50 focus:border-primary-blue disabled:opacity-50"
>
<option value="">Select driver...</option>
{otherParticipants.map((driver) => (
<option key={driver.id} value={driver.id}>
{driver.name}
</option>
))}
</select>
</div>
{/* Lap and Time */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-300 mb-2">
<Hash className="w-4 h-4 text-primary-blue" />
Lap number *
</label>
<input
type="number"
min="0"
value={lap}
onChange={(e) => setLap(e.target.value)}
disabled={step === 'submitting'}
placeholder="e.g. 5"
className="w-full px-3 py-2.5 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue/50 focus:border-primary-blue disabled:opacity-50"
/>
</div>
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-300 mb-2">
<Clock className="w-4 h-4 text-primary-blue" />
Time (seconds)
</label>
<input
type="number"
min="0"
value={timeInRace}
onChange={(e) => setTimeInRace(e.target.value)}
disabled={step === 'submitting'}
placeholder="Optional"
className="w-full px-3 py-2.5 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue/50 focus:border-primary-blue disabled:opacity-50"
/>
</div>
</div>
{/* Incident Description */}
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-300 mb-2">
<FileText className="w-4 h-4 text-primary-blue" />
What happened? *
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={step === 'submitting'}
placeholder="Describe the incident clearly and objectively..."
rows={3}
className="w-full px-3 py-2.5 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue/50 focus:border-primary-blue disabled:opacity-50 resize-none"
/>
</div>
{/* Additional Comment */}
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-300 mb-2">
<MessageSquare className="w-4 h-4 text-primary-blue" />
Additional comment
</label>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
disabled={step === 'submitting'}
placeholder="Any additional context for the stewards..."
rows={2}
className="w-full px-3 py-2.5 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue/50 focus:border-primary-blue disabled:opacity-50 resize-none"
/>
</div>
{/* Video Proof */}
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-300 mb-2">
<Video className="w-4 h-4 text-primary-blue" />
Video proof URL
</label>
<input
type="url"
value={proofVideoUrl}
onChange={(e) => setProofVideoUrl(e.target.value)}
disabled={step === 'submitting'}
placeholder="https://youtube.com/... or https://streamable.com/..."
className="w-full px-3 py-2.5 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue/50 focus:border-primary-blue disabled:opacity-50"
/>
<p className="mt-1.5 text-xs text-gray-500">
Providing video evidence significantly helps the stewards review your protest.
</p>
</div>
{/* Info Box */}
<div className="p-3 bg-iron-gray rounded-lg border border-charcoal-outline">
<p className="text-xs text-gray-400">
<strong className="text-gray-300">Note:</strong> Filing a protest does not guarantee action.
The stewards will review the incident and may apply penalties ranging from time penalties
to grid penalties for future races, depending on the severity.
</p>
</div>
{/* Actions */}
<div className="flex gap-3 pt-2">
<Button
variant="secondary"
onClick={handleClose}
disabled={step === 'submitting'}
className="flex-1"
>
Cancel
</Button>
<Button
variant="primary"
onClick={handleSubmit}
disabled={step === 'submitting'}
className="flex-1"
>
{step === 'submitting' ? 'Submitting...' : 'Submit Protest'}
</Button>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,79 @@
'use client';
import Link from 'next/link';
import { ChevronRight } from 'lucide-react';
import { Race } from '@gridpilot/racing/domain/entities/Race';
import { Result } from '@gridpilot/racing/domain/entities/Result';
import { League } from '@gridpilot/racing/domain/entities/League';
interface RaceResultCardProps {
race: Race;
result: Result;
league?: League;
showLeague?: boolean;
}
export default function RaceResultCard({
race,
result,
league,
showLeague = true,
}: RaceResultCardProps) {
const getPositionColor = (position: number) => {
if (position === 1) return 'bg-green-400/20 text-green-400';
if (position === 2) return 'bg-gray-400/20 text-gray-400';
if (position === 3) return 'bg-warning-amber/20 text-warning-amber';
return 'bg-charcoal-outline text-gray-400';
};
return (
<Link
href={`/races/${race.id}`}
className="block p-4 rounded bg-deep-graphite border border-charcoal-outline hover:border-primary-blue/50 transition-colors group"
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded flex items-center justify-center font-bold text-sm ${getPositionColor(result.position)}`}>
P{result.position}
</div>
<div>
<div className="text-white font-medium group-hover:text-primary-blue transition-colors">
{race.track}
</div>
<div className="text-sm text-gray-400">{race.car}</div>
</div>
</div>
<div className="flex items-center gap-3">
<div className="text-right">
<div className="text-sm text-gray-400">
{race.scheduledAt.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</div>
{showLeague && league && (
<div className="text-xs text-gray-500">{league.name}</div>
)}
</div>
<ChevronRight className="w-5 h-5 text-gray-500 group-hover:text-primary-blue transition-colors" />
</div>
</div>
<div className="flex items-center gap-4 text-xs text-gray-500">
<span>Started P{result.startPosition}</span>
<span></span>
<span className={result.incidents === 0 ? 'text-green-400' : result.incidents > 2 ? 'text-red-400' : ''}>
{result.incidents}x incidents
</span>
{result.position < result.startPosition && (
<>
<span></span>
<span className="text-green-400">
+{result.startPosition - result.position} positions
</span>
</>
)}
</div>
</Link>
);
}

View File

@@ -1,21 +1,55 @@
'use client';
import Link from 'next/link';
import { Result } from '@gridpilot/racing/domain/entities/Result';
import { Driver } from '@gridpilot/racing/domain/entities/Driver';
import type { PenaltyType } from '@gridpilot/racing/domain/entities/Penalty';
import { AlertTriangle, ExternalLink } from 'lucide-react';
/**
* Penalty data for display (can be domain Penalty or RacePenaltyDTO)
*/
interface PenaltyData {
driverId: string;
type: PenaltyType;
value?: number;
}
interface ResultsTableProps {
results: Result[];
drivers: Driver[];
pointsSystem: Record<number, number>;
fastestLapTime?: number;
penalties?: PenaltyData[];
currentDriverId?: string;
}
export default function ResultsTable({ results, drivers, pointsSystem, fastestLapTime }: ResultsTableProps) {
export default function ResultsTable({ results, drivers, pointsSystem, fastestLapTime, penalties = [], currentDriverId }: ResultsTableProps) {
const getDriver = (driverId: string): Driver | undefined => {
return drivers.find(d => d.id === driverId);
};
const getDriverName = (driverId: string): string => {
const driver = drivers.find(d => d.id === driverId);
const driver = getDriver(driverId);
return driver?.name || 'Unknown Driver';
};
const getDriverPenalties = (driverId: string): PenaltyData[] => {
return penalties.filter(p => p.driverId === driverId);
};
const getPenaltyDescription = (penalty: PenaltyData): string => {
const descriptions: Record<string, string> = {
time_penalty: `+${penalty.value}s time penalty`,
grid_penalty: `${penalty.value} place grid penalty`,
points_deduction: `-${penalty.value} points`,
disqualification: 'Disqualified',
warning: 'Warning',
license_points: `${penalty.value} license points`,
};
return descriptions[penalty.type] || penalty.type;
};
const formatLapTime = (seconds: number): string => {
const minutes = Math.floor(seconds / 60);
const secs = (seconds % 60).toFixed(3);
@@ -57,23 +91,70 @@ export default function ResultsTable({ results, drivers, pointsSystem, fastestLa
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Incidents</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Points</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">+/-</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Penalties</th>
</tr>
</thead>
<tbody>
{results.map((result) => {
const positionChange = result.getPositionChange();
const isFastestLap = fastestLapTime && result.fastestLap === fastestLapTime;
const driverPenalties = getDriverPenalties(result.driverId);
const driver = getDriver(result.driverId);
const isCurrentUser = currentDriverId === result.driverId;
const isPodium = result.position <= 3;
return (
<tr
<tr
key={result.id}
className="border-b border-charcoal-outline/50 hover:bg-iron-gray/20 transition-colors"
className={`
border-b border-charcoal-outline/50 transition-colors
${isCurrentUser
? 'bg-gradient-to-r from-primary-blue/20 via-primary-blue/10 to-transparent hover:from-primary-blue/30'
: 'hover:bg-iron-gray/20'}
`}
>
<td className="py-3 px-4">
<span className="text-white font-semibold">{result.position}</span>
<div className={`
inline-flex items-center justify-center w-8 h-8 rounded-lg font-bold text-sm
${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' :
'text-white'}
`}>
{result.position}
</div>
</td>
<td className="py-3 px-4">
<span className="text-white">{getDriverName(result.driverId)}</span>
<div className="flex items-center gap-3">
{driver ? (
<>
<div className={`
w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold flex-shrink-0
${isCurrentUser ? 'bg-primary-blue/30 text-primary-blue ring-2 ring-primary-blue/50' : 'bg-iron-gray text-gray-400'}
`}>
{driver.name.charAt(0)}
</div>
<Link
href={`/drivers/${driver.id}`}
className={`
flex items-center gap-1.5 group
${isCurrentUser ? 'text-primary-blue font-semibold' : 'text-white hover:text-primary-blue'}
transition-colors
`}
>
<span className="group-hover:underline">{driver.name}</span>
{isCurrentUser && (
<span className="px-1.5 py-0.5 text-[10px] font-bold bg-primary-blue text-white rounded-full uppercase">
You
</span>
)}
<ExternalLink className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" />
</Link>
</>
) : (
<span className="text-white">{getDriverName(result.driverId)}</span>
)}
</div>
</td>
<td className="py-3 px-4">
<span className={isFastestLap ? 'text-performance-green font-medium' : 'text-white'}>
@@ -93,6 +174,23 @@ export default function ResultsTable({ results, drivers, pointsSystem, fastestLa
{getPositionChangeText(positionChange)}
</span>
</td>
<td className="py-3 px-4">
{driverPenalties.length > 0 ? (
<div className="flex flex-col gap-1">
{driverPenalties.map((penalty, idx) => (
<div
key={idx}
className="flex items-center gap-1.5 text-xs text-red-400"
>
<AlertTriangle className="w-3 h-3" />
<span>{getPenaltyDescription(penalty)}</span>
</div>
))}
</div>
) : (
<span className="text-gray-500"></span>
)}
</td>
</tr>
);
})}