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,51 @@
import React from 'react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
interface AchievementCardProps {
title: string;
description: string;
icon: string;
unlockedAt: string;
rarity: 'common' | 'rare' | 'epic' | 'legendary';
}
const rarityColors = {
common: 'border-gray-500 bg-gray-500/10',
rare: 'border-blue-400 bg-blue-400/10',
epic: 'border-purple-400 bg-purple-400/10',
legendary: 'border-warning-amber bg-warning-amber/10'
};
export function AchievementCard({
title,
description,
icon,
unlockedAt,
rarity,
}: AchievementCardProps) {
return (
<Box
p={4}
rounded="lg"
border
className={rarityColors[rarity]}
>
<Box display="flex" alignItems="start" gap={3}>
<Text size="3xl">{icon}</Text>
<Stack gap={1} flexGrow={1}>
<Text weight="medium" color="text-white">{title}</Text>
<Text size="xs" color="text-gray-400">{description}</Text>
<Text size="xs" color="text-gray-500">
{new Date(unlockedAt).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})}
</Text>
</Stack>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,21 @@
import React from 'react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
interface MilestoneItemProps {
label: string;
value: string;
icon: string;
}
export function MilestoneItem({ label, value, icon }: MilestoneItemProps) {
return (
<Box display="flex" alignItems="center" justifyContent="between" p={3} rounded="md" bg="bg-deep-graphite" border borderColor="border-charcoal-outline">
<Box display="flex" alignItems="center" gap={3}>
<Text size="xl">{icon}</Text>
<Text size="sm" color="text-gray-400">{label}</Text>
</Box>
<Text size="sm" weight="medium" color="text-white">{value}</Text>
</Box>
);
}

View File

@@ -0,0 +1,67 @@
import { Box } from '@/ui/Box';
import { Image } from '@/ui/Image';
import { Text } from '@/ui/Text';
interface ActiveDriverCardProps {
name: string;
avatarUrl?: string;
categoryLabel?: string;
categoryColor?: string;
skillLevelLabel?: string;
skillLevelColor?: string;
onClick: () => void;
}
export function ActiveDriverCard({
name,
avatarUrl,
categoryLabel,
categoryColor,
skillLevelLabel,
skillLevelColor,
onClick,
}: ActiveDriverCardProps) {
return (
<Box
as="button"
type="button"
onClick={onClick}
p={3}
rounded="xl"
bg="bg-iron-gray/40"
border
borderColor="border-charcoal-outline"
transition
cursor="pointer"
hoverBorderColor="performance-green/40"
group
textAlign="center"
>
<Box position="relative" w="12" h="12" mx="auto" rounded="full" overflow="hidden" border borderColor="border-charcoal-outline" mb={2}>
<Image src={avatarUrl || '/default-avatar.png'} alt={name} objectFit="cover" fill />
<Box position="absolute" bottom="0" right="0" w="3" h="3" rounded="full" bg="bg-performance-green" border borderColor="border-iron-gray" style={{ borderWidth: '2px' }} />
</Box>
<Text
size="sm"
weight="medium"
color="text-white"
truncate
block
groupHoverTextColor="performance-green"
transition
>
{name}
</Text>
<Box display="flex" alignItems="center" justifyContent="center" gap={1}>
{categoryLabel && (
<Text size="xs" color={categoryColor}>{categoryLabel}</Text>
)}
{skillLevelLabel && (
<Text size="xs" color={skillLevelColor}>{skillLevelLabel}</Text>
)}
</Box>
</Box>
);
}

View File

@@ -0,0 +1,95 @@
import { AchievementCard } from '@/components/achievements/AchievementCard';
import { Box } from '@/ui/Box';
import { Card } from '@/ui/Card';
import { GoalCard } from '@/ui/GoalCard';
import { Heading } from '@/ui/Heading';
import { MilestoneItem } from '@/components/achievements/MilestoneItem';
import { Stack } from '@/ui/Stack';
interface Achievement {
id: string;
title: string;
description: string;
icon: string;
unlockedAt: string;
rarity: 'common' | 'rare' | 'epic' | 'legendary';
}
const mockAchievements: Achievement[] = [
{ id: '1', title: 'First Victory', description: 'Won your first race', icon: '🏆', unlockedAt: '2024-03-15', rarity: 'common' },
{ id: '2', title: '10 Podiums', description: 'Achieved 10 podium finishes', icon: '🥈', unlockedAt: '2024-05-22', rarity: 'rare' },
{ id: '3', title: 'Clean Racer', description: 'Completed 25 races with 0 incidents', icon: '✨', unlockedAt: '2024-08-10', rarity: 'epic' },
{ id: '4', title: 'Comeback King', description: 'Won a race after starting P10 or lower', icon: '⚡', unlockedAt: '2024-09-03', rarity: 'rare' },
{ id: '5', title: 'Perfect Weekend', description: 'Pole, fastest lap, and win in same race', icon: '💎', unlockedAt: '2024-10-17', rarity: 'legendary' },
{ id: '6', title: 'Century Club', description: 'Completed 100 races', icon: '💯', unlockedAt: '2024-11-01', rarity: 'epic' },
];
export function CareerHighlights() {
return (
<Stack gap={6}>
<Card>
<Heading level={3} mb={4}>Key Milestones</Heading>
<Stack gap={3}>
<MilestoneItem
label="First Race"
value="March 15, 2024"
icon="🏁"
/>
<MilestoneItem
label="First Win"
value="March 15, 2024 (Imola)"
icon="🏆"
/>
<MilestoneItem
label="Highest Rating"
value="1487 (Nov 2024)"
icon="📈"
/>
<MilestoneItem
label="Longest Win Streak"
value="4 races (Oct 2024)"
icon="🔥"
/>
<MilestoneItem
label="Most Wins (Track)"
value="Spa-Francorchamps (7)"
icon="🗺️"
/>
<MilestoneItem
label="Favorite Car"
value="Porsche 911 GT3 R (45 races)"
icon="🏎️"
/>
</Stack>
</Card>
<Card>
<Heading level={3} mb={4}>Achievements</Heading>
<Box display="grid" gridCols={{ base: 1, sm: 2 }} gap={3}>
{mockAchievements.map((achievement) => (
<AchievementCard
key={achievement.id}
title={achievement.title}
description={achievement.description}
icon={achievement.icon}
unlockedAt={achievement.unlockedAt}
rarity={achievement.rarity}
/>
))}
</Box>
</Card>
<GoalCard
title="Next Goals"
icon="🎯"
goalLabel="Win 25 races"
currentValue={23}
maxValue={25}
/>
</Stack>
);
}

View File

@@ -0,0 +1,36 @@
import { Box } from '@/ui/Box';
import { Card } from '@/ui/Card';
import { Grid } from '@/ui/Grid';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { StatGridItem } from '@/ui/StatGridItem';
import { TrendingUp } from 'lucide-react';
interface CareerStatsProps {
stats: {
totalRaces: number;
wins: number;
podiums: number;
consistency: number | null;
};
}
export function CareerStats({ stats }: CareerStatsProps) {
return (
<Card>
<Box mb={4}>
<Heading level={2} icon={<Icon icon={TrendingUp} size={5} color="#10b981" />}>
Career Statistics
</Heading>
</Box>
<Grid cols={2} gap={4}>
<StatGridItem label="Races" value={stats.totalRaces} />
<StatGridItem label="Wins" value={stats.wins} color="text-performance-green" />
<StatGridItem label="Podiums" value={stats.podiums} color="text-warning-amber" />
<StatGridItem label="Consistency" value={`${stats.consistency}%`} color="text-primary-blue" />
</Grid>
</Card>
);
}

View File

@@ -1,10 +1,10 @@
import React from 'react';
import { Card } from '@/ui/Card';
import { RankBadge } from '@/ui/RankBadge';
import { RankBadge } from '@/components/leaderboards/RankBadge';
import { DriverIdentity } from '@/components/drivers/DriverIdentity';
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import { Stack } from '@/ui/Stack';
import { DriverStats } from '@/ui/DriverStats';
import { DriverStats } from '@/components/drivers/DriverStats';
import { routes } from '@/lib/routing/RouteConfig';
export interface DriverCardProps {

View File

@@ -0,0 +1,109 @@
import React from 'react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Image } from '@/ui/Image';
import { RatingBadge } from '@/components/drivers/RatingBadge';
interface DriverHeaderPanelProps {
name: string;
avatarUrl?: string;
nationality: string;
rating: number;
globalRank?: number | null;
bio?: string | null;
actions?: React.ReactNode;
}
export function DriverHeaderPanel({
name,
avatarUrl,
nationality,
rating,
globalRank,
bio,
actions
}: DriverHeaderPanelProps) {
const defaultAvatar = 'https://cdn.gridpilot.com/avatars/default.png';
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">
{/* Avatar */}
<Box
width="32"
height="32"
rounded="2xl"
overflow="hidden"
border
borderColor="border-charcoal-outline"
bg="bg-graphite-black"
flexShrink={0}
>
<Image
src={avatarUrl || defaultAvatar}
alt={name}
fill
objectFit="cover"
/>
</Box>
{/* Info */}
<Box flexGrow={1}>
<Stack gap={2}>
<Stack direction="row" align="center" gap={3} wrap>
<Text as="h1" size="3xl" weight="bold" color="text-white">
{name}
</Text>
<RatingBadge rating={rating} size="lg" />
</Stack>
<Stack direction="row" align="center" gap={4} wrap>
<Text size="sm" color="text-gray-400">
{nationality}
</Text>
{globalRank !== undefined && globalRank !== null && (
<Text size="sm" color="text-gray-400">
Global Rank: <Text color="text-warning-amber" weight="semibold">#{globalRank}</Text>
</Text>
)}
</Stack>
{bio && (
<Text size="sm" color="text-gray-400" className="max-w-2xl mt-2" lineClamp={2}>
{bio}
</Text>
)}
</Stack>
</Box>
{/* Actions */}
{actions && (
<Box flexShrink={0}>
{actions}
</Box>
)}
</Stack>
</Box>
</Box>
);
}

View File

@@ -9,9 +9,9 @@ import { Stack } from '@/ui/Stack';
import { StatCard } from '@/ui/StatCard';
import { ProfileHeader } from '@/components/drivers/ProfileHeader';
import { ProfileStats } from './ProfileStats';
import { CareerHighlights } from '@/ui/CareerHighlights';
import { CareerHighlights } from '@/components/drivers/CareerHighlights';
import { DriverRankings } from '@/components/drivers/DriverRankings';
import { PerformanceMetrics } from '@/ui/PerformanceMetrics';
import { PerformanceMetrics } from '@/components/drivers/PerformanceMetrics';
import { useDriverProfile } from "@/hooks/driver/useDriverProfile";
interface DriverProfileProps {

View File

@@ -5,7 +5,7 @@ import { Globe, Trophy, UserPlus, Check } from 'lucide-react';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { Button } from '@/ui/Button';
import { RatingBadge } from '@/ui/RatingBadge';
import { RatingBadge } from '@/components/drivers/RatingBadge';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Image } from '@/ui/Image';

View File

@@ -0,0 +1,31 @@
import { Box } from '@/ui/Box';
import { Icon } from '@/ui/Icon';
import { Text } from '@/ui/Text';
import { Star, Trophy } from 'lucide-react';
interface DriverRatingPillProps {
rating: number | null;
rank: number | null;
}
export function DriverRatingPill({ rating, rank }: DriverRatingPillProps) {
return (
<Box display="flex" alignItems="center" gap={2} mt={0.5} style={{ fontSize: '11px' }}>
<Box display="inline-flex" alignItems="center" gap={1}>
<Icon icon={Star} size={3} color="var(--warning-amber)" />
<Text color="text-amber-300" className="tabular-nums">
{rating !== null ? rating : '—'}
</Text>
</Box>
{rank !== null && (
<Box display="inline-flex" alignItems="center" gap={1}>
<Icon icon={Trophy} size={3} color="var(--primary-blue)" />
<Text color="text-primary-blue" className="tabular-nums">#{rank}</Text>
</Box>
)}
</Box>
);
}

View File

@@ -0,0 +1,34 @@
import React from 'react';
import { Stack } from '@/ui/Stack';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
interface DriverStatsProps {
rating: number;
wins: number;
podiums: number;
winRate: string;
}
export function DriverStats({ rating, wins, podiums, winRate }: DriverStatsProps) {
return (
<Stack direction="row" align="center" gap={8} textAlign="center">
<Box>
<Text size="2xl" weight="bold" color="text-primary-blue" block>{rating}</Text>
<Text size="xs" color="text-gray-400" block>Rating</Text>
</Box>
<Box>
<Text size="2xl" weight="bold" color="text-green-400" block>{wins}</Text>
<Text size="xs" color="text-gray-400" block>Wins</Text>
</Box>
<Box>
<Text size="2xl" weight="bold" color="text-warning-amber" block>{podiums}</Text>
<Text size="xs" color="text-gray-400" block>Podiums</Text>
</Box>
<Box>
<Text size="sm" color="text-gray-400" block>{winRate}%</Text>
<Text size="xs" color="text-gray-500" block>Win Rate</Text>
</Box>
</Stack>
);
}

