website refactor

This commit is contained in:
2026-01-17 15:46:55 +01:00
parent 4d5ce9bfd6
commit 72a626ce71
346 changed files with 19308 additions and 8605 deletions

View File

@@ -0,0 +1,183 @@
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Image } from '@/ui/Image';
import { Trophy, Users, Calendar, ChevronRight } from 'lucide-react';
interface LeagueCardProps {
id: string;
name: string;
description?: string;
coverUrl: string;
logoUrl?: string;
gameName?: string;
memberCount: number;
maxMembers?: number;
nextRaceDate?: string;
championshipType: 'driver' | 'team' | 'nations' | 'trophy';
onClick?: () => void;
}
export function LeagueCard({
name,
description,
coverUrl,
logoUrl,
gameName,
memberCount,
maxMembers,
nextRaceDate,
championshipType,
onClick,
}: LeagueCardProps) {
const fillPercentage = maxMembers ? (memberCount / maxMembers) * 100 : 0;
return (
<Box
as="article"
onClick={onClick}
position="relative"
display="flex"
flexDirection="col"
overflow="hidden"
border
borderColor="zinc-800"
bg="zinc-900/50"
hoverBorderColor="blue-500/30"
hoverBg="zinc-900"
transition
cursor="pointer"
group
>
{/* Cover Image */}
<Box position="relative" h="32" overflow="hidden">
<Box fullWidth fullHeight opacity={0.6}>
<Image
src={coverUrl}
alt={`${name} cover`}
fullWidth
fullHeight
objectFit="cover"
// eslint-disable-next-line gridpilot-rules/component-classification
className="transition-transform duration-500 group-hover:scale-105"
/>
</Box>
<Box position="absolute" inset="0" bg="linear-gradient(to top, #09090b, transparent)" />
{/* Game Badge */}
{gameName && (
<Box
position="absolute"
top="3"
left="3"
px={2}
py={1}
bg="zinc-900/80"
border
borderColor="white/10"
blur="sm"
>
<Text weight="bold" color="text-zinc-300" uppercase letterSpacing="0.05em" fontSize="10px">
{gameName}
</Text>
</Box>
)}
{/* Championship Icon */}
<Box
position="absolute"
top="3"
right="3"
p={1.5}
bg="zinc-900/80"
color="text-zinc-400"
border
borderColor="white/10"
blur="sm"
>
{championshipType === 'driver' && <Trophy size={14} />}
{championshipType === 'team' && <Users size={14} />}
</Box>
</Box>
{/* Content */}
<Box position="relative" display="flex" flexDirection="col" flexGrow={1} p={4} pt={6}>
{/* Logo */}
<Box
position="absolute"
top="-6"
left="4"
w="12"
h="12"
border
borderColor="zinc-800"
bg="zinc-950"
shadow="xl"
overflow="hidden"
>
{logoUrl ? (
<Image src={logoUrl} alt={`${name} logo`} fullWidth fullHeight objectFit="cover" />
) : (
<Box fullWidth fullHeight display="flex" alignItems="center" justifyContent="center" bg="zinc-900" color="text-zinc-700">
<Trophy size={20} />
</Box>
)}
</Box>
<Box display="flex" flexDirection="col" gap={1} mb={4}>
<Heading level={3} fontSize="lg" weight="bold" color="text-white"
// eslint-disable-next-line gridpilot-rules/component-classification
className="group-hover:text-blue-400 transition-colors truncate"
>
{name}
</Heading>
<Text size="xs" color="text-zinc-500" lineClamp={2} leading="relaxed" h="8">
{description || 'No description available'}
</Text>
</Box>
{/* Stats */}
<Box display="flex" flexDirection="col" gap={3} mt="auto">
<Box display="flex" flexDirection="col" gap={1.5}>
<Box display="flex" justifyContent="between">
<Text weight="bold" color="text-zinc-500" uppercase letterSpacing="widest" fontSize="10px">Drivers</Text>
<Text color="text-zinc-400" font="mono" fontSize="10px">{memberCount}/{maxMembers || '∞'}</Text>
</Box>
<Box h="1" bg="zinc-800" overflow="hidden">
<Box
h="full"
transition
bg={fillPercentage > 90 ? 'bg-amber-500' : 'bg-blue-500'}
w={`${Math.min(fillPercentage, 100)}%`}
/>
</Box>
</Box>
<Box display="flex" alignItems="center" justifyContent="between" pt={3} borderTop borderColor="zinc-800/50">
<Box display="flex" alignItems="center" gap={2} color="text-zinc-500">
<Calendar size={12} />
<Text weight="bold" uppercase font="mono" fontSize="10px">
{nextRaceDate || 'TBD'}
</Text>
</Box>
<Box display="flex" alignItems="center" gap={1} color="text-zinc-500"
// eslint-disable-next-line gridpilot-rules/component-classification
className="group-hover:text-blue-400 transition-colors"
>
<Text weight="bold" uppercase letterSpacing="widest" fontSize="10px">View</Text>
<Box
// eslint-disable-next-line gridpilot-rules/component-classification
className="transition-transform group-hover:translate-x-0.5"
>
<ChevronRight size={12} />
</Box>
</Box>
</Box>
</Box>
</Box>
</Box>
);
}

