website refactor

This commit is contained in:
2026-01-18 13:26:35 +01:00
parent 350c78504d
commit 0b301feb61
225 changed files with 1678 additions and 26666 deletions

View 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>
);
}

View File

@@ -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 = {

View 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>
);
}

View 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>
);
}

View File

@@ -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';

View 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>
);
}

View File

@@ -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: {

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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: {

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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} />;
}

View 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&apos;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;
}

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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>

View 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()}
/>
);
}

View 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>
);
}

View File

@@ -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: {

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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} />;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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`
}}
/>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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';

View File

@@ -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;