View File

@@ -0,0 +1,137 @@
import React from 'react';
import { Box } from '@/ui/Box';
import { Image } from '@/ui/Image';
import { Link } from '@/ui/Link';
import { PlaceholderImage } from '@/ui/PlaceholderImage';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
interface DriverSummaryPillProps {
name: string;
avatarSrc?: string | null;
rating?: number | null;
rank?: number | null;
onClick?: () => void;
href?: string;
ratingComponent?: React.ReactNode;
}
export function DriverSummaryPill({
name,
avatarSrc,
onClick,
href,
ratingComponent,
}: DriverSummaryPillProps) {
const content = (
<>
<Box
w="8"
h="8"
rounded="full"
overflow="hidden"
bg="bg-charcoal-outline"
display="flex"
alignItems="center"
justifyContent="center"
border
borderColor="border-charcoal-outline/80"
>
{avatarSrc ? (
<Image
src={avatarSrc}
alt={name}
width={32}
height={32}
objectFit="cover"
fill
/>
) : (
<PlaceholderImage size={32} />
)}
</Box>
<Stack direction="col" align="start" justify="center">
<Text
size="xs"
weight="semibold"
color="text-white"
truncate
block
style={{ maxWidth: '140px' }}
>
{name}
</Text>
{ratingComponent}
</Stack>
</>
);
if (href) {
return (
<Link
href={href}
block
display="flex"
alignItems="center"
gap={3}
rounded="full"
bg="bg-iron-gray/70"
px={3}
py={1.5}
border
borderColor="border-charcoal-outline/80"
shadow="0 0 18px rgba(0,0,0,0.45)"
transition
hoverBorderColor="primary-blue/60"
className="hover:bg-iron-gray"
>
{content}
</Link>
);
}
if (onClick) {
return (
<Box
as="button"
type="button"
onClick={onClick}
cursor="pointer"
display="flex"
alignItems="center"
gap={3}
rounded="full"
bg="bg-iron-gray/70"
px={3}
py={1.5}
border
borderColor="border-charcoal-outline/80"
shadow="0 0 18px rgba(0,0,0,0.45)"
transition
hoverBorderColor="primary-blue/60"
className="hover:bg-iron-gray"
>
{content}
</Box>
);
}
return (
<Box
display="flex"
alignItems="center"
gap={3}
rounded="full"
bg="bg-iron-gray/70"
px={3}
py={1.5}
border
borderColor="border-charcoal-outline/80"
>
{content}
</Box>
);
}

View File

@@ -1,8 +1,8 @@
import type { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import { DriverRatingPill } from '@/ui/DriverRatingPill';
import { DriverSummaryPill as UiDriverSummaryPill } from '@/ui/DriverSummaryPill';
import { DriverRatingPill } from '@/components/drivers/DriverRatingPill';
import { DriverSummaryPill as UiDriverSummaryPill } from '@/components/drivers/DriverSummaryPill';
export interface DriverSummaryPillProps {
driver: DriverViewModel;

View File

@@ -1,7 +1,7 @@
'use client';
import React from 'react';
import { RatingBadge } from '@/ui/RatingBadge';
import { RatingBadge } from '@/components/drivers/RatingBadge';
import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';

View File

@@ -0,0 +1,27 @@
import { Box } from '@/ui/Box';
import { Icon } from '@/ui/Icon';
import { Input } from '@/ui/Input';
import { Search } from 'lucide-react';
interface DriversSearchProps {
query: string;
onChange: (query: string) => void;
}
export function DriversSearch({ query, onChange }: DriversSearchProps) {
return (
<Box mb={8}>
<Box maxWidth="28rem">
<Input
type="text"
placeholder="Search drivers by name or nationality..."
value={query}
onChange={(e) => onChange(e.target.value)}
icon={<Icon icon={Search} size={5} color="#6b7280" />}
/>
</Box>
</Box>
);
}

View File

@@ -6,7 +6,7 @@ import { Box } from '@/ui/Box';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Image } from '@/ui/Image';
import { MedalBadge } from '@/ui/MedalBadge';
import { MedalBadge } from '@/components/leaderboards/MedalBadge';
import { MiniStat } from '@/ui/MiniStat';
import { Text } from '@/ui/Text';
import { Flag, Shield, Star, TrendingUp } from 'lucide-react';

View File

@@ -0,0 +1,87 @@
import { Card } from '@/ui/Card';
import { Button } from '@/ui/Button';
import { Car, Download, Trash2, Edit } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Badge } from '@/ui/Badge';
import { Icon } from '@/ui/Icon';
interface DriverLiveryItem {
id: string;
carId: string;
carName: string;
thumbnailUrl: string;
uploadedAt: Date;
isValidated: boolean;
}
interface LiveryCardProps {
livery: DriverLiveryItem;
onEdit?: (id: string) => void;
onDownload?: (id: string) => void;
onDelete?: (id: string) => void;
}
export function LiveryCard({ livery, onEdit, onDownload, onDelete }: LiveryCardProps) {
return (
<Card className="overflow-hidden hover:border-primary-blue/50 transition-colors">
{/* Livery Preview */}
<Box height={48} backgroundColor="deep-graphite" rounded="lg" mb={4} display="flex" center border borderColor="charcoal-outline">
<Icon icon={Car} size={16} color="text-gray-600" />
</Box>
{/* Livery Info */}
<Stack gap={3}>
<Stack direction="row" align="center" justify="between">
<Heading level={3}>{livery.carName}</Heading>
{livery.isValidated ? (
<Badge variant="success">
Validated
</Badge>
) : (
<Badge variant="warning">
Pending
</Badge>
)}
</Stack>
<Text size="xs" color="text-gray-500">
Uploaded {new Date(livery.uploadedAt).toLocaleDateString()}
</Text>
{/* Actions */}
<Stack direction="row" gap={2} pt={2}>
<Button
variant="secondary"
size="sm"
fullWidth
onClick={() => onEdit?.(livery.id)}
icon={<Icon icon={Edit} size={4} />}
>
Edit
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => onDownload?.(livery.id)}
icon={<Icon icon={Download} size={4} />}
aria-label="Download"
>
{null}
</Button>
<Button
variant="danger"
size="sm"
onClick={() => onDelete?.(livery.id)}
icon={<Icon icon={Trash2} size={4} />}
aria-label="Delete"
>
{null}
</Button>
</Stack>
</Stack>
</Card>
);
}

View File

@@ -0,0 +1,86 @@
import { Box } from '@/ui/Box';
import { Card } from '@/ui/Card';
import { Text } from '@/ui/Text';
interface PerformanceMetricsProps {
stats: {
winRate: number;
podiumRate: number;
dnfRate: number;
avgFinish: number;
consistency: number;
bestFinish: number;
worstFinish: number;
};
}
export function PerformanceMetrics({ stats }: PerformanceMetricsProps) {
const getPerformanceVariant = (value: number, type: 'rate' | 'finish' | 'consistency'): 'blue' | 'green' | 'orange' | 'purple' => {
if (type === 'rate') {
if (value >= 30) return 'green';
if (value >= 15) return 'orange';
return 'blue';
}
if (type === 'consistency') {
if (value >= 80) return 'green';
if (value >= 60) return 'orange';
return 'blue';
}
return 'blue';
};
const metrics = [
{
label: 'Win Rate',
value: `${stats.winRate.toFixed(1)}%`,
variant: getPerformanceVariant(stats.winRate, 'rate'),
icon: '🏆'
},
{
label: 'Podium Rate',
value: `${stats.podiumRate.toFixed(1)}%`,
variant: getPerformanceVariant(stats.podiumRate, 'rate'),
icon: '🥇'
},
{
label: 'DNF Rate',
value: `${stats.dnfRate.toFixed(1)}%`,
variant: stats.dnfRate < 10 ? 'green' : 'orange',
icon: '❌'
},
{
label: 'Avg Finish',
value: stats.avgFinish.toFixed(1),
variant: 'blue' as const,
icon: '📊'
},
{
label: 'Consistency',
value: `${stats.consistency.toFixed(0)}%`,
variant: getPerformanceVariant(stats.consistency, 'consistency'),
icon: '🎯'
},
{
label: 'Best / Worst',
value: `${stats.bestFinish} / ${stats.worstFinish}`,
variant: 'blue' as const,
icon: '📈'
}
];
return (
<Box display="grid" responsiveGridCols={{ base: 2, md: 3 }} gap={4}>
{metrics.map((metric, index) => (
<Card key={index}>
<Box p={4} textAlign="center">
<Text size="2xl" block mb={2}>{metric.icon}</Text>
<Text size="sm" color="text-gray-400" block mb={1}>{metric.label}</Text>
<Text size="xl" weight="bold" color="text-white" block>{metric.value}</Text>
</Box>
</Card>
))}
</Box>
);
}

View File

@@ -0,0 +1,112 @@
import { Box } from '@/ui/Box';
import { Card } from '@/ui/Card';
import { CircularProgress } from '@/ui/CircularProgress';
import { Grid } from '@/ui/Grid';
import { GridItem } from '@/ui/GridItem';
import { Heading } from '@/ui/Heading';
import { HorizontalBarChart } from '@/ui/HorizontalBarChart';
import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Activity, BarChart3, Target, TrendingUp } from 'lucide-react';
interface PerformanceOverviewProps {
stats: {
wins: number;
podiums: number;
totalRaces: number;
consistency: number | null;
dnfs: number;
bestFinish: number;
avgFinish: number | null;
};
}
export function PerformanceOverview({ stats }: PerformanceOverviewProps) {
return (
<Card>
<Box mb={6}>
<Heading level={2} icon={<Icon icon={Activity} size={5} color="#00f2ff" />}>
Performance Overview
</Heading>
</Box>
<Grid cols={12} gap={8}>
<GridItem colSpan={12} lgSpan={6}>
<Stack align="center" gap={4}>
<Stack direction="row" gap={6}>
<CircularProgress
value={stats.wins}
max={stats.totalRaces}
label="Win Rate"
color="#10b981"
/>
<CircularProgress
value={stats.podiums}
max={stats.totalRaces}
label="Podium Rate"
color="#f59e0b"
/>
</Stack>
<Stack direction="row" gap={6}>
<CircularProgress
value={stats.consistency ?? 0}
max={100}
label="Consistency"
color="#3b82f6"
/>
<CircularProgress
value={stats.totalRaces - stats.dnfs}
max={stats.totalRaces}
label="Finish Rate"
color="#00f2ff"
/>
</Stack>
</Stack>
</GridItem>
<GridItem colSpan={12} lgSpan={6}>
<Box mb={4}>
<Heading level={3} icon={<Icon icon={BarChart3} size={4} color="#9ca3af" />}>
Results Breakdown
</Heading>
</Box>
<HorizontalBarChart
data={[
{ label: 'Wins', value: stats.wins, color: 'bg-performance-green' },
{ label: 'Podiums (2nd-3rd)', value: stats.podiums - stats.wins, color: 'bg-warning-amber' },
{ label: 'DNFs', value: stats.dnfs, color: 'bg-red-500' },
]}
maxValue={stats.totalRaces}
/>
<Box mt={6}>
<Grid cols={2} gap={4}>
<Box p={4} style={{ backgroundColor: '#0f1115', borderRadius: '0.75rem', border: '1px solid #262626' }}>
<Stack gap={2}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={TrendingUp} size={4} color="#10b981" />
<Text size="xs" color="text-gray-500" style={{ textTransform: 'uppercase' }}>Best Finish</Text>
</Stack>
<Text size="2xl" weight="bold" color="text-performance-green">P{stats.bestFinish}</Text>
</Stack>
</Box>
<Box p={4} style={{ backgroundColor: '#0f1115', borderRadius: '0.75rem', border: '1px solid #262626' }}>
<Stack gap={2}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Target} size={4} color="#3b82f6" />
<Text size="xs" color="text-gray-500" style={{ textTransform: 'uppercase' }}>Avg Finish</Text>
</Stack>
<Text size="2xl" weight="bold" color="text-primary-blue">
P{(stats.avgFinish ?? 0).toFixed(1)}
</Text>
</Stack>
</Box>
</Grid>
</Box>
</GridItem>
</Grid>
</Card>
);
}

View File