View File

@@ -1,11 +1,13 @@
import React from 'react';
import { MembershipStatus } from './MembershipStatus';
import { getMediaUrl } from '@/lib/utilities/media';
import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
import { LeagueHeader as UiLeagueHeader } from '@/ui/LeagueHeader';
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { Image } from '@/ui/Image';
import { MembershipStatus } from './MembershipStatus';
// Main sponsor info for "by XYZ" display
interface MainSponsorInfo {
name: string;
logoUrl?: string;
@@ -27,33 +29,61 @@ export function LeagueHeader({
description,
mainSponsor,
}: LeagueHeaderProps) {
const logoUrl = getMediaUrl('league-logo', leagueId);
return (
<UiLeagueHeader
name={leagueName}
description={description}
logoUrl={logoUrl}
statusContent={<MembershipStatus leagueId={leagueId} />}
sponsorContent={
mainSponsor ? (
mainSponsor.websiteUrl ? (
<Box
as="a"
href={mainSponsor.websiteUrl}
target="_blank"
rel="noreferrer"
color="text-primary-blue"
hoverTextColor="text-primary-blue/80"
transition
>
{mainSponsor.name}
</Box>
) : (
<Text color="text-primary-blue">{mainSponsor.name}</Text>
)
) : undefined
}
/>
<Box as="header" mb={8}>
<Stack direction="row" align="center" gap={6}>
<Box
position="relative"
w="20"
h="20"
overflow="hidden"
border
borderColor="white/10"
bg="zinc-900"
shadow="2xl"
>
<Image
src={`/api/media/league-logo/${leagueId}`}
alt={`${leagueName} logo`}
fullWidth
fullHeight
objectFit="cover"
/>
</Box>
<Stack gap={1}>
<Stack direction="row" align="center" gap={4}>
<Heading level={1} fontSize="3xl" weight="bold" color="text-white">
{leagueName}
{mainSponsor && (
<Text ml={3} size="lg" weight="normal" color="text-zinc-500">
by{' '}
{mainSponsor.websiteUrl ? (
<Box
as="a"
href={mainSponsor.websiteUrl}
target="_blank"
rel="noreferrer"
color="text-blue-500"
hoverTextColor="text-blue-400"
transition
>
{mainSponsor.name}
</Box>
) : (
<Text color="text-blue-500">{mainSponsor.name}</Text>
)}
</Text>
)}
</Heading>
<MembershipStatus leagueId={leagueId} />
</Stack>
{description && (
<Text color="text-zinc-400" size="sm" maxWidth="2xl" block leading="relaxed">
{description}
</Text>
)}
</Stack>
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,85 @@
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Surface } from '@/ui/Surface';
import { Icon } from '@/ui/Icon';
import { Trophy, Users, Timer, Activity, type LucideIcon } from 'lucide-react';
import type { LeagueDetailViewData } from '@/lib/view-data/LeagueDetailViewData';
interface LeagueHeaderPanelProps {
viewData: LeagueDetailViewData;
}
export function LeagueHeaderPanel({ viewData }: LeagueHeaderPanelProps) {
return (
<Surface variant="dark" border rounded="lg" padding={6} position="relative" overflow="hidden">
{/* Background Accent */}
<Box
position="absolute"
top={0}
right={0}
w="300px"
h="100%"
bg="bg-gradient-to-l from-primary-blue/5 to-transparent"
pointerEvents="none"
/>
<Stack direction={{ base: 'col', md: 'row' }} justify="between" align="center" gap={6}>
<Stack gap={2}>
<Stack direction="row" align="center" gap={3}>
<Box p={2} bg="bg-primary-blue/10" rounded="md" border borderColor="border-primary-blue/20">
<Icon icon={Trophy} size={6} color="text-primary-blue" />
</Box>
<Heading level={1} letterSpacing="tight">
{viewData.name}
</Heading>
</Stack>
<Text color="text-gray-400" size="sm" maxWidth="42rem">
{viewData.description}
</Text>
</Stack>
<Stack direction="row" gap={8} wrap>
<StatItem
icon={Users}
label="Members"
value={viewData.info.membersCount.toString()}
color="text-primary-blue"
/>
<StatItem
icon={Timer}
label="Races"
value={viewData.info.racesCount.toString()}
color="text-neon-aqua"
/>
<StatItem
icon={Activity}
label="Avg SOF"
value={(viewData.info.avgSOF ?? 0).toString()}
color="text-performance-green"
/>
</Stack>
</Stack>
</Surface>
);
}
function StatItem({ icon, label, value, color }: { icon: LucideIcon, label: string, value: string, color: string }) {
return (
<Stack gap={1}>
<Stack direction="row" align="center" gap={1.5}>
<Icon icon={icon} size={3.5} color="text-gray-500" />
<Text size="xs" color="text-gray-500" weight="medium" letterSpacing="wider" display="block">
{label.toUpperCase()}
</Text>
</Stack>
<Text size="xl" weight="bold" color={color} lineHeight="none">
{value}
</Text>
</Stack>
);
}

View File

@@ -0,0 +1,59 @@
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Link } from '@/ui/Link';
interface Tab {
label: string;
href: string;
exact?: boolean;
}
interface LeagueNavTabsProps {
tabs: Tab[];
currentPathname: string;
}
export function LeagueNavTabs({ tabs, currentPathname }: LeagueNavTabsProps) {
return (
<Box as="nav" borderBottom borderColor="zinc-800" mb={6}>
<Stack as="ul" direction="row" gap={8} overflow="auto" hideScrollbar>
{tabs.map((tab) => {
const isActive = tab.exact
? currentPathname === tab.href
: currentPathname.startsWith(tab.href);
return (
<Box as="li" key={tab.href} position="relative">
<Link
href={tab.href}
variant="ghost"
pb={4}
display="block"
size="sm"
weight="medium"
color={isActive ? 'text-blue-500' : 'text-zinc-400'}
hoverTextColor={isActive ? 'text-blue-500' : 'text-zinc-200'}
transition
>
{tab.label}
</Link>
{isActive && (
<Box
position="absolute"
bottom="0"
left="0"
right="0"
h="0.5"
bg="bg-blue-500"
/>
)}
</Box>
);
})}
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,53 @@
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Shield, Info } from 'lucide-react';
interface Rule {
id: string;
title: string;
content: string;
}
interface LeagueRulesPanelProps {
rules: Rule[];
}
export function LeagueRulesPanel({ rules }: LeagueRulesPanelProps) {
return (
<Box as="section">
<Stack gap={8}>
<Box display="flex" alignItems="start" gap={4} p={4} bg="blue-500/5" border borderColor="blue-500/20">
<Box color="text-blue-500" mt={0.5}><Info size={20} /></Box>
<Stack gap={1}>
<Text size="sm" weight="bold" color="text-blue-500" uppercase letterSpacing="0.05em">Code of Conduct</Text>
<Text size="sm" color="text-zinc-400" leading="relaxed">
All drivers are expected to maintain a high standard of sportsmanship.
Intentional wrecking or abusive behavior will result in immediate disqualification.
</Text>
</Stack>
</Box>
<Box display="grid" responsiveGridCols={{ base: 1, md: 2 }} gap={6}>
{rules.map((rule) => (
<Box as="article" key={rule.id} display="flex" flexDirection="col" gap={3} p={6} border borderColor="zinc-800" bg="zinc-900/30">
<Box display="flex" alignItems="center" gap={3}>
<Box p={2} bg="zinc-800" color="text-zinc-400">
<Shield size={18} />
</Box>
<Heading level={3} fontSize="md" weight="bold" color="text-white">{rule.title}</Heading>
</Box>
<Text size="sm" color="text-zinc-400" leading="relaxed">
{rule.content}
</Text>
</Box>
))}
</Box>
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,93 @@
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { MapPin, Clock } from 'lucide-react';
interface RaceEvent {
id: string;
title: string;
trackName: string;
date: string;
time: string;
status: 'upcoming' | 'live' | 'completed';
}
interface LeagueSchedulePanelProps {
events: RaceEvent[];
}
export function LeagueSchedulePanel({ events }: LeagueSchedulePanelProps) {
return (
<Box as="section">
<Stack gap={4}>
{events.map((event) => (
<Box
as="article"
key={event.id}
display="flex"
alignItems="center"
gap={6}
p={4}
border
borderColor="zinc-800"
bg="zinc-900/50"
hoverBorderColor="zinc-700"
transition
>
<Box display="flex" flexDirection="col" alignItems="center" justifyContent="center" w="16" h="16" borderRight borderColor="zinc-800" pr={6}>
<Text size="xs" weight="bold" color="text-zinc-500" uppercase>
{new Date(event.date).toLocaleDateString('en-US', { month: 'short' })}
</Text>
<Text size="2xl" weight="bold" color="text-white">
{new Date(event.date).toLocaleDateString('en-US', { day: 'numeric' })}
</Text>
</Box>
<Box flexGrow={1}>
<Heading level={3} fontSize="lg" weight="bold" color="text-white">{event.title}</Heading>
<Stack direction="row" gap={4} mt={1}>
<Box display="flex" alignItems="center" gap={1.5}>
<Box color="text-zinc-600"><MapPin size={14} /></Box>
<Text size="sm" color="text-zinc-400">{event.trackName}</Text>
</Box>
<Box display="flex" alignItems="center" gap={1.5}>
<Box color="text-zinc-600"><Clock size={14} /></Box>
<Text size="sm" color="text-zinc-400">{event.time}</Text>
</Box>
</Stack>
</Box>
<Box display="flex" alignItems="center" gap={3}>
{event.status === 'live' && (
<Box display="flex" alignItems="center" gap={1.5} px={2} py={1} bg="red-500/10" border borderColor="red-500/20">
<Box w="1.5" h="1.5" rounded="full" bg="bg-red-500" animate="pulse" />
<Text size="xs" weight="bold" color="text-red-500" uppercase letterSpacing="0.05em">
Live
</Text>
</Box>
)}
{event.status === 'upcoming' && (
<Box px={2} py={1} bg="blue-500/10" border borderColor="blue-500/20">
<Text size="xs" weight="bold" color="text-blue-500" uppercase letterSpacing="0.05em">
Upcoming
</Text>
</Box>
)}
{event.status === 'completed' && (
<Box px={2} py={1} bg="zinc-800" border borderColor="zinc-700">
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="0.05em">
Results
</Text>
</Box>
)}
</Box>
</Box>
))}
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,147 @@
'use client';
import React, { useCallback, useRef, useState } from 'react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import { Link } from '@/ui/Link';
import { ChevronLeft, ChevronRight, type LucideIcon } from 'lucide-react';
import { LeagueCard } from '@/components/leagues/LeagueCardWrapper';
import { LeagueSummaryViewModelBuilder } from '@/lib/builders/view-models/LeagueSummaryViewModelBuilder';
import type { LeaguesViewData } from '@/lib/view-data/LeaguesViewData';
import { routes } from '@/lib/routing/RouteConfig';
interface LeagueSliderProps {
title: string;
icon: LucideIcon;
description: string;
leagues: LeaguesViewData['leagues'];
autoScroll?: boolean;
iconColor?: string;
scrollSpeedMultiplier?: number;
scrollDirection?: 'left' | 'right';
}
export function LeagueSlider({
title,
icon: IconComp,
description,
leagues,
iconColor = 'text-primary-blue',
}: LeagueSliderProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(true);
const checkScrollButtons = useCallback(() => {
if (scrollRef.current) {
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
setCanScrollLeft(scrollLeft > 0);
setCanScrollRight(scrollLeft < scrollWidth - clientWidth - 10);
}
}, []);
const scroll = useCallback((direction: 'left' | 'right') => {
if (scrollRef.current) {
const cardWidth = 340;
const scrollAmount = direction === 'left' ? -cardWidth : cardWidth;
scrollRef.current.scrollBy({ left: scrollAmount, behavior: 'smooth' });
}
}, []);
if (leagues.length === 0) return null;
return (
<Box mb={10}>
{/* Section header */}
<Box display="flex" alignItems="center" justifyContent="between" mb={4}>
<Stack direction="row" align="center" gap={3}>
<Box display="flex" h={10} w={10} alignItems="center" justifyContent="center" rounded="xl" bg="bg-iron-gray" border borderColor="border-charcoal-outline">
<Icon icon={IconComp} size={5} color={iconColor} />
</Box>
<Box>
<Heading level={2}>{title}</Heading>
<Text size="xs" color="text-gray-500">{description}</Text>
</Box>
<Box as="span" ml={2} px={2} py={0.5} rounded="full" fontSize="0.75rem" bg="bg-charcoal-outline/50" color="text-gray-400">
{leagues.length}
</Box>
</Stack>
{/* Navigation arrows */}
<Stack direction="row" align="center" gap={2}>
<Button
variant="secondary"
onClick={() => scroll('left')}
disabled={!canScrollLeft}
size="sm"
w="2rem"
h="2rem"
>
<Icon icon={ChevronLeft} size={4} />
</Button>
<Button
variant="secondary"
onClick={() => scroll('right')}
disabled={!canScrollRight}
size="sm"
w="2rem"
h="2rem"
>
<Icon icon={ChevronRight} size={4} />
</Button>
</Stack>
</Box>
{/* Scrollable container with fade edges */}
<Box position="relative">
<Box
position="absolute"
top={0}
bottom={4}
left={0}
w="3rem"
bg="bg-gradient-to-r from-deep-graphite to-transparent"
zIndex={10}
pointerEvents="none"
/>
<Box
position="absolute"
top={0}
bottom={4}
right={0}
w="3rem"
bg="bg-gradient-to-l from-deep-graphite to-transparent"
zIndex={10}
pointerEvents="none"
/>
<Box
ref={scrollRef}
onScroll={checkScrollButtons}
display="flex"
gap={4}
overflow="auto"
pb={4}
px={4}
hideScrollbar
>
{leagues.map((league) => {
const viewModel = LeagueSummaryViewModelBuilder.build(league);
return (
<Box key={league.id} flexShrink={0} w="320px">
<Link href={routes.league.detail(league.id)} block>
<LeagueCard league={viewModel} />
</Link>
</Box>
);
})}
</Box>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,80 @@
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
interface StandingEntry {
position: number;
driverName: string;
teamName?: string;
points: number;
wins: number;
podiums: number;
gap: string;
}
interface LeagueStandingsTableProps {
standings: StandingEntry[];
}
export function LeagueStandingsTable({ standings }: LeagueStandingsTableProps) {
return (
<Box as="section" overflow="hidden" border borderColor="zinc-800" bg="zinc-900/50">
<Box as="table" w="full" textAlign="left">
<Box as="thead">
<Box as="tr" borderBottom borderColor="zinc-800" bg="zinc-900/80">
<Box as="th" px={4} py={3} w="12">
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Pos</Text>
</Box>
<Box as="th" px={4} py={3}>
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Driver</Text>
</Box>
<Box as="th" px={4} py={3} display={{ base: 'none', md: 'table-cell' }}>
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Team</Text>
</Box>
<Box as="th" px={4} py={3} textAlign="center">
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Wins</Text>
</Box>
<Box as="th" px={4} py={3} textAlign="center">
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Podiums</Text>
</Box>
<Box as="th" px={4} py={3} textAlign="right">
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Points</Text>
</Box>
<Box as="th" px={4} py={3} textAlign="right">
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Gap</Text>
</Box>
</Box>
</Box>
<Box as="tbody">
{standings.map((entry) => (
<Box as="tr" key={entry.driverName} borderBottom borderColor="zinc-800" hoverBg="zinc-800/50" transition>
<Box as="td" px={4} py={3}>
<Text size="sm" color="text-zinc-400" font="mono">{entry.position}</Text>
</Box>
<Box as="td" px={4} py={3}>
<Text size="sm" weight="medium" color="text-zinc-200">{entry.driverName}</Text>
</Box>
<Box as="td" px={4} py={3} display={{ base: 'none', md: 'table-cell' }}>
<Text size="sm" color="text-zinc-500">{entry.teamName || '—'}</Text>
</Box>
<Box as="td" px={4} py={3} textAlign="center">
<Text size="sm" color="text-zinc-400">{entry.wins}</Text>
</Box>
<Box as="td" px={4} py={3} textAlign="center">
<Text size="sm" color="text-zinc-400">{entry.podiums}</Text>
</Box>
<Box as="td" px={4} py={3} textAlign="right">
<Text size="sm" weight="bold" color="text-white">{entry.points}</Text>
</Box>
<Box as="td" px={4} py={3} textAlign="right">
<Text size="sm" color="text-zinc-500" font="mono">{entry.gap}</Text>
</Box>
</Box>
))}
</Box>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,118 @@
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
import { Calendar, MapPin, ChevronRight } from 'lucide-react';
import { Surface } from '@/ui/Surface';
import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from '@/ui/Table';
import { Link } from '@/ui/Link';
import { routes } from '@/lib/routing/RouteConfig';
interface RaceEntry {
id: string;
name: string;
track: string;
scheduledAt: string;
status: 'upcoming' | 'live' | 'completed';
}
interface ScheduleTableProps {
races: RaceEntry[];
}
export function ScheduleTable({ races }: ScheduleTableProps) {
return (
<Surface variant="dark" border rounded="lg" overflow="hidden">
<Table>
<TableHead>
<TableRow>
<TableHeader>Race</TableHeader>
<TableHeader>Track</TableHeader>
<TableHeader>Date</TableHeader>
<TableHeader>Status</TableHeader>
<TableHeader textAlign="right">Actions</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{races.length === 0 ? (
<TableRow>
<TableCell colSpan={5} textAlign="center" py={12}>
<Text color="text-gray-500">No races scheduled yet.</Text>
</TableCell>
</TableRow>
) : (
races.map((race) => (
<TableRow key={race.id}>
<TableCell>
<Text weight="medium" color="text-white">{race.name}</Text>
</TableCell>
<TableCell>
<Stack direction="row" align="center" gap={2}>
<Icon icon={MapPin} size={3.5} color="text-gray-500" />
<Text size="sm" color="text-gray-400">{race.track}</Text>
</Stack>
</TableCell>
<TableCell>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Calendar} size={3.5} color="text-gray-500" />
<Text size="sm" color="text-gray-400">
{new Date(race.scheduledAt).toLocaleDateString()}
</Text>
</Stack>
</TableCell>
<TableCell>
<StatusBadge status={race.status} />
</TableCell>
<TableCell textAlign="right">
<Link
href={routes.race.detail(race.id)}
variant="primary"
size="xs"
weight="bold"
letterSpacing="wider"
>
<Stack direction="row" align="center" gap={1}>
<Text>DETAILS</Text>
<Icon icon={ChevronRight} size={3} />
</Stack>
</Link>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</Surface>
);
}
function StatusBadge({ status }: { status: RaceEntry['status'] }) {
const styles = {
upcoming: 'bg-gray-500/10 text-gray-500 border-gray-500/20',
live: 'bg-performance-green/10 text-performance-green border-performance-green/20 animate-pulse',
completed: 'bg-primary-blue/10 text-primary-blue border-primary-blue/20',
};
return (
<Box
as="span"
px={2}
py={0.5}
rounded="full"
fontSize="10px"
border
bg={styles[status].split(' ')[0]}
color={styles[status].split(' ')[1]}
borderColor={styles[status].split(' ')[2]}
animate={status === 'live' ? 'pulse' : 'none'}
letterSpacing="widest"
weight="bold"
display="inline-block"
>
{status.toUpperCase()}
</Box>
);
}

View File

@@ -0,0 +1,118 @@
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Surface } from '@/ui/Surface';
import { Icon } from '@/ui/Icon';
import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from '@/ui/Table';
import { Trophy, TrendingUp } from 'lucide-react';
interface StandingsEntry {
position: number;
driverName: string;
points: number;
wins: number;
podiums: number;
change?: number;
}
interface StandingsTableShellProps {
standings: StandingsEntry[];
title?: string;
}
export function StandingsTableShell({ standings, title = 'Championship Standings' }: StandingsTableShellProps) {
return (
<Surface variant="dark" border rounded="lg" overflow="hidden">
<Box px={6} py={4} borderBottom borderColor="border-charcoal-outline" bg="bg-iron-gray/20">
<Stack direction="row" align="center" justify="between">
<Stack direction="row" align="center" gap={2}>
<Icon icon={Trophy} size={4} color="text-warning-amber" />
<Text weight="bold" letterSpacing="wider" size="sm" display="block">
{title.toUpperCase()}
</Text>
</Stack>
<Box px={2} py={0.5} rounded="md" bg="bg-charcoal-outline/50">
<Text size="xs" color="text-gray-400" weight="medium">{standings.length} Drivers</Text>
</Box>
</Stack>
</Box>
<Table>
<TableHead>
<TableRow>
<TableHeader w="4rem">Pos</TableHeader>
<TableHeader>Driver</TableHeader>
<TableHeader textAlign="center">Wins</TableHeader>
<TableHeader textAlign="center">Podiums</TableHeader>
<TableHeader textAlign="right">Points</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{standings.map((entry) => (
<TableRow key={entry.driverName}>
<TableCell>
<PositionBadge position={entry.position} />
</TableCell>
<TableCell>
<Stack direction="row" align="center" gap={3}>
<Text weight="bold" color="text-white">{entry.driverName}</Text>
{entry.change !== undefined && entry.change !== 0 && (
<Stack direction="row" align="center" gap={0.5}>
<Icon
icon={TrendingUp}
size={3}
color={entry.change > 0 ? 'text-performance-green' : 'text-error-red'}
transform={entry.change < 0 ? 'rotate(180deg)' : undefined}
/>
<Text size="xs" color={entry.change > 0 ? 'text-performance-green' : 'text-error-red'}>
{Math.abs(entry.change)}
</Text>
</Stack>
)}
</Stack>
</TableCell>
<TableCell textAlign="center">
<Text size="sm" color={entry.wins > 0 ? 'text-white' : 'text-gray-500'}>{entry.wins}</Text>
</TableCell>
<TableCell textAlign="center">
<Text size="sm" color={entry.podiums > 0 ? 'text-white' : 'text-gray-500'}>{entry.podiums}</Text>
</TableCell>
<TableCell textAlign="right">
<Text weight="bold" color="text-primary-blue">{entry.points}</Text>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Surface>
);
}
function PositionBadge({ position }: { position: number }) {
const isPodium = position <= 3;
const colors = {
1: 'text-warning-amber bg-warning-amber/10 border-warning-amber/20',
2: 'text-gray-300 bg-gray-300/10 border-gray-300/20',
3: 'text-orange-400 bg-orange-400/10 border-orange-400/20',
};
return (
<Box
center
w={8}
h={8}
rounded="md"
border={isPodium}
bg={isPodium ? colors[position as keyof typeof colors].split(' ')[1] : undefined}
color={isPodium ? colors[position as keyof typeof colors].split(' ')[0] : 'text-gray-500'}
borderColor={isPodium ? colors[position as keyof typeof colors].split(' ')[2] : undefined}
>
<Text size="sm" weight="bold">
{position}
</Text>
</Box>
);
}

View File

@@ -0,0 +1,117 @@
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Surface } from '@/ui/Surface';
import { Icon } from '@/ui/Icon';
import { Clock, ShieldAlert, MessageSquare } from 'lucide-react';
import { Button } from '@/ui/Button';
interface Protest {
id: string;
raceName: string;
protestingDriver: string;
accusedDriver: string;
description: string;
submittedAt: string;
status: 'pending' | 'under_review' | 'resolved' | 'rejected';
}
interface StewardingQueuePanelProps {
protests: Protest[];
onReview: (id: string) => void;
}
export function StewardingQueuePanel({ protests, onReview }: StewardingQueuePanelProps) {
return (
<Surface variant="dark" border rounded="lg" overflow="hidden">
<Box px={6} py={4} borderBottom borderColor="border-charcoal-outline" bg="bg-iron-gray/20">
<Stack direction="row" align="center" justify="between">
<Stack direction="row" align="center" gap={2}>
<Icon icon={ShieldAlert} size={4} color="text-error-red" />
<Text weight="bold" letterSpacing="wider" size="sm" display="block">
STEWARDING QUEUE
</Text>
</Stack>
<Box px={2} py={0.5} rounded="md" bg="bg-error-red/10" border borderColor="border-error-red/20">
<Text size="xs" color="text-error-red" weight="bold">
{protests.filter(p => p.status === 'pending').length} Pending
</Text>
</Box>
</Stack>
</Box>
<Stack gap={0}>
{protests.length === 0 ? (
<Box py={12} center>
<Stack align="center" gap={3}>
<Icon icon={ShieldAlert} size={8} color="text-gray-700" />
<Text color="text-gray-500">No active protests in the queue.</Text>
</Stack>
</Box>
) : (
protests.map((protest) => (
<Box key={protest.id} p={6} borderBottom borderColor="border-charcoal-outline" hoverBg="bg-white/5" transition>
<Stack direction={{ base: 'col', md: 'row' }} justify="between" align="start" gap={4}>
<Stack gap={3} flexGrow={1}>
<Stack direction="row" align="center" gap={2}>
<StatusIndicator status={protest.status} />
<Text size="xs" color="text-gray-500" weight="bold" letterSpacing="widest">
{protest.raceName.toUpperCase()}
</Text>
<Box w={1} h={1} rounded="full" bg="bg-gray-700" />
<Stack direction="row" align="center" gap={1.5}>
<Icon icon={Clock} size={3} color="text-gray-600" />
<Text size="xs" color="text-gray-500">
{new Date(protest.submittedAt).toLocaleString()}
</Text>
</Stack>
</Stack>
<Box>
<Stack direction="row" align="center" gap={2} wrap>
<Text weight="bold" color="text-white">{protest.protestingDriver}</Text>
<Text size="xs" color="text-gray-600" weight="bold">VS</Text>
<Text weight="bold" color="text-white">{protest.accusedDriver}</Text>
</Stack>
<Text size="sm" color="text-gray-400" mt={2} lineClamp={2}>
&ldquo;{protest.description}&rdquo;
</Text>
</Box>
</Stack>
<Stack direction="row" align="center" gap={3} w={{ base: 'full', md: 'auto' }}>
<Button
variant="secondary"
size="sm"
onClick={() => onReview(protest.id)}
>
<Stack direction="row" align="center" gap={2}>
<Icon icon={MessageSquare} size={3.5} />
<Text>Review</Text>
</Stack>
</Button>
</Stack>
</Stack>
</Box>
))
)}
</Stack>
</Surface>
);
}
function StatusIndicator({ status }: { status: Protest['status'] }) {
const colors = {
pending: 'bg-warning-amber',
under_review: 'bg-primary-blue',
resolved: 'bg-performance-green',
rejected: 'bg-gray-500',
};
return (
<Box w={2} h={2} rounded="full" bg={colors[status]} animate={status === 'under_review' ? 'pulse' : 'none'} />
);
}

View File

@@ -0,0 +1,128 @@
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Surface } from '@/ui/Surface';
import { Icon } from '@/ui/Icon';
import { Button } from '@/ui/Button';
import { Wallet, ArrowUpRight, ArrowDownLeft, History } from 'lucide-react';
interface Transaction {
id: string;
type: 'credit' | 'debit';
amount: number;
description: string;
date: string;
}
interface WalletSummaryPanelProps {
balance: number;
currency: string;
transactions: Transaction[];
onDeposit: () => void;
onWithdraw: () => void;
}
export function WalletSummaryPanel({ balance, currency, transactions, onDeposit, onWithdraw }: WalletSummaryPanelProps) {
return (
<Stack gap={6}>
<Surface variant="dark" border rounded="lg" padding={8} position="relative" overflow="hidden">
{/* Background Pattern */}
<Box
position="absolute"
top="-5rem"
right="-5rem"
w="12rem"
h="12rem"
bg="bg-primary-blue/5"
rounded="full"
blur="xl"
pointerEvents="none"
/>
<Stack direction={{ base: 'col', md: 'row' }} justify="between" align="center" gap={8}>
<Stack gap={2}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Wallet} size={4} color="text-gray-500" />
<Text size="xs" color="text-gray-500" weight="bold" letterSpacing="widest">AVAILABLE BALANCE</Text>
</Stack>
<Stack direction="row" align="baseline" gap={2}>
<Text size="4xl" weight="bold" color="text-white">
{balance.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</Text>
<Text size="xl" weight="medium" color="text-gray-500">{currency}</Text>
</Stack>
</Stack>
<Stack direction="row" gap={3}>
<Button variant="primary" onClick={onDeposit}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={ArrowDownLeft} size={4} />
<Text>Deposit</Text>
</Stack>
</Button>
<Button variant="secondary" onClick={onWithdraw}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={ArrowUpRight} size={4} />
<Text>Withdraw</Text>
</Stack>
</Button>
</Stack>
</Stack>
</Surface>
<Surface variant="dark" border rounded="lg" overflow="hidden">
<Box px={6} py={4} borderBottom borderColor="border-charcoal-outline" bg="bg-iron-gray/20">
<Stack direction="row" align="center" gap={2}>
<Icon icon={History} size={4} color="text-primary-blue" />
<Text weight="bold" letterSpacing="wider" size="sm" display="block">
RECENT TRANSACTIONS
</Text>
</Stack>
</Box>
<Stack gap={0}>
{transactions.length === 0 ? (
<Box py={12} center>
<Text color="text-gray-500">No recent transactions.</Text>
</Box>
) : (
transactions.map((tx) => (
<Box key={tx.id} p={4} borderBottom borderColor="border-charcoal-outline" hoverBg="bg-white/5" transition>
<Stack direction="row" justify="between" align="center">
<Stack direction="row" align="center" gap={4}>
<Box
center
w={10}
h={10}
rounded="full"
bg={tx.type === 'credit' ? 'bg-performance-green/10' : 'bg-error-red/10'}
>
<Icon
icon={tx.type === 'credit' ? ArrowDownLeft : ArrowUpRight}
size={4}
color={tx.type === 'credit' ? 'text-performance-green' : 'text-error-red'}
/>
</Box>
<Stack gap={0.5}>
<Text weight="medium" color="text-white">{tx.description}</Text>
<Text size="xs" color="text-gray-500">{new Date(tx.date).toLocaleDateString()}</Text>
</Stack>
</Stack>
<Text
weight="bold"
color={tx.type === 'credit' ? 'text-performance-green' : 'text-white'}
>
{tx.type === 'credit' ? '+' : '-'}{tx.amount.toFixed(2)}
</Text>
</Stack>
</Box>
))
)}
</Stack>
</Surface>
</Stack>
);
}