website refactor
This commit is contained in:
49
apps/website/components/races/FinishDistributionChart.tsx
Normal file
49
apps/website/components/races/FinishDistributionChart.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
interface FinishDistributionProps {
|
||||
wins: number;
|
||||
podiums: number;
|
||||
topTen: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export 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 (
|
||||
<Stack gap={3}>
|
||||
<Box h="4" rounded="full" overflow="hidden" display="flex" bg="bg-charcoal-outline">
|
||||
{segments.map((segment) => (
|
||||
<Box
|
||||
key={segment.label}
|
||||
bg={segment.color}
|
||||
transition
|
||||
style={{ width: `${(segment.value / total) * 100}%` }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<Box display="flex" flexWrap="wrap" gap={4} justifyContent="center">
|
||||
{segments.map((segment) => (
|
||||
<Box key={segment.label} display="flex" alignItems="center" gap={2}>
|
||||
<Box w="3" h="3" rounded="full" bg={segment.color} />
|
||||
<Text size="xs" color={segment.textColor}>
|
||||
{segment.label}: {segment.value} ({((segment.value / total) * 100).toFixed(0)}%)
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { RaceResultList } from '@/ui/RaceResultList';
|
||||
import { RaceSummaryItem } from '@/ui/RaceSummaryItem';
|
||||
import { RaceResultList } from '@/components/races/RaceResultList';
|
||||
import { RaceSummaryItem } from '@/components/races/RaceSummaryItem';
|
||||
import { Box } from '@/ui/Box';
|
||||
|
||||
type RaceWithResults = {
|
||||
|
||||
62
apps/website/components/races/LiveRaceBanner.tsx
Normal file
62
apps/website/components/races/LiveRaceBanner.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
|
||||
|
||||
import { Box } from '@/ui/Box';
|
||||
import { LiveRaceItem } from '@/components/races/LiveRaceItem';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
interface LiveRaceBannerProps {
|
||||
liveRaces: Array<{
|
||||
id: string;
|
||||
track: string;
|
||||
leagueName: string;
|
||||
}>;
|
||||
onRaceClick?: (raceId: string) => void;
|
||||
}
|
||||
|
||||
export function LiveRaceBanner({ liveRaces, onRaceClick }: LiveRaceBannerProps) {
|
||||
if (liveRaces.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
rounded="xl"
|
||||
p={6}
|
||||
border
|
||||
borderColor="border-performance-green/30"
|
||||
bg="linear-gradient(to right, rgba(16, 185, 129, 0.2), rgba(16, 185, 129, 0.1), transparent)"
|
||||
>
|
||||
<Box
|
||||
position="absolute"
|
||||
top="0"
|
||||
right="0"
|
||||
w="32"
|
||||
h="32"
|
||||
bg="bg-performance-green/20"
|
||||
rounded="full"
|
||||
blur="xl"
|
||||
animate="pulse"
|
||||
/>
|
||||
|
||||
<Box position="relative" zIndex={10}>
|
||||
<Box display="flex" alignItems="center" gap={2} mb={4}>
|
||||
<Box display="flex" alignItems="center" gap={2} px={3} py={1} bg="bg-performance-green/20" rounded="full">
|
||||
<Box as="span" w="2" h="2" bg="bg-performance-green" rounded="full" animate="pulse" />
|
||||
<Text color="text-performance-green" weight="semibold" size="sm">LIVE NOW</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box display="flex" flexDirection="col" gap={3}>
|
||||
{liveRaces.map((race) => (
|
||||
<LiveRaceItem
|
||||
key={race.id}
|
||||
track={race.track}
|
||||
leagueName={race.leagueName}
|
||||
onClick={() => onRaceClick?.(race.id)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
43
apps/website/components/races/LiveRaceItem.tsx
Normal file
43
apps/website/components/races/LiveRaceItem.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
|
||||
|
||||
import { ChevronRight, PlayCircle } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
interface LiveRaceItemProps {
|
||||
track: string;
|
||||
leagueName: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function LiveRaceItem({ track, leagueName, onClick }: LiveRaceItemProps) {
|
||||
return (
|
||||
<Box
|
||||
onClick={onClick}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="between"
|
||||
p={4}
|
||||
bg="bg-deep-graphite/80"
|
||||
rounded="lg"
|
||||
border
|
||||
borderColor="border-performance-green/20"
|
||||
cursor="pointer"
|
||||
hoverBorderColor="performance-green/40"
|
||||
transition
|
||||
>
|
||||
<Box display="flex" alignItems="center" gap={4}>
|
||||
<Box p={2} bg="bg-performance-green/20" rounded="lg">
|
||||
<Icon icon={PlayCircle} size={5} color="rgb(16, 185, 129)" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Heading level={3}>{track}</Heading>
|
||||
<Text size="sm" color="text-gray-400">{leagueName}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Icon icon={ChevronRight} size={5} color="rgb(156, 163, 175)" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { RaceViewData } from '@/lib/view-data/RacesViewData';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { LiveRaceItem } from '@/ui/LiveRaceItem';
|
||||
import { LiveRaceItem } from '@/components/races/LiveRaceItem';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
|
||||
123
apps/website/components/races/NextRaceCard.tsx
Normal file
123
apps/website/components/races/NextRaceCard.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
|
||||
|
||||
import { Calendar, ChevronRight, Clock } from 'lucide-react';
|
||||
import { Badge } from '@/ui/Badge';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Link } from '@/ui/Link';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
interface NextRaceCardProps {
|
||||
track: string;
|
||||
car: string;
|
||||
formattedDate: string;
|
||||
formattedTime: string;
|
||||
timeUntil: string;
|
||||
isMyLeague: boolean;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export function NextRaceCard({
|
||||
track,
|
||||
car,
|
||||
formattedDate,
|
||||
formattedTime,
|
||||
timeUntil,
|
||||
isMyLeague,
|
||||
href,
|
||||
}: NextRaceCardProps) {
|
||||
return (
|
||||
<Surface
|
||||
variant="muted"
|
||||
rounded="xl"
|
||||
border
|
||||
padding={6}
|
||||
style={{
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
background: 'linear-gradient(to bottom right, #262626, rgba(38, 38, 38, 0.8))',
|
||||
borderColor: 'rgba(59, 130, 246, 0.3)',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
position="absolute"
|
||||
top="0"
|
||||
right="0"
|
||||
w="40"
|
||||
h="40"
|
||||
style={{
|
||||
background: 'linear-gradient(to bottom left, rgba(59, 130, 246, 0.2), transparent)',
|
||||
borderBottomLeftRadius: '9999px',
|
||||
}}
|
||||
/>
|
||||
<Box position="relative">
|
||||
<Stack direction="row" align="center" gap={2} mb={4}>
|
||||
<Badge variant="primary" style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
Next Race
|
||||
</Badge>
|
||||
{isMyLeague && (
|
||||
<Badge variant="success">
|
||||
Your League
|
||||
</Badge>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" align="end" justify="between" wrap gap={4}>
|
||||
<Box>
|
||||
<Heading level={2} style={{ fontSize: '1.5rem', marginBottom: '0.5rem' }}>
|
||||
{track}
|
||||
</Heading>
|
||||
<Text color="text-gray-400" block mb={3}>
|
||||
{car}
|
||||
</Text>
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
<Stack direction="row" align="center" gap={1.5}>
|
||||
<Icon icon={Calendar} size={4} color="var(--text-gray-500)" />
|
||||
<Text size="sm" color="text-gray-400">
|
||||
{formattedDate}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack direction="row" align="center" gap={1.5}>
|
||||
<Icon icon={Clock} size={4} color="var(--text-gray-500)" />
|
||||
<Text size="sm" color="text-gray-400">
|
||||
{formattedTime}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Stack align="end" gap={3}>
|
||||
<Box textAlign="right">
|
||||
<Text
|
||||
size="xs"
|
||||
color="text-gray-500"
|
||||
style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}
|
||||
block
|
||||
mb={1}
|
||||
>
|
||||
Starts in
|
||||
</Text>
|
||||
<Text size="3xl" weight="bold" color="text-primary-blue" font="mono">
|
||||
{timeUntil}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Link href={href}>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Icon icon={ChevronRight} size={4} />}
|
||||
>
|
||||
View Details
|
||||
</Button>
|
||||
</Link>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { NextRaceCard as UiNextRaceCard } from '@/ui/NextRaceCard';
|
||||
import { NextRaceCard as UiNextRaceCard } from '@/components/races/NextRaceCard';
|
||||
|
||||
interface NextRaceCardProps {
|
||||
nextRace: {
|
||||
|
||||
31
apps/website/components/races/PenaltyFAB.tsx
Normal file
31
apps/website/components/races/PenaltyFAB.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
|
||||
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
interface PenaltyFABProps {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function PenaltyFAB({ onClick }: PenaltyFABProps) {
|
||||
return (
|
||||
<Box position="fixed" bottom={6} right={6} zIndex={50}>
|
||||
<Button
|
||||
variant="primary"
|
||||
w="14"
|
||||
h="14"
|
||||
rounded="full"
|
||||
shadow="lg"
|
||||
onClick={onClick}
|
||||
title="Add Penalty"
|
||||
p={0}
|
||||
display="flex"
|
||||
center
|
||||
>
|
||||
<Icon icon={Plus} size={6} />
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
62
apps/website/components/races/PenaltyRow.tsx
Normal file
62
apps/website/components/races/PenaltyRow.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
|
||||
|
||||
import { Badge } from '@/ui/Badge';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
interface PenaltyRowProps {
|
||||
driverName: string;
|
||||
type: string;
|
||||
reason: string;
|
||||
notes?: string;
|
||||
value: string | number;
|
||||
valueLabel?: string;
|
||||
}
|
||||
|
||||
export function PenaltyRow({
|
||||
driverName,
|
||||
type,
|
||||
reason,
|
||||
notes,
|
||||
value,
|
||||
valueLabel,
|
||||
}: PenaltyRowProps) {
|
||||
return (
|
||||
<Surface variant="dark" rounded="lg" p={3} border borderColor="border-charcoal-outline/50">
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Box
|
||||
display="flex"
|
||||
h="10"
|
||||
w="10"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
rounded="full"
|
||||
bg="bg-red-600/20"
|
||||
>
|
||||
<Text color="text-red-500" weight="bold">!</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Stack direction="row" align="center" gap={2} mb={1}>
|
||||
<Text weight="medium" color="text-white">{driverName}</Text>
|
||||
<Badge variant="danger">
|
||||
{type.replace('_', ' ')}
|
||||
</Badge>
|
||||
</Stack>
|
||||
<Text size="sm" color="text-gray-400" block>{reason}</Text>
|
||||
{notes && (
|
||||
<Text size="sm" color="text-gray-500" block mt={1} italic>
|
||||
{notes}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box textAlign="right">
|
||||
<Text size="2xl" weight="bold" color="text-red-500">
|
||||
{value} {valueLabel}
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
65
apps/website/components/races/PointsTable.tsx
Normal file
65
apps/website/components/races/PointsTable.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from '@/ui/Table';
|
||||
|
||||
interface PointsTableProps {
|
||||
title?: string;
|
||||
points: { position: number; points: number }[];
|
||||
}
|
||||
|
||||
export function PointsTable({ title = 'Points Distribution', points }: PointsTableProps) {
|
||||
return (
|
||||
<Card>
|
||||
<Heading level={2} mb={4}>{title}</Heading>
|
||||
<Box overflow="auto">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader>Position</TableHeader>
|
||||
<TableHeader className="text-right">Points</TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{points.map(({ position, points: pts }) => (
|
||||
<TableRow
|
||||
key={position}
|
||||
className={position <= 3 ? 'bg-iron-gray/20' : ''}
|
||||
>
|
||||
<TableCell>
|
||||
<Box display="flex" alignItems="center" gap={3}>
|
||||
<Box
|
||||
w="7"
|
||||
h="7"
|
||||
rounded="full"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
className={`text-xs font-bold ${
|
||||
position === 1 ? 'bg-yellow-500 text-black' :
|
||||
position === 2 ? 'bg-gray-400 text-black' :
|
||||
position === 3 ? 'bg-amber-600 text-white' :
|
||||
'bg-charcoal-outline text-white'
|
||||
}`}
|
||||
>
|
||||
{position}
|
||||
</Box>
|
||||
<Text color="text-white" weight="medium">
|
||||
{position === 1 ? '1st' : position === 2 ? '2nd' : position === 3 ? '3rd' : `${position}th`}
|
||||
</Text>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Text color="text-white" weight="semibold" className="tabular-nums">{pts}</Text>
|
||||
<Text color="text-gray-500" ml={1}>pts</Text>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
146
apps/website/components/races/RaceActionBar.tsx
Normal file
146
apps/website/components/races/RaceActionBar.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import React from 'react';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Trophy, Scale, LogOut, CheckCircle, XCircle, PlayCircle } from 'lucide-react';
|
||||
|
||||
interface RaceActionBarProps {
|
||||
status: 'scheduled' | 'running' | 'completed' | 'cancelled' | string;
|
||||
isUserRegistered: boolean;
|
||||
canRegister: boolean;
|
||||
onRegister?: () => void;
|
||||
onWithdraw?: () => void;
|
||||
onResultsClick?: () => void;
|
||||
onStewardingClick?: () => void;
|
||||
onFileProtest?: () => void;
|
||||
isAdmin?: boolean;
|
||||
onCancel?: () => void;
|
||||
onReopen?: () => void;
|
||||
onEndRace?: () => void;
|
||||
isLoading?: {
|
||||
register?: boolean;
|
||||
withdraw?: boolean;
|
||||
cancel?: boolean;
|
||||
reopen?: boolean;
|
||||
complete?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export function RaceActionBar({
|
||||
status,
|
||||
isUserRegistered,
|
||||
canRegister,
|
||||
onRegister,
|
||||
onWithdraw,
|
||||
onResultsClick,
|
||||
onStewardingClick,
|
||||
onFileProtest,
|
||||
isAdmin,
|
||||
onCancel,
|
||||
onReopen,
|
||||
onEndRace,
|
||||
isLoading = {}
|
||||
}: RaceActionBarProps) {
|
||||
return (
|
||||
<Stack direction="row" gap={3} wrap>
|
||||
{status === 'scheduled' && (
|
||||
<>
|
||||
{!isUserRegistered && canRegister && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onRegister}
|
||||
disabled={isLoading.register}
|
||||
icon={<Icon icon={CheckCircle} size={4} />}
|
||||
>
|
||||
Register
|
||||
</Button>
|
||||
)}
|
||||
{isUserRegistered && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onWithdraw}
|
||||
disabled={isLoading.withdraw}
|
||||
icon={<Icon icon={LogOut} size={4} />}
|
||||
>
|
||||
Withdraw
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={onCancel}
|
||||
disabled={isLoading.cancel}
|
||||
icon={<Icon icon={XCircle} size={4} />}
|
||||
>
|
||||
Cancel Race
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'running' && (
|
||||
<>
|
||||
<Button variant="race-final" disabled icon={<Icon icon={PlayCircle} size={4} />}>
|
||||
Live Now
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onEndRace}
|
||||
disabled={isLoading.complete}
|
||||
>
|
||||
End Race
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'completed' && (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onResultsClick}
|
||||
icon={<Icon icon={Trophy} size={4} />}
|
||||
>
|
||||
View Results
|
||||
</Button>
|
||||
{isUserRegistered && onFileProtest && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onFileProtest}
|
||||
icon={<Icon icon={Scale} size={4} />}
|
||||
>
|
||||
File Protest
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onStewardingClick}
|
||||
icon={<Icon icon={Scale} size={4} />}
|
||||
>
|
||||
Stewarding
|
||||
</Button>
|
||||
{isAdmin && onReopen && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onReopen}
|
||||
disabled={isLoading.reopen}
|
||||
>
|
||||
Reopen Race
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'cancelled' && isAdmin && onReopen && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onReopen}
|
||||
disabled={isLoading.reopen}
|
||||
>
|
||||
Reopen Race
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,91 +1,171 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Clock, MapPin, Users } from 'lucide-react';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { ArrowRight, Car, ChevronRight, LucideIcon, Trophy, Zap } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { SessionStatusBadge, type SessionStatus } from './SessionStatusBadge';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Link } from '@/ui/Link';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
|
||||
interface RaceCardProps {
|
||||
id: string;
|
||||
title: string;
|
||||
leagueName: string;
|
||||
trackName: string;
|
||||
track: string;
|
||||
car: string;
|
||||
scheduledAt: string;
|
||||
entrantCount: number;
|
||||
status: SessionStatus;
|
||||
onClick: (id: string) => void;
|
||||
status: string;
|
||||
leagueName: string;
|
||||
leagueId?: string;
|
||||
strengthOfField?: number | null;
|
||||
onClick?: () => void;
|
||||
statusConfig: {
|
||||
border: string;
|
||||
bg: string;
|
||||
color: string;
|
||||
icon: LucideIcon | null;
|
||||
label: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function RaceCard({
|
||||
id,
|
||||
title,
|
||||
leagueName,
|
||||
trackName,
|
||||
track,
|
||||
car,
|
||||
scheduledAt,
|
||||
entrantCount,
|
||||
status,
|
||||
leagueName,
|
||||
leagueId,
|
||||
strengthOfField,
|
||||
onClick,
|
||||
statusConfig,
|
||||
}: RaceCardProps) {
|
||||
const scheduledAtDate = new Date(scheduledAt);
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="article"
|
||||
onClick={() => onClick(id)}
|
||||
<Surface
|
||||
bg="bg-surface-charcoal"
|
||||
rounded="xl"
|
||||
border
|
||||
borderColor="border-outline-steel"
|
||||
p={4}
|
||||
padding={4}
|
||||
onClick={onClick}
|
||||
cursor={onClick ? 'pointer' : 'default'}
|
||||
hoverBorderColor="border-primary-accent"
|
||||
transition
|
||||
cursor="pointer"
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
group
|
||||
>
|
||||
{/* Hover Glow */}
|
||||
<Box
|
||||
position="absolute"
|
||||
inset="0"
|
||||
bg="bg-primary-accent"
|
||||
bgOpacity={0.05}
|
||||
opacity={0}
|
||||
groupHoverOpacity={1}
|
||||
transition
|
||||
/>
|
||||
|
||||
<Stack gap={4}>
|
||||
<Stack direction="row" justifyContent="between" alignItems="start">
|
||||
<Stack gap={1}>
|
||||
<Text size="xs" color="text-gray-500" weight="bold" uppercase>
|
||||
{leagueName}
|
||||
</Text>
|
||||
<Text size="lg" weight="bold" groupHoverTextColor="text-primary-accent">
|
||||
{title}
|
||||
</Text>
|
||||
</Stack>
|
||||
<SessionStatusBadge status={status} />
|
||||
</Stack>
|
||||
{/* Live indicator */}
|
||||
{status === 'running' && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top="0"
|
||||
left="0"
|
||||
right="0"
|
||||
h="1"
|
||||
bg="bg-success-green"
|
||||
animate="pulse"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box display="grid" gridCols={2} gap={4}>
|
||||
<Stack direction="row" alignItems="center" gap={2}>
|
||||
<Icon icon={MapPin} size={3} color="#6b7280" />
|
||||
<Text size="xs" color="text-gray-400">{trackName}</Text>
|
||||
</Stack>
|
||||
<Stack direction="row" alignItems="center" gap={2}>
|
||||
<Icon icon={Clock} size={3} color="#6b7280" />
|
||||
<Text size="xs" color="text-gray-400">{scheduledAt}</Text>
|
||||
</Stack>
|
||||
<Stack direction="row" align="start" gap={4}>
|
||||
{/* Time Column */}
|
||||
<Box textAlign="center" flexShrink={0} width="16">
|
||||
<Text size="lg" weight="bold" color="text-white" block>
|
||||
{scheduledAtDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</Text>
|
||||
<Text size="xs" color={statusConfig.color} block>
|
||||
{status === 'running' ? 'LIVE' : scheduledAtDate.toLocaleDateString()}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" alignItems="center" gap={2} pt={2} borderTop borderColor="border-outline-steel" bgOpacity={0.5}>
|
||||
<Icon icon={Users} size={3} color="#4ED4E0" />
|
||||
<Text size="xs" color="text-gray-400">
|
||||
<Text as="span" color="text-telemetry-aqua" weight="bold">{entrantCount}</Text> ENTRANTS
|
||||
</Text>
|
||||
</Stack>
|
||||
{/* Divider */}
|
||||
<Box
|
||||
w="px"
|
||||
bg="border-outline-steel"
|
||||
alignSelf="stretch"
|
||||
/>
|
||||
|
||||
{/* Main Content */}
|
||||
<Box flex={1} minWidth="0">
|
||||
<Stack direction="row" align="start" justify="between" gap={4}>
|
||||
<Box minWidth="0">
|
||||
<Heading
|
||||
level={3}
|
||||
truncate
|
||||
groupHoverTextColor="text-primary-accent"
|
||||
transition
|
||||
>
|
||||
{track}
|
||||
</Heading>
|
||||
<Stack direction="row" align="center" gap={3} mt={1}>
|
||||
<Stack direction="row" align="center" gap={1}>
|
||||
<Icon icon={Car} size={3.5} color="var(--text-gray-400)" />
|
||||
<Text size="sm" color="text-gray-400">
|
||||
{car}
|
||||
</Text>
|
||||
</Stack>
|
||||
{strengthOfField && (
|
||||
<Stack direction="row" align="center" gap={1}>
|
||||
<Icon icon={Zap} size={3.5} color="var(--warning-amber)" />
|
||||
<Text size="sm" color="text-gray-400">
|
||||
SOF {strengthOfField}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Status Badge */}
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={1.5}
|
||||
px={2.5}
|
||||
py={1}
|
||||
rounded="full"
|
||||
border
|
||||
borderColor="border-outline-steel"
|
||||
bg="bg-base-black"
|
||||
bgOpacity={0.5}
|
||||
>
|
||||
{statusConfig.icon && (
|
||||
<Icon icon={statusConfig.icon} size={3.5} color={statusConfig.color} />
|
||||
)}
|
||||
<Text size="xs" weight="medium" color={statusConfig.color}>
|
||||
{statusConfig.label}
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{/* League Link */}
|
||||
<Box mt={3} pt={3} borderTop borderColor="border-outline-steel" borderOpacity={0.3}>
|
||||
<Link
|
||||
href={routes.league.detail(leagueId ?? '')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={Trophy} size={3.5} color="var(--primary-accent)" />
|
||||
<Text size="sm" color="text-primary-accent">
|
||||
{leagueName}
|
||||
</Text>
|
||||
<Icon icon={ArrowRight} size={3} color="var(--primary-accent)" />
|
||||
</Stack>
|
||||
</Link>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Arrow */}
|
||||
<Icon
|
||||
icon={ChevronRight}
|
||||
size={5}
|
||||
color="var(--text-gray-500)"
|
||||
groupHoverTextColor="text-primary-accent"
|
||||
transition
|
||||
flexShrink={0}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import { raceStatusConfig } from '@/lib/utilities/raceStatus';
|
||||
import { RaceCard as UiRaceCard } from '@/ui/RaceCard';
|
||||
import { RaceCard as UiRaceCard } from './RaceCard';
|
||||
|
||||
interface RaceCardProps {
|
||||
race: {
|
||||
|
||||
33
apps/website/components/races/RaceDetailCard.tsx
Normal file
33
apps/website/components/races/RaceDetailCard.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
|
||||
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Grid } from '@/ui/Grid';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { StatGridItem } from '@/ui/StatGridItem';
|
||||
import { Flag } from 'lucide-react';
|
||||
|
||||
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="text-primary-blue" />}>Race Details</Heading>
|
||||
<Grid cols={2} gap={4}>
|
||||
<StatGridItem label="Track" value={track} />
|
||||
<StatGridItem label="Car" value={car} />
|
||||
<StatGridItem label="Session Type" value={sessionType} />
|
||||
<StatGridItem label="Status" value={statusLabel} color={statusColor} />
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
130
apps/website/components/races/RaceFilterModal.tsx
Normal file
130
apps/website/components/races/RaceFilterModal.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
'use client';
|
||||
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Input } from '@/ui/Input';
|
||||
import { Modal } from '@/ui/Modal';
|
||||
import { Select } from '@/ui/Select';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Filter, Search } from 'lucide-react';
|
||||
|
||||
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) {
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
title="Filters"
|
||||
icon={<Icon icon={Filter} size={5} color="text-primary-accent" />}
|
||||
>
|
||||
<Stack gap={4}>
|
||||
{/* Search */}
|
||||
{showSearch && (
|
||||
<Input
|
||||
label="Search"
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Track, car, or league..."
|
||||
icon={<Icon icon={Search} size={4} color="text-gray-500" />}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Time Filter */}
|
||||
{showTimeFilter && (
|
||||
<Box>
|
||||
<Text as="label" size="sm" color="text-gray-400" block mb={2}>Time</Text>
|
||||
<Box display="flex" flexWrap="wrap" gap={2}>
|
||||
{(['upcoming', 'live', 'past', 'all'] as TimeFilter[]).map(filter => (
|
||||
<Button
|
||||
key={filter}
|
||||
variant={timeFilter === filter ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => setTimeFilter(filter)}
|
||||
>
|
||||
{filter === 'live' && <Box as="span" width="2" height="2" bg="bg-success-green" rounded="full" mr={1.5} animate="pulse" />}
|
||||
{filter.charAt(0).toUpperCase() + filter.slice(1)}
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Status Filter */}
|
||||
<Select
|
||||
label="Status"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as StatusFilter)}
|
||||
options={[
|
||||
{ value: 'all', label: 'All Statuses' },
|
||||
{ value: 'scheduled', label: 'Scheduled' },
|
||||
{ value: 'running', label: 'Live' },
|
||||
{ value: 'completed', label: 'Completed' },
|
||||
{ value: 'cancelled', label: 'Cancelled' },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* League Filter */}
|
||||
<Select
|
||||
label="League"
|
||||
value={leagueFilter}
|
||||
onChange={(e) => setLeagueFilter(e.target.value)}
|
||||
options={[
|
||||
{ value: 'all', label: 'All Leagues' },
|
||||
...leagues.map(league => ({ value: league.id, label: league.name }))
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Clear Filters */}
|
||||
{(statusFilter !== 'all' || leagueFilter !== 'all' || searchQuery || (showTimeFilter && timeFilter !== 'upcoming')) && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setStatusFilter('all');
|
||||
setLeagueFilter('all');
|
||||
setSearchQuery('');
|
||||
if (showTimeFilter) setTimeFilter('upcoming');
|
||||
}}
|
||||
fullWidth
|
||||
>
|
||||
Clear All Filters
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
95
apps/website/components/races/RaceHeaderPanel.tsx
Normal file
95
apps/website/components/races/RaceHeaderPanel.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { RaceStatusBadge } from './RaceStatusBadge';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Calendar, MapPin, Car } from 'lucide-react';
|
||||
|
||||
interface RaceHeaderPanelProps {
|
||||
track: string;
|
||||
car: string;
|
||||
scheduledAt: string;
|
||||
status: string;
|
||||
leagueName?: string;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function RaceHeaderPanel({
|
||||
track,
|
||||
car,
|
||||
scheduledAt,
|
||||
status,
|
||||
leagueName,
|
||||
actions
|
||||
}: RaceHeaderPanelProps) {
|
||||
return (
|
||||
<Box
|
||||
bg="bg-panel-gray"
|
||||
rounded="xl"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
overflow="hidden"
|
||||
position="relative"
|
||||
>
|
||||
{/* Background Accent */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
height="24"
|
||||
bg="bg-gradient-to-r from-primary-blue/20 to-transparent"
|
||||
opacity={0.5}
|
||||
/>
|
||||
|
||||
<Box p={6} position="relative">
|
||||
<Stack direction={{ base: 'col', md: 'row' }} gap={6} align="start" className="md:items-center">
|
||||
{/* Info */}
|
||||
<Box flexGrow={1}>
|
||||
<Stack gap={3}>
|
||||
<Stack direction="row" align="center" gap={3} wrap>
|
||||
<Text as="h1" size="3xl" weight="bold" color="text-white">
|
||||
{track}
|
||||
</Text>
|
||||
<RaceStatusBadge status={status} />
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" align="center" gap={6} wrap>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={Car} size={4} color="#9ca3af" />
|
||||
<Text size="sm" color="text-gray-400">
|
||||
{car}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={Calendar} size={4} color="#9ca3af" />
|
||||
<Text size="sm" color="text-gray-400">
|
||||
{scheduledAt}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{leagueName && (
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={MapPin} size={4} color="#9ca3af" />
|
||||
<Text size="sm" color="text-gray-400">
|
||||
{leagueName}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Actions */}
|
||||
{actions && (
|
||||
<Box flexShrink={0} width={{ base: 'full', md: 'auto' }}>
|
||||
{actions}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
77
apps/website/components/races/RaceHero.tsx
Normal file
77
apps/website/components/races/RaceHero.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
|
||||
|
||||
import { Calendar, Car, Clock, LucideIcon } from 'lucide-react';
|
||||
import { Badge } from '@/ui/Badge';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Hero } from '@/ui/Hero';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
interface RaceHeroProps {
|
||||
track: string;
|
||||
scheduledAt: string;
|
||||
car: string;
|
||||
status: 'scheduled' | 'running' | 'completed' | 'cancelled';
|
||||
statusConfig: {
|
||||
icon: LucideIcon;
|
||||
variant: 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info';
|
||||
label: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function RaceHero({ track, scheduledAt, car, status, statusConfig }: RaceHeroProps) {
|
||||
const StatusIcon = statusConfig.icon;
|
||||
const date = new Date(scheduledAt);
|
||||
|
||||
return (
|
||||
<Hero variant="primary">
|
||||
{status === 'running' && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top="0"
|
||||
left="0"
|
||||
right="0"
|
||||
h="1"
|
||||
style={{ background: 'linear-gradient(to right, #10b981, rgba(16, 185, 129, 0.5), #10b981)' }}
|
||||
animate="pulse"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Stack gap={4}>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Badge variant={statusConfig.variant}>
|
||||
{status === 'running' && (
|
||||
<Box w="2" h="2" bg="bg-performance-green" rounded="full" animate="pulse" mr={1.5} />
|
||||
)}
|
||||
<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: '2.5rem' }}>{track}</Heading>
|
||||
|
||||
<Stack direction="row" align="center" gap={6} wrap>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={Calendar} size={4} color="rgb(156, 163, 175)" />
|
||||
<Text color="text-gray-400">{date.toLocaleDateString()}</Text>
|
||||
</Stack>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={Clock} size={4} color="rgb(156, 163, 175)" />
|
||||
<Text color="text-gray-400">{date.toLocaleTimeString()}</Text>
|
||||
</Stack>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={Car} size={4} color="rgb(156, 163, 175)" />
|
||||
<Text color="text-gray-400">{car}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Hero>
|
||||
);
|
||||
}
|
||||
34
apps/website/components/races/RaceHeroWrapper.tsx
Normal file
34
apps/website/components/races/RaceHeroWrapper.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
|
||||
|
||||
import { RaceHero as UiRaceHero } from '@/components/races/RaceHero';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
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(props: RaceHeroProps) {
|
||||
const { statusConfig, ...rest } = props;
|
||||
|
||||
// Map variant to match UI component expectations
|
||||
const mappedConfig: {
|
||||
icon: LucideIcon;
|
||||
variant: 'primary' | 'success' | 'default' | 'warning' | 'danger' | 'info';
|
||||
label: string;
|
||||
description: string;
|
||||
} = {
|
||||
...statusConfig,
|
||||
variant: statusConfig.variant === 'default' ? 'default' : statusConfig.variant
|
||||
};
|
||||
|
||||
return <UiRaceHero {...rest} statusConfig={mappedConfig} />;
|
||||
}
|
||||
124
apps/website/components/races/RaceJoinButton.tsx
Normal file
124
apps/website/components/races/RaceJoinButton.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
|
||||
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { InfoBanner } from '@/ui/InfoBanner';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { CheckCircle2, PlayCircle, UserMinus, UserPlus, XCircle } from 'lucide-react';
|
||||
|
||||
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"
|
||||
fullWidth
|
||||
onClick={onRegister}
|
||||
disabled={isLoading.register}
|
||||
icon={<Icon icon={UserPlus} size={4} />}
|
||||
>
|
||||
{isLoading.register ? 'Registering...' : 'Register for Race'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (isUserRegistered) {
|
||||
return (
|
||||
<Stack gap={3} fullWidth>
|
||||
<InfoBanner type="success" icon={CheckCircle2}>
|
||||
You're Registered
|
||||
</InfoBanner>
|
||||
<Button
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
onClick={onWithdraw}
|
||||
disabled={isLoading.withdraw}
|
||||
icon={<Icon icon={UserMinus} size={4} />}
|
||||
>
|
||||
{isLoading.withdraw ? 'Withdrawing...' : 'Withdraw'}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// Show cancel button for owners/admins
|
||||
if (isOwnerOrAdmin) {
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
onClick={onCancel}
|
||||
disabled={isLoading.cancel}
|
||||
icon={<Icon icon={XCircle} size={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"
|
||||
fullWidth
|
||||
onClick={onEndRace}
|
||||
icon={<Icon icon={CheckCircle2} size={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"
|
||||
fullWidth
|
||||
onClick={onReopen}
|
||||
disabled={isLoading.reopen}
|
||||
icon={<Icon icon={PlayCircle} size={4} />}
|
||||
>
|
||||
{isLoading.reopen ? 'Re-opening...' : 'Re-open Race'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -47,10 +47,10 @@ export function RaceList({ racesByDate, totalCount, onRaceClick }: RaceListProps
|
||||
|
||||
if (racesByDate.length === 0) {
|
||||
return (
|
||||
<Card py={12} textAlign="center">
|
||||
<Card py={12} textAlign="center" bg="bg-surface-charcoal" border borderColor="border-outline-steel">
|
||||
<Stack align="center" gap={4}>
|
||||
<Box p={4} bg="bg-iron-gray" rounded="full">
|
||||
<Icon icon={Calendar} size={8} color="rgb(115, 115, 115)" />
|
||||
<Box p={4} bg="bg-base-black" rounded="full" border borderColor="border-outline-steel">
|
||||
<Icon icon={Calendar} size={8} color="var(--text-gray-500)" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text weight="medium" color="text-white" block mb={1}>No races found</Text>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { ArrowRight, Car, ChevronRight, LucideIcon, Trophy, Zap } from 'lucide-react';
|
||||
import { Badge } from '@/ui/Badge';
|
||||
@@ -50,9 +50,9 @@ export function RaceListItem({
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
rounded="xl"
|
||||
bg="bg-iron-gray"
|
||||
bg="bg-surface-charcoal"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
borderColor="border-outline-steel"
|
||||
p={4}
|
||||
cursor="pointer"
|
||||
transition
|
||||
@@ -67,44 +67,45 @@ export function RaceListItem({
|
||||
left="0"
|
||||
right="0"
|
||||
h="1"
|
||||
style={{ background: 'linear-gradient(to right, #10b981, rgba(16, 185, 129, 0.5), #10b981)' }}
|
||||
bg="bg-success-green"
|
||||
animate="pulse"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
{/* Time/Date Column */}
|
||||
<Box flexShrink={0} textAlign="center" minWidth="60px">
|
||||
<Box flexShrink={0} textAlign="center" width="16">
|
||||
{dateLabel && (
|
||||
<Text size="xs" color="text-gray-500" block style={{ textTransform: 'uppercase' }}>
|
||||
<Text size="xs" color="text-gray-500" block uppercase>
|
||||
{dateLabel}
|
||||
</Text>
|
||||
)}
|
||||
<Text size={dayLabel ? "2xl" : "lg"} weight="bold" color="text-white" block>
|
||||
{dayLabel || timeLabel}
|
||||
</Text>
|
||||
<Text size="xs" color={status === 'running' ? 'text-performance-green' : 'text-gray-400'} block>
|
||||
<Text size="xs" color={status === 'running' ? 'text-success-green' : 'text-gray-400'} block>
|
||||
{status === 'running' ? 'LIVE' : relativeTimeLabel || timeLabel}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Divider */}
|
||||
<Box w="px" h="10" alignSelf="stretch" bg="bg-charcoal-outline" />
|
||||
<Box w="px" h="10" alignSelf="stretch" bg="border-outline-steel" />
|
||||
|
||||
{/* Main Content */}
|
||||
<Box flexGrow={1} minWidth="0">
|
||||
<Stack direction="row" align="start" justify="between" gap={4}>
|
||||
<Box minWidth="0">
|
||||
<Heading level={3} truncate>
|
||||
<Heading level={3} truncate groupHoverTextColor="text-primary-accent" transition>
|
||||
{track}
|
||||
</Heading>
|
||||
<Stack direction="row" align="center" gap={3} mt={1}>
|
||||
<Stack direction="row" align="center" gap={1}>
|
||||
<Icon icon={Car} size={3.5} color="rgb(156, 163, 175)" />
|
||||
<Icon icon={Car} size={3.5} color="var(--text-gray-400)" />
|
||||
<Text size="sm" color="text-gray-400">{car}</Text>
|
||||
</Stack>
|
||||
{strengthOfField && (
|
||||
<Stack direction="row" align="center" gap={1}>
|
||||
<Icon icon={Zap} size={3.5} color="rgb(245, 158, 11)" />
|
||||
<Icon icon={Zap} size={3.5} color="var(--warning-amber)" />
|
||||
<Text size="sm" color="text-gray-400">SOF {strengthOfField}</Text>
|
||||
</Stack>
|
||||
)}
|
||||
@@ -120,23 +121,23 @@ export function RaceListItem({
|
||||
|
||||
{/* League Link */}
|
||||
{leagueName && leagueHref && (
|
||||
<Box mt={3} pt={3} borderTop borderColor="border-charcoal-outline" bgOpacity={0.5}>
|
||||
<Box mt={3} pt={3} borderTop borderColor="border-outline-steel" borderOpacity={0.3}>
|
||||
<Link
|
||||
href={leagueHref}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
<Icon icon={Trophy} size={3.5} mr={2} />
|
||||
{leagueName}
|
||||
<Icon icon={ArrowRight} size={3} ml={2} />
|
||||
<Icon icon={Trophy} size={3.5} mr={2} color="var(--primary-accent)" />
|
||||
<Text as="span" color="text-primary-accent">{leagueName}</Text>
|
||||
<Icon icon={ArrowRight} size={3} ml={2} color="var(--primary-accent)" />
|
||||
</Link>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Arrow */}
|
||||
<Icon icon={ChevronRight} size={5} color="rgb(115, 115, 115)" flexShrink={0} />
|
||||
<Icon icon={ChevronRight} size={5} color="var(--text-gray-500)" flexShrink={0} groupHoverTextColor="text-primary-accent" transition />
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,36 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Flag, CalendarDays, Clock, Zap, Trophy, type LucideIcon } from 'lucide-react';
|
||||
import { Flag, CalendarDays, Clock, Zap, Trophy, LucideIcon } from 'lucide-react';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Grid } from '@/ui/Grid';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
|
||||
interface RacesHeaderProps {
|
||||
interface RacePageHeaderProps {
|
||||
totalCount: number;
|
||||
scheduledCount: number;
|
||||
runningCount: number;
|
||||
completedCount: number;
|
||||
}
|
||||
|
||||
export function RacesHeader({
|
||||
export function RacePageHeader({
|
||||
totalCount,
|
||||
scheduledCount,
|
||||
runningCount,
|
||||
completedCount,
|
||||
}: RacesHeaderProps) {
|
||||
}: RacePageHeaderProps) {
|
||||
return (
|
||||
<Box as="header" bg="bg-surface-charcoal" rounded="xl" border borderColor="border-outline-steel" p={6} position="relative" overflow="hidden">
|
||||
<Surface
|
||||
bg="bg-surface-charcoal"
|
||||
rounded="xl"
|
||||
border
|
||||
borderColor="border-outline-steel"
|
||||
padding={6}
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* Background Accent */}
|
||||
<Box position="absolute" top={0} left={0} right={0} h="1" bg="bg-primary-accent" />
|
||||
<Box
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
height="1"
|
||||
bg="bg-primary-accent"
|
||||
/>
|
||||
|
||||
<Stack gap={6}>
|
||||
<Stack gap={2}>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Icon icon={Flag} size={6} color="var(--color-primary)" />
|
||||
<Icon icon={Flag} size={6} color="var(--primary-accent)" />
|
||||
<Heading level={1}>RACE DASHBOARD</Heading>
|
||||
</Stack>
|
||||
<Text color="text-gray-400" size="sm">
|
||||
@@ -45,30 +61,16 @@ export function RacesHeader({
|
||||
<StatItem icon={Trophy} label="COMPLETED" value={completedCount} color="text-gray-400" />
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
function StatItem({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
color = 'text-white'
|
||||
}: {
|
||||
icon: LucideIcon,
|
||||
label: string,
|
||||
value: number,
|
||||
color?: string
|
||||
}) {
|
||||
function StatItem({ icon, label, value, color = 'text-white' }: { icon: LucideIcon, label: string, value: number, color?: string }) {
|
||||
return (
|
||||
<Box p={4} bg="bg-base-black" bgOpacity={0.5} border borderColor="border-outline-steel">
|
||||
<Stack gap={1}>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon
|
||||
icon={icon}
|
||||
size={3}
|
||||
color={color === 'text-white' ? '#9ca3af' : undefined}
|
||||
/>
|
||||
<Icon icon={icon} size={3} color={color === 'text-white' ? '#9ca3af' : undefined} groupHoverTextColor={color !== 'text-white' ? color : undefined} />
|
||||
<Text size="xs" color="text-gray-500" weight="bold" uppercase>{label}</Text>
|
||||
</Stack>
|
||||
<Text size="2xl" weight="bold" color={color}>{value}</Text>
|
||||
51
apps/website/components/races/RacePenaltyRowWrapper.tsx
Normal file
51
apps/website/components/races/RacePenaltyRowWrapper.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
|
||||
|
||||
import { PenaltyRow } from '@/components/races/PenaltyRow';
|
||||
|
||||
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) {
|
||||
const getValue = () => {
|
||||
switch (penalty.type) {
|
||||
case 'time_penalty': return `+${penalty.value}`;
|
||||
case 'grid_penalty': return `+${penalty.value}`;
|
||||
case 'points_deduction': return `-${penalty.value}`;
|
||||
case 'disqualification': return 'DSQ';
|
||||
case 'warning': return 'Warning';
|
||||
case 'license_points': return `${penalty.value}`;
|
||||
default: return penalty.value;
|
||||
}
|
||||
};
|
||||
|
||||
const getValueLabel = () => {
|
||||
switch (penalty.type) {
|
||||
case 'time_penalty': return 's';
|
||||
case 'grid_penalty': return 'grid';
|
||||
case 'points_deduction': return 'pts';
|
||||
case 'license_points': return 'LP';
|
||||
default: return '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PenaltyRow
|
||||
driverName={penalty.driverName}
|
||||
type={penalty.type}
|
||||
reason={penalty.reason}
|
||||
notes={penalty.notes}
|
||||
value={getValue()}
|
||||
valueLabel={getValueLabel()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
113
apps/website/components/races/RaceResultCard.tsx
Normal file
113
apps/website/components/races/RaceResultCard.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
'use client';
|
||||
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Link } from '@/ui/Link';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
|
||||
interface RaceResultCardProps {
|
||||
raceId: string;
|
||||
track: string;
|
||||
car: string;
|
||||
scheduledAt: string | Date;
|
||||
position: number;
|
||||
startPosition: number;
|
||||
incidents: number;
|
||||
leagueName?: string;
|
||||
showLeague?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function RaceResultCard({
|
||||
raceId,
|
||||
track,
|
||||
car,
|
||||
scheduledAt,
|
||||
position,
|
||||
startPosition,
|
||||
incidents,
|
||||
leagueName,
|
||||
showLeague = true,
|
||||
onClick,
|
||||
}: RaceResultCardProps) {
|
||||
const getPositionStyles = (pos: number) => {
|
||||
if (pos === 1) return { color: 'text-warning-amber', bg: 'bg-warning-amber', bgOpacity: 0.2 };
|
||||
if (pos === 2) return { color: 'text-gray-300', bg: 'bg-gray-400', bgOpacity: 0.2 };
|
||||
if (pos === 3) return { color: 'text-amber-600', bg: 'bg-amber-600', bgOpacity: 0.2 };
|
||||
return { color: 'text-gray-400', bg: 'bg-base-black', bgOpacity: 0.5 };
|
||||
};
|
||||
|
||||
const positionStyles = getPositionStyles(position);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={routes.race.detail(raceId)}
|
||||
variant="ghost"
|
||||
block
|
||||
onClick={onClick}
|
||||
>
|
||||
<Card p={4} hoverBorderColor="border-primary-accent" transition group bg="bg-surface-charcoal" border borderColor="border-outline-steel">
|
||||
<Box display="flex" alignItems="center" justifyContent="between" mb={2}>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Box
|
||||
width="8"
|
||||
height="8"
|
||||
rounded="md"
|
||||
display="flex"
|
||||
center
|
||||
weight="bold"
|
||||
size="sm"
|
||||
color={positionStyles.color}
|
||||
bg={positionStyles.bg}
|
||||
bgOpacity={positionStyles.bgOpacity}
|
||||
border
|
||||
borderColor="border-outline-steel"
|
||||
>
|
||||
P{position}
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color="text-white" weight="medium" block groupHoverTextColor="text-primary-accent" transition>
|
||||
{track}
|
||||
</Text>
|
||||
<Text size="sm" color="text-gray-400" block>{car}</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Box textAlign="right">
|
||||
<Text size="sm" color="text-gray-400" block>
|
||||
{new Date(scheduledAt).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</Text>
|
||||
{showLeague && leagueName && (
|
||||
<Text size="xs" color="text-gray-500" block>{leagueName}</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Icon icon={ChevronRight} size={5} color="text-gray-500" groupHoverTextColor="text-primary-accent" transition />
|
||||
</Stack>
|
||||
</Box>
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
<Text size="xs" color="text-gray-500">Started P{startPosition}</Text>
|
||||
<Text size="xs" color="text-gray-500">•</Text>
|
||||
<Text size="xs" color={incidents === 0 ? 'text-success-green' : incidents > 2 ? 'text-error-red' : 'text-gray-500'}>
|
||||
{incidents}x incidents
|
||||
</Text>
|
||||
{position < startPosition && (
|
||||
<>
|
||||
<Text size="xs" color="text-gray-500">•</Text>
|
||||
<Text size="xs" color="text-success-green">
|
||||
+{startPosition - position} positions
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
|
||||
import { RaceResultViewModel } from '@/lib/view-models/RaceResultViewModel';
|
||||
import { RaceResultCard as UiRaceResultCard } from '@/ui/RaceResultCard';
|
||||
import { RaceResultCard as UiRaceResultCard } from './RaceResultCard';
|
||||
|
||||
interface RaceResultCardProps {
|
||||
race: {
|
||||
|
||||
158
apps/website/components/races/RaceResultHero.tsx
Normal file
158
apps/website/components/races/RaceResultHero.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
|
||||
|
||||
import { Trophy } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { DecorativeBlur } from '@/ui/DecorativeBlur';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
interface RaceResultHeroProps {
|
||||
position: number;
|
||||
startPosition: number;
|
||||
positionChange: number;
|
||||
incidents: number;
|
||||
isClean: boolean;
|
||||
isPodium: boolean;
|
||||
ratingChange?: number;
|
||||
animatedRatingChange: number;
|
||||
}
|
||||
|
||||
export function RaceResultHero({
|
||||
position,
|
||||
startPosition,
|
||||
positionChange,
|
||||
incidents,
|
||||
isClean,
|
||||
isPodium,
|
||||
ratingChange,
|
||||
animatedRatingChange,
|
||||
}: RaceResultHeroProps) {
|
||||
const isVictory = position === 1;
|
||||
const isSecond = position === 2;
|
||||
const isThird = position === 3;
|
||||
|
||||
const getPositionBg = () => {
|
||||
if (isVictory) return 'linear-gradient(to bottom right, #facc15, #d97706)';
|
||||
if (isSecond) return 'linear-gradient(to bottom right, #d1d5db, #6b7280)';
|
||||
if (isThird) return 'linear-gradient(to bottom right, #3b82f6, #2563eb)';
|
||||
return 'linear-gradient(to bottom right, #3b82f6, #2563eb)';
|
||||
};
|
||||
|
||||
const getOuterBg = () => {
|
||||
if (isVictory) return 'linear-gradient(to right, #eab308, #facc15, #d97706)';
|
||||
if (isPodium) return 'linear-gradient(to right, #9ca3af, #d1d5db, #6b7280)';
|
||||
return 'linear-gradient(to right, #3b82f6, #60a5fa, #2563eb)';
|
||||
};
|
||||
|
||||
return (
|
||||
<Surface
|
||||
rounded="2xl"
|
||||
p={1}
|
||||
style={{ background: getOuterBg() }}
|
||||
>
|
||||
<Surface variant="dark" rounded="xl" p={8} position="relative">
|
||||
<DecorativeBlur color="blue" size="lg" position="top-right" opacity={10} />
|
||||
|
||||
<Stack direction="row" align="center" justify="between" wrap gap={6} position="relative" zIndex={10}>
|
||||
<Stack direction="row" align="center" gap={5}>
|
||||
<Box
|
||||
position="relative"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
w="28"
|
||||
h="28"
|
||||
rounded="2xl"
|
||||
color={position <= 2 ? 'text-iron-gray' : 'text-white'}
|
||||
shadow="0 20px 25px -5px rgba(0, 0, 0, 0.1)"
|
||||
style={{
|
||||
background: getPositionBg(),
|
||||
fontWeight: 900,
|
||||
fontSize: '3rem'
|
||||
}}
|
||||
>
|
||||
{isVictory && (
|
||||
<Icon
|
||||
icon={Trophy}
|
||||
size={8}
|
||||
color="#fef08a"
|
||||
position="absolute"
|
||||
top="-3"
|
||||
right="-2"
|
||||
/>
|
||||
)}
|
||||
P{position}
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text
|
||||
size="3xl"
|
||||
weight="bold"
|
||||
block
|
||||
mb={1}
|
||||
color={isVictory ? 'text-yellow-400' : isPodium ? 'text-gray-300' : 'text-white'}
|
||||
>
|
||||
{isVictory ? '🏆 VICTORY!' : isSecond ? '🥈 Second Place' : isThird ? '🥉 Podium Finish' : `P${position} Finish`}
|
||||
</Text>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Text size="sm" color="text-gray-400">Started P{startPosition}</Text>
|
||||
<Box w="1" h="1" rounded="full" bg="bg-charcoal-outline" />
|
||||
<Text size="sm" color={isClean ? 'text-performance-green' : 'text-gray-400'}>
|
||||
{incidents}x incidents {isClean && '✨'}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" gap={3} wrap>
|
||||
{positionChange !== 0 && (
|
||||
<Surface
|
||||
variant="muted"
|
||||
rounded="2xl"
|
||||
border
|
||||
p={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" color={positionChange > 0 ? 'text-performance-green' : 'text-red-500'}>
|
||||
{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
|
||||
p={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" color={ratingChange > 0 ? 'text-warning-amber' : 'text-red-500'}>
|
||||
{animatedRatingChange > 0 ? '+' : ''}{animatedRatingChange}
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-400">Rating</Text>
|
||||
</Stack>
|
||||
</Surface>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Surface>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
14
apps/website/components/races/RaceResultList.tsx
Normal file
14
apps/website/components/races/RaceResultList.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
|
||||
interface RaceResultListProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function RaceResultList({ children }: RaceResultListProps) {
|
||||
return (
|
||||
<Stack as="ul" gap={3}>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
64
apps/website/components/races/RaceResultsHeader.tsx
Normal file
64
apps/website/components/races/RaceResultsHeader.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
|
||||
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { PageHero } from '@/ui/PageHero';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
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 function RaceResultsHeader({
|
||||
raceTrack = 'Race',
|
||||
raceScheduledAt,
|
||||
totalDrivers,
|
||||
leagueName,
|
||||
raceSOF
|
||||
}: RaceResultsHeaderProps) {
|
||||
const stats = [
|
||||
...(raceScheduledAt ? [{
|
||||
icon: Calendar,
|
||||
value: new Date(raceScheduledAt).toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}),
|
||||
label: '',
|
||||
color: 'text-gray-400'
|
||||
}] : []),
|
||||
...(totalDrivers !== undefined && totalDrivers !== null ? [{
|
||||
icon: Users,
|
||||
value: totalDrivers,
|
||||
label: 'drivers classified',
|
||||
color: 'text-gray-400'
|
||||
}] : []),
|
||||
...(leagueName ? [{
|
||||
value: leagueName,
|
||||
label: '',
|
||||
color: 'text-primary-blue'
|
||||
}] : [])
|
||||
];
|
||||
|
||||
return (
|
||||
<PageHero
|
||||
title={`${raceTrack || DEFAULT_RACE_TRACK} Results`}
|
||||
icon={Trophy}
|
||||
stats={stats}
|
||||
>
|
||||
{raceSOF && (
|
||||
<Stack direction="row" align="center" gap={1.5} mt={4}>
|
||||
<Icon icon={Zap} size={4} color="text-warning-amber" />
|
||||
<Text size="sm" color="text-warning-amber">SOF {raceSOF}</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</PageHero>
|
||||
);
|
||||
}
|
||||
264
apps/website/components/races/RaceResultsTable.tsx
Normal file
264
apps/website/components/races/RaceResultsTable.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
|
||||
|
||||
import { AlertTriangle, ExternalLink } from 'lucide-react';
|
||||
import { ReactNode } from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Link } from '@/ui/Link';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/ui/Table';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
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 RaceResultsTableProps {
|
||||
results: ResultDTO[];
|
||||
drivers: DriverDTO[];
|
||||
pointsSystem: Record<number, number>;
|
||||
fastestLapTime?: number | undefined;
|
||||
penalties?: PenaltyData[];
|
||||
currentDriverId?: string | undefined;
|
||||
isAdmin?: boolean;
|
||||
onPenaltyClick?: (driver: DriverDTO) => void;
|
||||
penaltyButtonRenderer?: (driver: DriverDTO) => ReactNode;
|
||||
}
|
||||
|
||||
export function RaceResultsTable({
|
||||
results,
|
||||
drivers,
|
||||
pointsSystem,
|
||||
fastestLapTime,
|
||||
penalties = [],
|
||||
currentDriverId,
|
||||
isAdmin = false,
|
||||
penaltyButtonRenderer,
|
||||
}: RaceResultsTableProps) {
|
||||
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 (
|
||||
<Box textAlign="center" py={8}>
|
||||
<Text color="text-gray-400">No results available</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box overflow="auto">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader>Pos</TableHeader>
|
||||
<TableHeader>Driver</TableHeader>
|
||||
<TableHeader>Fastest Lap</TableHeader>
|
||||
<TableHeader>Incidents</TableHeader>
|
||||
<TableHeader>Points</TableHeader>
|
||||
<TableHeader>+/-</TableHeader>
|
||||
<TableHeader>Penalties</TableHeader>
|
||||
{isAdmin && <TableHeader className="text-right">Actions</TableHeader>}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{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 (
|
||||
<TableRow
|
||||
key={result.id}
|
||||
variant={isCurrentUser ? 'highlight' : 'default'}
|
||||
>
|
||||
<TableCell>
|
||||
<Box
|
||||
display="inline-flex"
|
||||
center
|
||||
width="8"
|
||||
height="8"
|
||||
rounded="lg"
|
||||
weight="bold"
|
||||
size="sm"
|
||||
bg={
|
||||
result.position === 1
|
||||
? 'bg-yellow-500/20'
|
||||
: result.position === 2
|
||||
? 'bg-gray-400/20'
|
||||
: result.position === 3
|
||||
? 'bg-amber-600/20'
|
||||
: undefined
|
||||
}
|
||||
color={
|
||||
result.position === 1
|
||||
? 'text-yellow-400'
|
||||
: result.position === 2
|
||||
? 'text-gray-300'
|
||||
: result.position === 3
|
||||
? 'text-amber-500'
|
||||
: 'text-white'
|
||||
}
|
||||
>
|
||||
{result.position}
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
{driver ? (
|
||||
<>
|
||||
<Box
|
||||
width="8"
|
||||
height="8"
|
||||
rounded="full"
|
||||
display="flex"
|
||||
center
|
||||
size="sm"
|
||||
weight="bold"
|
||||
flexShrink={0}
|
||||
bg={isCurrentUser ? 'bg-primary-blue/30' : 'bg-iron-gray'}
|
||||
color={isCurrentUser ? 'text-primary-blue' : 'text-gray-400'}
|
||||
className={isCurrentUser ? 'ring-2 ring-primary-blue/50' : ''}
|
||||
>
|
||||
{driver.name.charAt(0)}
|
||||
</Box>
|
||||
<Link
|
||||
href={`/drivers/${driver.id}`}
|
||||
variant="ghost"
|
||||
className={`group ${isCurrentUser ? 'text-primary-blue font-semibold' : 'text-white'}`}
|
||||
>
|
||||
<Text className="group-hover:underline">{driver.name}</Text>
|
||||
{isCurrentUser && (
|
||||
<Box as="span" px={1.5} py={0.5} ml={1.5} bg="bg-primary-blue" color="text-white" rounded="full" uppercase style={{ fontSize: '10px', fontWeight: 'bold' }}>
|
||||
You
|
||||
</Box>
|
||||
)}
|
||||
<Icon icon={ExternalLink} size={3} className="ml-1.5 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<Text color="text-white">{getDriverName(result.driverId)}</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text color={isFastestLap ? 'text-performance-green' : 'text-white'} weight={isFastestLap ? 'medium' : 'normal'}>
|
||||
{formatLapTime(result.fastestLap)}
|
||||
</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text color={result.incidents > 0 ? 'text-warning-amber' : 'text-white'}>
|
||||
{result.incidents}×
|
||||
</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text color="text-white" weight="medium">
|
||||
{getPoints(result.position)}
|
||||
</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text weight="medium" className={getPositionChangeColor(positionChange)}>
|
||||
{getPositionChangeText(positionChange)}
|
||||
</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{driverPenalties.length > 0 ? (
|
||||
<Stack gap={1}>
|
||||
{driverPenalties.map((penalty, idx) => (
|
||||
<Stack key={idx} direction="row" align="center" gap={1.5} color="text-red-400">
|
||||
<Icon icon={AlertTriangle} size={3} />
|
||||
<Text size="xs">{getPenaltyDescription(penalty)}</Text>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Text color="text-gray-500">—</Text>
|
||||
)}
|
||||
</TableCell>
|
||||
{isAdmin && (
|
||||
<TableCell className="text-right">
|
||||
{driver && penaltyButtonRenderer && penaltyButtonRenderer(driver)}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { Text } from '@/ui/Text';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { SidebarRaceItem } from '@/ui/SidebarRaceItem';
|
||||
import { SidebarRaceItem } from '@/components/races/SidebarRaceItem';
|
||||
import { SidebarActionLink } from '@/ui/SidebarActionLink';
|
||||
import type { RaceViewData } from '@/lib/view-data/RacesViewData';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
@@ -24,7 +24,7 @@ export function RaceSidebar({ upcomingRaces, recentResults, onRaceClick }: RaceS
|
||||
<Card>
|
||||
<Stack gap={4}>
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Heading level={3} icon={<Icon icon={Clock} size={4} color="rgb(59, 130, 246)" />}>
|
||||
<Heading level={3} icon={<Icon icon={Clock} size={4} color="var(--primary-accent)" />}>
|
||||
Next Up
|
||||
</Heading>
|
||||
<Text size="xs" color="text-gray-500">This week</Text>
|
||||
@@ -55,7 +55,7 @@ export function RaceSidebar({ upcomingRaces, recentResults, onRaceClick }: RaceS
|
||||
{/* Recent Results */}
|
||||
<Card>
|
||||
<Stack gap={4}>
|
||||
<Heading level={3} icon={<Icon icon={Trophy} size={4} color="rgb(245, 158, 11)" />}>
|
||||
<Heading level={3} icon={<Icon icon={Trophy} size={4} color="var(--warning-amber)" />}>
|
||||
Recent Results
|
||||
</Heading>
|
||||
|
||||
|
||||
40
apps/website/components/races/RaceSidebarPanel.tsx
Normal file
40
apps/website/components/races/RaceSidebarPanel.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface RaceSidebarPanelProps {
|
||||
title: string;
|
||||
icon?: LucideIcon;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function RaceSidebarPanel({
|
||||
title,
|
||||
icon,
|
||||
children
|
||||
}: RaceSidebarPanelProps) {
|
||||
return (
|
||||
<Box
|
||||
bg="bg-panel-gray"
|
||||
rounded="xl"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
overflow="hidden"
|
||||
>
|
||||
<Box p={4} borderBottom="1px solid" borderColor="border-charcoal-outline" bg="bg-graphite-black/30">
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
{icon && <Icon icon={icon} size={4} color="#198CFF" />}
|
||||
<Text weight="bold" size="sm" color="text-white" uppercase>
|
||||
{title}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Box p={4}>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
44
apps/website/components/races/RaceStats.tsx
Normal file
44
apps/website/components/races/RaceStats.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { CalendarDays, Clock, Zap, Trophy } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { StatGridItem } from '@/ui/StatGridItem';
|
||||
|
||||
interface RaceStatsProps {
|
||||
stats: {
|
||||
total: number;
|
||||
scheduled: number;
|
||||
running: number;
|
||||
completed: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function RaceStats({ stats }: RaceStatsProps) {
|
||||
return (
|
||||
<Box display="grid" gridCols={{ base: 2, md: 4 }} gap={4} mt={6}>
|
||||
<StatGridItem
|
||||
label="Total"
|
||||
value={stats.total}
|
||||
icon={CalendarDays}
|
||||
color="text-gray-400"
|
||||
/>
|
||||
<StatGridItem
|
||||
label="Scheduled"
|
||||
value={stats.scheduled}
|
||||
icon={Clock}
|
||||
color="text-primary-blue"
|
||||
/>
|
||||
<StatGridItem
|
||||
label="Live Now"
|
||||
value={stats.running}
|
||||
icon={Zap}
|
||||
color="text-performance-green"
|
||||
/>
|
||||
<StatGridItem
|
||||
label="Completed"
|
||||
value={stats.completed}
|
||||
icon={Trophy}
|
||||
color="text-gray-400"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
60
apps/website/components/races/RaceStatusBadge.tsx
Normal file
60
apps/website/components/races/RaceStatusBadge.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
|
||||
interface RaceStatusBadgeProps {
|
||||
status: 'scheduled' | 'running' | 'completed' | 'cancelled' | string;
|
||||
}
|
||||
|
||||
export function RaceStatusBadge({ status }: RaceStatusBadgeProps) {
|
||||
const config = {
|
||||
scheduled: {
|
||||
variant: 'info' as const,
|
||||
label: 'SCHEDULED',
|
||||
color: 'text-primary-blue',
|
||||
bg: 'bg-primary-blue/10',
|
||||
border: 'border-primary-blue/30'
|
||||
},
|
||||
running: {
|
||||
variant: 'success' as const,
|
||||
label: 'LIVE',
|
||||
color: 'text-performance-green',
|
||||
bg: 'bg-performance-green/10',
|
||||
border: 'border-performance-green/30'
|
||||
},
|
||||
completed: {
|
||||
variant: 'neutral' as const,
|
||||
label: 'COMPLETED',
|
||||
color: 'text-gray-400',
|
||||
bg: 'bg-gray-400/10',
|
||||
border: 'border-gray-400/30'
|
||||
},
|
||||
cancelled: {
|
||||
variant: 'warning' as const,
|
||||
label: 'CANCELLED',
|
||||
color: 'text-warning-amber',
|
||||
bg: 'bg-warning-amber/10',
|
||||
border: 'border-warning-amber/30'
|
||||
},
|
||||
};
|
||||
|
||||
const badgeConfig = config[status as keyof typeof config] || {
|
||||
variant: 'neutral' as const,
|
||||
label: status.toUpperCase(),
|
||||
color: 'text-gray-400',
|
||||
bg: 'bg-gray-400/10',
|
||||
border: 'border-gray-400/30'
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
px={2.5}
|
||||
py={0.5}
|
||||
rounded="none"
|
||||
border
|
||||
className={`${badgeConfig.bg} ${badgeConfig.color} ${badgeConfig.border}`}
|
||||
style={{ fontSize: '10px', fontWeight: '800', letterSpacing: '0.05em' }}
|
||||
>
|
||||
{badgeConfig.label}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
35
apps/website/components/races/RaceStewardingStats.tsx
Normal file
35
apps/website/components/races/RaceStewardingStats.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { CheckCircle, Clock, Gavel } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { StatGridItem } from '@/ui/StatGridItem';
|
||||
|
||||
interface RaceStewardingStatsProps {
|
||||
pendingCount: number;
|
||||
resolvedCount: number;
|
||||
penaltiesCount: number;
|
||||
}
|
||||
|
||||
export function RaceStewardingStats({ pendingCount, resolvedCount, penaltiesCount }: RaceStewardingStatsProps) {
|
||||
return (
|
||||
<Box display="grid" gridCols={3} gap={4}>
|
||||
<StatGridItem
|
||||
label="Pending"
|
||||
value={pendingCount}
|
||||
icon={Clock}
|
||||
color="text-warning-amber"
|
||||
/>
|
||||
<StatGridItem
|
||||
label="Resolved"
|
||||
value={resolvedCount}
|
||||
icon={CheckCircle}
|
||||
color="text-performance-green"
|
||||
/>
|
||||
<StatGridItem
|
||||
label="Penalties"
|
||||
value={penaltiesCount}
|
||||
icon={Gavel}
|
||||
color="text-red-400"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
28
apps/website/components/races/RaceSummaryItem.tsx
Normal file
28
apps/website/components/races/RaceSummaryItem.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
interface RaceSummaryItemProps {
|
||||
track: string;
|
||||
meta: string;
|
||||
date: Date;
|
||||
}
|
||||
|
||||
export function RaceSummaryItem({ track, meta, date }: RaceSummaryItemProps) {
|
||||
return (
|
||||
<Box display="flex" justifyContent="between" gap={3}>
|
||||
<Box flexGrow={1} minWidth="0">
|
||||
<Text size="xs" color="text-white" block truncate>{track}</Text>
|
||||
<Text size="xs" color="text-gray-400" block truncate>{meta}</Text>
|
||||
</Box>
|
||||
<Box textAlign="right">
|
||||
<Text size="xs" color="text-gray-500" className="whitespace-nowrap">
|
||||
{date.toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
18
apps/website/components/races/RaceUserResultWrapper.tsx
Normal file
18
apps/website/components/races/RaceUserResultWrapper.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
|
||||
|
||||
import { RaceResultHero } from '@/components/races/RaceResultHero';
|
||||
|
||||
interface RaceUserResultProps {
|
||||
position: number;
|
||||
startPosition: number;
|
||||
positionChange: number;
|
||||
incidents: number;
|
||||
isClean: boolean;
|
||||
isPodium: boolean;
|
||||
ratingChange?: number;
|
||||
animatedRatingChange: number;
|
||||
}
|
||||
|
||||
export function RaceUserResult(props: RaceUserResultProps) {
|
||||
return <RaceResultHero {...props} />;
|
||||
}
|
||||
68
apps/website/components/races/SessionSummaryPanel.tsx
Normal file
68
apps/website/components/races/SessionSummaryPanel.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import { Panel } from '@/ui/Panel';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { StatusDot } from '@/ui/StatusDot';
|
||||
|
||||
interface SessionSummaryPanelProps {
|
||||
title: string;
|
||||
status: 'live' | 'upcoming' | 'completed';
|
||||
startTime?: string;
|
||||
trackName?: string;
|
||||
carName?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* SessionSummaryPanel
|
||||
*
|
||||
* Displays a dense summary of a racing session.
|
||||
* Part of the "Telemetry Workspace" layout.
|
||||
*/
|
||||
export function SessionSummaryPanel({
|
||||
title,
|
||||
status,
|
||||
startTime,
|
||||
trackName,
|
||||
carName,
|
||||
className = '',
|
||||
}: SessionSummaryPanelProps) {
|
||||
const statusColor = status === 'live' ? '#4ED4E0' : status === 'upcoming' ? '#FFBE4D' : '#94a3b8';
|
||||
|
||||
return (
|
||||
<Panel title="Session Summary" className={className}>
|
||||
<Box display="flex" flexDirection="col" gap={3}>
|
||||
<Box display="flex" align="center" justify="between">
|
||||
<Text weight="bold" size="lg">{title}</Text>
|
||||
<Box display="flex" align="center" gap={2}>
|
||||
<StatusDot color={statusColor} pulse={status === 'live'} size={2} />
|
||||
<Text size="xs" uppercase weight="bold" style={{ color: statusColor }}>
|
||||
{status}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box display="grid" gridCols={2} gap={4} borderTop borderStyle="solid" borderColor="border-gray/10" pt={3}>
|
||||
{startTime && (
|
||||
<Box>
|
||||
<Text size="xs" color="text-gray-500" uppercase block mb={1}>Start Time</Text>
|
||||
<Text size="sm">{startTime}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{trackName && (
|
||||
<Box>
|
||||
<Text size="xs" color="text-gray-500" uppercase block mb={1}>Track</Text>
|
||||
<Text size="sm">{trackName}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{carName && (
|
||||
<Box colSpan={2}>
|
||||
<Text size="xs" color="text-gray-500" uppercase block mb={1}>Vehicle</Text>
|
||||
<Text size="sm">{carName}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
43
apps/website/components/races/SidebarRaceItem.tsx
Normal file
43
apps/website/components/races/SidebarRaceItem.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
|
||||
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 (
|
||||
<Box
|
||||
onClick={onClick}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={3}
|
||||
p={2}
|
||||
rounded="lg"
|
||||
cursor="pointer"
|
||||
className={`hover:bg-deep-graphite transition-colors ${className || ''}`}
|
||||
>
|
||||
<Box flexShrink={0} width="10" height="10" bg="bg-primary-blue/10" rounded="lg" display="flex" center>
|
||||
<Text size="sm" weight="bold" color="text-primary-blue">
|
||||
{scheduledAtDate.getDate()}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} minWidth="0">
|
||||
<Text size="sm" weight="medium" color="text-white" block truncate>{race.track}</Text>
|
||||
<Text size="xs" color="text-gray-500" block>{scheduledAtDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</Text>
|
||||
</Box>
|
||||
<Icon icon={ChevronRight} size={4} color="text-gray-500" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
48
apps/website/components/races/StandingsItem.tsx
Normal file
48
apps/website/components/races/StandingsItem.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Badge } from '@/ui/Badge';
|
||||
import { Grid } from '@/ui/Grid';
|
||||
import { StatItem } from '@/ui/StatItem';
|
||||
|
||||
interface StandingsItemProps {
|
||||
leagueName: string;
|
||||
position: number;
|
||||
points: number;
|
||||
wins: number;
|
||||
racesCompleted: number;
|
||||
}
|
||||
|
||||
export function StandingsItem({
|
||||
leagueName,
|
||||
position,
|
||||
points,
|
||||
wins,
|
||||
racesCompleted,
|
||||
}: StandingsItemProps) {
|
||||
return (
|
||||
<Box
|
||||
bg="bg-iron-gray/50"
|
||||
rounded="lg"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
p={4}
|
||||
>
|
||||
<Stack direction="row" align="center" justify="between" mb={3}>
|
||||
<Heading level={4}>
|
||||
{leagueName}
|
||||
</Heading>
|
||||
<Badge variant="primary">
|
||||
P{position}
|
||||
</Badge>
|
||||
</Stack>
|
||||
|
||||
<Grid cols={3} gap={4}>
|
||||
<StatItem label="Points" value={points} align="center" />
|
||||
<StatItem label="Wins" value={wins} align="center" />
|
||||
<StatItem label="Races" value={racesCompleted} align="center" />
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
33
apps/website/components/races/StandingsList.tsx
Normal file
33
apps/website/components/races/StandingsList.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { StandingsItem } from './StandingsItem';
|
||||
|
||||
interface Standing {
|
||||
leagueId: string;
|
||||
leagueName: string;
|
||||
position: number;
|
||||
points: number;
|
||||
wins: number;
|
||||
racesCompleted: number;
|
||||
}
|
||||
|
||||
interface StandingsListProps {
|
||||
standings: Standing[];
|
||||
}
|
||||
|
||||
export function StandingsList({ standings }: StandingsListProps) {
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
{standings.map((standing) => (
|
||||
<StandingsItem
|
||||
key={standing.leagueId}
|
||||
leagueName={standing.leagueName}
|
||||
position={standing.position}
|
||||
points={standing.points}
|
||||
wins={standing.wins}
|
||||
racesCompleted={standing.racesCompleted}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
43
apps/website/components/races/TelemetryLine.tsx
Normal file
43
apps/website/components/races/TelemetryLine.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
|
||||
interface TelemetryLineProps {
|
||||
color?: 'primary' | 'aqua' | 'amber' | 'green' | 'red';
|
||||
height?: number | string;
|
||||
animate?: boolean;
|
||||
opacity?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TelemetryLine({
|
||||
color = 'primary',
|
||||
height = '2px',
|
||||
animate = false,
|
||||
opacity = 1,
|
||||
className = ''
|
||||
}: TelemetryLineProps) {
|
||||
const colorMap = {
|
||||
primary: 'bg-primary-accent',
|
||||
aqua: 'bg-telemetry-aqua',
|
||||
amber: 'bg-warning-amber',
|
||||
green: 'bg-success-green',
|
||||
red: 'bg-critical-red',
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
height={height}
|
||||
fullWidth
|
||||
className={`${colorMap[color]} ${animate ? 'animate-pulse' : ''} ${className}`}
|
||||
style={{
|
||||
opacity,
|
||||
boxShadow: `0 0 8px ${
|
||||
color === 'primary' ? '#198CFF' :
|
||||
color === 'aqua' ? '#4ED4E0' :
|
||||
color === 'amber' ? '#FFBE4D' :
|
||||
color === 'green' ? '#6FE37A' : '#E35C5C'
|
||||
}4D`
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
50
apps/website/components/races/TelemetryStrip.tsx
Normal file
50
apps/website/components/races/TelemetryStrip.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
interface TelemetryItem {
|
||||
label: string;
|
||||
value: string | number;
|
||||
trend?: 'up' | 'down' | 'neutral';
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface TelemetryStripProps {
|
||||
items: TelemetryItem[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* TelemetryStrip
|
||||
*
|
||||
* A thin, dense strip showing key telemetry or performance metrics.
|
||||
* Follows the "Precision Racing Minimal" theme.
|
||||
*/
|
||||
export function TelemetryStrip({ items, className = '' }: TelemetryStripProps) {
|
||||
return (
|
||||
<Box
|
||||
className={`bg-graphite-black/80 border-y border-border-gray/30 py-2 px-4 flex items-center gap-8 overflow-x-auto no-scrollbar ${className}`}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<Box key={index} display="flex" align="center" gap={2} className="whitespace-nowrap">
|
||||
<Text size="xs" color="text-gray-500" uppercase weight="bold" letterSpacing="widest">
|
||||
{item.label}
|
||||
</Text>
|
||||
<Text
|
||||
size="sm"
|
||||
weight="bold"
|
||||
style={{ color: item.color || '#4ED4E0' }}
|
||||
className="font-mono"
|
||||
>
|
||||
{item.value}
|
||||
</Text>
|
||||
{item.trend && (
|
||||
<Text size="xs" style={{ color: item.trend === 'up' ? '#10b981' : item.trend === 'down' ? '#ef4444' : '#94a3b8' }}>
|
||||
{item.trend === 'up' ? '↑' : item.trend === 'down' ? '↓' : '•'}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
46
apps/website/components/races/TrackImage.tsx
Normal file
46
apps/website/components/races/TrackImage.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { ImagePlaceholder } from '@/ui/ImagePlaceholder';
|
||||
|
||||
export interface TrackImageProps {
|
||||
trackId?: string;
|
||||
src?: string;
|
||||
alt: string;
|
||||
aspectRatio?: string;
|
||||
className?: string;
|
||||
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
||||
}
|
||||
|
||||
export function TrackImage({
|
||||
trackId,
|
||||
src,
|
||||
alt,
|
||||
aspectRatio = '16/9',
|
||||
className = '',
|
||||
rounded = 'lg',
|
||||
}: TrackImageProps) {
|
||||
const imageSrc = src || (trackId ? `/media/tracks/${trackId}/image` : undefined);
|
||||
|
||||
return (
|
||||
<Box
|
||||
width="full"
|
||||
overflow="hidden"
|
||||
bg="bg-charcoal-outline/10"
|
||||
rounded={rounded}
|
||||
className={className}
|
||||
style={{ aspectRatio }}
|
||||
>
|
||||
{imageSrc ? (
|
||||
<Image
|
||||
src={imageSrc}
|
||||
alt={alt}
|
||||
className="w-full h-full object-cover"
|
||||
fallbackSrc="/default-track-image.png"
|
||||
/>
|
||||
) : (
|
||||
<ImagePlaceholder aspectRatio={aspectRatio} rounded="none" />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
61
apps/website/components/races/UpcomingRaceItem.tsx
Normal file
61
apps/website/components/races/UpcomingRaceItem.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
|
||||
|
||||
import { Badge } from '@/ui/Badge';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
interface UpcomingRaceItemProps {
|
||||
track: string;
|
||||
car: string;
|
||||
formattedDate: string;
|
||||
formattedTime: string;
|
||||
isMyLeague: boolean;
|
||||
}
|
||||
|
||||
export function UpcomingRaceItem({
|
||||
track,
|
||||
car,
|
||||
formattedDate,
|
||||
formattedTime,
|
||||
isMyLeague,
|
||||
}: UpcomingRaceItemProps) {
|
||||
return (
|
||||
<Surface
|
||||
variant="muted"
|
||||
padding={4}
|
||||
rounded="none"
|
||||
border
|
||||
borderColor="border-gray/30"
|
||||
className="hover:border-primary-accent/30 transition-colors bg-panel-gray/20 group"
|
||||
>
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
<Box w="1" h="8" bg="primary-accent" opacity={0.3} className="group-hover:opacity-100 transition-opacity" />
|
||||
<Box flexGrow={1}>
|
||||
<Text color="text-white" weight="bold" block className="tracking-tight">
|
||||
{track}
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-500" block weight="medium" className="uppercase tracking-widest mt-0.5">
|
||||
{car}
|
||||
</Text>
|
||||
</Box>
|
||||
<Stack align="end" gap={1}>
|
||||
<Text size="xs" color="text-gray-400" font="mono" weight="bold">
|
||||
{formattedDate}
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-600" font="mono">
|
||||
{formattedTime}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
{isMyLeague && (
|
||||
<Box mt={3} display="flex" justifyContent="end">
|
||||
<Badge variant="success" size="xs">
|
||||
YOUR LEAGUE
|
||||
</Badge>
|
||||
</Box>
|
||||
)}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { Icon } from '@/ui/Icon';
|
||||
import { Link } from '@/ui/Link';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { UpcomingRaceItem } from '@/ui/UpcomingRaceItem';
|
||||
import { UpcomingRaceItem } from '@/components/races/UpcomingRaceItem';
|
||||
import { UpcomingRacesList } from '@/components/races/UpcomingRacesList';
|
||||
import { Calendar } from 'lucide-react';
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Card } from '@/ui/Card';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { RaceSummaryItem } from '@/ui/RaceSummaryItem';
|
||||
import { RaceSummaryItem } from '@/components/races/RaceSummaryItem';
|
||||
|
||||
type UpcomingRace = {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user