@@ -5,7 +5,7 @@ import { Badge } from '@/ui/Badge';
import { Box } from '@/ui/Box';
import { Button } from '@/ui/Button';
import { CountryFlag } from '@/ui/CountryFlag';
import { DriverRatingPill } from '@/ui/DriverRatingPill';
import { DriverRatingPill } from '@/components/drivers/DriverRatingPill';
import { Heading } from '@/ui/Heading';
import { Image } from '@/ui/Image';
import { PlaceholderImage } from '@/ui/PlaceholderImage';

View File

@@ -8,7 +8,7 @@ import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Stack } from '@/ui/Stack';
import { StatCard } from '@/ui/StatCard';
import { RankBadge } from '@/ui/RankBadge';
import { RankBadge } from '@/components/leaderboards/RankBadge';
interface ProfileStatsProps {
driverId?: string;

View File

@@ -0,0 +1,78 @@
import { Box } from '@/ui/Box';
import { Card } from '@/ui/Card';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/Stack';
import { Surface } from '@/ui/Surface';
import { Text } from '@/ui/Text';
import { Flag, UserPlus, Users } from 'lucide-react';
interface RacingProfileProps {
racingStyle: string;
favoriteTrack: string;
favoriteCar: string;
availableHours: string;
lookingForTeam: boolean;
openToRequests: boolean;
}
export function RacingProfile({
racingStyle,
favoriteTrack,
favoriteCar,
availableHours,
lookingForTeam,
openToRequests,
}: RacingProfileProps) {
return (
<Card>
<Box mb={4}>
<Heading level={2} icon={<Icon icon={Flag} size={5} color="#00f2ff" />}>
Racing Profile
</Heading>
</Box>
<Stack gap={4}>
<Box>
<Text size="xs" color="text-gray-500" style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }} block>Racing Style</Text>
<Text color="text-white" weight="medium">{racingStyle}</Text>
</Box>
<Box>
<Text size="xs" color="text-gray-500" style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }} block>Favorite Track</Text>
<Text color="text-white" weight="medium">{favoriteTrack}</Text>
</Box>
<Box>
<Text size="xs" color="text-gray-500" style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }} block>Favorite Car</Text>
<Text color="text-white" weight="medium">{favoriteCar}</Text>
</Box>
<Box>
<Text size="xs" color="text-gray-500" style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }} block>Available</Text>
<Text color="text-white" weight="medium">{availableHours}</Text>
</Box>
{/* Status badges */}
<Box mt={4} pt={4} style={{ borderTop: '1px solid rgba(38, 38, 38, 0.5)' }}>
<Stack gap={2}>
{lookingForTeam && (
<Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: 'rgba(16, 185, 129, 0.1)', border: '1px solid rgba(16, 185, 129, 0.3)' }}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Users} size={4} color="#10b981" />
<Text size="sm" color="text-performance-green" weight="medium">Looking for Team</Text>
</Stack>
</Surface>
)}
{openToRequests && (
<Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: 'rgba(59, 130, 246, 0.1)', border: '1px solid rgba(59, 130, 246, 0.3)' }}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={UserPlus} size={4} color="#3b82f6" />
<Text size="sm" color="text-primary-blue" weight="medium">Open to Friend Requests</Text>
</Stack>
</Surface>
)}
</Stack>
</Box>
</Stack>
</Card>
);
}

View File

@@ -0,0 +1,29 @@
import React from 'react';
interface RatingBadgeProps {
rating: number;
size?: 'sm' | 'md' | 'lg';
className?: string;
}
export function RatingBadge({ rating, size = 'md', className = '' }: RatingBadgeProps) {
const getColor = (val: number) => {
if (val >= 2500) return 'text-yellow-400 bg-yellow-400/10 border-yellow-400/20';
if (val >= 2000) return 'text-purple-400 bg-purple-400/10 border-purple-400/20';
if (val >= 1500) return 'text-primary-blue bg-primary-blue/10 border-primary-blue/20';
if (val >= 1000) return 'text-performance-green bg-performance-green/10 border-performance-green/20';
return 'text-gray-400 bg-gray-400/10 border-gray-400/20';
};
const sizeMap = {
sm: 'px-1.5 py-0.5 text-[10px]',
md: 'px-2 py-1 text-xs',
lg: 'px-3 py-1.5 text-sm',
};
return (
<div className={`inline-flex items-center justify-center font-mono font-bold rounded border ${sizeMap[size]} ${getColor(rating)} ${className}`}>
{rating.toLocaleString()}
</div>
);
}

View File

@@ -0,0 +1,118 @@
import { Box } from '@/ui/Box';
import { Card } from '@/ui/Card';
import { Heading } from '@/ui/Heading';
import { RatingComponent } from '@/components/drivers/RatingComponent';
import { RatingHistoryItem } from '@/components/drivers/RatingHistoryItem';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
interface RatingBreakdownProps {
skillRating?: number;
safetyRating?: number;
sportsmanshipRating?: number;
}
export function RatingBreakdown({
skillRating = 1450,
safetyRating = 92,
sportsmanshipRating = 4.8
}: RatingBreakdownProps) {
return (
<Stack gap={6}>
<Card>
<Heading level={3} mb={6}>Rating Components</Heading>
<Stack gap={6}>
<RatingComponent
label="Skill Rating"
value={skillRating}
maxValue={2000}
color="text-primary-blue"
description="Based on race results, competition strength, and consistency"
breakdown={[
{ label: 'Race Results', percentage: 60 },
{ label: 'Competition Quality', percentage: 25 },
{ label: 'Consistency', percentage: 15 }
]}
/>
<RatingComponent
label="Safety Rating"
value={safetyRating}
maxValue={100}
color="text-performance-green"
suffix="%"
description="Reflects incident-free racing and clean overtakes"
breakdown={[
{ label: 'Incident Rate', percentage: 70 },
{ label: 'Clean Overtakes', percentage: 20 },
{ label: 'Position Awareness', percentage: 10 }
]}
/>
<RatingComponent
label="Sportsmanship"
value={sportsmanshipRating}
maxValue={5}
color="text-warning-amber"
suffix="/5"
description="Community feedback on racing behavior and fair play"
breakdown={[
{ label: 'Peer Reviews', percentage: 50 },
{ label: 'Fair Racing', percentage: 30 },
{ label: 'Team Play', percentage: 20 }
]}
/>
</Stack>
</Card>
<Card>
<Heading level={3} mb={4}>Rating History</Heading>
<Stack gap={3}>
<RatingHistoryItem
date="November 2024"
skillChange={+15}
safetyChange={+2}
sportsmanshipChange={0}
/>
<RatingHistoryItem
date="October 2024"
skillChange={+28}
safetyChange={-1}
sportsmanshipChange={+0.1}
/>
<RatingHistoryItem
date="September 2024"
skillChange={-12}
safetyChange={+3}
sportsmanshipChange={0}
/>
</Stack>
</Card>
<Card borderColor="border-primary-blue/30" bg="bg-charcoal-outline/20">
<Box display="flex" alignItems="center" gap={3} mb={3}>
<Text size="2xl">📈</Text>
<Heading level={3}>Rating Insights</Heading>
</Box>
<Stack as="ul" gap={2}>
<Box as="li" display="flex" alignItems="start" gap={2}>
<Text color="text-performance-green" mt={0.5}></Text>
<Text size="sm" color="text-gray-400">Strong safety rating - keep up the clean racing!</Text>
</Box>
<Box as="li" display="flex" alignItems="start" gap={2}>
<Text color="text-warning-amber" mt={0.5}></Text>
<Text size="sm" color="text-gray-400">Skill rating improving - competitive against higher-rated drivers</Text>
</Box>
<Box as="li" display="flex" alignItems="start" gap={2}>
<Text color="text-primary-blue" mt={0.5}>i</Text>
<Text size="sm" color="text-gray-400">Complete more races to stabilize your ratings</Text>
</Box>
</Stack>
</Card>
</Stack>
);
}

View File

