website refactor
This commit is contained in:
@@ -1,23 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Modal from '@/ui/Modal';
|
||||
import Button from '@/ui/Button';
|
||||
import type { FileProtestCommandDTO } from '@/lib/types/generated/FileProtestCommandDTO';
|
||||
import { useFileProtest } from "@/lib/hooks/race/useFileProtest";
|
||||
import { Modal } from '@/ui/Modal';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { InfoBox } from '@/ui/InfoBox';
|
||||
import { Input } from '@/ui/Input';
|
||||
import { TextArea } from '@/ui/TextArea';
|
||||
import { Select } from '@/ui/Select';
|
||||
import { useFileProtest } from "@/hooks/race/useFileProtest";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Video,
|
||||
MessageSquare,
|
||||
Hash,
|
||||
Clock,
|
||||
User,
|
||||
FileText,
|
||||
CheckCircle2,
|
||||
} from 'lucide-react';
|
||||
import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { PROTEST_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
import type { ProtestParticipant } from '@/lib/services/protests/ProtestService';
|
||||
|
||||
export interface ProtestParticipant {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface FileProtestModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -28,17 +31,15 @@ interface FileProtestModalProps {
|
||||
participants: ProtestParticipant[];
|
||||
}
|
||||
|
||||
export default function FileProtestModal({
|
||||
export function FileProtestModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
raceId,
|
||||
leagueId,
|
||||
protestingDriverId,
|
||||
participants,
|
||||
}: FileProtestModalProps) {
|
||||
const fileProtestMutation = useFileProtest();
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const protestService = useInject(PROTEST_SERVICE_TOKEN);
|
||||
|
||||
// Form state
|
||||
const [accusedDriverId, setAccusedDriverId] = useState<string>('');
|
||||
@@ -51,24 +52,26 @@ export default function FileProtestModal({
|
||||
const otherParticipants = participants.filter(p => p.id !== protestingDriverId);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
// Use ProtestService for validation and command construction
|
||||
const command = protestService.constructFileProtestCommand({
|
||||
raceId,
|
||||
leagueId,
|
||||
protestingDriverId,
|
||||
accusedDriverId,
|
||||
lap,
|
||||
timeInRace,
|
||||
description,
|
||||
comment,
|
||||
proofVideoUrl,
|
||||
});
|
||||
if (!accusedDriverId || !lap || !description) {
|
||||
setErrorMessage('Please fill in all required fields');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setErrorMessage(null);
|
||||
|
||||
// Use existing hook for the actual API call
|
||||
fileProtestMutation.mutate(command, {
|
||||
fileProtestMutation.mutate({
|
||||
raceId,
|
||||
protestingDriverId,
|
||||
accusedDriverId,
|
||||
incident: {
|
||||
lap: parseInt(lap, 10),
|
||||
description,
|
||||
timeInRace: timeInRace ? parseInt(timeInRace, 10) : undefined,
|
||||
},
|
||||
comment,
|
||||
proofVideoUrl,
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
// Reset form state on success
|
||||
setAccusedDriverId('');
|
||||
@@ -78,7 +81,7 @@ export default function FileProtestModal({
|
||||
setComment('');
|
||||
setProofVideoUrl('');
|
||||
},
|
||||
onError: (error) => {
|
||||
onError: (error: Error) => {
|
||||
setErrorMessage(error.message || 'Failed to file protest');
|
||||
},
|
||||
});
|
||||
@@ -109,19 +112,19 @@ export default function FileProtestModal({
|
||||
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">
|
||||
<Box display="flex" flexDirection="col" alignItems="center" py={6} textAlign="center">
|
||||
<Box p={4} bg="bg-performance-green/20" rounded="full" mb={4}>
|
||||
<Icon icon={CheckCircle2} size={8} color="rgb(16, 185, 129)" />
|
||||
</Box>
|
||||
<Text color="text-white" weight="medium" mb={2} block>Your protest has been submitted</Text>
|
||||
<Text size="sm" color="text-gray-400" mb={6} block>
|
||||
The stewards will review your protest and make a decision.
|
||||
You'll be notified of the outcome.
|
||||
</p>
|
||||
You'll be notified of the outcome.
|
||||
</Text>
|
||||
<Button variant="primary" onClick={handleClose}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -133,136 +136,103 @@ export default function FileProtestModal({
|
||||
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">
|
||||
<Stack gap={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>
|
||||
<InfoBox
|
||||
variant="warning"
|
||||
icon={AlertTriangle}
|
||||
title="Error"
|
||||
description={errorMessage}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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
|
||||
<Box>
|
||||
<Select
|
||||
label="Driver involved *"
|
||||
options={[
|
||||
{ value: '', label: 'Select driver...' },
|
||||
...otherParticipants.map(driver => ({ value: driver.id, label: driver.name }))
|
||||
]}
|
||||
value={accusedDriverId}
|
||||
onChange={(e) => setAccusedDriverId(e.target.value)}
|
||||
disabled={fileProtestMutation.isPending}
|
||||
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>
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 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={fileProtestMutation.isPending}
|
||||
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={fileProtestMutation.isPending}
|
||||
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>
|
||||
<Box display="grid" gridCols={2} gap={4}>
|
||||
<Input
|
||||
label="Lap number *"
|
||||
type="number"
|
||||
min="0"
|
||||
value={lap}
|
||||
onChange={(e) => setLap(e.target.value)}
|
||||
disabled={fileProtestMutation.isPending}
|
||||
placeholder="e.g. 5"
|
||||
/>
|
||||
<Input
|
||||
label="Time (seconds)"
|
||||
type="number"
|
||||
min="0"
|
||||
value={timeInRace}
|
||||
onChange={(e) => setTimeInRace(e.target.value)}
|
||||
disabled={fileProtestMutation.isPending}
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 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={fileProtestMutation.isPending}
|
||||
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>
|
||||
<TextArea
|
||||
label="What happened? *"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
disabled={fileProtestMutation.isPending}
|
||||
placeholder="Describe the incident clearly and objectively..."
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
{/* 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={fileProtestMutation.isPending}
|
||||
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>
|
||||
<TextArea
|
||||
label="Additional comment"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
disabled={fileProtestMutation.isPending}
|
||||
placeholder="Any additional context for the stewards..."
|
||||
rows={2}
|
||||
/>
|
||||
|
||||
{/* 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
|
||||
<Box>
|
||||
<Input
|
||||
label="Video proof URL"
|
||||
type="url"
|
||||
value={proofVideoUrl}
|
||||
onChange={(e) => setProofVideoUrl(e.target.value)}
|
||||
disabled={fileProtestMutation.isPending}
|
||||
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">
|
||||
<Text size="xs" color="text-gray-500" mt={1.5} block>
|
||||
Providing video evidence significantly helps the stewards review your protest.
|
||||
</p>
|
||||
</div>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* 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.
|
||||
<Box p={3} bg="bg-iron-gray" rounded="lg" border borderColor="border-charcoal-outline">
|
||||
<Text size="xs" color="text-gray-400" block>
|
||||
<Text as="strong" color="text-gray-300">Note:</Text> 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>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Box display="flex" gap={3} pt={2}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleClose}
|
||||
disabled={fileProtestMutation.isPending}
|
||||
className="flex-1"
|
||||
fullWidth
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
@@ -270,12 +240,12 @@ export default function FileProtestModal({
|
||||
variant="primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={fileProtestMutation.isPending}
|
||||
className="flex-1"
|
||||
fullWidth
|
||||
>
|
||||
{fileProtestMutation.isPending ? 'Submitting...' : 'Submit Protest'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
interface FinishDistributionProps {
|
||||
wins: number;
|
||||
podiums: number;
|
||||
topTen: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export default function FinishDistributionChart({ wins, podiums, topTen, total }: FinishDistributionProps) {
|
||||
const outsideTopTen = total - topTen;
|
||||
const podiumsNotWins = podiums - wins;
|
||||
const topTenNotPodium = topTen - podiums;
|
||||
|
||||
const segments = [
|
||||
{ label: 'Wins', value: wins, color: 'bg-performance-green', textColor: 'text-performance-green' },
|
||||
{ label: 'Podiums', value: podiumsNotWins, color: 'bg-warning-amber', textColor: 'text-warning-amber' },
|
||||
{ label: 'Top 10', value: topTenNotPodium, color: 'bg-primary-blue', textColor: 'text-primary-blue' },
|
||||
{ label: 'Other', value: outsideTopTen, color: 'bg-gray-600', textColor: 'text-gray-400' },
|
||||
].filter(s => s.value > 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="h-4 rounded-full overflow-hidden flex bg-charcoal-outline">
|
||||
{segments.map((segment, index) => (
|
||||
<div
|
||||
key={segment.label}
|
||||
className={`${segment.color} transition-all duration-500`}
|
||||
style={{ width: `${(segment.value / total) * 100}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4 justify-center">
|
||||
{segments.map((segment) => (
|
||||
<div key={segment.label} className="flex items-center gap-2">
|
||||
<div className={`w-3 h-3 rounded-full ${segment.color}`} />
|
||||
<span className={`text-xs ${segment.textColor}`}>
|
||||
{segment.label}: {segment.value} ({((segment.value / total) * 100).toFixed(0)}%)
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Button from '../ui/Button';
|
||||
import React, { useState } from 'react';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { RACE_RESULTS_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
import { FilePicker } from '@/ui/FilePicker';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { InfoBox } from '@/ui/InfoBox';
|
||||
|
||||
interface ImportResultsFormProps {
|
||||
raceId: string;
|
||||
onSuccess: (results: any[]) => void;
|
||||
onSuccess: (results: unknown[]) => void;
|
||||
onError: (error: string) => void;
|
||||
}
|
||||
|
||||
export default function ImportResultsForm({ raceId, onSuccess, onError }: ImportResultsFormProps) {
|
||||
export function ImportResultsForm({ raceId, onSuccess, onError }: ImportResultsFormProps) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const raceResultsService = useInject(RACE_RESULTS_SERVICE_TOKEN);
|
||||
@@ -25,6 +30,7 @@ export default function ImportResultsForm({ raceId, onSuccess, onError }: Import
|
||||
|
||||
try {
|
||||
const content = await file.text();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const results = (raceResultsService as any).parseAndTransformCSV(content, raceId);
|
||||
onSuccess(results);
|
||||
} catch (err) {
|
||||
@@ -39,54 +45,39 @@ export default function ImportResultsForm({ raceId, onSuccess, onError }: Import
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-2">
|
||||
Upload Results CSV
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
CSV format: driverId, position, fastestLap, incidents, startPosition
|
||||
</p>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={handleFileChange}
|
||||
disabled={uploading}
|
||||
className="block w-full text-sm text-gray-400
|
||||
file:mr-4 file:py-2 file:px-4
|
||||
file:rounded file:border-0
|
||||
file:text-sm file:font-semibold
|
||||
file:bg-primary-blue file:text-white
|
||||
file:cursor-pointer file:transition-colors
|
||||
hover:file:bg-primary-blue/80
|
||||
disabled:file:opacity-50 disabled:file:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
<Stack gap={4}>
|
||||
<FilePicker
|
||||
label="Upload Results CSV"
|
||||
description="CSV format: driverId, position, fastestLap, incidents, startPosition"
|
||||
accept=".csv"
|
||||
onChange={handleFileChange}
|
||||
disabled={uploading}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-warning-amber/10 border border-warning-amber/30 rounded text-warning-amber text-sm">
|
||||
<strong>Error:</strong> {error}
|
||||
</div>
|
||||
<InfoBox
|
||||
variant="warning"
|
||||
icon={AlertCircle}
|
||||
title="Error"
|
||||
description={error}
|
||||
/>
|
||||
)}
|
||||
|
||||
{uploading && (
|
||||
<div className="text-center text-gray-400 text-sm">
|
||||
<Text align="center" color="text-gray-400" size="sm">
|
||||
Parsing CSV and importing results...
|
||||
</div>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<div className="p-4 bg-iron-gray/20 rounded text-xs text-gray-500">
|
||||
<p className="font-semibold mb-2">CSV Example:</p>
|
||||
<pre className="text-gray-400">
|
||||
<Box p={4} bg="bg-iron-gray/20" rounded="lg">
|
||||
<Text weight="semibold" block mb={2} size="xs" color="text-gray-500">CSV Example:</Text>
|
||||
<Box as="pre" color="text-gray-400">
|
||||
{`driverId,position,fastestLap,incidents,startPosition
|
||||
550e8400-e29b-41d4-a716-446655440001,1,92.456,0,3
|
||||
550e8400-e29b-41d4-a716-446655440002,2,92.789,1,1
|
||||
550e8400-e29b-41d4-a716-446655440003,3,93.012,2,2`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import Button from '../ui/Button';
|
||||
|
||||
interface DriverDTO {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface InlinePenaltyButtonProps {
|
||||
driver: DriverDTO;
|
||||
onPenaltyClick?: (driver: DriverDTO) => void;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
export default function InlinePenaltyButton({
|
||||
driver,
|
||||
onPenaltyClick,
|
||||
isAdmin,
|
||||
}: InlinePenaltyButtonProps) {
|
||||
if (!isAdmin) return null;
|
||||
|
||||
const handleButtonClick = () => {
|
||||
if (onPenaltyClick) {
|
||||
onPenaltyClick(driver);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="danger"
|
||||
className="p-1.5 min-h-[32px] w-8 h-8 rounded-full flex items-center justify-center"
|
||||
onClick={handleButtonClick}
|
||||
title={`Issue penalty to ${driver.name}`}
|
||||
>
|
||||
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import Card from '@/ui/Card';
|
||||
|
||||
type RaceWithResults = {
|
||||
raceId: string;
|
||||
track: string;
|
||||
car: string;
|
||||
winnerName: string;
|
||||
scheduledAt: string | Date;
|
||||
};
|
||||
|
||||
interface LatestResultsSidebarProps {
|
||||
results: RaceWithResults[];
|
||||
}
|
||||
|
||||
export default function LatestResultsSidebar({ results }: LatestResultsSidebarProps) {
|
||||
if (!results.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-iron-gray/80">
|
||||
<h3 className="text-sm font-semibold text-white mb-3">Latest results</h3>
|
||||
<ul className="space-y-3">
|
||||
{results.slice(0, 4).map((result) => {
|
||||
const scheduledAt = typeof result.scheduledAt === 'string' ? new Date(result.scheduledAt) : result.scheduledAt;
|
||||
|
||||
return (
|
||||
<li key={result.raceId} className="flex items-start justify-between gap-3 text-xs">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white truncate">{result.track}</p>
|
||||
<p className="text-gray-400 truncate">
|
||||
{result.winnerName} • {result.car}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right text-gray-500 whitespace-nowrap">
|
||||
{scheduledAt.toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { ChevronRight, PlayCircle } from 'lucide-react';
|
||||
|
||||
interface LiveRaceBannerProps {
|
||||
liveRaces: Array<{
|
||||
id: string;
|
||||
track: string;
|
||||
leagueName: string;
|
||||
}>;
|
||||
onRaceClick?: (raceId: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LiveRaceBanner({ liveRaces, onRaceClick, className }: LiveRaceBannerProps) {
|
||||
if (liveRaces.length === 0) return null;
|
||||
|
||||
return (
|
||||
<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 ${className || ''}`}>
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-performance-green/20 rounded-full blur-2xl animate-pulse" />
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="flex items-center gap-2 px-3 py-1 bg-performance-green/20 rounded-full">
|
||||
<span className="w-2 h-2 bg-performance-green rounded-full animate-pulse" />
|
||||
<span className="text-performance-green font-semibold text-sm">LIVE NOW</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{liveRaces.map((race) => (
|
||||
<div
|
||||
key={race.id}
|
||||
onClick={() => onRaceClick?.(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"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 bg-performance-green/20 rounded-lg">
|
||||
<PlayCircle className="w-5 h-5 text-performance-green" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">{race.track}</h3>
|
||||
<p className="text-sm text-gray-400">{race.leagueName}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import React from 'react';
|
||||
import { PlayCircle, ChevronRight } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import type { RaceViewData } from '@/lib/view-data/RacesViewData';
|
||||
|
||||
interface LiveRacesBannerProps {
|
||||
liveRaces: RaceViewData[];
|
||||
onRaceClick: (raceId: string) => void;
|
||||
}
|
||||
|
||||
export function LiveRacesBanner({ liveRaces, onRaceClick }: LiveRacesBannerProps) {
|
||||
if (liveRaces.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box style={{
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
borderRadius: '0.75rem',
|
||||
background: 'linear-gradient(to right, rgba(16, 185, 129, 0.2), rgba(16, 185, 129, 0.1), transparent)',
|
||||
border: '1px solid rgba(16, 185, 129, 0.3)',
|
||||
padding: '1.5rem'
|
||||
}}>
|
||||
<Box style={{ position: 'absolute', top: 0, right: 0, width: '8rem', height: '8rem', backgroundColor: 'rgba(16, 185, 129, 0.2)', borderRadius: '9999px', filter: 'blur(24px)' }} />
|
||||
|
||||
<Box style={{ position: 'relative', zIndex: 10 }}>
|
||||
<Box style={{ marginBottom: '1rem' }}>
|
||||
<Stack direction="row" align="center" gap={2} style={{ backgroundColor: 'rgba(16, 185, 129, 0.2)', padding: '0.25rem 0.75rem', borderRadius: '9999px', width: 'fit-content' }}>
|
||||
<Box style={{ width: '0.5rem', height: '0.5rem', backgroundColor: '#10b981', borderRadius: '9999px' }} />
|
||||
<Text weight="semibold" size="sm" color="text-performance-green">LIVE NOW</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Stack gap={3}>
|
||||
{liveRaces.map((race) => (
|
||||
<Box
|
||||
key={race.id}
|
||||
onClick={() => onRaceClick(race.id)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '1rem',
|
||||
backgroundColor: 'rgba(15, 17, 21, 0.8)',
|
||||
borderRadius: '0.5rem',
|
||||
border: '1px solid rgba(16, 185, 129, 0.2)',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
<Box style={{ padding: '0.5rem', backgroundColor: 'rgba(16, 185, 129, 0.2)', borderRadius: '0.5rem' }}>
|
||||
<PlayCircle style={{ width: '1.25rem', height: '1.25rem', color: '#10b981' }} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Heading level={3}>{race.track}</Heading>
|
||||
<Text size="sm" color="text-gray-400">{race.leagueName}</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
<ChevronRight style={{ width: '1.25rem', height: '1.25rem', color: '#9ca3af' }} />
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { AlertCircle, AlertTriangle, Video } from 'lucide-react';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Link } from '@/ui/Link';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
|
||||
interface Protest {
|
||||
id: string;
|
||||
status: string;
|
||||
protestingDriverId: string;
|
||||
accusedDriverId: string;
|
||||
filedAt: string;
|
||||
incident: {
|
||||
lap: number;
|
||||
description: string;
|
||||
};
|
||||
proofVideoUrl?: string;
|
||||
decisionNotes?: string;
|
||||
}
|
||||
|
||||
interface Driver {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ProtestCardProps {
|
||||
protest: Protest;
|
||||
protester?: Driver;
|
||||
accused?: Driver;
|
||||
isAdmin: boolean;
|
||||
onReview: (id: string) => void;
|
||||
formatDate: (date: string) => string;
|
||||
}
|
||||
|
||||
export function ProtestCard({ protest, protester, accused, isAdmin, onReview, formatDate }: ProtestCardProps) {
|
||||
const daysSinceFiled = Math.floor(
|
||||
(Date.now() - new Date(protest.filedAt).getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
const isUrgent = daysSinceFiled > 2 && protest.status === 'pending';
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const variants: Record<string, { bg: string, text: string, label: string }> = {
|
||||
pending: { bg: 'rgba(245, 158, 11, 0.2)', text: '#f59e0b', label: 'Pending' },
|
||||
under_review: { bg: 'rgba(245, 158, 11, 0.2)', text: '#f59e0b', label: 'Pending' },
|
||||
upheld: { bg: 'rgba(239, 68, 68, 0.2)', text: '#ef4444', label: 'Upheld' },
|
||||
dismissed: { bg: 'rgba(115, 115, 115, 0.2)', text: '#9ca3af', label: 'Dismissed' },
|
||||
withdrawn: { bg: 'rgba(59, 130, 246, 0.2)', text: '#3b82f6', label: 'Withdrawn' },
|
||||
};
|
||||
const config = variants[status] || variants.pending;
|
||||
return (
|
||||
<Surface variant="muted" rounded="full" padding={1} style={{ backgroundColor: config.bg, paddingLeft: '0.5rem', paddingRight: '0.5rem' }}>
|
||||
<Text size="xs" weight="medium" style={{ color: config.text }}>{config.label}</Text>
|
||||
</Surface>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card style={{ borderLeft: isUrgent ? '4px solid #ef4444' : undefined }}>
|
||||
<Stack direction="row" align="start" justify="between" gap={4}>
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Stack direction="row" align="center" gap={2} mb={2} wrap>
|
||||
<Icon icon={AlertCircle} size={4} color="#9ca3af" />
|
||||
<Link href={`/drivers/${protest.protestingDriverId}`}>
|
||||
<Text weight="medium" color="text-white">{protester?.name || 'Unknown'}</Text>
|
||||
</Link>
|
||||
<Text size="sm" color="text-gray-500">vs</Text>
|
||||
<Link href={`/drivers/${protest.accusedDriverId}`}>
|
||||
<Text weight="medium" color="text-white">{accused?.name || 'Unknown'}</Text>
|
||||
</Link>
|
||||
{getStatusBadge(protest.status)}
|
||||
{isUrgent && (
|
||||
<Surface variant="muted" rounded="full" padding={1} style={{ backgroundColor: 'rgba(239, 68, 68, 0.2)', paddingLeft: '0.5rem', paddingRight: '0.5rem' }}>
|
||||
<Stack direction="row" align="center" gap={1}>
|
||||
<Icon icon={AlertTriangle} size={3} color="#ef4444" />
|
||||
<Text size="xs" weight="medium" color="text-error-red">{daysSinceFiled}d old</Text>
|
||||
</Stack>
|
||||
</Surface>
|
||||
)}
|
||||
</Stack>
|
||||
<Stack direction="row" align="center" gap={4} mb={2} wrap>
|
||||
<Text size="sm" color="text-gray-400">Lap {protest.incident.lap}</Text>
|
||||
<Text size="sm" color="text-gray-400">•</Text>
|
||||
<Text size="sm" color="text-gray-400">Filed {formatDate(protest.filedAt)}</Text>
|
||||
{protest.proofVideoUrl && (
|
||||
<>
|
||||
<Text size="sm" color="text-gray-400">•</Text>
|
||||
<Link href={protest.proofVideoUrl} target="_blank">
|
||||
<Stack direction="row" align="center" gap={1.5}>
|
||||
<Icon icon={Video} size={3.5} color="#3b82f6" />
|
||||
<Text size="sm" color="text-primary-blue">Video Evidence</Text>
|
||||
</Stack>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
<Text size="sm" color="text-gray-300" block>{protest.incident.description}</Text>
|
||||
|
||||
{protest.decisionNotes && (
|
||||
<Box mt={4} p={3} style={{ backgroundColor: 'rgba(38, 38, 38, 0.5)', borderRadius: '0.5rem', border: '1px solid #262626' }}>
|
||||
<Text size="xs" color="text-gray-500" style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }} block mb={1}>Steward Decision</Text>
|
||||
<Text size="sm" color="text-gray-300">{protest.decisionNotes}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{isAdmin && protest.status === 'pending' && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => onReview(protest.id)}
|
||||
size="sm"
|
||||
>
|
||||
Review
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import Link from 'next/link';
|
||||
import { Users, Trophy, ChevronRight } from 'lucide-react';
|
||||
|
||||
export function QuickActions({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import Link from 'next/link';
|
||||
import { ChevronRight, Car, Zap, Trophy, ArrowRight } from 'lucide-react';
|
||||
import { raceStatusConfig } from '@/lib/utilities/raceStatus';
|
||||
|
||||
interface RaceCardProps {
|
||||
race: {
|
||||
id: string;
|
||||
track: string;
|
||||
car: string;
|
||||
scheduledAt: string;
|
||||
status: string;
|
||||
leagueId?: string;
|
||||
leagueName: string;
|
||||
strengthOfField?: number | null;
|
||||
};
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RaceCard({ race, onClick, className }: RaceCardProps) {
|
||||
const scheduledAtDate = new Date(race.scheduledAt);
|
||||
const config = raceStatusConfig[race.status as keyof typeof raceStatusConfig] || {
|
||||
border: 'border-charcoal-outline',
|
||||
bg: 'bg-charcoal-outline',
|
||||
color: 'text-gray-400',
|
||||
icon: () => null,
|
||||
label: 'Scheduled',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
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 ${className || ''}`}
|
||||
>
|
||||
{/* 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">
|
||||
{scheduledAtDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
<p className={`text-xs ${config.color}`}>
|
||||
{race.status === 'running'
|
||||
? 'LIVE'
|
||||
: scheduledAtDate.toLocaleDateString()}
|
||||
</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`}>
|
||||
{config.icon && <config.icon className={`w-3.5 h-3.5 ${config.color}`} />}
|
||||
<span className={`text-xs font-medium ${config.color}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* League Link */}
|
||||
<div className="mt-3 pt-3 border-t border-charcoal-outline/50">
|
||||
<Link
|
||||
href={`/leagues/${race.leagueId ?? ''}`}
|
||||
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" />
|
||||
{race.leagueName}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Flag } from 'lucide-react';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Grid } from '@/ui/Grid';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
|
||||
interface RaceDetailCardProps {
|
||||
track: string;
|
||||
car: string;
|
||||
sessionType: string;
|
||||
statusLabel: string;
|
||||
statusColor: string;
|
||||
}
|
||||
|
||||
export function RaceDetailCard({ track, car, sessionType, statusLabel, statusColor }: RaceDetailCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<Stack gap={4}>
|
||||
<Heading level={2} icon={<Icon icon={Flag} size={5} color="#3b82f6" />}>Race Details</Heading>
|
||||
<Grid cols={2} gap={4}>
|
||||
<DetailItem label="Track" value={track} />
|
||||
<DetailItem label="Car" value={car} />
|
||||
<DetailItem label="Session Type" value={sessionType} capitalize />
|
||||
<DetailItem label="Status" value={statusLabel} color={statusColor} />
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailItem({ label, value, capitalize, color }: { label: string, value: string | number, capitalize?: boolean, color?: string }) {
|
||||
return (
|
||||
<Box p={4} style={{ backgroundColor: '#0f1115', borderRadius: '0.5rem' }}>
|
||||
<Text size="xs" color="text-gray-500" style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }} block mb={1}>{label}</Text>
|
||||
<Text weight="medium" color="text-white" style={{ textTransform: capitalize ? 'capitalize' : 'none', color: color || 'white' }}>{value}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Users, Zap } from 'lucide-react';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { Badge } from '@/ui/Badge';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { CountryFlagDisplay } from '@/lib/display-objects/CountryFlagDisplay';
|
||||
|
||||
interface Entry {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
country: string;
|
||||
rating?: number | null;
|
||||
isCurrentUser: boolean;
|
||||
}
|
||||
|
||||
interface RaceEntryListProps {
|
||||
entries: Entry[];
|
||||
onDriverClick: (driverId: string) => void;
|
||||
}
|
||||
|
||||
export function RaceEntryList({ entries, onDriverClick }: RaceEntryListProps) {
|
||||
return (
|
||||
<Card>
|
||||
<Stack gap={4}>
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Heading level={2} icon={<Icon icon={Users} size={5} color="#3b82f6" />}>Entry List</Heading>
|
||||
<Text size="sm" color="text-gray-400">{entries.length} drivers</Text>
|
||||
</Stack>
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<Stack center py={8} gap={3}>
|
||||
<Surface variant="muted" rounded="full" padding={4}>
|
||||
<Icon icon={Users} size={6} color="#525252" />
|
||||
</Surface>
|
||||
<Text color="text-gray-400">No drivers registered yet</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack gap={1}>
|
||||
{entries.map((driver, index) => (
|
||||
<Box
|
||||
key={driver.id}
|
||||
onClick={() => onDriverClick(driver.id)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '0.75rem', borderRadius: '0.75rem', cursor: 'pointer', transition: 'all 0.2s', backgroundColor: driver.isCurrentUser ? 'rgba(59, 130, 246, 0.1)' : 'transparent', border: driver.isCurrentUser ? '1px solid rgba(59, 130, 246, 0.3)' : '1px solid transparent' }}
|
||||
>
|
||||
<Box style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '2rem', height: '2rem', borderRadius: '0.5rem', fontWeight: 'bold', fontSize: '0.875rem', backgroundColor: '#262626', color: '#737373' }}>
|
||||
{index + 1}
|
||||
</Box>
|
||||
|
||||
<Box style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<Box style={{ width: '2.5rem', height: '2.5rem', borderRadius: '9999px', overflow: 'hidden', border: driver.isCurrentUser ? '2px solid #3b82f6' : 'none' }}>
|
||||
<Image src={driver.avatarUrl} alt={driver.name} width={40} height={40} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
</Box>
|
||||
<Box style={{ position: 'absolute', bottom: '-0.125rem', right: '-0.125rem', width: '1.25rem', height: '1.25rem', borderRadius: '9999px', backgroundColor: '#0f1115', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '0.625rem' }}>
|
||||
{CountryFlagDisplay.fromCountryCode(driver.country).toString()}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Text weight="semibold" size="sm" color={driver.isCurrentUser ? 'text-primary-blue' : 'text-white'} truncate>{driver.name}</Text>
|
||||
{driver.isCurrentUser && <Badge variant="primary">You</Badge>}
|
||||
</Stack>
|
||||
<Text size="xs" color="text-gray-500">{driver.country}</Text>
|
||||
</Box>
|
||||
|
||||
{driver.rating != null && (
|
||||
<Badge variant="warning">
|
||||
<Icon icon={Zap} size={3} />
|
||||
{driver.rating}
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Select } from '@/ui/Select';
|
||||
import { Box } from '@/ui/Box';
|
||||
import type { TimeFilter } from '@/templates/RacesTemplate';
|
||||
|
||||
interface RaceFilterBarProps {
|
||||
timeFilter: TimeFilter;
|
||||
setTimeFilter: (filter: TimeFilter) => void;
|
||||
leagueFilter: string;
|
||||
setLeagueFilter: (filter: string) => void;
|
||||
leagues: Array<{ id: string; name: string }>;
|
||||
onShowMoreFilters: () => void;
|
||||
}
|
||||
|
||||
export function RaceFilterBar({
|
||||
timeFilter,
|
||||
setTimeFilter,
|
||||
leagueFilter,
|
||||
setLeagueFilter,
|
||||
leagues,
|
||||
onShowMoreFilters,
|
||||
}: RaceFilterBarProps) {
|
||||
const leagueOptions = [
|
||||
{ value: 'all', label: 'All Leagues' },
|
||||
...leagues.map(l => ({ value: l.id, label: l.name }))
|
||||
];
|
||||
|
||||
return (
|
||||
<Card style={{ padding: '1rem' }}>
|
||||
<Stack direction="row" align="center" gap={4} wrap>
|
||||
{/* Time Filter Tabs */}
|
||||
<Stack direction="row" align="center" gap={1} style={{ backgroundColor: '#0f1115', padding: '0.25rem', borderRadius: '0.5rem' }}>
|
||||
{(['upcoming', 'live', 'past', 'all'] as TimeFilter[]).map(filter => (
|
||||
<Button
|
||||
key={filter}
|
||||
variant={timeFilter === filter ? 'primary' : 'ghost'}
|
||||
onClick={() => setTimeFilter(filter)}
|
||||
style={{ padding: '0.5rem 1rem' }}
|
||||
>
|
||||
{filter === 'live' && <Box as="span" style={{ display: 'inline-block', width: '0.5rem', height: '0.5rem', backgroundColor: '#10b981', borderRadius: '9999px', marginRight: '0.5rem' }} />}
|
||||
{filter.charAt(0).toUpperCase() + filter.slice(1)}
|
||||
</Button>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{/* League Filter */}
|
||||
<Box style={{ width: 'auto' }}>
|
||||
<Select
|
||||
value={leagueFilter}
|
||||
onChange={(e) => setLeagueFilter(e.target.value)}
|
||||
options={leagueOptions}
|
||||
style={{
|
||||
backgroundColor: '#0f1115',
|
||||
border: '1px solid #262626',
|
||||
borderRadius: '0.5rem',
|
||||
color: 'white',
|
||||
fontSize: '0.875rem',
|
||||
width: 'auto'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Filter Button */}
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onShowMoreFilters}
|
||||
>
|
||||
More Filters
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { X, Filter, Search } from 'lucide-react';
|
||||
import Button from '@/ui/Button';
|
||||
import Card from '@/ui/Card';
|
||||
|
||||
export type TimeFilter = 'all' | 'upcoming' | 'live' | 'past';
|
||||
export type StatusFilter = 'scheduled' | 'running' | 'completed' | 'cancelled' | 'all';
|
||||
|
||||
interface RaceFilterModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
statusFilter: StatusFilter;
|
||||
setStatusFilter: (filter: StatusFilter) => void;
|
||||
leagueFilter: string;
|
||||
setLeagueFilter: (filter: string) => void;
|
||||
timeFilter: TimeFilter;
|
||||
setTimeFilter: (filter: TimeFilter) => void;
|
||||
searchQuery: string;
|
||||
setSearchQuery: (query: string) => void;
|
||||
leagues: Array<{ id: string; name: string }>;
|
||||
showSearch?: boolean;
|
||||
showTimeFilter?: boolean;
|
||||
}
|
||||
|
||||
export function RaceFilterModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
leagueFilter,
|
||||
setLeagueFilter,
|
||||
timeFilter,
|
||||
setTimeFilter,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
leagues,
|
||||
showSearch = true,
|
||||
showTimeFilter = true,
|
||||
}: RaceFilterModalProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={onClose}>
|
||||
<div className="w-full max-w-md" onClick={(e) => e.stopPropagation()}>
|
||||
<Card className="!p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="w-5 h-5 text-primary-blue" />
|
||||
<h3 className="text-lg font-semibold text-white">Filters</h3>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-white">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Search */}
|
||||
{showSearch && (
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-2">Search</label>
|
||||
<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="Track, car, or league..."
|
||||
className="w-full pl-10 pr-4 py-2 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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Time Filter */}
|
||||
{showTimeFilter && (
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-2">Time</label>
|
||||
<div className="flex gap-2">
|
||||
{(['upcoming', 'live', 'past', 'all'] as TimeFilter[]).map(filter => (
|
||||
<button
|
||||
key={filter}
|
||||
onClick={() => setTimeFilter(filter)}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
timeFilter === filter
|
||||
? 'bg-primary-blue text-white'
|
||||
: 'bg-deep-graphite text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{filter === 'live' && <span className="inline-block w-2 h-2 bg-performance-green rounded-full mr-1 animate-pulse" />}
|
||||
{filter.charAt(0).toUpperCase() + filter.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Filter */}
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-2">Status</label>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as StatusFilter)}
|
||||
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 Statuses</option>
|
||||
<option value="scheduled">Scheduled</option>
|
||||
<option value="running">Live</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* League Filter */}
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 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>
|
||||
{leagues.map(league => (
|
||||
<option key={league.id} value={league.id}>
|
||||
{league.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Clear Filters */}
|
||||
{(statusFilter !== 'all' || leagueFilter !== 'all' || searchQuery || (showTimeFilter && timeFilter !== 'upcoming')) && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setStatusFilter('all');
|
||||
setLeagueFilter('all');
|
||||
setSearchQuery('');
|
||||
if (showTimeFilter) setTimeFilter('upcoming');
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
Clear All Filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Calendar, Clock, Car, LucideIcon } from 'lucide-react';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Badge } from '@/ui/Badge';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { DecorativeBlur } from '@/ui/DecorativeBlur';
|
||||
|
||||
interface RaceHeroProps {
|
||||
track: string;
|
||||
scheduledAt: string;
|
||||
car: string;
|
||||
status: 'scheduled' | 'running' | 'completed' | 'cancelled';
|
||||
statusConfig: {
|
||||
icon: LucideIcon;
|
||||
variant: 'primary' | 'success' | 'default' | 'warning';
|
||||
label: string;
|
||||
description: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function RaceHero({ track, scheduledAt, car, status, statusConfig }: RaceHeroProps) {
|
||||
const StatusIcon = statusConfig.icon;
|
||||
|
||||
return (
|
||||
<Surface variant="muted" rounded="2xl" border padding={8} style={{ position: 'relative', overflow: 'hidden', backgroundColor: 'rgba(38, 38, 38, 0.3)' }}>
|
||||
{status === 'running' && (
|
||||
<Box style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '0.25rem', background: 'linear-gradient(to right, #10b981, rgba(16, 185, 129, 0.5), #10b981)' }} className="animate-pulse" />
|
||||
)}
|
||||
<DecorativeBlur color="blue" size="lg" position="top-right" opacity={5} />
|
||||
|
||||
<Box style={{ position: 'relative', zIndex: 10 }}>
|
||||
<Stack direction="row" align="center" gap={3} mb={4}>
|
||||
<Badge variant={statusConfig.variant}>
|
||||
{status === 'running' && <Box style={{ width: '0.5rem', height: '0.5rem', borderRadius: '9999px', backgroundColor: '#10b981' }} className="animate-pulse" />}
|
||||
<Icon icon={StatusIcon} size={4} />
|
||||
{statusConfig.label}
|
||||
</Badge>
|
||||
{status === 'scheduled' && (
|
||||
<Text size="sm" color="text-gray-400">
|
||||
Starts in <Text color="text-white" weight="medium">TBD</Text>
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Heading level={1} style={{ fontSize: '2rem', marginBottom: '0.5rem' }}>{track}</Heading>
|
||||
|
||||
<Stack direction="row" align="center" gap={6} wrap className="text-gray-400">
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={Calendar} size={4} />
|
||||
<Text>{new Date(scheduledAt).toLocaleDateString()}</Text>
|
||||
</Stack>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={Clock} size={4} />
|
||||
<Text>{new Date(scheduledAt).toLocaleTimeString()}</Text>
|
||||
</Stack>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={Car} size={4} />
|
||||
<Text>{car}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { UserPlus, UserMinus, CheckCircle2, PlayCircle, XCircle } from 'lucide-react';
|
||||
import Button from '@/ui/Button';
|
||||
|
||||
interface RaceJoinButtonProps {
|
||||
raceStatus: 'scheduled' | 'running' | 'completed' | 'cancelled';
|
||||
isUserRegistered: boolean;
|
||||
canRegister: boolean;
|
||||
onRegister: () => void;
|
||||
onWithdraw: () => void;
|
||||
onCancel: () => void;
|
||||
onReopen?: () => void;
|
||||
onEndRace?: () => void;
|
||||
canReopenRace?: boolean;
|
||||
isOwnerOrAdmin?: boolean;
|
||||
isLoading?: {
|
||||
register?: boolean;
|
||||
withdraw?: boolean;
|
||||
cancel?: boolean;
|
||||
reopen?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export function RaceJoinButton({
|
||||
raceStatus,
|
||||
isUserRegistered,
|
||||
canRegister,
|
||||
onRegister,
|
||||
onWithdraw,
|
||||
onCancel,
|
||||
onReopen,
|
||||
onEndRace,
|
||||
canReopenRace = false,
|
||||
isOwnerOrAdmin = false,
|
||||
isLoading = {},
|
||||
}: RaceJoinButtonProps) {
|
||||
// Show registration button for scheduled races
|
||||
if (raceStatus === 'scheduled') {
|
||||
if (canRegister && !isUserRegistered) {
|
||||
return (
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full flex items-center justify-center gap-2"
|
||||
onClick={onRegister}
|
||||
disabled={isLoading.register}
|
||||
>
|
||||
<UserPlus className="w-4 h-4" />
|
||||
{isLoading.register ? 'Registering...' : 'Register for Race'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (isUserRegistered) {
|
||||
return (
|
||||
<>
|
||||
<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={onWithdraw}
|
||||
disabled={isLoading.withdraw}
|
||||
>
|
||||
<UserMinus className="w-4 h-4" />
|
||||
{isLoading.withdraw ? 'Withdrawing...' : 'Withdraw'}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Show cancel button for owners/admins
|
||||
if (isOwnerOrAdmin) {
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full flex items-center justify-center gap-2"
|
||||
onClick={onCancel}
|
||||
disabled={isLoading.cancel}
|
||||
>
|
||||
<XCircle className="w-4 h-4" />
|
||||
{isLoading.cancel ? 'Cancelling...' : 'Cancel Race'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Show end race button for running races (owners/admins only)
|
||||
if (raceStatus === 'running' && isOwnerOrAdmin && onEndRace) {
|
||||
return (
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full flex items-center justify-center gap-2"
|
||||
onClick={onEndRace}
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
End Race & Process Results
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Show reopen button for completed/cancelled races (owners/admins only)
|
||||
if ((raceStatus === 'completed' || raceStatus === 'cancelled') && canReopenRace && isOwnerOrAdmin && onReopen) {
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full flex items-center justify-center gap-2"
|
||||
onClick={onReopen}
|
||||
disabled={isLoading.reopen}
|
||||
>
|
||||
<PlayCircle className="w-4 h-4" />
|
||||
{isLoading.reopen ? 'Re-opening...' : 'Re-open Race'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Calendar, Clock, PlayCircle, CheckCircle2, XCircle, Car, Zap, Trophy, ArrowRight, ChevronRight } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Link } from '@/ui/Link';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Badge } from '@/ui/Badge';
|
||||
import type { RaceViewData } from '@/lib/view-data/RacesViewData';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
|
||||
interface RaceListProps {
|
||||
racesByDate: Array<{
|
||||
dateKey: string;
|
||||
dateLabel: string;
|
||||
races: RaceViewData[];
|
||||
}>;
|
||||
totalCount: number;
|
||||
onRaceClick: (raceId: string) => void;
|
||||
}
|
||||
|
||||
export function RaceList({ racesByDate, totalCount, onRaceClick }: RaceListProps) {
|
||||
const statusConfig = {
|
||||
scheduled: {
|
||||
icon: Clock,
|
||||
variant: 'primary' as const,
|
||||
label: 'Scheduled',
|
||||
},
|
||||
running: {
|
||||
icon: PlayCircle,
|
||||
variant: 'success' as const,
|
||||
label: 'LIVE',
|
||||
},
|
||||
completed: {
|
||||
icon: CheckCircle2,
|
||||
variant: 'default' as const,
|
||||
label: 'Completed',
|
||||
},
|
||||
cancelled: {
|
||||
icon: XCircle,
|
||||
variant: 'warning' as const,
|
||||
label: 'Cancelled',
|
||||
},
|
||||
};
|
||||
|
||||
if (racesByDate.length === 0) {
|
||||
return (
|
||||
<Card style={{ textAlign: 'center', padding: '3rem 0' }}>
|
||||
<Stack align="center" gap={4}>
|
||||
<Box style={{ padding: '1rem', backgroundColor: '#262626', borderRadius: '9999px' }}>
|
||||
<Calendar style={{ width: '2rem', height: '2rem', color: '#737373' }} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text weight="medium" color="text-white" style={{ marginBottom: '0.25rem' }}>No races found</Text>
|
||||
<Text size="sm" color="text-gray-500">
|
||||
{totalCount === 0
|
||||
? 'No races have been scheduled yet'
|
||||
: 'Try adjusting your filters'}
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
{racesByDate.map((group) => (
|
||||
<Stack key={group.dateKey} gap={3}>
|
||||
{/* Date Header */}
|
||||
<Stack direction="row" align="center" gap={3} style={{ padding: '0 0.5rem' }}>
|
||||
<Box style={{ padding: '0.5rem', backgroundColor: 'rgba(59, 130, 246, 0.1)', borderRadius: '0.5rem' }}>
|
||||
<Calendar style={{ width: '1rem', height: '1rem', color: '#3b82f6' }} />
|
||||
</Box>
|
||||
<Text weight="semibold" size="sm" color="text-white">
|
||||
{group.dateLabel}
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-500">
|
||||
{group.races.length} race{group.races.length !== 1 ? 's' : ''}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{/* Races for this date */}
|
||||
<Stack gap={2}>
|
||||
{group.races.map((race) => {
|
||||
const config = statusConfig[race.status as keyof typeof statusConfig] || statusConfig.scheduled;
|
||||
const StatusIcon = config.icon;
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={race.id}
|
||||
onClick={() => onRaceClick(race.id)}
|
||||
style={{
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
borderRadius: '0.75rem',
|
||||
backgroundColor: '#262626',
|
||||
border: '1px solid rgba(38, 38, 38, 1)',
|
||||
padding: '1rem',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{/* Live indicator */}
|
||||
{race.status === 'running' && (
|
||||
<Box style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '0.25rem', background: 'linear-gradient(to right, #10b981, rgba(16, 185, 129, 0.5), #10b981)' }} />
|
||||
)}
|
||||
|
||||
<Stack direction="row" align="start" gap={4}>
|
||||
{/* Time Column */}
|
||||
<Box style={{ flexShrink: 0, textAlign: 'center', minWidth: '60px' }}>
|
||||
<Text size="lg" weight="bold" color="text-white">
|
||||
{race.timeLabel}
|
||||
</Text>
|
||||
<Text size="xs" style={{ color: race.status === 'running' ? '#10b981' : '#9ca3af' }}>
|
||||
{race.status === 'running'
|
||||
? 'LIVE'
|
||||
: race.relativeTimeLabel}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Divider */}
|
||||
<Box style={{ width: '1px', alignSelf: 'stretch', backgroundColor: 'rgba(38, 38, 38, 1)' }} />
|
||||
|
||||
{/* Main Content */}
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Stack direction="row" align="start" justify="between" gap={4}>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Heading level={3} style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{race.track}
|
||||
</Heading>
|
||||
<Stack direction="row" align="center" gap={3} style={{ marginTop: '0.25rem' }}>
|
||||
<Stack direction="row" align="center" gap={1}>
|
||||
<Car style={{ width: '0.875rem', height: '0.875rem', color: '#9ca3af' }} />
|
||||
<Text size="sm" color="text-gray-400">{race.car}</Text>
|
||||
</Stack>
|
||||
{race.strengthOfField && (
|
||||
<Stack direction="row" align="center" gap={1}>
|
||||
<Zap style={{ width: '0.875rem', height: '0.875rem', color: '#f59e0b' }} />
|
||||
<Text size="sm" color="text-gray-400">SOF {race.strengthOfField}</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Status Badge */}
|
||||
<Badge variant={config.variant}>
|
||||
<StatusIcon style={{ width: '0.875rem', height: '0.875rem' }} />
|
||||
{config.label}
|
||||
</Badge>
|
||||
</Stack>
|
||||
|
||||
{/* League Link */}
|
||||
<Box style={{ marginTop: '0.75rem', paddingTop: '0.75rem', borderTop: '1px solid rgba(38, 38, 38, 0.5)' }}>
|
||||
<Link
|
||||
href={routes.league.detail(race.leagueId ?? '')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
variant="primary"
|
||||
style={{ fontSize: '0.875rem' }}
|
||||
>
|
||||
<Trophy style={{ width: '0.875rem', height: '0.875rem', marginRight: '0.5rem' }} />
|
||||
{race.leagueName}
|
||||
<ArrowRight style={{ width: '0.75rem', height: '0.75rem', marginLeft: '0.5rem' }} />
|
||||
</Link>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Arrow */}
|
||||
<ChevronRight style={{ width: '1.25rem', height: '1.25rem', color: '#737373', flexShrink: 0 }} />
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Calendar, Clock, Car, Trophy, Zap, ChevronRight, PlayCircle, CheckCircle2, XCircle } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Link } from '@/ui/Link';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
|
||||
interface Race {
|
||||
id: string;
|
||||
track: string;
|
||||
car: string;
|
||||
scheduledAt: string;
|
||||
status: 'scheduled' | 'running' | 'completed' | 'cancelled';
|
||||
sessionType: string;
|
||||
leagueId?: string;
|
||||
leagueName?: string;
|
||||
strengthOfField?: number | null;
|
||||
}
|
||||
|
||||
interface RaceListItemProps {
|
||||
race: Race;
|
||||
onClick: (id: string) => void;
|
||||
}
|
||||
|
||||
export function RaceListItem({ race, onClick }: RaceListItemProps) {
|
||||
const statusConfig = {
|
||||
scheduled: {
|
||||
icon: Clock,
|
||||
color: '#3b82f6',
|
||||
bg: 'rgba(59, 130, 246, 0.1)',
|
||||
border: 'rgba(59, 130, 246, 0.3)',
|
||||
label: 'Scheduled',
|
||||
},
|
||||
running: {
|
||||
icon: PlayCircle,
|
||||
color: '#10b981',
|
||||
bg: 'rgba(16, 185, 129, 0.1)',
|
||||
border: 'rgba(16, 185, 129, 0.3)',
|
||||
label: 'LIVE',
|
||||
},
|
||||
completed: {
|
||||
icon: CheckCircle2,
|
||||
color: '#9ca3af',
|
||||
bg: 'rgba(156, 163, 175, 0.1)',
|
||||
border: 'rgba(156, 163, 175, 0.3)',
|
||||
label: 'Completed',
|
||||
},
|
||||
cancelled: {
|
||||
icon: XCircle,
|
||||
color: '#ef4444',
|
||||
bg: 'rgba(239, 68, 68, 0.1)',
|
||||
border: 'rgba(239, 68, 68, 0.3)',
|
||||
label: 'Cancelled',
|
||||
},
|
||||
};
|
||||
|
||||
const config = statusConfig[race.status];
|
||||
const StatusIcon = config.icon;
|
||||
|
||||
const formatTime = (date: string) => {
|
||||
return new Date(date).toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Surface
|
||||
variant="muted"
|
||||
rounded="xl"
|
||||
border
|
||||
padding={4}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
borderColor: config.border,
|
||||
position: 'relative',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
onClick={() => onClick(race.id)}
|
||||
>
|
||||
{race.status === 'running' && (
|
||||
<Box style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '0.25rem', background: 'linear-gradient(to right, #10b981, rgba(16, 185, 129, 0.5), #10b981)' }} className="animate-pulse" />
|
||||
)}
|
||||
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
{/* Date Column */}
|
||||
<Box style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', minWidth: '5rem', textAlign: 'center' }}>
|
||||
<Text size="xs" color="text-gray-500" style={{ textTransform: 'uppercase' }}>
|
||||
{new Date(race.scheduledAt).toLocaleDateString('en-US', { month: 'short' })}
|
||||
</Text>
|
||||
<Text size="2xl" weight="bold" color="text-white">
|
||||
{new Date(race.scheduledAt).getDate()}
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-500">
|
||||
{formatTime(race.scheduledAt)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Divider */}
|
||||
<Box style={{ width: '1px', height: '4rem', backgroundColor: '#262626' }} />
|
||||
|
||||
{/* Main Content */}
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text weight="semibold" color="text-white" block truncate>{race.track}</Text>
|
||||
<Stack direction="row" align="center" gap={4} mt={1} wrap>
|
||||
<Stack direction="row" align="center" gap={1.5}>
|
||||
<Icon icon={Car} size={3.5} color="#9ca3af" />
|
||||
<Text size="sm" color="text-gray-400">{race.car}</Text>
|
||||
</Stack>
|
||||
{race.strengthOfField && (
|
||||
<Stack direction="row" align="center" gap={1.5}>
|
||||
<Icon icon={Zap} size={3.5} color="#f59e0b" />
|
||||
<Text size="sm" color="text-warning-amber">SOF {race.strengthOfField}</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
{race.leagueName && (
|
||||
<Box mt={2}>
|
||||
<Link href={`/leagues/${race.leagueId}`} onClick={(e) => e.stopPropagation()}>
|
||||
<Stack direction="row" align="center" gap={1.5}>
|
||||
<Icon icon={Trophy} size={3.5} color="#3b82f6" />
|
||||
<Text size="sm" color="text-primary-blue">{race.leagueName}</Text>
|
||||
</Stack>
|
||||
</Link>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Status Badge */}
|
||||
<Surface variant="muted" rounded="full" border padding={1} style={{ backgroundColor: config.bg, borderColor: config.border, paddingLeft: '0.75rem', paddingRight: '0.75rem' }}>
|
||||
<Stack direction="row" align="center" gap={1.5}>
|
||||
<Icon icon={StatusIcon} size={3.5} color={config.color} />
|
||||
<Text size="xs" weight="medium" style={{ color: config.color }}>{config.label}</Text>
|
||||
</Stack>
|
||||
</Surface>
|
||||
|
||||
<Icon icon={ChevronRight} size={5} color="#525252" />
|
||||
</Stack>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Flag, CalendarDays, Clock, Zap, Trophy, LucideIcon } from 'lucide-react';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Hero } from '@/ui/Hero';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Box } from '@/ui/Box';
|
||||
|
||||
interface RacePageHeaderProps {
|
||||
totalCount: number;
|
||||
scheduledCount: number;
|
||||
runningCount: number;
|
||||
completedCount: number;
|
||||
}
|
||||
|
||||
export function RacePageHeader({
|
||||
totalCount,
|
||||
scheduledCount,
|
||||
runningCount,
|
||||
completedCount,
|
||||
}: RacePageHeaderProps) {
|
||||
return (
|
||||
<Hero variant="primary">
|
||||
<Stack gap={2}>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Box style={{ padding: '0.5rem', backgroundColor: 'rgba(59, 130, 246, 0.1)', borderRadius: '0.5rem' }}>
|
||||
<Flag style={{ width: '1.5rem', height: '1.5rem', color: '#3b82f6' }} />
|
||||
</Box>
|
||||
<Heading level={1}>Race Calendar</Heading>
|
||||
</Stack>
|
||||
<Text color="text-gray-400" style={{ maxWidth: '42rem' }}>
|
||||
Track upcoming races, view live events, and explore results across all your leagues.
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<Box style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: '1rem', marginTop: '1.5rem' }}>
|
||||
<StatBox icon={CalendarDays} label="Total" value={totalCount} />
|
||||
<StatBox icon={Clock} label="Scheduled" value={scheduledCount} color="#3b82f6" />
|
||||
<StatBox icon={Zap} label="Live Now" value={runningCount} color="#10b981" />
|
||||
<StatBox icon={Trophy} label="Completed" value={completedCount} />
|
||||
</Box>
|
||||
</Hero>
|
||||
);
|
||||
}
|
||||
|
||||
function StatBox({ icon: Icon, label, value, color }: { icon: LucideIcon, label: string, value: number, color?: string }) {
|
||||
return (
|
||||
<Box style={{ backgroundColor: 'rgba(15, 17, 21, 0.6)', backdropFilter: 'blur(8px)', borderRadius: '0.75rem', padding: '1rem', border: '1px solid rgba(38, 38, 38, 0.5)' }}>
|
||||
<Stack gap={1}>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon style={{ width: '1rem', height: '1rem', color: color || '#9ca3af' }} />
|
||||
<Text size="sm" color="text-gray-400">{label}</Text>
|
||||
</Stack>
|
||||
<Text size="2xl" weight="bold" color="text-white">{value}</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
interface RacePaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
totalItems: number;
|
||||
itemsPerPage: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export function RacePagination({
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems,
|
||||
itemsPerPage,
|
||||
onPageChange,
|
||||
}: RacePaginationProps) {
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
const startItem = ((currentPage - 1) * itemsPerPage) + 1;
|
||||
const endItem = Math.min(currentPage * itemsPerPage, totalItems);
|
||||
|
||||
const getPageNumbers = () => {
|
||||
const pages: number[] = [];
|
||||
|
||||
if (totalPages <= 5) {
|
||||
return Array.from({ length: totalPages }, (_, i) => i + 1);
|
||||
}
|
||||
|
||||
if (currentPage <= 3) {
|
||||
return [1, 2, 3, 4, 5];
|
||||
}
|
||||
|
||||
if (currentPage >= totalPages - 2) {
|
||||
return [totalPages - 4, totalPages - 3, totalPages - 2, totalPages - 1, totalPages];
|
||||
}
|
||||
|
||||
return [currentPage - 2, currentPage - 1, currentPage, currentPage + 1, currentPage + 2];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between pt-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
Showing {startItem}–{endItem} of {totalItems}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onPageChange(Math.max(1, currentPage - 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">
|
||||
{getPageNumbers().map(pageNum => (
|
||||
<button
|
||||
key={pageNum}
|
||||
onClick={() => onPageChange(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={() => onPageChange(Math.min(totalPages, currentPage + 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>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
|
||||
interface PenaltyEntry {
|
||||
driverId: string;
|
||||
driverName: string;
|
||||
type: 'time_penalty' | 'grid_penalty' | 'points_deduction' | 'disqualification' | 'warning' | 'license_points';
|
||||
value: number;
|
||||
reason: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
interface RacePenaltyRowProps {
|
||||
penalty: PenaltyEntry;
|
||||
}
|
||||
|
||||
export function RacePenaltyRow({ penalty }: RacePenaltyRowProps) {
|
||||
return (
|
||||
<Surface variant="dark" rounded="lg" padding={3}>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Surface variant="muted" rounded="full" padding={1} style={{ width: '2.5rem', height: '2.5rem', backgroundColor: 'rgba(239, 68, 68, 0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Text color="text-error-red" weight="bold">!</Text>
|
||||
</Surface>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Stack direction="row" align="center" gap={2} mb={1}>
|
||||
<Text weight="medium" color="text-white">{penalty.driverName}</Text>
|
||||
<Surface variant="muted" rounded="full" padding={1} style={{ backgroundColor: 'rgba(239, 68, 68, 0.2)', paddingLeft: '0.5rem', paddingRight: '0.5rem' }}>
|
||||
<Text size="xs" weight="medium" color="text-error-red" style={{ textTransform: 'capitalize' }}>
|
||||
{penalty.type.replace('_', ' ')}
|
||||
</Text>
|
||||
</Surface>
|
||||
</Stack>
|
||||
<Text size="sm" color="text-gray-400" block>{penalty.reason}</Text>
|
||||
{penalty.notes && (
|
||||
<Box mt={1}>
|
||||
<Text size="sm" color="text-gray-500" block style={{ fontStyle: 'italic' }}>{penalty.notes}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box style={{ textAlign: 'right' }}>
|
||||
<Text size="2xl" weight="bold" color="text-error-red">
|
||||
{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`}
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { RaceResultViewModel } from '@/lib/view-models/RaceResultViewModel';
|
||||
|
||||
interface RaceResultCardProps {
|
||||
race: {
|
||||
id: string;
|
||||
track: string;
|
||||
car: string;
|
||||
scheduledAt: string;
|
||||
};
|
||||
result: RaceResultViewModel;
|
||||
league?: {
|
||||
name: string;
|
||||
};
|
||||
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">
|
||||
{new Date(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>
|
||||
);
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { CountryFlagDisplay } from '@/lib/display-objects/CountryFlagDisplay';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
interface RaceResultRowProps {
|
||||
result: ResultEntry;
|
||||
points: number;
|
||||
}
|
||||
|
||||
export function RaceResultRow({ result, points }: RaceResultRowProps) {
|
||||
const { isCurrentUser, position, driverAvatar, driverName, country, car, laps, incidents, time, fastestLap } = result;
|
||||
|
||||
return (
|
||||
<Surface
|
||||
variant={isCurrentUser ? 'muted' : 'dark'}
|
||||
rounded="xl"
|
||||
border={isCurrentUser}
|
||||
padding={3}
|
||||
style={{
|
||||
background: isCurrentUser ? 'linear-gradient(to right, rgba(59, 130, 246, 0.2), rgba(59, 130, 246, 0.1), transparent)' : undefined,
|
||||
borderColor: isCurrentUser ? 'rgba(59, 130, 246, 0.4)' : undefined
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
{/* Position */}
|
||||
<Surface
|
||||
variant="muted"
|
||||
rounded="lg"
|
||||
padding={1}
|
||||
style={{
|
||||
width: '2.5rem',
|
||||
height: '2.5rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: position === 1 ? 'rgba(250, 204, 21, 0.2)' : position === 2 ? 'rgba(209, 213, 219, 0.2)' : position === 3 ? 'rgba(217, 119, 6, 0.2)' : 'rgba(38, 38, 38, 0.5)',
|
||||
color: position === 1 ? '#facc15' : position === 2 ? '#d1d5db' : position === 3 ? '#d97706' : '#737373'
|
||||
}}
|
||||
>
|
||||
<Text weight="bold">{position}</Text>
|
||||
</Surface>
|
||||
|
||||
{/* Avatar */}
|
||||
<Box style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<Box style={{ width: '2.5rem', height: '2.5rem', borderRadius: '9999px', overflow: 'hidden', border: isCurrentUser ? '2px solid rgba(59, 130, 246, 0.5)' : 'none' }}>
|
||||
<Image src={driverAvatar} alt={driverName} width={40} height={40} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
</Box>
|
||||
<Box style={{ position: 'absolute', bottom: '-0.125rem', right: '-0.125rem', width: '1.25rem', height: '1.25rem', borderRadius: '9999px', backgroundColor: '#0f1115', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '0.625rem' }}>
|
||||
{CountryFlagDisplay.fromCountryCode(country).toString()}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Driver Info */}
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Text weight="semibold" size="sm" color={isCurrentUser ? 'text-primary-blue' : 'text-white'} truncate>{driverName}</Text>
|
||||
{isCurrentUser && (
|
||||
<Surface variant="muted" rounded="full" padding={1} style={{ backgroundColor: '#3b82f6', paddingLeft: '0.5rem', paddingRight: '0.5rem' }}>
|
||||
<Text size="xs" weight="bold" color="text-white">YOU</Text>
|
||||
</Surface>
|
||||
)}
|
||||
</Stack>
|
||||
<Stack direction="row" align="center" gap={2} mt={1}>
|
||||
<Text size="xs" color="text-gray-500">{car}</Text>
|
||||
<Text size="xs" color="text-gray-500">•</Text>
|
||||
<Text size="xs" color="text-gray-500">Laps: {laps}</Text>
|
||||
<Text size="xs" color="text-gray-500">•</Text>
|
||||
<Text size="xs" color="text-gray-500">Incidents: {incidents}</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Times */}
|
||||
<Box style={{ textAlign: 'right', minWidth: '100px' }}>
|
||||
<Text size="sm" font="mono" color="text-white" block>{time}</Text>
|
||||
<Text size="xs" color="text-performance-green" block mt={1}>FL: {fastestLap}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Points */}
|
||||
<Surface variant="muted" rounded="lg" border padding={2} style={{ backgroundColor: 'rgba(245, 158, 11, 0.1)', borderColor: 'rgba(245, 158, 11, 0.2)', textAlign: 'center', minWidth: '3.5rem' }}>
|
||||
<Text size="xs" color="text-gray-500" block>PTS</Text>
|
||||
<Text size="sm" weight="bold" color="text-warning-amber">{points}</Text>
|
||||
</Surface>
|
||||
</Stack>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Calendar, Trophy, Users, Zap } from 'lucide-react';
|
||||
|
||||
interface RaceResultsHeaderProps {
|
||||
raceTrack: string | undefined;
|
||||
raceScheduledAt: string | undefined;
|
||||
totalDrivers: number | undefined;
|
||||
leagueName: string | undefined;
|
||||
raceSOF: number | null | undefined;
|
||||
}
|
||||
|
||||
const DEFAULT_RACE_TRACK = 'Race';
|
||||
|
||||
export default function RaceResultsHeader({
|
||||
raceTrack = 'Race',
|
||||
raceScheduledAt,
|
||||
totalDrivers,
|
||||
leagueName,
|
||||
raceSOF
|
||||
}: RaceResultsHeaderProps) {
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-2xl bg-gray-500/10 border border-gray-500/30 p-6 sm:p-8">
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-white/5 rounded-full blur-3xl" />
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-performance-green/10 border border-performance-green/30">
|
||||
<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>
|
||||
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-white mb-2">
|
||||
{raceTrack || DEFAULT_RACE_TRACK} Results
|
||||
</h1>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-gray-400">
|
||||
{raceScheduledAt && (
|
||||
<span className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
{new Date(raceScheduledAt).toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{totalDrivers !== undefined && totalDrivers !== null && (
|
||||
<span className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4" />
|
||||
{totalDrivers} drivers classified
|
||||
</span>
|
||||
)}
|
||||
{leagueName && <span className="text-primary-blue">{leagueName}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Clock, Trophy, Users, ChevronRight, CheckCircle2 } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Link } from '@/ui/Link';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import type { RaceViewData } from '@/lib/view-data/RacesViewData';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
|
||||
interface RaceSidebarProps {
|
||||
upcomingRaces: RaceViewData[];
|
||||
recentResults: RaceViewData[];
|
||||
onRaceClick: (raceId: string) => void;
|
||||
}
|
||||
|
||||
export function RaceSidebar({ upcomingRaces, recentResults, onRaceClick }: RaceSidebarProps) {
|
||||
return (
|
||||
<Stack gap={6}>
|
||||
{/* Upcoming This Week */}
|
||||
<Card>
|
||||
<Stack gap={4}>
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Heading level={3} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<Clock style={{ width: '1rem', height: '1rem', color: '#3b82f6' }} />
|
||||
Next Up
|
||||
</Heading>
|
||||
<Text size="xs" color="text-gray-500">This week</Text>
|
||||
</Stack>
|
||||
|
||||
{upcomingRaces.length === 0 ? (
|
||||
<Text size="sm" color="text-gray-500" style={{ textAlign: 'center', padding: '1rem 0' }}>
|
||||
No races scheduled this week
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap={3}>
|
||||
{upcomingRaces.map((race) => (
|
||||
<Box
|
||||
key={race.id}
|
||||
onClick={() => onRaceClick(race.id)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
padding: '0.5rem',
|
||||
borderRadius: '0.5rem',
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.2s'
|
||||
}}
|
||||
>
|
||||
<Box style={{ flexShrink: 0, width: '2.5rem', height: '2.5rem', backgroundColor: 'rgba(59, 130, 246, 0.1)', borderRadius: '0.5rem', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Text weight="bold" color="text-primary-blue" style={{ width: '100%', textAlign: 'center' }}>
|
||||
{new Date(race.scheduledAt).getDate()}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text size="sm" weight="medium" color="text-white" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', display: 'block' }}>{race.track}</Text>
|
||||
<Text size="xs" color="text-gray-500">{race.timeLabel}</Text>
|
||||
</Box>
|
||||
<ChevronRight style={{ width: '1rem', height: '1rem', color: '#737373' }} />
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Recent Results */}
|
||||
<Card>
|
||||
<Stack gap={4}>
|
||||
<Heading level={3} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<Trophy style={{ width: '1rem', height: '1rem', color: '#f59e0b' }} />
|
||||
Recent Results
|
||||
</Heading>
|
||||
|
||||
{recentResults.length === 0 ? (
|
||||
<Text size="sm" color="text-gray-500" style={{ textAlign: 'center', padding: '1rem 0' }}>
|
||||
No completed races yet
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap={3}>
|
||||
{recentResults.map((race) => (
|
||||
<Box
|
||||
key={race.id}
|
||||
onClick={() => onRaceClick(race.id)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
padding: '0.5rem',
|
||||
borderRadius: '0.5rem',
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.2s'
|
||||
}}
|
||||
>
|
||||
<Box style={{ flexShrink: 0, width: '2.5rem', height: '2.5rem', backgroundColor: 'rgba(115, 115, 115, 0.1)', borderRadius: '0.5rem', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<CheckCircle2 style={{ width: '1.25rem', height: '1.25rem', color: '#737373' }} />
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text size="sm" weight="medium" color="text-white" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', display: 'block' }}>{race.track}</Text>
|
||||
<Text size="xs" color="text-gray-500">{race.scheduledAtLabel}</Text>
|
||||
</Box>
|
||||
<ChevronRight style={{ width: '1rem', height: '1rem', color: '#737373' }} />
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card>
|
||||
<Stack gap={4}>
|
||||
<Heading level={3}>Quick Actions</Heading>
|
||||
<Stack gap={2}>
|
||||
<Link
|
||||
href={routes.public.leagues}
|
||||
variant="ghost"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '0.75rem', borderRadius: '0.5rem', backgroundColor: '#0f1115' }}
|
||||
>
|
||||
<Box style={{ padding: '0.5rem', backgroundColor: 'rgba(59, 130, 246, 0.1)', borderRadius: '0.5rem' }}>
|
||||
<Users style={{ width: '1rem', height: '1rem', color: '#3b82f6' }} />
|
||||
</Box>
|
||||
<Text size="sm" color="text-white">Browse Leagues</Text>
|
||||
<ChevronRight style={{ width: '1rem', height: '1rem', color: '#737373', marginLeft: 'auto' }} />
|
||||
</Link>
|
||||
<Link
|
||||
href={routes.public.leaderboards}
|
||||
variant="ghost"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '0.75rem', borderRadius: '0.5rem', backgroundColor: '#0f1115' }}
|
||||
>
|
||||
<Box style={{ padding: '0.5rem', backgroundColor: 'rgba(245, 158, 11, 0.1)', borderRadius: '0.5rem' }}>
|
||||
<Trophy style={{ width: '1rem', height: '1rem', color: '#f59e0b' }} />
|
||||
</Box>
|
||||
<Text size="sm" color="text-white">View Leaderboards</Text>
|
||||
<ChevronRight style={{ width: '1rem', height: '1rem', color: '#737373', marginLeft: 'auto' }} />
|
||||
</Link>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { CalendarDays, Clock, Zap, Trophy } from 'lucide-react';
|
||||
|
||||
interface RaceStatsProps {
|
||||
stats: {
|
||||
total: number;
|
||||
scheduled: number;
|
||||
running: number;
|
||||
completed: number;
|
||||
};
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RaceStats({ stats, className }: RaceStatsProps) {
|
||||
return (
|
||||
<div className={`relative z-10 grid grid-cols-2 md:grid-cols-4 gap-4 mt-6 ${className || ''}`}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import React from 'react';
|
||||
import { CheckCircle, Clock, Gavel } from 'lucide-react';
|
||||
|
||||
interface RaceStewardingStatsProps {
|
||||
pendingCount: number;
|
||||
resolvedCount: number;
|
||||
penaltiesCount: number;
|
||||
}
|
||||
|
||||
export default function RaceStewardingStats({ pendingCount, resolvedCount, penaltiesCount }: RaceStewardingStatsProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="rounded-lg bg-deep-graphite/50 border border-charcoal-outline p-4">
|
||||
<div className="flex items-center gap-2 text-warning-amber mb-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span className="text-xs font-medium uppercase">Pending</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">{pendingCount}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-deep-graphite/50 border border-charcoal-outline p-4">
|
||||
<div className="flex items-center gap-2 text-performance-green mb-1">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
<span className="text-xs font-medium uppercase">Resolved</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">{resolvedCount}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-deep-graphite/50 border border-charcoal-outline p-4">
|
||||
<div className="flex items-center gap-2 text-red-400 mb-1">
|
||||
<Gavel className="w-4 h-4" />
|
||||
<span className="text-xs font-medium uppercase">Penalties</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">{penaltiesCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Trophy } from 'lucide-react';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { DecorativeBlur } from '@/ui/DecorativeBlur';
|
||||
|
||||
interface RaceUserResultProps {
|
||||
position: number;
|
||||
startPosition: number;
|
||||
positionChange: number;
|
||||
incidents: number;
|
||||
isClean: boolean;
|
||||
isPodium: boolean;
|
||||
ratingChange?: number;
|
||||
animatedRatingChange: number;
|
||||
}
|
||||
|
||||
export function RaceUserResult({
|
||||
position,
|
||||
startPosition,
|
||||
positionChange,
|
||||
incidents,
|
||||
isClean,
|
||||
isPodium,
|
||||
ratingChange,
|
||||
animatedRatingChange,
|
||||
}: RaceUserResultProps) {
|
||||
return (
|
||||
<Surface
|
||||
variant={position === 1 ? 'gradient-gold' : isPodium ? 'muted' : 'gradient-blue'}
|
||||
rounded="2xl"
|
||||
padding={1}
|
||||
style={{ background: position === 1 ? 'linear-gradient(to right, #eab308, #facc15, #d97706)' : isPodium ? 'linear-gradient(to right, #9ca3af, #d1d5db, #6b7280)' : 'linear-gradient(to right, #3b82f6, #60a5fa, #2563eb)' }}
|
||||
>
|
||||
<Surface variant="dark" rounded="xl" padding={8} style={{ position: 'relative' }}>
|
||||
<DecorativeBlur color="blue" size="lg" position="top-right" opacity={10} />
|
||||
|
||||
<Stack direction="row" align="center" justify="between" wrap gap={6} style={{ position: 'relative', zIndex: 10 }}>
|
||||
<Stack direction="row" align="center" gap={5}>
|
||||
<Box style={{ position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center', width: '7rem', height: '7rem', borderRadius: '1.5rem', fontWeight: 900, fontSize: '3rem', background: position === 1 ? 'linear-gradient(to bottom right, #facc15, #d97706)' : position === 2 ? 'linear-gradient(to bottom right, #d1d5db, #6b7280)' : 'linear-gradient(to bottom right, #3b82f6, #2563eb)', color: position <= 2 ? '#0f1115' : 'white', boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1)' }}>
|
||||
{position === 1 && (
|
||||
<Trophy style={{ position: 'absolute', top: '-0.75rem', right: '-0.5rem', width: '2rem', height: '2rem', color: '#fef08a' }} />
|
||||
)}
|
||||
P{position}
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text size="3xl" weight="bold" block mb={1} style={{ color: position === 1 ? '#facc15' : isPodium ? '#d1d5db' : 'white' }}>
|
||||
{position === 1 ? '🏆 VICTORY!' : position === 2 ? '🥈 Second Place' : position === 3 ? '🥉 Podium Finish' : `P${position} Finish`}
|
||||
</Text>
|
||||
<Stack direction="row" align="center" gap={3} className="text-sm text-gray-400">
|
||||
<Text>Started P{startPosition}</Text>
|
||||
<Box style={{ width: '0.25rem', height: '0.25rem', borderRadius: '9999px', backgroundColor: '#525252' }} />
|
||||
<Text color={isClean ? 'text-performance-green' : ''}>
|
||||
{incidents}x incidents {isClean && '✨'}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" gap={3} wrap>
|
||||
{positionChange !== 0 && (
|
||||
<Surface variant="muted" rounded="2xl" border padding={3} style={{ minWidth: '100px', textAlign: 'center', background: positionChange > 0 ? 'rgba(16, 185, 129, 0.1)' : 'rgba(239, 68, 68, 0.1)', borderColor: positionChange > 0 ? 'rgba(16, 185, 129, 0.3)' : 'rgba(239, 68, 68, 0.3)' }}>
|
||||
<Stack align="center">
|
||||
<Text size="2xl" weight="bold" style={{ color: positionChange > 0 ? '#10b981' : '#ef4444' }}>
|
||||
{positionChange > 0 ? '↑' : '↓'}{Math.abs(positionChange)}
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-400">{positionChange > 0 ? 'Gained' : 'Lost'}</Text>
|
||||
</Stack>
|
||||
</Surface>
|
||||
)}
|
||||
|
||||
{ratingChange !== undefined && (
|
||||
<Surface variant="muted" rounded="2xl" border padding={3} style={{ minWidth: '100px', textAlign: 'center', background: ratingChange > 0 ? 'rgba(245, 158, 11, 0.1)' : 'rgba(239, 68, 68, 0.1)', borderColor: ratingChange > 0 ? 'rgba(245, 158, 11, 0.3)' : 'rgba(239, 68, 68, 0.3)' }}>
|
||||
<Stack align="center">
|
||||
<Text font="mono" size="2xl" weight="bold" style={{ color: ratingChange > 0 ? '#f59e0b' : '#ef4444' }}>
|
||||
{animatedRatingChange > 0 ? '+' : ''}{animatedRatingChange}
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-400">Rating</Text>
|
||||
</Stack>
|
||||
</Surface>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Surface>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { AlertTriangle, ExternalLink } from 'lucide-react';
|
||||
import InlinePenaltyButton from './InlinePenaltyButton';
|
||||
|
||||
type PenaltyTypeDTO =
|
||||
| 'time_penalty'
|
||||
| 'grid_penalty'
|
||||
| 'points_deduction'
|
||||
| 'disqualification'
|
||||
| 'warning'
|
||||
| 'license_points'
|
||||
| string;
|
||||
|
||||
interface ResultDTO {
|
||||
id: string;
|
||||
raceId: string;
|
||||
driverId: string;
|
||||
position: number;
|
||||
fastestLap: number;
|
||||
incidents: number;
|
||||
startPosition: number;
|
||||
getPositionChange(): number;
|
||||
}
|
||||
|
||||
interface DriverDTO {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface PenaltyData {
|
||||
driverId: string;
|
||||
type: PenaltyTypeDTO;
|
||||
value?: number;
|
||||
}
|
||||
|
||||
interface ResultsTableProps {
|
||||
results: ResultDTO[];
|
||||
drivers: DriverDTO[];
|
||||
pointsSystem: Record<number, number>;
|
||||
fastestLapTime?: number | undefined;
|
||||
penalties?: PenaltyData[];
|
||||
currentDriverId?: string | undefined;
|
||||
isAdmin?: boolean;
|
||||
onPenaltyClick?: (driver: DriverDTO) => void;
|
||||
}
|
||||
|
||||
export default function ResultsTable({
|
||||
results,
|
||||
drivers,
|
||||
pointsSystem,
|
||||
fastestLapTime,
|
||||
penalties = [],
|
||||
currentDriverId,
|
||||
isAdmin = false,
|
||||
onPenaltyClick,
|
||||
}: ResultsTableProps) {
|
||||
const getDriver = (driverId: string): DriverDTO | undefined => {
|
||||
return drivers.find((d) => d.id === driverId);
|
||||
};
|
||||
|
||||
const getDriverName = (driverId: string): string => {
|
||||
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);
|
||||
return `${minutes}:${secs.padStart(6, '0')}`;
|
||||
};
|
||||
|
||||
const getPoints = (position: number): number => {
|
||||
return pointsSystem[position] || 0;
|
||||
};
|
||||
|
||||
const getPositionChangeColor = (change: number): string => {
|
||||
if (change > 0) return 'text-performance-green';
|
||||
if (change < 0) return 'text-warning-amber';
|
||||
return 'text-gray-500';
|
||||
};
|
||||
|
||||
const getPositionChangeText = (change: number): string => {
|
||||
if (change > 0) return `+${change}`;
|
||||
if (change < 0) return `${change}`;
|
||||
return '0';
|
||||
};
|
||||
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
No results available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-charcoal-outline">
|
||||
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Pos</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Driver</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Fastest Lap</th>
|
||||
<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>
|
||||
{isAdmin && <th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Actions</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.map((result) => {
|
||||
const positionChange = result.getPositionChange();
|
||||
const isFastestLap =
|
||||
typeof fastestLapTime === 'number' && result.fastestLap === fastestLapTime;
|
||||
const driverPenalties = getDriverPenalties(result.driverId);
|
||||
const driver = getDriver(result.driverId);
|
||||
const isCurrentUser = currentDriverId === result.driverId;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={result.id}
|
||||
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">
|
||||
<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">
|
||||
<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'
|
||||
}
|
||||
>
|
||||
{formatLapTime(result.fastestLap)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span
|
||||
className={result.incidents > 0 ? 'text-warning-amber' : 'text-white'}
|
||||
>
|
||||
{result.incidents}×
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="text-white font-medium">
|
||||
{getPoints(result.position)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span
|
||||
className={`font-medium ${getPositionChangeColor(positionChange)}`}
|
||||
>
|
||||
{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>
|
||||
{isAdmin && (
|
||||
<td className="py-3 px-4">
|
||||
{driver && onPenaltyClick && (
|
||||
<InlinePenaltyButton
|
||||
driver={driver}
|
||||
onPenaltyClick={onPenaltyClick}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
|
||||
interface SidebarRaceItemProps {
|
||||
race: {
|
||||
id: string;
|
||||
track: string;
|
||||
scheduledAt: string;
|
||||
};
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SidebarRaceItem({ race, onClick, className }: SidebarRaceItemProps) {
|
||||
const scheduledAtDate = new Date(race.scheduledAt);
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`flex items-center gap-3 p-2 rounded-lg hover:bg-deep-graphite cursor-pointer transition-colors ${className || ''}`}
|
||||
>
|
||||
<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">
|
||||
{scheduledAtDate.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">{scheduledAtDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</p>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-gray-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
export type StewardingTab = 'pending' | 'resolved' | 'penalties';
|
||||
|
||||
interface StewardingTabsProps {
|
||||
activeTab: StewardingTab;
|
||||
onTabChange: (tab: StewardingTab) => void;
|
||||
pendingCount: number;
|
||||
}
|
||||
|
||||
export function StewardingTabs({ activeTab, onTabChange, pendingCount }: StewardingTabsProps) {
|
||||
const tabs: Array<{ id: StewardingTab; label: string }> = [
|
||||
{ id: 'pending', label: 'Pending' },
|
||||
{ id: 'resolved', label: 'Resolved' },
|
||||
{ id: 'penalties', label: 'Penalties' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="border-b border-charcoal-outline">
|
||||
<div className="flex gap-4">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
className={`pb-3 px-1 font-medium transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'text-primary-blue border-b-2 border-primary-blue'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.id === 'pending' && pendingCount > 0 && (
|
||||
<span className="ml-2 px-2 py-0.5 text-xs bg-warning-amber/20 text-warning-amber rounded-full">
|
||||
{pendingCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
/**
|
||||
* TrackImage
|
||||
*
|
||||
* Pure UI component for displaying track images.
|
||||
* Renders an optimized image with fallback on error.
|
||||
*/
|
||||
|
||||
import Image from 'next/image';
|
||||
|
||||
export interface TrackImageProps {
|
||||
trackId: string;
|
||||
alt: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TrackImage({ trackId, alt, className = '' }: TrackImageProps) {
|
||||
return (
|
||||
<Image
|
||||
src={`/media/tracks/${trackId}/image`}
|
||||
alt={alt}
|
||||
width={800}
|
||||
height={256}
|
||||
className={`w-full h-64 object-cover ${className}`}
|
||||
onError={(e) => {
|
||||
// Fallback to default track image
|
||||
(e.target as HTMLImageElement).src = '/default-track-image.png';
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
|
||||
type UpcomingRace = {
|
||||
id: string;
|
||||
track: string;
|
||||
car: string;
|
||||
scheduledAt: string | Date;
|
||||
};
|
||||
|
||||
interface UpcomingRacesSidebarProps {
|
||||
races: UpcomingRace[];
|
||||
}
|
||||
|
||||
export function UpcomingRacesSidebar({ races }: UpcomingRacesSidebarProps) {
|
||||
if (!races.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-iron-gray/80">
|
||||
<Stack direction="row" align="baseline" justify="between" mb={3}>
|
||||
<Heading level={3}>Upcoming races</Heading>
|
||||
<Button
|
||||
as="a"
|
||||
href="/races"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
View all
|
||||
</Button>
|
||||
</Stack>
|
||||
<Stack gap={3}>
|
||||
{races.slice(0, 4).map((race) => {
|
||||
const scheduledAt = typeof race.scheduledAt === 'string' ? new Date(race.scheduledAt) : race.scheduledAt;
|
||||
|
||||
return (
|
||||
<Box key={race.id} display="flex" justify="between" gap={3}>
|
||||
<Box flex={1} className="min-w-0">
|
||||
<Text size="xs" color="text-white" block className="truncate">{race.track}</Text>
|
||||
<Text size="xs" color="text-gray-400" block className="truncate">{race.car}</Text>
|
||||
</Box>
|
||||
<Box textAlign="right">
|
||||
<Text size="xs" color="text-gray-500" className="whitespace-nowrap">
|
||||
{scheduledAt.toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user