@@ -0,0 +1,52 @@
import { Box } from '@/ui/Box';
import { ProgressBar } from '@/ui/ProgressBar';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
interface RatingComponentProps {
label: string;
value: number;
maxValue: number;
color: string;
suffix?: string;
description: string;
breakdown: { label: string; percentage: number }[];
}
export function RatingComponent({
label,
value,
maxValue,
color,
suffix = '',
description,
breakdown,
}: RatingComponentProps) {
const percentage = (value / maxValue) * 100;
return (
<Box>
<Box display="flex" alignItems="center" justifyContent="between" mb={2}>
<Text weight="medium" color="text-white">{label}</Text>
<Text size="2xl" weight="bold" color={color}>
{value}{suffix}
</Text>
</Box>
<ProgressBar value={percentage} max={100} color={color} mb={3} />
<Text size="xs" color="text-gray-400" block mb={3}>{description}</Text>
<Stack gap={1}>
{breakdown.map((item, index) => (
<Box key={index} display="flex" alignItems="center" justifyContent="between">
<Text size="xs" color="text-gray-500">{item.label}</Text>
<Text size="xs" color="text-gray-400">{item.percentage}%</Text>
</Box>
))}
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,48 @@
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
interface RatingHistoryItemProps {
date: string;
skillChange: number;
safetyChange: number;
sportsmanshipChange: number;
}
export function RatingHistoryItem({
date,
skillChange,
safetyChange,
sportsmanshipChange,
}: RatingHistoryItemProps) {
const formatChange = (value: number) => {
if (value === 0) return '—';
return value > 0 ? `+${value}` : `${value}`;
};
const getChangeColor = (value: number) => {
if (value === 0) return 'text-gray-500';
return value > 0 ? 'text-performance-green' : 'text-red-400';
};
return (
<Box display="flex" alignItems="center" justifyContent="between" p={3} rounded="md" bg="bg-deep-graphite" border borderColor="border-charcoal-outline">
<Text color="text-white" size="sm">{date}</Text>
<Box display="flex" alignItems="center" gap={4}>
<Box textAlign="center">
<Text size="xs" color="text-gray-500" block mb={1} style={{ fontSize: '10px' }}>Skill</Text>
<Text size="xs" weight="bold" color={getChangeColor(skillChange)}>{formatChange(skillChange)}</Text>
</Box>
<Box textAlign="center">
<Text size="xs" color="text-gray-500" block mb={1} style={{ fontSize: '10px' }}>Safety</Text>
<Text size="xs" weight="bold" color={getChangeColor(safetyChange)}>{formatChange(safetyChange)}</Text>
</Box>
<Box textAlign="center">
<Text size="xs" color="text-gray-500" block mb={1} style={{ fontSize: '10px' }}>Sports</Text>
<Text size="xs" weight="bold" color={getChangeColor(sportsmanshipChange)}>{formatChange(sportsmanshipChange)}</Text>
</Box>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,89 @@
import { Box } from '@/ui/Box';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Text } from '@/ui/Text';
import { BarChart3 } from 'lucide-react';
const SKILL_LEVELS = [
{ id: 'pro', label: 'Pro', icon: BarChart3, color: 'text-yellow-400', bgColor: 'bg-yellow-400/10', borderColor: 'border-yellow-400/30' },
{ id: 'advanced', label: 'Advanced', icon: BarChart3, color: 'text-purple-400', bgColor: 'bg-purple-400/10', borderColor: 'border-purple-400/30' },
{ id: 'intermediate', label: 'Intermediate', icon: BarChart3, color: 'text-primary-blue', bgColor: 'bg-primary-blue/10', borderColor: 'border-primary-blue/30' },
{ id: 'beginner', label: 'Beginner', icon: BarChart3, color: 'text-green-400', bgColor: 'bg-green-400/10', borderColor: 'border-green-400/30' },
];
interface SkillDistributionProps {
drivers: {
skillLevel?: string;
}[];
}
export function SkillDistribution({ drivers }: SkillDistributionProps) {
const distribution = SKILL_LEVELS.map((level) => ({
...level,
count: drivers.filter((d) => d.skillLevel === level.id).length,
percentage: drivers.length > 0
? Math.round((drivers.filter((d) => d.skillLevel === level.id).length / drivers.length) * 100)
: 0,
}));
return (
<Box mb={10}>
<Box display="flex" alignItems="center" gap={3} mb={4}>
<Box
display="flex"
h="10"
w="10"
alignItems="center"
justifyContent="center"
rounded="xl"
bg="bg-neon-aqua/10"
border
borderColor="border-neon-aqua/20"
>
<Icon icon={BarChart3} size={5} color="var(--neon-aqua)" />
</Box>
<Box>
<Heading level={2}>Skill Distribution</Heading>
<Text size="xs" color="text-gray-500">Driver population by skill level</Text>
</Box>
</Box>
<Box display="grid" responsiveGridCols={{ base: 2, lg: 4 }} gap={4}>
{distribution.map((level) => {
return (
<Box
key={level.id}
p={4}
rounded="xl"
border
className={`${level.bgColor} ${level.borderColor}`}
>
<Box display="flex" alignItems="center" justifyContent="between" mb={3}>
<Icon icon={level.icon} size={5} className={level.color} />
<Text size="2xl" weight="bold" className={level.color}>{level.count}</Text>
</Box>
<Text color="text-white" weight="medium" block mb={1}>{level.label}</Text>
<Box fullWidth h="2" rounded="full" bg="bg-deep-graphite/50" overflow="hidden">
<Box
h="full"
rounded="full"
transition
className={
level.id === 'pro' ? 'bg-yellow-400' :
level.id === 'advanced' ? 'bg-purple-400' :
level.id === 'intermediate' ? 'bg-primary-blue' :
'bg-green-400'
}
style={{ width: `${level.percentage}%` }}
/>
</Box>
<Text size="xs" color="text-gray-500" block mt={1}>{level.percentage}% of drivers</Text>
</Box>
);
})}
</Box>
</Box>
);
}

View File

@@ -0,0 +1,41 @@
import React from 'react';
import { LucideIcon } from 'lucide-react';
import { Button } from '@/ui/Button';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
interface SkillLevelButtonProps {
label: string;
icon: LucideIcon;
color: string;
bgColor: string;
borderColor: string;
count: number;
onClick: () => void;
}
export function SkillLevelButton({
label,
icon,
color,
bgColor,
borderColor,
count,
onClick,
}: SkillLevelButtonProps) {
return (
<Button
variant="ghost"
onClick={onClick}
fullWidth
className={`${bgColor} border ${borderColor} flex items-center justify-between p-3 rounded-lg h-auto`}
>
<Stack direction="row" align="center" gap={2}>
<Icon icon={icon} size={4} className={color} />
<Text weight="medium" color="text-white">{label}</Text>
</Stack>
<Text size="sm" color="text-gray-400">{count} teams</Text>
</Button>
);
}

View File

@@ -0,0 +1,85 @@
import React from 'react';
import { LucideIcon, ChevronRight, UserPlus } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Badge } from '@/ui/Badge';
interface SkillLevelHeaderProps {
label: string;
icon: LucideIcon;
bgColor: string;
borderColor: string;
color: string;
description: string;
teamCount: number;
recruitingCount: number;
isExpanded: boolean;
onToggle: () => void;
showToggle: boolean;
}
export function SkillLevelHeader({
label,
icon,
bgColor,
borderColor,
color,
description,
teamCount,
recruitingCount,
isExpanded,
onToggle,
showToggle,
}: SkillLevelHeaderProps) {
return (
<Box display="flex" alignItems="center" justifyContent="between" mb={4}>
<Stack direction="row" align="center" gap={3}>
<Box
display="flex"
center
width="11"
height="11"
rounded="xl"
className={`${bgColor} border ${borderColor}`}
>
<Icon icon={icon} size={5} className={color} />
</Box>
<Box>
<Stack direction="row" align="center" gap={2}>
<Heading level={2}>{label}</Heading>
<Badge variant="default">
{teamCount} {teamCount === 1 ? 'team' : 'teams'}
</Badge>
{recruitingCount > 0 && (
<Badge variant="success" icon={UserPlus}>
{recruitingCount} recruiting
</Badge>
)}
</Stack>
<Text size="sm" color="text-gray-500">{description}</Text>
</Box>
</Stack>
{showToggle && (
<Box
as="button"
type="button"
onClick={onToggle}
display="flex"
alignItems="center"
gap={1}
px={3}
py={1.5}
rounded="lg"
className="text-sm text-gray-400 hover:text-white hover:bg-iron-gray/50 transition-all"
>
<Text size="sm">{isExpanded ? 'Show less' : `View all ${teamCount}`}</Text>
<Icon icon={ChevronRight} size={4} className={`transition-transform ${isExpanded ? 'rotate-90' : ''}`} />
</Box>
)}
</Box>
);
}

View File

@@ -1,7 +1,7 @@
import { mediaConfig } from '@/lib/config/mediaConfig';
import { ActiveDriverCard } from '@/ui/ActiveDriverCard';
import { ActiveDriverCard } from '@/components/drivers/ActiveDriverCard';
import { Box } from '@/ui/Box';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';

View File

@@ -1,7 +1,7 @@
'use client';
import React from 'react';
import { LeagueCard } from '@/ui/LeagueCard';
import { LeagueCard } from '@/components/leagues/LeagueCard';
import { routes } from '@/lib/routing/RouteConfig';
import { Box } from '@/ui/Box';
import { Heading } from '@/ui/Heading';

View File

@@ -1,7 +1,7 @@
'use client';
import React from 'react';
import { UpcomingRaceItem } from '@/ui/UpcomingRaceItem';
import { UpcomingRaceItem } from '@/components/races/UpcomingRaceItem';
import { routes } from '@/lib/routing/RouteConfig';
import { Box } from '@/ui/Box';
import { Heading } from '@/ui/Heading';

View File

@@ -1,7 +1,7 @@
'use client';
import React from 'react';
import { TeamCard } from '@/ui/TeamCard';
import { TeamCard } from '@/components/teams/TeamCard';
import { routes } from '@/lib/routing/RouteConfig';
import { Box } from '@/ui/Box';
import { Heading } from '@/ui/Heading';

View File

@@ -9,9 +9,9 @@ import { Heading } from '@/ui/Heading';
import { Link } from '@/ui/Link';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { LeagueCard } from '@/ui/LeagueCard';
import { TeamCard } from '@/ui/TeamCard';
import { UpcomingRaceItem } from '@/ui/UpcomingRaceItem';
import { LeagueCard } from '@/components/leagues/LeagueCard';
import { TeamCard } from '@/components/teams/TeamCard';
import { UpcomingRaceItem } from '@/components/races/UpcomingRaceItem';
import { HomeViewData } from '@/templates/HomeTemplate';
interface DiscoverySectionProps {

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { NavLink } from './NavLink';
import { Stack } from '@/ui/Stack';
import { Home, Trophy, Layout, Users, Calendar, Settings } from 'lucide-react';
import { routes } from '@/lib/routing/RouteConfig';
interface AuthedNavProps {
pathname: string;
direction?: 'row' | 'col';
}
/**
* AuthedNav displays navigation items for authenticated users.
*/
export function AuthedNav({ pathname, direction = 'col' }: AuthedNavProps) {
const items = [
{ label: 'Dashboard', href: routes.protected.dashboard, icon: Home },
{ label: 'Leagues', href: routes.public.leagues, icon: Trophy },
{ label: 'Leaderboards', href: routes.public.leaderboards, icon: Layout },
{ label: 'Teams', href: routes.public.teams, icon: Users },
{ label: 'Races', href: routes.public.races, icon: Calendar },
{ label: 'Settings', href: routes.protected.profileSettings, icon: Settings },
];
return (
<Stack direction={direction} gap={direction === 'row' ? 4 : 1}>
{items.map((item) => (
<NavLink
key={item.href}
href={item.href}
label={item.label}
icon={item.icon}
isActive={pathname === item.href}
variant={direction === 'row' ? 'top' : 'sidebar'}
/>
))}
</Stack>
);
}

View File

@@ -0,0 +1,41 @@
import React from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { Box } from '@/ui/Box';
interface BrandMarkProps {
href?: string;
priority?: boolean;
}
/**
* BrandMark provides the consistent logo/wordmark for the application.
* Aligned with "Precision Racing Minimal" theme.
*/
export function BrandMark({ href = '/', priority = false }: BrandMarkProps) {
return (
<Box as={Link} href={href} display="inline-flex" alignItems="center" group>
<Box position="relative">
<Box h={{ base: '24px', md: '28px' }} w="auto" transition opacity={1} groupHoverOpacity={0.8}>
<Image
src="/images/logos/wordmark-rectangle-dark.svg"
alt="GridPilot"
width={160}
height={30}
priority={priority}
/>
</Box>
<Box
position="absolute"
bottom="-4px"
left="0"
w="0"
h="2px"
bg="primary-accent"
transition
groupHoverWidth="full"
/>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,49 @@
import React from 'react';
import { Button } from '@/ui/Button';
import { Stack } from '@/ui/Stack';
import { routes } from '@/lib/routing/RouteConfig';
import { LogIn, UserPlus } from 'lucide-react';
interface HeaderActionsProps {
isAuthenticated: boolean;
}
/**
* HeaderActions provides the primary actions in the header (Login, Signup, Profile).
*/
export function HeaderActions({ isAuthenticated }: HeaderActionsProps) {
if (isAuthenticated) {
return (
<Stack direction="row" gap={3}>
<Button as="a" href={routes.protected.profile} variant="secondary" size="sm">
Profile
</Button>
</Stack>
);
}
return (
<Stack direction="row" gap={3}>
<Button
as="a"
href={routes.auth.login}
variant="ghost"
size="sm"
icon={<LogIn size={16} />}
data-testid="public-nav-login"
>
Login
</Button>
<Button
as="a"
href={routes.auth.signup}
variant="primary"
size="sm"
icon={<UserPlus size={16} />}
data-testid="public-nav-signup"
>
Sign Up
</Button>
</Stack>
);
}

View File

@@ -0,0 +1,66 @@
import React from 'react';
import Link from 'next/link';
import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
import { LucideIcon } from 'lucide-react';
interface NavLinkProps {
href: string;
label: string;
icon?: LucideIcon;
isActive?: boolean;
variant?: 'sidebar' | 'top';
}
/**
* NavLink provides a consistent link component for navigation.
* Supports both sidebar and top navigation variants.
*/
export function NavLink({ href, label, icon: Icon, isActive, variant = 'sidebar' }: NavLinkProps) {
if (variant === 'top') {
return (
<Box
as={Link}
href={href}
display="flex"
alignItems="center"
gap={2}
px={3}
py={2}
transition
color={isActive ? 'primary-accent' : 'text-gray-400'}
hoverTextColor="white"
>
{Icon && <Icon size={18} />}
<Text size="sm" weight={isActive ? 'bold' : 'medium'}>
{label}
</Text>
</Box>
);
}
return (
<Box
as={Link}
href={href}
display="flex"
alignItems="center"
gap={3}
px={3}
py={2}
rounded="md"
transition
bg={isActive ? 'primary-accent/10' : 'transparent'}
color={isActive ? 'primary-accent' : 'text-gray-400'}
hoverBg={isActive ? 'primary-accent/10' : 'white/5'}
hoverTextColor={isActive ? 'primary-accent' : 'white'}
group
>
{Icon && <Icon size={20} color={isActive ? '#198CFF' : '#6B7280'} />}
<Text weight="medium">{label}</Text>
{isActive && (
<Box ml="auto" w="4px" h="16px" bg="primary-accent" rounded="full" shadow="[0_0_8px_rgba(25,140,255,0.5)]" />
)}
</Box>
);
}

View File

@@ -0,0 +1,38 @@
import React from 'react';
import { NavLink } from './NavLink';
import { Stack } from '@/ui/Stack';
import { Home, Trophy, Layout, Users, Calendar } from 'lucide-react';
import { routes } from '@/lib/routing/RouteConfig';
interface PublicNavProps {
pathname: string;
direction?: 'row' | 'col';
}
/**
* PublicNav displays navigation items for unauthenticated users.
*/
export function PublicNav({ pathname, direction = 'col' }: PublicNavProps) {
const items = [
{ label: 'Home', href: routes.public.home, icon: Home },
{ label: 'Leagues', href: routes.public.leagues, icon: Trophy },
{ label: 'Leaderboards', href: routes.public.leaderboards, icon: Layout },
{ label: 'Teams', href: routes.public.teams, icon: Users },
{ label: 'Races', href: routes.public.races, icon: Calendar },
];
return (
<Stack direction={direction} gap={direction === 'row' ? 4 : 1}>
{items.map((item) => (
<NavLink
key={item.href}
href={item.href}
label={item.label}
icon={item.icon}
isActive={pathname === item.href}
variant={direction === 'row' ? 'top' : 'sidebar'}
/>
))}
</Stack>
);
}

View File

@@ -1,16 +1,12 @@
import React from 'react';
import { Trophy, ChevronRight } from 'lucide-react';
import { Button } from '@/ui/Button';
import { Trophy } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/Stack';
import { Image } from '@/ui/Image';
import { LeaderboardList } from '@/ui/LeaderboardList';
import { LeaderboardPreviewShell } from '@/ui/LeaderboardPreviewShell';
import { RankBadge } from '@/components/leaderboards/RankBadge';
import { SkillLevelDisplay } from '@/lib/display-objects/SkillLevelDisplay';
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
import { RankMedal } from './RankMedal';
import { LeaderboardTableShell } from './LeaderboardTableShell';
interface DriverLeaderboardPreviewProps {
drivers: {
@@ -32,54 +28,18 @@ export function DriverLeaderboardPreview({ drivers, onDriverClick, onNavigateToD
const top10 = drivers; // Already sliced in builder
return (
<LeaderboardTableShell>
<Box
display="flex"
alignItems="center"
justifyContent="between"
px={5}
py={4}
borderBottom
borderColor="border-charcoal-outline/50"
bg="bg-deep-charcoal/40"
>
<Box display="flex" alignItems="center" gap={3}>
<Box
display="flex"
h="10"
w="10"
alignItems="center"
justifyContent="center"
rounded="lg"
bg="bg-gradient-to-br from-primary-blue/15 to-primary-blue/5"
border
borderColor="border-primary-blue/20"
>
<Icon icon={Trophy} size={5} color="text-primary-blue" />
</Box>
<Box>
<Heading level={3} fontSize="lg" weight="bold" color="text-white" letterSpacing="tight">Driver Rankings</Heading>
<Text size="xs" color="text-gray-500" block uppercase letterSpacing="wider" weight="bold">Top Performers</Text>
</Box>
</Box>
<Button
variant="secondary"
onClick={onNavigateToDrivers}
size="sm"
hoverBg="bg-primary-blue/10"
transition
>
<Stack direction="row" align="center" gap={2}>
<Text size="sm" weight="medium">View All</Text>
<Icon icon={ChevronRight} size={4} />
</Stack>
</Button>
</Box>
<Stack gap={0}>
<LeaderboardPreviewShell
title="Driver Rankings"
subtitle="Top Performers"
onViewFull={onNavigateToDrivers}
icon={Trophy}
iconColor="var(--primary-blue)"
iconBgGradient="linear-gradient(to bottom right, rgba(25, 140, 255, 0.2), rgba(25, 140, 255, 0.1))"
viewFullLabel="View All"
>
<LeaderboardList>
{top10.map((driver, index) => {
const position = index + 1;
const isLast = index === top10.length - 1;
return (
<Box
@@ -97,11 +57,9 @@ export function DriverLeaderboardPreview({ drivers, onDriverClick, onNavigateToD
transition
hoverBg="bg-white/[0.02]"
group
borderBottom={!isLast}
borderColor="border-charcoal-outline/30"
>
<Box w="8" display="flex" justifyContent="center">
<RankMedal rank={position} size="sm" />
<RankBadge rank={position} />
</Box>
<Box
@@ -111,7 +69,6 @@ export function DriverLeaderboardPreview({ drivers, onDriverClick, onNavigateToD
rounded="full"
overflow="hidden"
border
borderWidth="1px"
borderColor="border-charcoal-outline"
groupHoverBorderColor="primary-blue/50"
transition
@@ -152,7 +109,7 @@ export function DriverLeaderboardPreview({ drivers, onDriverClick, onNavigateToD
</Box>
);
})}
</Stack>
</LeaderboardTableShell>
</LeaderboardList>
</LeaderboardPreviewShell>
);
}

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { Table, TableBody, TableHead, TableHeader, TableRow } from '@/ui/Table';
import { RankingRow } from './RankingRow';
import { LeaderboardTableShell } from './LeaderboardTableShell';
import { LeaderboardTableShell } from '@/ui/LeaderboardTableShell';
interface LeaderboardDriver {
id: string;
@@ -22,28 +22,23 @@ interface LeaderboardTableProps {
}
export function LeaderboardTable({ drivers, onDriverClick }: LeaderboardTableProps) {
const columns = [
{ key: 'rank', label: 'Rank', width: '8rem' },
{ key: 'driver', label: 'Driver' },
{ key: 'races', label: 'Races', align: 'center' as const },
{ key: 'rating', label: 'Rating', align: 'center' as const },
{ key: 'wins', label: 'Wins', align: 'center' as const },
];
return (
<LeaderboardTableShell isEmpty={drivers.length === 0} emptyMessage="No drivers found">
<Table>
<TableHead>
<TableRow>
<TableHeader w="32">Rank</TableHeader>
<TableHeader>Driver</TableHeader>
<TableHeader textAlign="center">Races</TableHeader>
<TableHeader textAlign="center">Rating</TableHeader>
<TableHeader textAlign="center">Wins</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{drivers.map((driver) => (
<RankingRow
key={driver.id}
{...driver}
onClick={() => onDriverClick?.(driver.id)}
/>
))}
</TableBody>
</Table>
<LeaderboardTableShell columns={columns}>
{drivers.map((driver) => (
<RankingRow
key={driver.id}
{...driver}
onClick={() => onDriverClick?.(driver.id)}
/>
))}
</LeaderboardTableShell>
);
}

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { Crown } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Icon } from '@/ui/Icon';
import { Text } from '@/ui/Text';
interface MedalBadgeProps {
position: number;
}
export function MedalBadge({ position }: MedalBadgeProps) {
const getMedalColor = (pos: number) => {
switch (pos) {
case 1: return 'var(--warning-amber)';
case 2: return 'var(--iron-gray)';
case 3: return 'var(--amber-600)';
default: return 'var(--charcoal-outline)';
}
};
const isMedal = position <= 3;
return (
<Box
display="flex"
h="10"
w="10"
alignItems="center"
justifyContent="center"
rounded="full"
bg={isMedal ? 'bg-gradient-to-br from-yellow-400/20 to-amber-600/10' : 'bg-iron-gray'}
>
{isMedal ? (
<Icon icon={Crown} size={5} color={getMedalColor(position)} />
) : (
<Text size="lg" weight="bold" color="text-gray-400">#{position}</Text>
)}
</Box>
);
}

View File

@@ -0,0 +1,52 @@
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
interface RankBadgeProps {
rank: number;
size?: 'sm' | 'md' | 'lg';
showLabel?: boolean;
}
export function RankBadge({ rank, size = 'md', showLabel = true }: RankBadgeProps) {
const getMedalEmoji = (rank: number) => {
switch (rank) {
case 1: return '🥇';
case 2: return '🥈';
case 3: return '🥉';
default: return null;
}
};
const medal = getMedalEmoji(rank);
const sizeClasses = {
sm: 'text-sm px-2 py-1',
md: 'text-base px-3 py-1.5',
lg: 'text-lg px-4 py-2'
};
const getRankColor = (rank: number) => {
if (rank <= 3) return 'bg-warning-amber/20 text-warning-amber border-warning-amber/30';
if (rank <= 10) return 'bg-primary-blue/20 text-primary-blue border-primary-blue/30';
if (rank <= 50) return 'bg-purple-500/20 text-purple-400 border-purple-500/30';
return 'bg-charcoal-outline/20 text-gray-300 border-charcoal-outline';
};
return (
<Box
as="span"
display="inline-flex"
alignItems="center"
gap={1.5}
rounded="md"
border
className={`font-medium ${getRankColor(rank)} ${sizeClasses[size]}`}
>
{medal && <Text>{medal}</Text>}
{showLabel && <Text>#{rank}</Text>}
{!showLabel && !medal && <Text>#{rank}</Text>}
</Box>
);
}

View File

@@ -0,0 +1,141 @@
import { Box } from '@/ui/Box';
import { Image } from '@/ui/Image';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
interface PodiumDriver {
id: string;
name: string;
avatarUrl: string;
rating: number;
wins: number;
podiums: number;
}
interface RankingsPodiumProps {
podium: PodiumDriver[];
onDriverClick?: (id: string) => void;
}
export function RankingsPodium({ podium, onDriverClick }: RankingsPodiumProps) {
return (
<Box mb={10}>
<Box display="flex" alignItems="end" justifyContent="center" gap={4}>
{[1, 0, 2].map((index) => {
const driver = podium[index];
if (!driver) return null;
const position = index === 1 ? 1 : index === 0 ? 2 : 3;
const config = {
1: { height: '10rem', color: 'rgba(250, 204, 21, 0.2)', borderColor: 'rgba(250, 204, 21, 0.4)', crown: '#facc15' },
2: { height: '8rem', color: 'rgba(209, 213, 219, 0.2)', borderColor: 'rgba(209, 213, 219, 0.4)', crown: '#d1d5db' },
3: { height: '6rem', color: 'rgba(217, 119, 6, 0.2)', borderColor: 'rgba(217, 119, 6, 0.4)', crown: '#d97706' },
}[position] || { height: '6rem', color: 'rgba(217, 119, 6, 0.2)', borderColor: 'rgba(217, 119, 6, 0.4)', crown: '#d97706' };
return (
<Box
key={driver.id}
as="button"
type="button"
onClick={() => onDriverClick?.(driver.id)}
display="flex"
flexDirection="col"
alignItems="center"
bg="transparent"
border={false}
cursor="pointer"
>
<Box position="relative" mb={4}>
<Box
position="relative"
w={position === 1 ? '24' : '20'}
h={position === 1 ? '24' : '20'}
rounded="full"
overflow="hidden"
border
borderColor={config.crown === '#facc15' ? 'border-warning-amber' : config.crown === '#d1d5db' ? 'border-gray-300' : 'border-orange-600'}
style={{ borderWidth: '4px', boxShadow: position === 1 ? '0 0 30px rgba(250, 204, 21, 0.3)' : 'none' }}
>
<Image
src={driver.avatarUrl}
alt={driver.name}
width={112}
height={112}
objectFit="cover"
/>
</Box>
<Box
position="absolute"
bottom="-2"
left="50%"
w="8"
h="8"
rounded="full"
display="flex"
alignItems="center"
justifyContent="center"
border
weight="bold"
size="sm"
borderColor={config.crown === '#facc15' ? 'border-warning-amber' : config.crown === '#d1d5db' ? 'border-gray-300' : 'border-orange-600'}
color={config.crown === '#facc15' ? 'text-warning-amber' : config.crown === '#d1d5db' ? 'text-gray-300' : 'text-orange-600'}
style={{
transform: 'translateX(-50%)',
borderWidth: '2px',
background: `linear-gradient(to bottom right, ${config.color}, transparent)`
}}
>
{position}
</Box>
</Box>
<Text weight="semibold" color="text-white" size={position === 1 ? 'lg' : 'base'} mb={1} block>
{driver.name}
</Text>
<Text font="mono" weight="bold" size={position === 1 ? 'xl' : 'lg'} block color={position === 1 ? 'text-warning-amber' : 'text-primary-blue'}>
{driver.rating.toString()}
</Text>
<Stack direction="row" align="center" gap={2} mt={1}>
<Stack direction="row" align="center" gap={1}>
<Text>🏆</Text>
<Text color="text-gray-400">{driver.wins}</Text>
</Stack>
<Text color="text-gray-500"></Text>
<Stack direction="row" align="center" gap={1}>
<Text>🏅</Text>
<Text color="text-gray-400">{driver.podiums}</Text>
</Stack>
</Stack>
<Box
mt={4}
w={position === 1 ? '28' : '24'}
h={config.height}
rounded="lg"
display="flex"
alignItems="end"
justifyContent="center"
pb={4}
style={{
borderRadius: '0.5rem 0.5rem 0 0',
background: `linear-gradient(to top, ${config.color}, transparent)`,
borderTop: `1px solid ${config.borderColor}`,
borderLeft: `1px solid ${config.borderColor}`,
borderRight: `1px solid ${config.borderColor}`
}}
>
<Text weight="bold" size={position === 1 ? '4xl' : '3xl'} color={config.crown === '#facc15' ? 'text-warning-amber' : config.crown === '#d1d5db' ? 'text-gray-300' : 'text-orange-600'}>
{position}
</Text>
</Box>
</Box>
);
})}
</Box>
</Box>
);
}

View File

@@ -0,0 +1,116 @@
import { Box } from '@/ui/Box';
import { Icon } from '@/ui/Icon';
import { Image } from '@/ui/Image';
import { Stack } from '@/ui/Stack';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/ui/Table';
import { Text } from '@/ui/Text';
import { Medal } from 'lucide-react';
interface Driver {
id: string;
name: string;
avatarUrl: string;
rank: number;
nationality: string;
skillLevel: string;
racesCompleted: number;
rating: number;
wins: number;
medalBg?: string;
medalColor?: string;
}
interface RankingsTableProps {
drivers: Driver[];
onDriverClick?: (id: string) => void;
}
export function RankingsTable({ drivers, onDriverClick }: RankingsTableProps) {
if (drivers.length === 0) {
return (
<Box py={16} textAlign="center" bg="bg-iron-gray/30" border borderColor="border-charcoal-outline" rounded="xl">
<Text size="4xl" block mb={4}>🔍</Text>
<Text color="text-gray-400" block mb={2}>No drivers found</Text>
<Text size="sm" color="text-gray-500">There are no drivers in the system yet</Text>
</Box>
);
}
return (
<Box rounded="xl" bg="bg-iron-gray/30" border borderColor="border-charcoal-outline" overflow="hidden">
<Table>
<TableHead>
<TableRow>
<TableHeader className="text-center w-16">Rank</TableHeader>
<TableHeader>Driver</TableHeader>
<TableHeader className="text-center">Races</TableHeader>
<TableHeader className="text-center">Rating</TableHeader>
<TableHeader className="text-center">Wins</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{drivers.map((driver) => (
<TableRow
key={driver.id}
clickable
onClick={() => onDriverClick?.(driver.id)}
>
<TableCell className="text-center">
<Box
display="inline-flex"
h="9"
w="9"
alignItems="center"
justifyContent="center"
rounded="full"
border
borderColor="border-charcoal-outline"
bg={driver.medalBg}
color={driver.medalColor}
className="text-sm font-bold"
>
{driver.rank <= 3 ? <Icon icon={Medal} size={4} /> : driver.rank}
</Box>
</TableCell>
<TableCell>
<Box display="flex" alignItems="center" gap={3}>
<Box position="relative" w="10" h="10" rounded="full" overflow="hidden" border borderColor="border-charcoal-outline" borderTop={false} borderBottom={false} borderLeft={false} borderRight={false}>
<Image src={driver.avatarUrl} alt={driver.name} width={40} height={40} className="w-full h-full object-cover" />
</Box>
<Box minWidth="0">
<Text weight="semibold" color="text-white" block truncate>
{driver.name}
</Text>
<Stack direction="row" align="center" gap={2} mt={1}>
<Text size="xs" color="text-gray-500">{driver.nationality}</Text>
<Text size="xs" color="text-gray-500">{driver.skillLevel}</Text>
</Stack>
</Box>
</Box>
</TableCell>
<TableCell className="text-center">
<Text color="text-gray-400">{driver.racesCompleted}</Text>
</TableCell>
<TableCell className="text-center">
<Text font="mono" weight="semibold" color="text-white">
{driver.rating.toString()}
</Text>
</TableCell>
<TableCell className="text-center">
<Text font="mono" weight="semibold" color="text-performance-green">
{driver.wins}
</Text>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Box>
);
}

View File

@@ -1,15 +1,12 @@
import React from 'react';
import { Users, ChevronRight } from 'lucide-react';
import { Button } from '@/ui/Button';
import { Users } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/Stack';
import { Image } from '@/ui/Image';
import { LeaderboardList } from '@/ui/LeaderboardList';
import { LeaderboardPreviewShell } from '@/ui/LeaderboardPreviewShell';
import { RankBadge } from '@/components/leaderboards/RankBadge';
import { getMediaUrl } from '@/lib/utilities/media';
import { RankMedal } from './RankMedal';
import { LeaderboardTableShell } from './LeaderboardTableShell';
import { Icon } from '@/ui/Icon';
interface TeamLeaderboardPreviewProps {
teams: {
@@ -30,54 +27,18 @@ export function TeamLeaderboardPreview({ teams, onTeamClick, onNavigateToTeams }
const top5 = teams;
return (
<LeaderboardTableShell>
<Box
display="flex"
alignItems="center"
justifyContent="between"
px={5}
py={4}
borderBottom
borderColor="border-charcoal-outline/50"
bg="bg-deep-charcoal/40"
>
<Box display="flex" alignItems="center" gap={3}>
<Box
display="flex"
h="10"
w="10"
alignItems="center"
justifyContent="center"
rounded="lg"
bg="bg-gradient-to-br from-purple-500/15 to-purple-500/5"
border
borderColor="border-purple-500/20"
>
<Icon icon={Users} size={5} color="text-purple-400" />
</Box>
<Box>
<Heading level={3} fontSize="lg" weight="bold" color="text-white" letterSpacing="tight">Team Rankings</Heading>
<Text size="xs" color="text-gray-500" block uppercase letterSpacing="wider" weight="bold">Top Performing Teams</Text>
</Box>
</Box>
<Button
variant="secondary"
onClick={onNavigateToTeams}
size="sm"
hoverBg="bg-purple-500/10"
transition
>
<Stack direction="row" align="center" gap={2}>
<Text size="sm" weight="medium">View All</Text>
<Icon icon={ChevronRight} size={4} />
</Stack>
</Button>
</Box>
<Stack gap={0}>
{top5.map((team, index) => {
<LeaderboardPreviewShell
title="Team Rankings"
subtitle="Top Performing Teams"
onViewFull={onNavigateToTeams}
icon={Users}
iconColor="var(--neon-purple)"
iconBgGradient="linear-gradient(to bottom right, rgba(168, 85, 247, 0.2), rgba(168, 85, 247, 0.1))"
viewFullLabel="View All"
>
<LeaderboardList>
{top5.map((team) => {
const position = team.position;
const isLast = index === top5.length - 1;
return (
<Box
@@ -95,11 +56,9 @@ export function TeamLeaderboardPreview({ teams, onTeamClick, onNavigateToTeams }
transition
hoverBg="bg-white/[0.02]"
group
borderBottom={!isLast}
borderColor="border-charcoal-outline/30"
>
<Box w="8" display="flex" justifyContent="center">
<RankMedal rank={position} size="sm" />
<RankBadge rank={position} />
</Box>
<Box
@@ -166,7 +125,7 @@ export function TeamLeaderboardPreview({ teams, onTeamClick, onNavigateToTeams }
</Box>
);
})}
</Stack>
</LeaderboardTableShell>
</LeaderboardList>
</LeaderboardPreviewShell>
);
}

View File

@@ -0,0 +1,161 @@
import { CheckCircle2, Clock, Star } from 'lucide-react';
import { Badge } from '@/ui/Badge';
import { Box } from '@/ui/Box';
import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Link } from '@/ui/Link';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
interface AvailableLeague {
id: string;
name: string;
game: string;
drivers: number;
avgViewsPerRace: number;
mainSponsorSlot: { available: boolean; price: number };
secondarySlots: { available: number; total: number; price: number };
rating: number;
tier: 'premium' | 'standard' | 'starter';
nextRace?: string;
seasonStatus: 'active' | 'upcoming' | 'completed';
description: string;
formattedAvgViews: string;
formattedCpm: string;
}
interface AvailableLeagueCardProps {
league: AvailableLeague;
}
export function AvailableLeagueCard({ league }: AvailableLeagueCardProps) {
const tierConfig = {
premium: { icon: '⭐', label: 'Premium' },
standard: { icon: '🏆', label: 'Standard' },
starter: { icon: '🚀', label: 'Starter' },
};
const statusConfig = {
active: { color: 'text-performance-green', bgColor: 'bg-performance-green/10', label: 'Active Season' },
upcoming: { color: 'text-warning-amber', bgColor: 'bg-warning-amber/10', label: 'Starting Soon' },
completed: { color: 'text-gray-400', bgColor: 'bg-gray-400/10', label: 'Season Ended' },
};
const config = tierConfig[league.tier];
const status = statusConfig[league.seasonStatus];
return (
<Card>
<Stack gap={4}>
{/* Header */}
<Stack direction="row" align="start" justify="between">
<Box flexGrow={1}>
<Stack direction="row" align="center" gap={2} mb={1} wrap>
<Badge variant="primary">{config.icon} {config.label}</Badge>
<Box px={2} py={0.5} rounded="full" className={status.bgColor}>
<Text size="xs" weight="medium" className={status.color}>{status.label}</Text>
</Box>
</Stack>
<Heading level={3}>{league.name}</Heading>
<Text size="sm" color="text-gray-500" block mt={1}>{league.game}</Text>
</Box>
<Box px={2} py={1} rounded="lg" bg="bg-iron-gray/50">
<Stack direction="row" align="center" gap={1}>
<Icon icon={Star} size={3.5} color="text-yellow-400" />
<Text size="sm" weight="medium" color="text-white">{league.rating}</Text>
</Stack>
</Box>
</Stack>
{/* Description */}
<Text size="sm" color="text-gray-400" block truncate>{league.description}</Text>
{/* Stats Grid */}
<Box display="grid" gridCols={3} gap={2}>
<StatItem label="Drivers" value={league.drivers} />
<StatItem label="Avg Views" value={league.formattedAvgViews} />
<StatItem label="CPM" value={league.formattedCpm} color="text-performance-green" />
</Box>
{/* Next Race */}
{league.nextRace && (
<Stack direction="row" align="center" gap={2}>
<Icon icon={Clock} size={4} color="text-gray-400" />
<Text size="sm" color="text-gray-400">Next:</Text>
<Text size="sm" color="text-white">{league.nextRace}</Text>
</Stack>
)}
{/* Sponsorship Slots */}
<Stack gap={2}>
<SlotRow
label="Main Sponsor"
available={league.mainSponsorSlot.available}
price={`$${league.mainSponsorSlot.price}/season`}
/>
<SlotRow
label="Secondary Slots"
available={league.secondarySlots.available > 0}
price={`${league.secondarySlots.available}/${league.secondarySlots.total} @ $${league.secondarySlots.price}`}
/>
</Stack>
{/* Actions */}
<Stack direction="row" gap={2}>
<Box flexGrow={1}>
<Link href={`/sponsor/leagues/${league.id}`} block>
<Button variant="secondary" fullWidth size="sm">
View Details
</Button>
</Link>
</Box>
{(league.mainSponsorSlot.available || league.secondarySlots.available > 0) && (
<Box flexGrow={1}>
<Link href={`/sponsor/leagues/${league.id}?action=sponsor`} block>
<Button variant="primary" fullWidth size="sm">
Sponsor
</Button>
</Link>
</Box>
)}
</Stack>
</Stack>
</Card>
);
}
function StatItem({ label, value, color = 'text-white' }: { label: string, value: string | number, color?: string }) {
return (
<Box p={2} bg="bg-iron-gray/50" rounded="lg" textAlign="center">
<Text weight="bold" className={color}>{value}</Text>
<Text size="xs" color="text-gray-500" block mt={1}>{label}</Text>
</Box>
);
}
function SlotRow({ label, available, price }: { label: string, available: boolean, price: string }) {
return (
<Box p={2} rounded="lg" bg="bg-iron-gray/30">
<Stack direction="row" align="center" justify="between">
<Stack direction="row" align="center" gap={2}>
<Box width="2.5" height="2.5" rounded="full" bg={available ? 'bg-performance-green' : 'bg-error-red'} />
<Text size="sm" color="text-gray-300">{label}</Text>
</Stack>
<Box>
{available ? (
<Text size="sm" weight="semibold" color="text-white">{price}</Text>
) : (
<Stack direction="row" align="center" gap={1}>
<Icon icon={CheckCircle2} size={3} color="text-gray-500" />
<Text size="sm" color="text-gray-500">Filled</Text>
</Stack>
)}
</Box>
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,76 @@
import React from 'react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Button } from '@/ui/Button';
interface JoinRequestItemProps {
driverId: string;
requestedAt: string | Date;
onApprove: () => void;
onReject: () => void;
isApproving?: boolean;
isRejecting?: boolean;
}
export function JoinRequestItem({
driverId,
requestedAt,
onApprove,
onReject,
isApproving,
isRejecting,
}: JoinRequestItemProps) {
return (
<Box
display="flex"
alignItems="center"
justifyContent="between"
p={4}
rounded="lg"
bg="bg-deep-graphite"
border={true}
borderColor="border-charcoal-outline"
>
<Stack direction="row" align="center" gap={4} flexGrow={1}>
<Box
width="12"
height="12"
rounded="full"
bg="bg-primary-blue/20"
display="flex"
center
color="text-white"
weight="bold"
style={{ fontSize: '1.125rem' }}
>
{driverId.charAt(0)}
</Box>
<Box flexGrow={1}>
<Text color="text-white" weight="medium" block>{driverId}</Text>
<Text size="sm" color="text-gray-400" block>
Requested {new Date(requestedAt).toLocaleDateString()}
</Text>
</Box>
</Stack>
<Stack direction="row" gap={2}>
<Button
variant="primary"
onClick={onApprove}
disabled={isApproving}
size="sm"
>
{isApproving ? 'Approving...' : 'Approve'}
</Button>
<Button
variant="danger"
onClick={onReject}
disabled={isRejecting}
size="sm"
>
{isRejecting ? 'Rejecting...' : 'Reject'}
</Button>
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,14 @@
import React, { ReactNode } from 'react';
import { Stack } from '@/ui/Stack';
interface JoinRequestListProps {
children: ReactNode;
}
export function JoinRequestList({ children }: JoinRequestListProps) {
return (
<Stack gap={3}>
{children}
</Stack>
);
}

View File

@@ -0,0 +1,88 @@
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 { Button } from '@/ui/Button';
import { Check, X, Clock } from 'lucide-react';
import { Icon } from '@/ui/Icon';
interface JoinRequestsPanelProps {
requests: Array<{
id: string;
driverName: string;
driverAvatar?: string;
message?: string;
requestedAt: string;
}>;
onAccept: (id: string) => void;
onDecline: (id: string) => void;
}
export function JoinRequestsPanel({ requests, onAccept, onDecline }: JoinRequestsPanelProps) {
if (requests.length === 0) {
return (
<Box p={8} border borderDash borderColor="border-steel-grey" bg="surface-charcoal/20" textAlign="center">
<Text color="text-gray-500" size="sm">No pending join requests</Text>
</Box>
);
}
return (
<Box border borderColor="border-steel-grey" bg="surface-charcoal/50" overflow="hidden">
<Box p={4} borderBottom borderColor="border-steel-grey" bg="base-graphite/50">
<Heading level={4} weight="bold" className="uppercase tracking-widest text-gray-400 text-[10px]">
Pending Requests ({requests.length})
</Heading>
</Box>
<Stack gap={0} className="divide-y divide-border-steel-grey/30">
{requests.map((request) => (
<Box key={request.id} p={4} className="hover:bg-white/[0.02] transition-colors">
<Stack direction="row" align="start" justify="between" gap={4}>
<Stack direction="row" align="center" gap={3}>
<Box w="10" h="10" bg="base-graphite" border borderColor="border-steel-grey" display="flex" center>
<Text size="xs" weight="bold" color="text-primary-blue">
{request.driverName.substring(0, 2).toUpperCase()}
</Text>
</Box>
<Box>
<Text weight="bold" size="sm" color="text-white" block>{request.driverName}</Text>
<Stack direction="row" align="center" gap={1.5} mt={0.5}>
<Icon icon={Clock} size={3} color="text-gray-500" />
<Text size="xs" color="text-gray-500" font="mono">{request.requestedAt}</Text>
</Stack>
</Box>
</Stack>
<Stack direction="row" align="center" gap={2}>
<Button
variant="secondary"
size="sm"
onClick={() => onDecline(request.id)}
className="h-8 w-8 p-0 flex items-center justify-center border-red-500/30 hover:bg-red-500/10"
>
<Icon icon={X} size={4} color="text-red-400" />
</Button>
<Button
variant="primary"
size="sm"
onClick={() => onAccept(request.id)}
className="h-8 w-8 p-0 flex items-center justify-center"
>
<Icon icon={Check} size={4} />
</Button>
</Stack>
</Stack>
{request.message && (
<Box mt={3} p={3} bg="base-graphite/30" borderLeft borderPrimary borderColor="primary-blue/40">
<Text size="xs" color="text-gray-400" italic leading="relaxed">
&ldquo;{request.message}&rdquo;
</Text>
</Box>
)}
</Box>
))}
</Stack>
</Box>
);
}

View File

@@ -1,23 +1,32 @@
'use client';
import React from 'react';
import { ReactNode } from 'react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Image } from '@/ui/Image';
import { Trophy, Users, Calendar, ChevronRight } from 'lucide-react';
import { PlaceholderImage } from '@/ui/PlaceholderImage';
import { Calendar as LucideCalendar, ChevronRight as LucideChevronRight } 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';
badges?: ReactNode;
championshipBadge?: ReactNode;
slotLabel: string;
usedSlots: number;
maxSlots: number | string;
fillPercentage: number;
hasOpenSlots: boolean;
openSlotsCount: number;
isTeamLeague?: boolean;
usedDriverSlots?: number;
maxDrivers?: number | string;
timingSummary?: string;
onClick?: () => void;
}
@@ -26,154 +35,154 @@ export function LeagueCard({
description,
coverUrl,
logoUrl,
gameName,
memberCount,
maxMembers,
nextRaceDate,
championshipType,
badges,
championshipBadge,
slotLabel,
usedSlots,
maxSlots,
fillPercentage,
hasOpenSlots,
openSlotsCount,
isTeamLeague: _isTeamLeague,
usedDriverSlots: _usedDriverSlots,
maxDrivers: _maxDrivers,
timingSummary,
onClick,
}: LeagueCardProps) {
const fillPercentage = maxMembers ? (memberCount / maxMembers) * 100 : 0;
return (
<Box
as="article"
onClick={onClick}
<Box
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
cursor={onClick ? 'pointer' : 'default'}
h="full"
onClick={onClick}
className="group"
>
{/* Cover Image */}
<Box position="relative" h="32" overflow="hidden">
<Box fullWidth fullHeight opacity={0.6}>
{/* Card Container */}
<Box
position="relative"
h="full"
rounded="none"
bg="panel-gray/40"
border
borderColor="border-gray/50"
overflow="hidden"
transition
className="hover:border-primary-accent/30 hover:bg-panel-gray/60 transition-all duration-300"
>
{/* Cover Image */}
<Box position="relative" h="32" overflow="hidden">
<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"
className="transition-transform duration-500 group-hover:scale-105 opacity-60"
/>
</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>
{/* Gradient Overlay */}
<Box position="absolute" inset="0" bg="linear-gradient(to top, #0C0D0F, transparent)" />
{/* Badges - Top Left */}
<Box position="absolute" top="3" left="3" display="flex" alignItems="center" gap={2}>
{badges}
</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>
{/* Championship Type Badge - Top Right */}
<Box position="absolute" top="3" right="3">
{championshipBadge}
</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} />
{/* Logo */}
<Box position="absolute" left="4" bottom="-6" zIndex={10}>
<Box w="12" h="12" rounded="none" overflow="hidden" border borderColor="border-gray/50" bg="graphite-black" shadow="xl">
{logoUrl ? (
<Image
src={logoUrl}
alt={`${name} logo`}
width={48}
height={48}
fullWidth
fullHeight
objectFit="cover"
/>
) : (
<PlaceholderImage size={48} />
)}
</Box>
)}
</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">
{/* Content */}
<Box pt={8} px={4} pb={4} display="flex" flexDirection="col" fullHeight>
{/* Title & Description */}
<Stack direction="row" align="center" gap={2} mb={1}>
<Box w="1" h="4" bg="primary-accent" />
<Heading level={3} fontSize="lg" weight="bold" className="line-clamp-1 group-hover:text-primary-accent transition-colors tracking-tight">
{name}
</Heading>
</Stack>
<Text size="xs" color="text-gray-500" lineClamp={2} mb={4} style={{ height: '2.5rem' }} block leading="relaxed">
{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)}%`}
/>
{/* Stats Row */}
<Box display="flex" alignItems="center" gap={3} mb={4}>
{/* Primary Slots (Drivers/Teams/Nations) */}
<Box flexGrow={1}>
<Box display="flex" alignItems="center" justifyContent="between" mb={1.5}>
<Text size="xs" color="text-gray-500" weight="bold" className="uppercase tracking-widest">{slotLabel}</Text>
<Text size="xs" color="text-gray-400" font="mono">
{usedSlots}/{maxSlots || '∞'}
</Text>
</Box>
<Box h="1" rounded="none" bg="border-gray/30" overflow="hidden">
<Box
h="full"
rounded="none"
transition
bg={
fillPercentage >= 90
? 'warning-amber'
: fillPercentage >= 70
? 'primary-accent'
: 'success-green'
}
style={{ width: `${Math.min(fillPercentage, 100)}%` }}
/>
</Box>
</Box>
{/* Open Slots Badge */}
{hasOpenSlots && (
<Box display="flex" alignItems="center" gap={1.5} px={2} py={1} rounded="none" bg="primary-accent/5" border borderColor="primary-accent/20">
<Box w="1.5" h="1.5" rounded="full" bg="primary-accent" className="animate-pulse" />
<Text size="xs" color="text-primary-accent" weight="bold" className="uppercase tracking-tighter">
{openSlotsCount} OPEN
</Text>
</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>
{/* Spacer to push footer to bottom */}
<Box flexGrow={1} />
{/* Footer Info */}
<Box display="flex" alignItems="center" justifyContent="between" pt={3} borderTop borderColor="border-gray/30" mt="auto">
<Box display="flex" alignItems="center" gap={3}>
{timingSummary && (
<Box display="flex" alignItems="center" gap={2}>
<Icon icon={LucideCalendar} size={3} color="text-gray-500" />
<Text size="xs" color="text-gray-500" font="mono">
{timingSummary.split('•')[1]?.trim() || timingSummary}
</Text>
</Box>
)}
</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>
{/* View Arrow */}
<Box display="flex" alignItems="center" gap={1} className="group-hover:text-primary-accent transition-colors">
<Text size="xs" color="text-gray-500" weight="bold" className="uppercase tracking-widest">VIEW</Text>
<Icon icon={LucideChevronRight} size={3} color="text-gray-500" className="transition-transform group-hover:translate-x-0.5" />
</Box>
</Box>
</Box>
@@ -181,3 +190,4 @@ export function LeagueCard({
</Box>
);
}

View File

@@ -9,7 +9,7 @@ import {
import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
import { getMediaUrl } from '@/lib/utilities/media';
import { Badge } from '@/ui/Badge';
import { LeagueCard as UiLeagueCard } from '@/ui/LeagueCard';
import { LeagueCard as UiLeagueCard } from './LeagueCard';
interface LeagueCardProps {
league: LeagueSummaryViewModel;
@@ -117,8 +117,8 @@ export function LeagueCard({ league, onClick }: LeagueCardProps) {
const gameVariant = getGameVariant(league.scoring?.gameId);
const isNew = isNewLeague(league.createdAt);
const isTeamLeague = league.maxTeams && league.maxTeams > 0;
const categoryLabel = getCategoryLabel(league.category);
const categoryVariant = getCategoryVariant(league.category);
const categoryLabel = getCategoryLabel(league.category || undefined);
const categoryVariant = getCategoryVariant(league.category || undefined);
const usedSlots = isTeamLeague ? (league.usedTeamSlots ?? 0) : (league.usedDriverSlots ?? 0);
const maxSlots = isTeamLeague ? (league.maxTeams ?? 0) : (league.maxDrivers ?? 0);
@@ -135,7 +135,7 @@ export function LeagueCard({ league, onClick }: LeagueCardProps) {
return (
<UiLeagueCard
name={league.name}
description={league.description}
description={league.description || undefined}
coverUrl={coverUrl}
logoUrl={logoUrl || undefined}
slotLabel={slotLabel}

View File

@@ -0,0 +1,45 @@
import React from 'react';
import { Box } from '@/ui/Box';
import { SafeImage } from '@/components/shared/SafeImage';
import { ImagePlaceholder } from '@/ui/ImagePlaceholder';
export interface LeagueCoverProps {
leagueId?: string;
src?: string;
alt: string;
height?: string;
aspectRatio?: string;
className?: string;
}
export function LeagueCover({
leagueId,
src,
alt,
height,
aspectRatio = '21/9',
className = '',
}: LeagueCoverProps) {
const coverSrc = src || (leagueId ? `/api/media/leagues/${leagueId}/cover` : undefined);
return (
<Box
width="full"
overflow="hidden"
bg="bg-charcoal-outline/10"
className={className}
style={{ height, aspectRatio: height ? undefined : aspectRatio }}
>
{coverSrc ? (
<SafeImage
src={coverSrc}
alt={alt}
className="w-full h-full object-cover"
fallbackComponent={<ImagePlaceholder aspectRatio={aspectRatio} rounded="none" />}
/>
) : (
<ImagePlaceholder aspectRatio={aspectRatio} rounded="none" />
)}
</Box>
);
}

View File

@@ -0,0 +1,16 @@
import React from 'react';
import { LeagueCover as UiLeagueCover } from '@/components/leagues/LeagueCover';
export interface LeagueCoverProps {
leagueId: string;
alt: string;
}
export function LeagueCover({ leagueId, alt }: LeagueCoverProps) {
return (
<UiLeagueCover
leagueId={leagueId}
alt={alt}
/>
);
}

View File

@@ -1,89 +1,62 @@
'use client';
import React from 'react';
import { ReactNode } from 'react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Image } from '@/ui/Image';
import { MembershipStatus } from './MembershipStatus';
interface MainSponsorInfo {
interface LeagueHeaderProps {
name: string;
logoUrl?: string;
websiteUrl?: string;
}
export interface LeagueHeaderProps {
leagueId: string;
leagueName: string;
description?: string | null;
ownerId: string;
ownerName: string;
mainSponsor?: MainSponsorInfo | null;
logoUrl: string;
sponsorContent?: ReactNode;
statusContent?: ReactNode;
}
export function LeagueHeader({
leagueId,
leagueName,
name,
description,
mainSponsor,
logoUrl,
sponsorContent,
statusContent,
}: LeagueHeaderProps) {
return (
<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>
)}
<Box mb={8}>
<Box display="flex" alignItems="center" justifyContent="between" mb={6}>
<Stack direction="row" align="center" gap={4}>
<Box h="16" w="16" rounded="xl" overflow="hidden" border style={{ borderColor: 'rgba(38, 38, 38, 0.8)', backgroundColor: '#1a1d23' }} shadow="lg">
<Image
src={logoUrl}
alt={`${name} logo`}
width={64}
height={64}
fullWidth
fullHeight
objectFit="cover"
/>
</Box>
<Box>
<Box display="flex" alignItems="center" gap={3} mb={1}>
<Heading level={1}>
{name}
{sponsorContent && (
<Text color="text-gray-400" weight="normal" size="lg" ml={2}>
by {sponsorContent}
</Text>
)}
</Heading>
{statusContent}
</Box>
{description && (
<Text color="text-gray-400" size="sm" maxWidth="xl" block>
{description}
</Text>
)}
</Box>
</Stack>
</Stack>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,60 @@
import { Box } from '@/ui/Box';
import { Button } from '@/ui/Button';
import { Link } from '@/ui/Link';
import { Stack } from '@/ui/Stack';
import { Surface } from '@/ui/Surface';
import { Text } from '@/ui/Text';
interface League {
leagueId: string;
name: string;
description: string;
membershipRole?: string;
}
interface LeagueListItemProps {
league: League;
isAdmin?: boolean;
}
export function LeagueListItem({ league, isAdmin }: LeagueListItemProps) {
return (
<Surface
variant="dark"
rounded="lg"
border
padding={4}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', borderColor: '#262626' }}
>
<Box style={{ flex: 1, minWidth: 0 }}>
<Text weight="medium" color="text-white" block>{league.name}</Text>
<Text size="xs" color="text-gray-400" block mt={1} style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{league.description}
</Text>
{league.membershipRole && (
<Text size="xs" color="text-gray-500" block mt={1}>
Your role:{' '}
<Text color="text-gray-400" style={{ textTransform: 'capitalize' }}>{league.membershipRole}</Text>
</Text>
)}
</Box>
<Stack direction="row" align="center" gap={2} style={{ marginLeft: '1rem' }}>
<Link
href={`/leagues/${league.leagueId}`}
variant="ghost"
>
<Text size="sm" color="text-gray-300">View</Text>
</Link>
{isAdmin && (
<Link href={`/leagues/${league.leagueId}?tab=admin`} variant="ghost">
<Button variant="primary" size="sm">
Manage
</Button>
</Link>
)}
</Stack>
</Surface>
);
}

View File

@@ -0,0 +1,53 @@
import React from 'react';
import { Box } from '@/ui/Box';
import { SafeImage } from '@/components/shared/SafeImage';
import { Trophy } from 'lucide-react';
import { Icon } from '@/ui/Icon';
export interface LeagueLogoProps {
leagueId?: string;
src?: string;
alt: string;
size?: number;
className?: string;
border?: boolean;
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | 'full';
}
export function LeagueLogo({
leagueId,
src,
alt,
size = 64,
className = '',
border = true,
rounded = 'md',
}: LeagueLogoProps) {
const logoSrc = src || (leagueId ? `/api/media/leagues/${leagueId}/logo` : undefined);
return (
<Box
display="flex"
alignItems="center"
justifyContent="center"
rounded={rounded}
overflow="hidden"
bg="bg-charcoal-outline/10"
border={border}
borderColor="border-charcoal-outline/50"
className={className}
style={{ width: size, height: size, flexShrink: 0 }}
>
{logoSrc ? (
<SafeImage
src={logoSrc}
alt={alt}
className="w-full h-full object-contain p-1"
fallbackComponent={<Icon icon={Trophy} size={size > 32 ? 5 : 4} color="text-gray-500" />}
/>
) : (
<Icon icon={Trophy} size={size > 32 ? 5 : 4} color="text-gray-500" />
)}
</Box>
);
}

View File

@@ -0,0 +1,16 @@
import React from 'react';
import { LeagueLogo as UiLeagueLogo } from '@/components/leagues/LeagueLogo';
export interface LeagueLogoProps {
leagueId: string;
alt: string;
}
export function LeagueLogo({ leagueId, alt }: LeagueLogoProps) {
return (
<UiLeagueLogo
leagueId={leagueId}
alt={alt}
/>
);
}

View File

@@ -0,0 +1,28 @@
import React, { ReactNode } from 'react';
import { Table, TableHead, TableBody, TableRow, TableHeader } from '@/ui/Table';
interface LeagueMemberTableProps {
children: ReactNode;
showActions?: boolean;
}
export function LeagueMemberTable({ children, showActions }: LeagueMemberTableProps) {
return (
<Table>
<TableHead>
<TableRow>
<TableHeader>Driver</TableHeader>
<TableHeader>Rating</TableHeader>
<TableHeader>Rank</TableHeader>
<TableHeader>Wins</TableHeader>
<TableHeader>Role</TableHeader>
<TableHeader>Joined</TableHeader>
{showActions && <TableHeader textAlign="right">Actions</TableHeader>}
</TableRow>
</TableHead>
<TableBody>
{children}
</TableBody>
</Table>
);
}

View File

@@ -12,7 +12,7 @@ import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Select } from '@/ui/Select';
import { Button } from '@/ui/Button';
import { LeagueMemberTable } from '@/ui/LeagueMemberTable';
import { LeagueMemberTable } from '@/components/leagues/LeagueMemberTable';
import { LeagueMemberRow } from '@/components/leagues/LeagueMemberRow';
import { MinimalEmptyState } from '@/components/shared/state/EmptyState';

View File

@@ -0,0 +1,106 @@
import { ArrowRight } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import { Grid } from '@/ui/Grid';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { LeagueLogo } from './LeagueLogo';
import { Link } from '@/ui/Link';
import { Stack } from '@/ui/Stack';
import { Surface } from '@/ui/Surface';
import { Text } from '@/ui/Text';
interface LeagueSummaryCardProps {
id: string;
name: string;
description?: string;
maxDrivers: number;
qualifyingFormat: string;
href: string;
}
export function LeagueSummaryCard({
id,
name,
description,
maxDrivers,
qualifyingFormat,
href,
}: LeagueSummaryCardProps) {
return (
<Card p={0} style={{ overflow: 'hidden' }}>
<Box p={4}>
<Stack direction="row" align="center" gap={4} mb={4}>
<LeagueLogo leagueId={id} alt={name} size={56} />
<Box style={{ flex: 1, minWidth: 0 }}>
<Text
size="xs"
color="text-gray-500"
style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}
block
mb={0.5}
>
League
</Text>
<Heading level={3} style={{ fontSize: '1rem' }}>
{name}
</Heading>
</Box>
</Stack>
{description && (
<Text
size="sm"
color="text-gray-400"
block
mb={4}
lineClamp={2}
style={{ height: '2.5rem' }}
>
{description}
</Text>
)}
<Box mb={4}>
<Grid cols={2} gap={3}>
<Surface variant="dark" rounded="lg" padding={3}>
<Text size="xs" color="text-gray-500" block mb={1}>
Max Drivers
</Text>
<Text weight="medium" color="text-white">
{maxDrivers}
</Text>
</Surface>
<Surface variant="dark" rounded="lg" padding={3}>
<Text size="xs" color="text-gray-500" block mb={1}>
Format
</Text>
<Text
weight="medium"
color="text-white"
style={{ textTransform: 'capitalize' }}
>
{qualifyingFormat}
</Text>
</Surface>
</Grid>
</Box>
<Box>
<Link href={href}>
<Button
variant="secondary"
fullWidth
icon={<Icon icon={ArrowRight} size={4} />}
>
View League
</Button>
</Link>
</Box>
</Box>
</Card>
);
}

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { LeagueSummaryCard as UiLeagueSummaryCard } from '@/ui/LeagueSummaryCard';
import { LeagueSummaryCard as UiLeagueSummaryCard } from './LeagueSummaryCard';
import { routes } from '@/lib/routing/RouteConfig';
interface LeagueSummaryCardProps {

View File

@@ -0,0 +1,85 @@
import React, { ReactNode } from 'react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from '@/ui/Table';
interface RosterTableProps {
children: ReactNode;
columns?: string[];
}
export function RosterTable({ children, columns = ['Driver', 'Role', 'Joined', 'Rating', 'Rank'] }: RosterTableProps) {
return (
<Box border borderColor="border-steel-grey" bg="surface-charcoal/50" overflow="hidden">
<Table>
<TableHead className="bg-base-graphite/50">
<TableRow>
{columns.map((col) => (
<TableHeader key={col} className="py-3 border-b border-border-steel-grey">
<Text size="xs" weight="bold" color="text-gray-500" className="uppercase tracking-widest" block>
{col}
</Text>
</TableHeader>
))}
<TableHeader className="py-3 border-b border-border-steel-grey">
{null}
</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{children}
</TableBody>
</Table>
</Box>
);
}
interface RosterTableRowProps {
driver: ReactNode;
role: ReactNode;
joined: string;
rating: ReactNode;
rank: ReactNode;
actions?: ReactNode;
onClick?: () => void;
}
export function RosterTableRow({
driver,
role,
joined,
rating,
rank,
actions,
onClick,
}: RosterTableRowProps) {
return (
<TableRow
onClick={onClick}
clickable={!!onClick}
className="group hover:bg-primary-blue/5 transition-colors border-b border-border-steel-grey/30 last:border-0"
>
<TableCell className="py-4">
{driver}
</TableCell>
<TableCell className="py-4">
{role}
</TableCell>
<TableCell className="py-4">
<Text size="sm" color="text-gray-400" font="mono">{joined}</Text>
</TableCell>
<TableCell className="py-4">
{rating}
</TableCell>
<TableCell className="py-4">
{rank}
</TableCell>
<TableCell className="py-4 text-right">
<Stack direction="row" align="center" justify="end" gap={2}>
{actions}
</Stack>
</TableCell>
</TableRow>
);
}

View File

@@ -0,0 +1,27 @@
import { TabNavigation } from '@/ui/TabNavigation';
export type RulebookSection = 'scoring' | 'conduct' | 'protests' | 'penalties';
interface RulebookTabsProps {
activeSection: RulebookSection;
onSectionChange: (section: RulebookSection) => void;
}
export function RulebookTabs({ activeSection, onSectionChange }: RulebookTabsProps) {
const sections = [
{ id: 'scoring', label: 'Scoring' },
{ id: 'conduct', label: 'Conduct' },
{ id: 'protests', label: 'Protests' },
{ id: 'penalties', label: 'Penalties' },
];
return (
<TabNavigation
tabs={sections}
activeTab={activeSection}
onTabChange={(id) => onSectionChange(id as RulebookSection)}
/>
);
}

View File

@@ -0,0 +1,27 @@
import { BorderTabs } from '@/ui/BorderTabs';
export type StewardingTab = 'pending' | 'resolved' | 'penalties';
interface StewardingTabsProps {
activeTab: StewardingTab;
onTabChange: (tab: StewardingTab) => void;
pendingCount: number;
}
export function StewardingTabs({ activeTab, onTabChange, pendingCount }: StewardingTabsProps) {
const tabs: Array<{ id: StewardingTab; label: string; count?: number }> = [
{ id: 'pending', label: 'Pending', count: pendingCount },
{ id: 'resolved', label: 'Resolved' },
{ id: 'penalties', label: 'Penalties' },
];
return (
<BorderTabs
tabs={tabs}
activeTab={activeTab}
onTabChange={(id) => onTabChange(id as StewardingTab)}
/>
);
}

View File

@@ -2,7 +2,7 @@
import React from 'react';
import { Grid } from '@/ui/Grid';
import { LiveryCard } from '@/ui/LiveryCard';
import { LiveryCard } from '@/components/drivers/LiveryCard';
import { ProfileSection } from './ProfileSection';
import { ProfileLiveryViewData } from '@/lib/view-data/ProfileLiveriesViewData';

View File

@@ -4,7 +4,7 @@ import React from 'react';
import { Stack } from '@/ui/Stack';
import { Card } from '@/ui/Card';
import { Text } from '@/ui/Text';
import { LeagueListItem } from '@/ui/LeagueListItem';
import { LeagueListItem } from '@/components/leagues/LeagueListItem';
import { ProfileSection } from './ProfileSection';
interface League {

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

Some files were not shown because too many files have changed in this diff Show More