website refactor
This commit is contained in:
@@ -1,51 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Text } from './Text';
|
||||
import { Stack } from './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>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
|
||||
|
||||
import { Box } from './Box';
|
||||
import { Image } from './Image';
|
||||
import { Text } from './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>
|
||||
);
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
|
||||
|
||||
import { CheckCircle2, Clock, Star } from 'lucide-react';
|
||||
import { Badge } from './Badge';
|
||||
import { Box } from './Box';
|
||||
import { Button } from './Button';
|
||||
import { Card } from './Card';
|
||||
import { Heading } from './Heading';
|
||||
import { Icon } from './Icon';
|
||||
import { Link } from './Link';
|
||||
import { Stack } from './Stack';
|
||||
import { Text } from './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>
|
||||
);
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
|
||||
|
||||
import { AchievementCard } from '@/ui/AchievementCard';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { GoalCard } from '@/ui/GoalCard';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { MilestoneItem } from '@/ui/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>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import React from 'react';
|
||||
interface ContentViewportProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -10,10 +11,10 @@ interface ContentViewportProps {
|
||||
* It houses the primary content, track maps, and data tables.
|
||||
* Aligned with "Precision Racing Minimal" theme.
|
||||
*/
|
||||
export function ContentViewport({ children, className = '' }: ContentViewportProps) {
|
||||
export function ContentViewport({ children, className = '', fullWidth = false }: ContentViewportProps) {
|
||||
return (
|
||||
<main className={`flex-1 overflow-y-auto bg-[#0C0D0F] ${className}`}>
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-6 py-6">
|
||||
<div className={fullWidth ? '' : 'max-w-7xl mx-auto px-4 md:px-6 py-6'}>
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Text } from './Text';
|
||||
import { Stack } from './Stack';
|
||||
import { Image } from './Image';
|
||||
import { RatingBadge } from './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>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Stack } from './Stack';
|
||||
import { Box } from './Box';
|
||||
import { Text } from './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>
|
||||
);
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Image } from './Image';
|
||||
import { Link } from './Link';
|
||||
import { PlaceholderImage } from './PlaceholderImage';
|
||||
import { Stack } from './Stack';
|
||||
import { Text } from './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>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
interface FinishDistributionProps {
|
||||
wins: number;
|
||||
podiums: number;
|
||||
topTen: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function FinishDistributionChart({ wins, podiums, topTen, total }: FinishDistributionProps) {
|
||||
const outsideTopTen = total - topTen;
|
||||
const podiumsNotWins = podiums - wins;
|
||||
const topTenNotPodium = topTen - podiums;
|
||||
|
||||
const segments = [
|
||||
{ label: 'Wins', value: wins, color: 'bg-performance-green', textColor: 'text-performance-green' },
|
||||
{ label: 'Podiums', value: podiumsNotWins, color: 'bg-warning-amber', textColor: 'text-warning-amber' },
|
||||
{ label: 'Top 10', value: topTenNotPodium, color: 'bg-primary-blue', textColor: 'text-primary-blue' },
|
||||
{ label: 'Other', value: outsideTopTen, color: 'bg-gray-600', textColor: 'text-gray-400' },
|
||||
].filter(s => s.value > 0);
|
||||
|
||||
return (
|
||||
<Stack gap={3}>
|
||||
<Box h="4" rounded="full" overflow="hidden" display="flex" bg="bg-charcoal-outline">
|
||||
{segments.map((segment) => (
|
||||
<Box
|
||||
key={segment.label}
|
||||
bg={segment.color}
|
||||
transition
|
||||
style={{ width: `${(segment.value / total) * 100}%` }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<Box display="flex" flexWrap="wrap" gap={4} justifyContent="center">
|
||||
{segments.map((segment) => (
|
||||
<Box key={segment.label} display="flex" alignItems="center" gap={2}>
|
||||
<Box w="3" h="3" rounded="full" bg={segment.color} />
|
||||
<Text size="xs" color={segment.textColor}>
|
||||
{segment.label}: {segment.value} ({((segment.value / total) * 100).toFixed(0)}%)
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Image } from '@/ui/Image';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Link } from '@/ui/Link';
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
|
||||
|
||||
import { Box } from './Box';
|
||||
import { Image } from './Image';
|
||||
import { Surface } from './Surface';
|
||||
import { Text } from './Text';
|
||||
|
||||
interface FriendItemProps {
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
country: string;
|
||||
}
|
||||
|
||||
export function FriendItem({ name, avatarUrl, country }: FriendItemProps) {
|
||||
return (
|
||||
<Surface
|
||||
variant="muted"
|
||||
padding={2}
|
||||
rounded="lg"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}
|
||||
>
|
||||
<Box
|
||||
w="9"
|
||||
h="9"
|
||||
rounded="full"
|
||||
overflow="hidden"
|
||||
style={{
|
||||
background: 'linear-gradient(to bottom right, #3b82f6, #9333ea)',
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={avatarUrl}
|
||||
alt={name}
|
||||
width={36}
|
||||
height={36}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
</Box>
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" color="text-white" weight="medium" truncate block>
|
||||
{name}
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-500" block>
|
||||
{country}
|
||||
</Text>
|
||||
</Box>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Stack } from './Stack';
|
||||
|
||||
interface FriendsListProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function FriendsList({ children }: FriendsListProps) {
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,19 @@ interface ImageProps extends ImgHTMLAttributes<HTMLImageElement> {
|
||||
fullHeight?: boolean;
|
||||
}
|
||||
|
||||
export function Image({ src, alt, width, height, className = '', fallbackSrc, objectFit, fill, fullWidth, fullHeight, ...props }: ImageProps) {
|
||||
export function Image({
|
||||
src,
|
||||
alt,
|
||||
width,
|
||||
height,
|
||||
className = '',
|
||||
fallbackSrc,
|
||||
objectFit,
|
||||
fill,
|
||||
fullWidth,
|
||||
fullHeight,
|
||||
...props
|
||||
}: ImageProps) {
|
||||
const classes = [
|
||||
objectFit ? `object-${objectFit}` : '',
|
||||
fill ? 'absolute inset-0 w-full h-full' : '',
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Text } from './Text';
|
||||
import { Stack } from './Stack';
|
||||
import { Button } from './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>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Stack } from './Stack';
|
||||
|
||||
interface JoinRequestListProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function JoinRequestList({ children }: JoinRequestListProps) {
|
||||
return (
|
||||
<Stack gap={3}>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Stack } from './Stack';
|
||||
import { Text } from './Text';
|
||||
import { Heading } from './Heading';
|
||||
import { Button } from './Button';
|
||||
import { Check, X, Clock } from 'lucide-react';
|
||||
import { Icon } from './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">
|
||||
“{request.message}”
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
|
||||
|
||||
import { Award, ChevronRight } from 'lucide-react';
|
||||
import { Award, ChevronRight, LucideIcon } from 'lucide-react';
|
||||
import { ReactNode } from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Button } from './Button';
|
||||
@@ -9,19 +7,27 @@ import { Icon } from './Icon';
|
||||
import { Stack } from './Stack';
|
||||
import { Text } from './Text';
|
||||
|
||||
interface TeamLeaderboardPreviewProps {
|
||||
interface LeaderboardPreviewShellProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
onViewFull: () => void;
|
||||
children: ReactNode;
|
||||
icon?: LucideIcon;
|
||||
iconColor?: string;
|
||||
iconBgGradient?: string;
|
||||
viewFullLabel?: string;
|
||||
}
|
||||
|
||||
export function TeamLeaderboardPreview({
|
||||
export function LeaderboardPreviewShell({
|
||||
title,
|
||||
subtitle,
|
||||
onViewFull,
|
||||
children,
|
||||
}: TeamLeaderboardPreviewProps) {
|
||||
icon = Award,
|
||||
iconColor = "#facc15",
|
||||
iconBgGradient = 'linear-gradient(to bottom right, rgba(250, 204, 21, 0.2), rgba(217, 119, 6, 0.1))',
|
||||
viewFullLabel = "View Full Leaderboard",
|
||||
}: LeaderboardPreviewShellProps) {
|
||||
return (
|
||||
<Box mb={12}>
|
||||
{/* Header */}
|
||||
@@ -34,9 +40,9 @@ export function TeamLeaderboardPreview({
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
rounded="xl"
|
||||
style={{ background: 'linear-gradient(to bottom right, rgba(250, 204, 21, 0.2), rgba(217, 119, 6, 0.1))', border: '1px solid rgba(250, 204, 21, 0.3)' }}
|
||||
style={{ background: iconBgGradient, border: `1px solid ${iconColor}4D` }}
|
||||
>
|
||||
<Icon icon={Award} size={5} color="#facc15" />
|
||||
<Icon icon={icon} size={5} color={iconColor} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Heading level={2}>{title}</Heading>
|
||||
@@ -49,7 +55,7 @@ export function TeamLeaderboardPreview({
|
||||
onClick={onViewFull}
|
||||
icon={<Icon icon={ChevronRight} size={4} />}
|
||||
>
|
||||
View Full Leaderboard
|
||||
{viewFullLabel}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Heading } from './Heading';
|
||||
import { Icon } from './Icon';
|
||||
import { Text } from './Text';
|
||||
import { Stack } from './Stack';
|
||||
import { Image } from './Image';
|
||||
import { PlaceholderImage } from './PlaceholderImage';
|
||||
import { Calendar as LucideCalendar, ChevronRight as LucideChevronRight, Users as LucideUsers } from 'lucide-react';
|
||||
|
||||
interface LeagueCardProps {
|
||||
name: string;
|
||||
description?: string;
|
||||
coverUrl: string;
|
||||
logoUrl?: string;
|
||||
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;
|
||||
}
|
||||
|
||||
export function LeagueCard({
|
||||
name,
|
||||
description,
|
||||
coverUrl,
|
||||
logoUrl,
|
||||
badges,
|
||||
championshipBadge,
|
||||
slotLabel,
|
||||
usedSlots,
|
||||
maxSlots,
|
||||
fillPercentage,
|
||||
hasOpenSlots,
|
||||
openSlotsCount,
|
||||
isTeamLeague,
|
||||
usedDriverSlots,
|
||||
maxDrivers,
|
||||
timingSummary,
|
||||
onClick,
|
||||
}: LeagueCardProps) {
|
||||
return (
|
||||
<Box
|
||||
position="relative"
|
||||
cursor={onClick ? 'pointer' : 'default'}
|
||||
h="full"
|
||||
onClick={onClick}
|
||||
className="group"
|
||||
>
|
||||
{/* 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"
|
||||
className="transition-transform duration-500 group-hover:scale-105 opacity-60"
|
||||
/>
|
||||
{/* 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 Type Badge - Top Right */}
|
||||
<Box position="absolute" top="3" right="3">
|
||||
{championshipBadge}
|
||||
</Box>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Image } from './Image';
|
||||
import { ImagePlaceholder } from './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 ? `/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 ? (
|
||||
<Image
|
||||
src={coverSrc}
|
||||
alt={alt}
|
||||
className="w-full h-full object-cover"
|
||||
fallbackSrc="/default-league-cover.png"
|
||||
/>
|
||||
) : (
|
||||
<ImagePlaceholder aspectRatio={aspectRatio} rounded="none" />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import React from 'react';
|
||||
import { LeagueCover as UiLeagueCover } from '@/ui/LeagueCover';
|
||||
|
||||
export interface LeagueCoverProps {
|
||||
leagueId: string;
|
||||
alt: string;
|
||||
}
|
||||
|
||||
export function LeagueCover({ leagueId, alt }: LeagueCoverProps) {
|
||||
return (
|
||||
<UiLeagueCover
|
||||
leagueId={leagueId}
|
||||
alt={alt}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Heading } from './Heading';
|
||||
import { Stack } from './Stack';
|
||||
import { Text } from './Text';
|
||||
import { Image } from './Image';
|
||||
|
||||
interface LeagueHeaderProps {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
logoUrl: string;
|
||||
sponsorContent?: ReactNode;
|
||||
statusContent?: ReactNode;
|
||||
}
|
||||
|
||||
export function LeagueHeader({
|
||||
name,
|
||||
description,
|
||||
logoUrl,
|
||||
sponsorContent,
|
||||
statusContent,
|
||||
}: LeagueHeaderProps) {
|
||||
return (
|
||||
<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>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Image } from './Image';
|
||||
import { Trophy } from 'lucide-react';
|
||||
import { Icon } from './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 ? `/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 ? (
|
||||
<Image
|
||||
src={logoSrc}
|
||||
alt={alt}
|
||||
className="w-full h-full object-contain p-1"
|
||||
fallbackSrc="/default-league-logo.png"
|
||||
/>
|
||||
) : (
|
||||
<Icon icon={Trophy} size={size > 32 ? 5 : 4} color="text-gray-500" />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import React from 'react';
|
||||
import { LeagueLogo as UiLeagueLogo } from '@/ui/LeagueLogo';
|
||||
|
||||
export interface LeagueLogoProps {
|
||||
leagueId: string;
|
||||
alt: string;
|
||||
}
|
||||
|
||||
export function LeagueLogo({ leagueId, alt }: LeagueLogoProps) {
|
||||
return (
|
||||
<UiLeagueLogo
|
||||
leagueId={leagueId}
|
||||
alt={alt}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Table, TableHead, TableBody, TableRow, TableHeader } from './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>
|
||||
);
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
|
||||
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { Box } from './Box';
|
||||
import { Button } from './Button';
|
||||
import { Card } from './Card';
|
||||
import { Grid } from './Grid';
|
||||
import { Heading } from './Heading';
|
||||
import { Icon } from './Icon';
|
||||
import { LeagueLogo } from './LeagueLogo';
|
||||
import { Link } from './Link';
|
||||
import { Stack } from './Stack';
|
||||
import { Surface } from './Surface';
|
||||
import { Text } from './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>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
|
||||
|
||||
import { Box } from '@/ui/Box';
|
||||
import { LiveRaceItem } from '@/ui/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>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
|
||||
|
||||
import { ChevronRight, PlayCircle } from 'lucide-react';
|
||||
import { Box } from './Box';
|
||||
import { Heading } from './Heading';
|
||||
import { Icon } from './Icon';
|
||||
import { Text } from './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>
|
||||
);
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Crown } from 'lucide-react';
|
||||
import { Box } from './Box';
|
||||
import { Icon } from './Icon';
|
||||
import { Text } from './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>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Text } from './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>
|
||||
);
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
|
||||
|
||||
import { Calendar, ChevronRight, Clock } from 'lucide-react';
|
||||
import { Badge } from './Badge';
|
||||
import { Box } from './Box';
|
||||
import { Button } from './Button';
|
||||
import { Heading } from './Heading';
|
||||
import { Icon } from './Icon';
|
||||
import { Link } from './Link';
|
||||
import { Stack } from './Stack';
|
||||
import { Surface } from './Surface';
|
||||
import { Text } from './Text';
|
||||
|
||||
interface NextRaceCardProps {
|
||||
track: string;
|
||||
car: string;
|
||||
formattedDate: string;
|
||||
formattedTime: string;
|
||||
timeUntil: string;
|
||||
isMyLeague: boolean;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export function NextRaceCard({
|
||||
track,
|
||||
car,
|
||||
formattedDate,
|
||||
formattedTime,
|
||||
timeUntil,
|
||||
isMyLeague,
|
||||
href,
|
||||
}: NextRaceCardProps) {
|
||||
return (
|
||||
<Surface
|
||||
variant="muted"
|
||||
rounded="xl"
|
||||
border
|
||||
padding={6}
|
||||
style={{
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
background: 'linear-gradient(to bottom right, #262626, rgba(38, 38, 38, 0.8))',
|
||||
borderColor: 'rgba(59, 130, 246, 0.3)',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
position="absolute"
|
||||
top="0"
|
||||
right="0"
|
||||
w="40"
|
||||
h="40"
|
||||
style={{
|
||||
background: 'linear-gradient(to bottom left, rgba(59, 130, 246, 0.2), transparent)',
|
||||
borderBottomLeftRadius: '9999px',
|
||||
}}
|
||||
/>
|
||||
<Box position="relative">
|
||||
<Stack direction="row" align="center" gap={2} mb={4}>
|
||||
<Badge variant="primary" style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
Next Race
|
||||
</Badge>
|
||||
{isMyLeague && (
|
||||
<Badge variant="success">
|
||||
Your League
|
||||
</Badge>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" align="end" justify="between" wrap gap={4}>
|
||||
<Box>
|
||||
<Heading level={2} style={{ fontSize: '1.5rem', marginBottom: '0.5rem' }}>
|
||||
{track}
|
||||
</Heading>
|
||||
<Text color="text-gray-400" block mb={3}>
|
||||
{car}
|
||||
</Text>
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
<Stack direction="row" align="center" gap={1.5}>
|
||||
<Icon icon={Calendar} size={4} color="var(--text-gray-500)" />
|
||||
<Text size="sm" color="text-gray-400">
|
||||
{formattedDate}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack direction="row" align="center" gap={1.5}>
|
||||
<Icon icon={Clock} size={4} color="var(--text-gray-500)" />
|
||||
<Text size="sm" color="text-gray-400">
|
||||
{formattedTime}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Stack align="end" gap={3}>
|
||||
<Box textAlign="right">
|
||||
<Text
|
||||
size="xs"
|
||||
color="text-gray-500"
|
||||
style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}
|
||||
block
|
||||
mb={1}
|
||||
>
|
||||
Starts in
|
||||
</Text>
|
||||
<Text size="3xl" weight="bold" color="text-primary-blue" font="mono">
|
||||
{timeUntil}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Link href={href}>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Icon icon={ChevronRight} size={4} />}
|
||||
>
|
||||
View Details
|
||||
</Button>
|
||||
</Link>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
|
||||
|
||||
import { Badge } from './Badge';
|
||||
import { Box } from './Box';
|
||||
import { Stack } from './Stack';
|
||||
import { Surface } from './Surface';
|
||||
import { Text } from './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>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Stack } from './Stack';
|
||||
import { Button } from './Button';
|
||||
import { Icon } from './Icon';
|
||||
import { Trophy, Scale, LogOut, CheckCircle, XCircle, PlayCircle } from 'lucide-react';
|
||||
|
||||
interface RaceActionBarProps {
|
||||
status: 'scheduled' | 'running' | 'completed' | 'cancelled' | string;
|
||||
isUserRegistered: boolean;
|
||||
canRegister: boolean;
|
||||
onRegister?: () => void;
|
||||
onWithdraw?: () => void;
|
||||
onResultsClick?: () => void;
|
||||
onStewardingClick?: () => void;
|
||||
onFileProtest?: () => void;
|
||||
isAdmin?: boolean;
|
||||
onCancel?: () => void;
|
||||
onReopen?: () => void;
|
||||
onEndRace?: () => void;
|
||||
isLoading?: {
|
||||
register?: boolean;
|
||||
withdraw?: boolean;
|
||||
cancel?: boolean;
|
||||
reopen?: boolean;
|
||||
complete?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export function RaceActionBar({
|
||||
status,
|
||||
isUserRegistered,
|
||||
canRegister,
|
||||
onRegister,
|
||||
onWithdraw,
|
||||
onResultsClick,
|
||||
onStewardingClick,
|
||||
onFileProtest,
|
||||
isAdmin,
|
||||
onCancel,
|
||||
onReopen,
|
||||
onEndRace,
|
||||
isLoading = {}
|
||||
}: RaceActionBarProps) {
|
||||
return (
|
||||
<Stack direction="row" gap={3} wrap>
|
||||
{status === 'scheduled' && (
|
||||
<>
|
||||
{!isUserRegistered && canRegister && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onRegister}
|
||||
disabled={isLoading.register}
|
||||
icon={<Icon icon={CheckCircle} size={4} />}
|
||||
>
|
||||
Register
|
||||
</Button>
|
||||
)}
|
||||
{isUserRegistered && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onWithdraw}
|
||||
disabled={isLoading.withdraw}
|
||||
icon={<Icon icon={LogOut} size={4} />}
|
||||
>
|
||||
Withdraw
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={onCancel}
|
||||
disabled={isLoading.cancel}
|
||||
icon={<Icon icon={XCircle} size={4} />}
|
||||
>
|
||||
Cancel Race
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'running' && (
|
||||
<>
|
||||
<Button variant="race-final" disabled icon={<Icon icon={PlayCircle} size={4} />}>
|
||||
Live Now
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onEndRace}
|
||||
disabled={isLoading.complete}
|
||||
>
|
||||
End Race
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'completed' && (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onResultsClick}
|
||||
icon={<Icon icon={Trophy} size={4} />}
|
||||
>
|
||||
View Results
|
||||
</Button>
|
||||
{isUserRegistered && onFileProtest && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onFileProtest}
|
||||
icon={<Icon icon={Scale} size={4} />}
|
||||
>
|
||||
File Protest
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onStewardingClick}
|
||||
icon={<Icon icon={Scale} size={4} />}
|
||||
>
|
||||
Stewarding
|
||||
</Button>
|
||||
{isAdmin && onReopen && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onReopen}
|
||||
disabled={isLoading.reopen}
|
||||
>
|
||||
Reopen Race
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'cancelled' && isAdmin && onReopen && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onReopen}
|
||||
disabled={isLoading.reopen}
|
||||
>
|
||||
Reopen Race
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
|
||||
|
||||
import { ArrowRight, Car, ChevronRight, LucideIcon, Trophy, Zap } from 'lucide-react';
|
||||
import { Box } from './Box';
|
||||
import { Heading } from './Heading';
|
||||
import { Icon } from './Icon';
|
||||
import { Link } from './Link';
|
||||
import { Stack } from './Stack';
|
||||
import { Surface } from './Surface';
|
||||
import { Text } from './Text';
|
||||
|
||||
interface RaceCardProps {
|
||||
track: string;
|
||||
car: string;
|
||||
scheduledAt: string;
|
||||
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({
|
||||
track,
|
||||
car,
|
||||
scheduledAt,
|
||||
status,
|
||||
leagueName,
|
||||
leagueId,
|
||||
strengthOfField,
|
||||
onClick,
|
||||
statusConfig,
|
||||
}: RaceCardProps) {
|
||||
const scheduledAtDate = new Date(scheduledAt);
|
||||
|
||||
return (
|
||||
<Surface
|
||||
variant="muted"
|
||||
rounded="xl"
|
||||
border
|
||||
padding={4}
|
||||
onClick={onClick}
|
||||
style={{
|
||||
cursor: onClick ? 'pointer' : 'default',
|
||||
borderColor: statusConfig.border.includes('/') ? statusConfig.border : undefined, // Handle custom colors if needed
|
||||
}}
|
||||
className={`group relative overflow-hidden transition-all duration-200 hover:scale-[1.01] hover:border-primary-blue`}
|
||||
>
|
||||
{/* Live indicator */}
|
||||
{status === 'running' && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top="0"
|
||||
left="0"
|
||||
right="0"
|
||||
h="1"
|
||||
style={{
|
||||
background: 'linear-gradient(to right, var(--performance-green), rgba(16, 185, 129, 0.5), var(--performance-green))',
|
||||
}}
|
||||
animate="pulse"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Stack direction="row" align="start" gap={4}>
|
||||
{/* Time Column */}
|
||||
<Box textAlign="center" style={{ minWidth: '60px' }} flexShrink={0}>
|
||||
<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>
|
||||
|
||||
{/* Divider */}
|
||||
<Box
|
||||
w="px"
|
||||
style={{ alignSelf: 'stretch', backgroundColor: 'rgba(38, 38, 38, 0.8)' }}
|
||||
/>
|
||||
|
||||
{/* Main Content */}
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Stack direction="row" align="start" justify="between" gap={4}>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Heading
|
||||
level={3}
|
||||
className="truncate group-hover:text-primary-blue transition-colors"
|
||||
>
|
||||
{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
|
||||
style={{
|
||||
backgroundColor: 'rgba(38, 38, 38, 0.5)',
|
||||
borderColor: 'rgba(38, 38, 38, 0.8)',
|
||||
}}
|
||||
>
|
||||
{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 style={{ borderColor: 'rgba(38, 38, 38, 0.3)' }}>
|
||||
<Link
|
||||
href={`/leagues/${leagueId ?? ''}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={Trophy} size={3.5} color="var(--primary-blue)" />
|
||||
<Text size="sm" color="text-primary-blue" className="hover:underline">
|
||||
{leagueName}
|
||||
</Text>
|
||||
<Icon icon={ArrowRight} size={3} color="var(--primary-blue)" />
|
||||
</Stack>
|
||||
</Link>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Arrow */}
|
||||
<Icon
|
||||
icon={ChevronRight}
|
||||
size={5}
|
||||
color="var(--text-gray-500)"
|
||||
className="group-hover:text-primary-blue transition-colors"
|
||||
flexShrink={0}
|
||||
/>
|
||||
</Stack>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
|
||||
|
||||
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-blue" />}
|
||||
>
|
||||
<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-performance-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>
|
||||
);
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Text } from './Text';
|
||||
import { Stack } from './Stack';
|
||||
import { RaceStatusBadge } from './RaceStatusBadge';
|
||||
import { Icon } from './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>
|
||||
);
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
|
||||
|
||||
import { Calendar, Car, Clock, LucideIcon } from 'lucide-react';
|
||||
import { Badge } from './Badge';
|
||||
import { Box } from './Box';
|
||||
import { Heading } from './Heading';
|
||||
import { Hero } from './Hero';
|
||||
import { Icon } from './Icon';
|
||||
import { Stack } from './Stack';
|
||||
import { Text } from './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>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
|
||||
|
||||
import { RaceHero as UiRaceHero } from '@/ui/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} />;
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
|
||||
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { InfoBanner } from '@/ui/InfoBanner';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { CheckCircle2, PlayCircle, UserMinus, UserPlus, XCircle } from 'lucide-react';
|
||||
|
||||
interface RaceJoinButtonProps {
|
||||
raceStatus: 'scheduled' | 'running' | 'completed' | 'cancelled';
|
||||
isUserRegistered: boolean;
|
||||
canRegister: boolean;
|
||||
onRegister: () => void;
|
||||
onWithdraw: () => void;
|
||||
onCancel: () => void;
|
||||
onReopen?: () => void;
|
||||
onEndRace?: () => void;
|
||||
canReopenRace?: boolean;
|
||||
isOwnerOrAdmin?: boolean;
|
||||
isLoading?: {
|
||||
register?: boolean;
|
||||
withdraw?: boolean;
|
||||
cancel?: boolean;
|
||||
reopen?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export function RaceJoinButton({
|
||||
raceStatus,
|
||||
isUserRegistered,
|
||||
canRegister,
|
||||
onRegister,
|
||||
onWithdraw,
|
||||
onCancel,
|
||||
onReopen,
|
||||
onEndRace,
|
||||
canReopenRace = false,
|
||||
isOwnerOrAdmin = false,
|
||||
isLoading = {},
|
||||
}: RaceJoinButtonProps) {
|
||||
// Show registration button for scheduled races
|
||||
if (raceStatus === 'scheduled') {
|
||||
if (canRegister && !isUserRegistered) {
|
||||
return (
|
||||
<Button
|
||||
variant="primary"
|
||||
fullWidth
|
||||
onClick={onRegister}
|
||||
disabled={isLoading.register}
|
||||
icon={<Icon icon={UserPlus} size={4} />}
|
||||
>
|
||||
{isLoading.register ? 'Registering...' : 'Register for Race'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (isUserRegistered) {
|
||||
return (
|
||||
<Stack gap={3} fullWidth>
|
||||
<InfoBanner type="success" icon={CheckCircle2}>
|
||||
You're Registered
|
||||
</InfoBanner>
|
||||
<Button
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
onClick={onWithdraw}
|
||||
disabled={isLoading.withdraw}
|
||||
icon={<Icon icon={UserMinus} size={4} />}
|
||||
>
|
||||
{isLoading.withdraw ? 'Withdrawing...' : 'Withdraw'}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// Show cancel button for owners/admins
|
||||
if (isOwnerOrAdmin) {
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
onClick={onCancel}
|
||||
disabled={isLoading.cancel}
|
||||
icon={<Icon icon={XCircle} size={4} />}
|
||||
>
|
||||
{isLoading.cancel ? 'Cancelling...' : 'Cancel Race'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Show end race button for running races (owners/admins only)
|
||||
if (raceStatus === 'running' && isOwnerOrAdmin && onEndRace) {
|
||||
return (
|
||||
<Button
|
||||
variant="primary"
|
||||
fullWidth
|
||||
onClick={onEndRace}
|
||||
icon={<Icon icon={CheckCircle2} size={4} />}
|
||||
>
|
||||
End Race & Process Results
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Show reopen button for completed/cancelled races (owners/admins only)
|
||||
if ((raceStatus === 'completed' || raceStatus === 'cancelled') && canReopenRace && isOwnerOrAdmin && onReopen) {
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
onClick={onReopen}
|
||||
disabled={isLoading.reopen}
|
||||
icon={<Icon icon={PlayCircle} size={4} />}
|
||||
>
|
||||
{isLoading.reopen ? 'Re-opening...' : 'Re-open Race'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Flag, CalendarDays, Clock, Zap, Trophy, LucideIcon } from 'lucide-react';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { 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 RacePageHeaderProps {
|
||||
totalCount: number;
|
||||
scheduledCount: number;
|
||||
runningCount: number;
|
||||
completedCount: number;
|
||||
}
|
||||
|
||||
export function RacePageHeader({
|
||||
totalCount,
|
||||
scheduledCount,
|
||||
runningCount,
|
||||
completedCount,
|
||||
}: RacePageHeaderProps) {
|
||||
return (
|
||||
<Surface
|
||||
bg="bg-panel-gray"
|
||||
rounded="xl"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
padding={6}
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* Background Accent */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
height="1"
|
||||
bg="bg-primary-blue"
|
||||
/>
|
||||
|
||||
<Stack gap={6}>
|
||||
<Stack gap={2}>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Icon icon={Flag} size={6} color="#198CFF" />
|
||||
<Heading level={1}>RACE DASHBOARD</Heading>
|
||||
</Stack>
|
||||
<Text color="text-gray-400" size="sm">
|
||||
Precision tracking for upcoming sessions and live events.
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Grid cols={2} mdCols={4} gap={4}>
|
||||
<StatItem icon={CalendarDays} label="TOTAL SESSIONS" value={totalCount} />
|
||||
<StatItem icon={Clock} label="SCHEDULED" value={scheduledCount} color="text-primary-blue" />
|
||||
<StatItem icon={Zap} label="LIVE NOW" value={runningCount} color="text-performance-green" />
|
||||
<StatItem icon={Trophy} label="COMPLETED" value={completedCount} color="text-gray-400" />
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
function StatItem({ icon, label, value, color = 'text-white' }: { icon: LucideIcon, label: string, value: number, color?: string }) {
|
||||
return (
|
||||
<Box p={4} bg="bg-graphite-black/50" border borderColor="border-charcoal-outline">
|
||||
<Stack gap={1}>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={icon} size={3} color={color === 'text-white' ? '#9ca3af' : undefined} className={color !== 'text-white' ? color : ''} />
|
||||
<Text size="xs" color="text-gray-500" weight="bold" uppercase>{label}</Text>
|
||||
</Stack>
|
||||
<Text size="2xl" weight="bold" className={color}>{value}</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
|
||||
|
||||
import { PenaltyRow } from '@/ui/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()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
|
||||
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { Box } from './Box';
|
||||
import { Card } from './Card';
|
||||
import { Icon } from './Icon';
|
||||
import { Link } from './Link';
|
||||
import { Stack } from './Stack';
|
||||
import { Text } from './Text';
|
||||
|
||||
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 getPositionColor = (pos: number) => {
|
||||
if (pos === 1) return 'text-yellow-400 bg-yellow-400/20';
|
||||
if (pos === 2) return 'text-gray-300 bg-gray-400/20';
|
||||
if (pos === 3) return 'text-amber-600 bg-amber-600/20';
|
||||
return 'text-gray-400 bg-charcoal-outline';
|
||||
};
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/races/${raceId}`}
|
||||
variant="ghost"
|
||||
block
|
||||
onClick={onClick}
|
||||
>
|
||||
<Card p={4} className="hover:border-primary-blue/50 transition-colors group">
|
||||
<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"
|
||||
className={getPositionColor(position)}
|
||||
>
|
||||
P{position}
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color="text-white" weight="medium" block className="group-hover:text-primary-blue transition-colors">
|
||||
{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" className="group-hover:text-primary-blue transition-colors" />
|
||||
</Stack>
|
||||
</Box>
|
||||
<Stack direction="row" align="center" gap={4} color="text-gray-500">
|
||||
<Text size="xs">Started P{startPosition}</Text>
|
||||
<Text size="xs">•</Text>
|
||||
<Text size="xs" color={incidents === 0 ? 'text-performance-green' : incidents > 2 ? 'text-error-red' : undefined}>
|
||||
{incidents}x incidents
|
||||
</Text>
|
||||
{position < startPosition && (
|
||||
<>
|
||||
<Text size="xs">•</Text>
|
||||
<Text size="xs" color="text-performance-green">
|
||||
+{startPosition - position} positions
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
|
||||
|
||||
import { Trophy } from 'lucide-react';
|
||||
import { Box } from './Box';
|
||||
import { DecorativeBlur } from './DecorativeBlur';
|
||||
import { Icon } from './Icon';
|
||||
import { Stack } from './Stack';
|
||||
import { Surface } from './Surface';
|
||||
import { Text } from './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>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Stack } from './Stack';
|
||||
|
||||
interface RaceResultListProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function RaceResultList({ children }: RaceResultListProps) {
|
||||
return (
|
||||
<Stack as="ul" gap={3}>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
|
||||
|
||||
import { AlertTriangle, ExternalLink } from 'lucide-react';
|
||||
import { ReactNode } from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Icon } from './Icon';
|
||||
import { Link } from './Link';
|
||||
import { Stack } from './Stack';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './Table';
|
||||
import { Text } from './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>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Text } from './Text';
|
||||
import { Stack } from './Stack';
|
||||
import { Icon } from './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>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box } from './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>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import React from 'react';
|
||||
import { CheckCircle, Clock, Gavel } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { StatGridItem } from '@/ui/StatGridItem';
|
||||
|
||||
interface RaceStewardingStatsProps {
|
||||
pendingCount: number;
|
||||
resolvedCount: number;
|
||||
penaltiesCount: number;
|
||||
}
|
||||
|
||||
export function RaceStewardingStats({ pendingCount, resolvedCount, penaltiesCount }: RaceStewardingStatsProps) {
|
||||
return (
|
||||
<Box display="grid" gridCols={3} gap={4}>
|
||||
<StatGridItem
|
||||
label="Pending"
|
||||
value={pendingCount}
|
||||
icon={Clock}
|
||||
color="text-warning-amber"
|
||||
/>
|
||||
<StatGridItem
|
||||
label="Resolved"
|
||||
value={resolvedCount}
|
||||
icon={CheckCircle}
|
||||
color="text-performance-green"
|
||||
/>
|
||||
<StatGridItem
|
||||
label="Penalties"
|
||||
value={penaltiesCount}
|
||||
icon={Gavel}
|
||||
color="text-red-400"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Text } from './Text';
|
||||
|
||||
interface RaceSummaryItemProps {
|
||||
track: string;
|
||||
meta: string;
|
||||
date: Date;
|
||||
}
|
||||
|
||||
export function RaceSummaryItem({ track, meta, date }: RaceSummaryItemProps) {
|
||||
return (
|
||||
<Box display="flex" justifyContent="between" gap={3}>
|
||||
<Box flexGrow={1} minWidth="0">
|
||||
<Text size="xs" color="text-white" block truncate>{track}</Text>
|
||||
<Text size="xs" color="text-gray-400" block truncate>{meta}</Text>
|
||||
</Box>
|
||||
<Box textAlign="right">
|
||||
<Text size="xs" color="text-gray-500" className="whitespace-nowrap">
|
||||
{date.toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
|
||||
|
||||
import { RaceResultHero } from '@/ui/RaceResultHero';
|
||||
|
||||
interface RaceUserResultProps {
|
||||
position: number;
|
||||
startPosition: number;
|
||||
positionChange: number;
|
||||
incidents: number;
|
||||
isClean: boolean;
|
||||
isPodium: boolean;
|
||||
ratingChange?: number;
|
||||
animatedRatingChange: number;
|
||||
}
|
||||
|
||||
export function RaceUserResult(props: RaceUserResultProps) {
|
||||
return <RaceResultHero {...props} />;
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
|
||||
|
||||
import { Box } from './Box';
|
||||
import { Text } from './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>
|
||||
);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
|
||||
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { RatingComponent } from '@/ui/RatingComponent';
|
||||
import { RatingHistoryItem } from '@/ui/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>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
|
||||
|
||||
import { Box } from './Box';
|
||||
import { ProgressBar } from './ProgressBar';
|
||||
import { Stack } from './Stack';
|
||||
import { Text } from './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>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
|
||||
|
||||
import { Box } from './Box';
|
||||
import { Text } from './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>
|
||||
);
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Users, Trophy } from 'lucide-react';
|
||||
import { Box } from './Box';
|
||||
import { Text } from './Text';
|
||||
import { Stack } from './Stack';
|
||||
import { Image } from './Image';
|
||||
import { Icon } from './Icon';
|
||||
import { Badge } from './Badge';
|
||||
|
||||
interface RecruitingTeamCardProps {
|
||||
name: string;
|
||||
description?: string;
|
||||
logoUrl: string;
|
||||
category?: string;
|
||||
memberCount: number;
|
||||
totalWins: number;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function RecruitingTeamCard({
|
||||
name,
|
||||
description,
|
||||
logoUrl,
|
||||
category,
|
||||
memberCount,
|
||||
totalWins,
|
||||
onClick,
|
||||
}: RecruitingTeamCardProps) {
|
||||
return (
|
||||
<Box
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
p={4}
|
||||
rounded="xl"
|
||||
bg="bg-iron-gray/60"
|
||||
border={true}
|
||||
borderColor="border-charcoal-outline"
|
||||
className="hover:border-performance-green/40 transition-all duration-200 text-left group"
|
||||
>
|
||||
<Box display="flex" alignItems="start" justifyContent="between" mb={3}>
|
||||
<Box width="8" height="8" rounded="lg" bg="bg-charcoal-outline" border={true} borderColor="border-charcoal-outline" overflow="hidden">
|
||||
<Image
|
||||
src={logoUrl}
|
||||
alt={name}
|
||||
width={32}
|
||||
height={32}
|
||||
objectFit="cover"
|
||||
/>
|
||||
</Box>
|
||||
<Badge variant="success">
|
||||
<Box w="1.5" h="1.5" rounded="full" bg="bg-performance-green" animate="pulse" mr={1} />
|
||||
Recruiting
|
||||
</Badge>
|
||||
</Box>
|
||||
|
||||
<Text color="text-white" weight="semibold" block mb={1} className="group-hover:text-performance-green transition-colors line-clamp-1">
|
||||
{name}
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-500" block mb={3} className="line-clamp-2">{description}</Text>
|
||||
|
||||
<Stack direction="row" align="center" gap={2} wrap>
|
||||
{category && (
|
||||
<Stack direction="row" align="center" gap={1}>
|
||||
<Box w="1.5" h="1.5" rounded="full" bg="bg-purple-400" />
|
||||
<Text size="xs" color="text-purple-400">{category}</Text>
|
||||
</Stack>
|
||||
)}
|
||||
<Stack direction="row" align="center" gap={1}>
|
||||
<Icon icon={Users} size={3} color="text-gray-400" />
|
||||
<Text size="xs" color="text-gray-400">{memberCount}</Text>
|
||||
</Stack>
|
||||
<Stack direction="row" align="center" gap={1}>
|
||||
<Icon icon={Trophy} size={3} color="text-gray-400" />
|
||||
<Text size="xs" color="text-gray-400">{totalWins} wins</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './Box';
|
||||
|
||||
interface RecruitingTeamGridProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function RecruitingTeamGrid({ children }: RecruitingTeamGridProps) {
|
||||
return (
|
||||
<Box display="grid" gridCols={{ base: 1, md: 2, lg: 4 }} gap={4}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
|
||||
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import { Box } from './Box';
|
||||
import { Button } from './Button';
|
||||
import { Icon } from './Icon';
|
||||
import { Stack } from './Stack';
|
||||
import { Text } from './Text';
|
||||
|
||||
interface RenewalItemProps {
|
||||
name: string;
|
||||
renewDateLabel: string;
|
||||
priceLabel: string;
|
||||
icon: LucideIcon;
|
||||
onRenew?: () => void;
|
||||
}
|
||||
|
||||
export function RenewalItem({
|
||||
name,
|
||||
renewDateLabel,
|
||||
priceLabel,
|
||||
icon,
|
||||
onRenew,
|
||||
}: RenewalItemProps) {
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
align="center"
|
||||
justify="between"
|
||||
p={3}
|
||||
rounded="lg"
|
||||
bg="bg-warning-amber/10"
|
||||
border
|
||||
borderColor="border-warning-amber/30"
|
||||
>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Icon icon={icon} size={4} color="rgb(245, 158, 11)" />
|
||||
<Box>
|
||||
<Text size="sm" color="text-white" block>{name}</Text>
|
||||
<Text size="xs" color="text-gray-400">Renews {renewDateLabel}</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Box textAlign="right">
|
||||
<Text size="sm" weight="semibold" color="text-white" block>{priceLabel}</Text>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
mt={1}
|
||||
onClick={onRenew}
|
||||
style={{ minHeight: 0, padding: '0.25rem 0.5rem', fontSize: '0.75rem' }}
|
||||
>
|
||||
Renew
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Stack } from './Stack';
|
||||
import { Text } from './Text';
|
||||
import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from './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>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
|
||||
|
||||
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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Panel } from './Panel';
|
||||
import { Text } from './Text';
|
||||
import { Box } from './Box';
|
||||
import { StatusDot } from './StatusDot';
|
||||
|
||||
interface SessionSummaryPanelProps {
|
||||
title: string;
|
||||
status: 'live' | 'upcoming' | 'completed';
|
||||
startTime?: string;
|
||||
trackName?: string;
|
||||
carName?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* SessionSummaryPanel
|
||||
*
|
||||
* Displays a dense summary of a racing session.
|
||||
* Part of the "Telemetry Workspace" layout.
|
||||
*/
|
||||
export function SessionSummaryPanel({
|
||||
title,
|
||||
status,
|
||||
startTime,
|
||||
trackName,
|
||||
carName,
|
||||
className = '',
|
||||
}: SessionSummaryPanelProps) {
|
||||
const statusColor = status === 'live' ? '#4ED4E0' : status === 'upcoming' ? '#FFBE4D' : '#94a3b8';
|
||||
|
||||
return (
|
||||
<Panel title="Session Summary" className={className}>
|
||||
<Box display="flex" flexDirection="col" gap={3}>
|
||||
<Box display="flex" align="center" justify="between">
|
||||
<Text weight="bold" size="lg">{title}</Text>
|
||||
<Box display="flex" align="center" gap={2}>
|
||||
<StatusDot color={statusColor} pulse={status === 'live'} size={2} />
|
||||
<Text size="xs" uppercase weight="bold" style={{ color: statusColor }}>
|
||||
{status}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box display="grid" gridCols={2} gap={4} borderTop borderStyle="solid" borderColor="border-gray/10" pt={3}>
|
||||
{startTime && (
|
||||
<Box>
|
||||
<Text size="xs" color="text-gray-500" uppercase block mb={1}>Start Time</Text>
|
||||
<Text size="sm">{startTime}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{trackName && (
|
||||
<Box>
|
||||
<Text size="xs" color="text-gray-500" uppercase block mb={1}>Track</Text>
|
||||
<Text size="sm">{trackName}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{carName && (
|
||||
<Box colSpan={2}>
|
||||
<Text size="xs" color="text-gray-500" uppercase block mb={1}>Vehicle</Text>
|
||||
<Text size="sm">{carName}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import React from 'react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { Box } from './Box';
|
||||
import { Text } from './Text';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
interface SidebarRaceItemProps {
|
||||
race: {
|
||||
id: string;
|
||||
track: string;
|
||||
scheduledAt: string;
|
||||
};
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SidebarRaceItem({ race, onClick, className }: SidebarRaceItemProps) {
|
||||
const scheduledAtDate = new Date(race.scheduledAt);
|
||||
|
||||
return (
|
||||
<Box
|
||||
onClick={onClick}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={3}
|
||||
p={2}
|
||||
rounded="lg"
|
||||
cursor="pointer"
|
||||
className={`hover:bg-deep-graphite transition-colors ${className || ''}`}
|
||||
>
|
||||
<Box flexShrink={0} width="10" height="10" bg="bg-primary-blue/10" rounded="lg" display="flex" center>
|
||||
<Text size="sm" weight="bold" color="text-primary-blue">
|
||||
{scheduledAtDate.getDate()}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} minWidth="0">
|
||||
<Text size="sm" weight="medium" color="text-white" block truncate>{race.track}</Text>
|
||||
<Text size="xs" color="text-gray-500" block>{scheduledAtDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</Text>
|
||||
</Box>
|
||||
<Icon icon={ChevronRight} size={4} color="text-gray-500" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import React from 'react';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import { Button } from './Button';
|
||||
import { Stack } from './Stack';
|
||||
import { Text } from './Text';
|
||||
import { Icon } from './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>
|
||||
);
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import React from 'react';
|
||||
import { LucideIcon, ChevronRight, UserPlus } from 'lucide-react';
|
||||
import { Box } from './Box';
|
||||
import { Stack } from './Stack';
|
||||
import { Text } from './Text';
|
||||
import { Heading } from './Heading';
|
||||
import { Icon } from './Icon';
|
||||
import { Badge } from './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>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Stack } from './Stack';
|
||||
import { Text } from './Text';
|
||||
|
||||
interface SponsorActivityItemProps {
|
||||
message: string;
|
||||
time: string;
|
||||
typeColor: string;
|
||||
formattedImpressions?: string | null;
|
||||
}
|
||||
|
||||
export function SponsorActivityItem({
|
||||
message,
|
||||
time,
|
||||
typeColor,
|
||||
formattedImpressions,
|
||||
}: SponsorActivityItemProps) {
|
||||
return (
|
||||
<Stack direction="row" align="start" gap={3} py={3} borderBottom={true} borderColor="border-charcoal-outline/50">
|
||||
<Box width="2" height="2" rounded="full" mt={2} className={typeColor} flexShrink={0} />
|
||||
<Box flexGrow={1} minWidth="0">
|
||||
<Text size="sm" color="text-white" block truncate>{message}</Text>
|
||||
<Stack direction="row" align="center" gap={2} mt={1}>
|
||||
<Text size="xs" color="text-gray-500">{time}</Text>
|
||||
{formattedImpressions && (
|
||||
<>
|
||||
<Text size="xs" color="text-gray-600">•</Text>
|
||||
<Text size="xs" color="text-gray-400">{formattedImpressions} views</Text>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Image } from './Image';
|
||||
import { Building2 } from 'lucide-react';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
export interface SponsorLogoProps {
|
||||
sponsorId?: string;
|
||||
src?: string;
|
||||
alt: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
border?: boolean;
|
||||
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||
}
|
||||
|
||||
export function SponsorLogo({
|
||||
sponsorId,
|
||||
src,
|
||||
alt,
|
||||
size = 48,
|
||||
className = '',
|
||||
border = true,
|
||||
rounded = 'md',
|
||||
}: SponsorLogoProps) {
|
||||
const logoSrc = src || (sponsorId ? `/media/sponsors/${sponsorId}/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 ? (
|
||||
<Image
|
||||
src={logoSrc}
|
||||
alt={alt}
|
||||
className="w-full h-full object-contain p-1"
|
||||
fallbackSrc="/default-sponsor-logo.png"
|
||||
/>
|
||||
) : (
|
||||
<Icon icon={Building2} size={size > 32 ? 5 : 4} color="text-gray-500" />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Text } from './Text';
|
||||
import { Icon } from './Icon';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface SponsorMetricCardProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
icon: LucideIcon;
|
||||
color?: string;
|
||||
trend?: {
|
||||
value: number;
|
||||
isPositive: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export function SponsorMetricCard({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
color = 'text-primary-blue',
|
||||
trend,
|
||||
}: SponsorMetricCardProps) {
|
||||
return (
|
||||
<Box
|
||||
bg="bg-iron-gray/50"
|
||||
rounded="lg"
|
||||
p={3}
|
||||
border={true}
|
||||
borderColor="border-charcoal-outline"
|
||||
>
|
||||
<Box display="flex" alignItems="center" gap={1.5} className={color} mb={1}>
|
||||
<Icon icon={icon} size={4} />
|
||||
<Text size="xs" weight="medium">{label}</Text>
|
||||
</Box>
|
||||
<Box display="flex" alignItems="baseline" gap={2}>
|
||||
<Text size="xl" weight="bold" color="text-white">
|
||||
{typeof value === 'number' ? value.toLocaleString() : value}
|
||||
</Text>
|
||||
{trend && (
|
||||
<Text size="xs" color={trend.isPositive ? 'text-performance-green' : 'text-red-400'}>
|
||||
{trend.isPositive ? '+' : ''}{trend.value}%
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Text } from './Text';
|
||||
|
||||
interface SponsorSlotCardProps {
|
||||
title: string;
|
||||
status: string;
|
||||
statusColor: string;
|
||||
benefits: string;
|
||||
price?: string;
|
||||
action?: ReactNode;
|
||||
available: boolean;
|
||||
variant: 'main' | 'secondary';
|
||||
}
|
||||
|
||||
export function SponsorSlotCard({
|
||||
title,
|
||||
status,
|
||||
statusColor,
|
||||
benefits,
|
||||
price,
|
||||
action,
|
||||
available,
|
||||
variant,
|
||||
}: SponsorSlotCardProps) {
|
||||
const bgClass = available
|
||||
? (variant === 'main' ? 'bg-performance-green/10 border-performance-green/30' : 'bg-purple-500/10 border-purple-500/30')
|
||||
: 'bg-iron-gray/30 border-charcoal-outline';
|
||||
|
||||
return (
|
||||
<Box p={3} rounded="lg" border={true} className={bgClass}>
|
||||
<Box display="flex" alignItems="center" justifyContent="between" mb={1}>
|
||||
<Text size="sm" weight="medium" color="text-white">{title}</Text>
|
||||
<Text size="xs" className={statusColor}>{status}</Text>
|
||||
</Box>
|
||||
<Text size="xs" color="text-gray-400" block mb={2}>{benefits}</Text>
|
||||
{available && price && (
|
||||
<Box display="flex" alignItems="center" justifyContent="between">
|
||||
<Text size="lg" weight="bold" color="text-white">{price}</Text>
|
||||
{action}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
|
||||
|
||||
import { CheckCircle2, LucideIcon } from 'lucide-react';
|
||||
import { Badge } from './Badge';
|
||||
import { Box } from './Box';
|
||||
import { Heading } from './Heading';
|
||||
import { Icon } from './Icon';
|
||||
import { Stack } from './Stack';
|
||||
import { Surface } from './Surface';
|
||||
import { Text } from './Text';
|
||||
|
||||
interface SponsorTierCardProps {
|
||||
type: 'main' | 'secondary';
|
||||
available: boolean;
|
||||
availableCount?: number;
|
||||
totalCount?: number;
|
||||
price: number;
|
||||
benefits: string[];
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
icon: LucideIcon;
|
||||
iconColor: string;
|
||||
}
|
||||
|
||||
export function SponsorTierCard({
|
||||
type,
|
||||
available,
|
||||
availableCount,
|
||||
totalCount,
|
||||
price,
|
||||
benefits,
|
||||
isSelected,
|
||||
onClick,
|
||||
icon,
|
||||
iconColor,
|
||||
}: SponsorTierCardProps) {
|
||||
const isMain = type === 'main';
|
||||
|
||||
return (
|
||||
<Surface
|
||||
variant="muted"
|
||||
rounded="xl"
|
||||
border={true}
|
||||
padding={5}
|
||||
className={`transition-all duration-200 ${available ? 'cursor-pointer' : 'opacity-60 cursor-default'} ${isSelected ? 'border-primary-blue ring-2 ring-primary-blue/20' : 'border-charcoal-outline'}`}
|
||||
onClick={available ? onClick : undefined}
|
||||
position="relative"
|
||||
>
|
||||
<Stack direction="row" align="start" justify="between" mb={4}>
|
||||
<Box>
|
||||
<Stack direction="row" align="center" gap={2} mb={1}>
|
||||
<Icon icon={icon} size={5} className={iconColor} />
|
||||
<Heading level={3}>{isMain ? 'Main Sponsor' : 'Secondary Sponsor'}</Heading>
|
||||
</Stack>
|
||||
<Text size="sm" color="text-gray-400">
|
||||
{isMain ? 'Primary branding position' : 'Supporting branding position'}
|
||||
</Text>
|
||||
</Box>
|
||||
<Badge variant={available ? 'success' : 'default'}>
|
||||
{isMain
|
||||
? (available ? 'Available' : 'Filled')
|
||||
: (available ? `${availableCount}/${totalCount} Available` : 'Full')
|
||||
}
|
||||
</Badge>
|
||||
</Stack>
|
||||
|
||||
<Box mb={4}>
|
||||
<Text size="3xl" weight="bold" color="text-white">
|
||||
${price}
|
||||
<Text size="sm" weight="normal" color="text-gray-500">/season</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Stack gap={2} mb={4}>
|
||||
{benefits.map((benefit, i) => (
|
||||
<Stack key={i} direction="row" align="center" gap={2}>
|
||||
<Icon icon={CheckCircle2} size={4} color="text-performance-green" />
|
||||
<Text size="sm" color="text-gray-300">{benefit}</Text>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{isSelected && available && (
|
||||
<Box position="absolute" top="4" right="4">
|
||||
<Box
|
||||
width="4"
|
||||
height="4"
|
||||
rounded="full"
|
||||
bg="bg-primary-blue"
|
||||
display="flex"
|
||||
center
|
||||
>
|
||||
<Icon icon={CheckCircle2} size={3} color="text-white" />
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import React from 'react';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import { Link } from './Link';
|
||||
import { Card } from './Card';
|
||||
import { Box } from './Box';
|
||||
import { Stack } from './Stack';
|
||||
import { Text } from './Text';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
interface SponsorshipCategoryCardProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
count: number;
|
||||
impressions: number;
|
||||
color: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export function SponsorshipCategoryCard({
|
||||
icon,
|
||||
title,
|
||||
count,
|
||||
impressions,
|
||||
color,
|
||||
href
|
||||
}: SponsorshipCategoryCardProps) {
|
||||
return (
|
||||
<Link href={href} variant="ghost" block>
|
||||
<Card p={4} className="hover:border-charcoal-outline/60 transition-all cursor-pointer">
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Box
|
||||
width="10"
|
||||
height="10"
|
||||
rounded="lg"
|
||||
bg="bg-charcoal-outline"
|
||||
display="flex"
|
||||
center
|
||||
>
|
||||
<Icon icon={icon} size={5} className={color} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text weight="medium" color="text-white" block>{title}</Text>
|
||||
<Text size="sm" color="text-gray-500">{count} active</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Box textAlign="right">
|
||||
<Text weight="semibold" color="text-white" block>{impressions.toLocaleString()}</Text>
|
||||
<Text size="xs" color="text-gray-500">impressions</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
|
||||
|
||||
import { Building, Check, Clock, DollarSign, MessageCircle, X } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { Badge } from './Badge';
|
||||
import { Box } from './Box';
|
||||
import { Button } from './Button';
|
||||
import { Heading } from './Heading';
|
||||
import { Icon } from './Icon';
|
||||
import { Image } from './Image';
|
||||
import { Text } from './Text';
|
||||
|
||||
interface SponsorshipRequestItemProps {
|
||||
sponsorName: string;
|
||||
sponsorLogo?: string;
|
||||
tier: string;
|
||||
formattedAmount: string;
|
||||
netAmount: number;
|
||||
createdAt: Date;
|
||||
message?: string;
|
||||
isProcessing: boolean;
|
||||
isRejecting: boolean;
|
||||
rejectReason: string;
|
||||
onAccept: () => void;
|
||||
onRejectClick: () => void;
|
||||
onRejectConfirm: () => void;
|
||||
onRejectCancel: () => void;
|
||||
onRejectReasonChange: (reason: string) => void;
|
||||
}
|
||||
|
||||
export function SponsorshipRequestItem({
|
||||
sponsorName,
|
||||
sponsorLogo,
|
||||
tier,
|
||||
formattedAmount,
|
||||
netAmount,
|
||||
createdAt,
|
||||
message,
|
||||
isProcessing,
|
||||
isRejecting,
|
||||
rejectReason,
|
||||
onAccept,
|
||||
onRejectClick,
|
||||
onRejectConfirm,
|
||||
onRejectCancel,
|
||||
onRejectReasonChange,
|
||||
}: SponsorshipRequestItemProps) {
|
||||
return (
|
||||
<Box rounded="lg" border borderColor="border-charcoal-outline" bg="bg-deep-graphite/70" p={4}>
|
||||
{/* Reject Modal */}
|
||||
{isRejecting && (
|
||||
<Box mb={4} p={4} rounded="lg" bg="bg-iron-gray/50" border borderColor="border-red-500/30">
|
||||
<Heading level={4} mb={2}>
|
||||
Reject sponsorship from {sponsorName}?
|
||||
</Heading>
|
||||
<Box
|
||||
as="textarea"
|
||||
value={rejectReason}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onRejectReasonChange(e.target.value)}
|
||||
placeholder="Optional: Provide a reason for rejection..."
|
||||
rows={2}
|
||||
p={3}
|
||||
py={2}
|
||||
bg="bg-iron-gray/80"
|
||||
color="text-white"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
rounded="lg"
|
||||
fullWidth
|
||||
style={{ resize: 'none' }}
|
||||
className="text-sm placeholder:text-gray-500 focus:ring-2 focus:ring-red-500 mb-3"
|
||||
/>
|
||||
<Box display="flex" gap={2}>
|
||||
<Button variant="secondary" onClick={onRejectCancel} size="sm">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={onRejectConfirm}
|
||||
disabled={isProcessing}
|
||||
size="sm"
|
||||
>
|
||||
{isProcessing ? 'Rejecting...' : 'Confirm Reject'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box display="flex" alignItems="start" justifyContent="between" gap={4}>
|
||||
<Box display="flex" alignItems="start" gap={3} flexGrow={1}>
|
||||
{/* Sponsor Logo */}
|
||||
<Box display="flex" h="12" w="12" alignItems="center" justifyContent="center" rounded="lg" bg="bg-iron-gray/50" flexShrink={0}>
|
||||
{sponsorLogo ? (
|
||||
<Image src={sponsorLogo} alt={sponsorName} width={32} height={32} objectFit="contain" />
|
||||
) : (
|
||||
<Icon icon={Building} size={6} color="rgb(156, 163, 175)" />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box flexGrow={1} minWidth="0">
|
||||
<Box display="flex" alignItems="center" gap={2} mb={1}>
|
||||
<Heading level={4} truncate>
|
||||
{sponsorName}
|
||||
</Heading>
|
||||
<Badge variant={tier === 'main' ? 'primary' : 'default'}>
|
||||
{tier === 'main' ? 'Main Sponsor' : 'Secondary'}
|
||||
</Badge>
|
||||
</Box>
|
||||
|
||||
{/* Offer Details */}
|
||||
<Box display="flex" flexWrap="wrap" gap={3} mb={2}>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Icon icon={DollarSign} size={3} color="rgb(16, 185, 129)" />
|
||||
<Text weight="semibold" color="text-performance-green" size="xs">{formattedAmount}</Text>
|
||||
</Box>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Text color="text-gray-500" size="xs">Net: ${(netAmount / 100).toFixed(2)}</Text>
|
||||
</Box>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Icon icon={Clock} size={3} color="rgb(156, 163, 175)" />
|
||||
<Text color="text-gray-500" size="xs">
|
||||
{createdAt.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Message */}
|
||||
{message && (
|
||||
<Box display="flex" alignItems="start" gap={1.5} p={2} bg="bg-iron-gray/30" rounded>
|
||||
<Icon icon={MessageCircle} size={3} color="rgb(156, 163, 175)" flexShrink={0} mt={0.5} />
|
||||
<Text size="xs" color="text-gray-400" lineClamp={2}>{message}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Actions */}
|
||||
{!isRejecting && (
|
||||
<Box display="flex" gap={2} flexShrink={0}>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onAccept}
|
||||
disabled={isProcessing}
|
||||
size="sm"
|
||||
icon={<Icon icon={Check} size={3} />}
|
||||
>
|
||||
{isProcessing ? 'Accepting...' : 'Accept'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onRejectClick}
|
||||
disabled={isProcessing}
|
||||
size="sm"
|
||||
icon={<Icon icon={X} size={3} />}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
|
||||
|
||||
import { Badge } from './Badge';
|
||||
|
||||
interface SponsorshipTierBadgeProps {
|
||||
tier: 'premium' | 'standard' | 'starter';
|
||||
entityLabel: string;
|
||||
}
|
||||
|
||||
export function SponsorshipTierBadge({ tier, entityLabel }: SponsorshipTierBadgeProps) {
|
||||
const tierStyles = {
|
||||
premium: 'bg-yellow-400/10 border-yellow-400/30 text-yellow-400',
|
||||
standard: 'bg-purple-400/10 border-purple-400/30 text-purple-400',
|
||||
starter: 'bg-primary-blue/10 border-primary-blue/30 text-primary-blue',
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge
|
||||
bg={tierStyles[tier].split(' ')[0]}
|
||||
borderColor={tierStyles[tier].split(' ')[1]}
|
||||
color={tierStyles[tier].split(' ')[2]}
|
||||
>
|
||||
{tier.charAt(0).toUpperCase() + tier.slice(1)} {entityLabel}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Stack } from './Stack';
|
||||
import { Box } from './Box';
|
||||
import { Heading } from './Heading';
|
||||
import { Badge } from './Badge';
|
||||
import { Grid } from './Grid';
|
||||
import { StatItem } from './StatItem';
|
||||
|
||||
interface StandingsItemProps {
|
||||
leagueName: string;
|
||||
position: number;
|
||||
points: number;
|
||||
wins: number;
|
||||
racesCompleted: number;
|
||||
}
|
||||
|
||||
export function StandingsItem({
|
||||
leagueName,
|
||||
position,
|
||||
points,
|
||||
wins,
|
||||
racesCompleted,
|
||||
}: StandingsItemProps) {
|
||||
return (
|
||||
<Box
|
||||
bg="bg-iron-gray/50"
|
||||
rounded="lg"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
p={4}
|
||||
>
|
||||
<Stack direction="row" align="center" justify="between" mb={3}>
|
||||
<Heading level={4}>
|
||||
{leagueName}
|
||||
</Heading>
|
||||
<Badge variant="primary">
|
||||
P{position}
|
||||
</Badge>
|
||||
</Stack>
|
||||
|
||||
<Grid cols={3} gap={4}>
|
||||
<StatItem label="Points" value={points} align="center" />
|
||||
<StatItem label="Wins" value={wins} align="center" />
|
||||
<StatItem label="Races" value={racesCompleted} align="center" />
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Stack } from './Stack';
|
||||
import { StandingsItem } from './StandingsItem';
|
||||
|
||||
interface Standing {
|
||||
leagueId: string;
|
||||
leagueName: string;
|
||||
position: number;
|
||||
points: number;
|
||||
wins: number;
|
||||
racesCompleted: number;
|
||||
}
|
||||
|
||||
interface StandingsListProps {
|
||||
standings: Standing[];
|
||||
}
|
||||
|
||||
export function StandingsList({ standings }: StandingsListProps) {
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
{standings.map((standing) => (
|
||||
<StandingsItem
|
||||
key={standing.leagueId}
|
||||
leagueName={standing.leagueName}
|
||||
position={standing.position}
|
||||
points={standing.points}
|
||||
wins={standing.wins}
|
||||
racesCompleted={standing.racesCompleted}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
|
||||
|
||||
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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
|
||||
|
||||
import {
|
||||
ChevronRight,
|
||||
Globe,
|
||||
UserPlus,
|
||||
Users
|
||||
} from 'lucide-react';
|
||||
import { ReactNode } from 'react';
|
||||
import { Badge } from './Badge';
|
||||
import { Box } from './Box';
|
||||
import { Card } from './Card';
|
||||
import { Heading } from './Heading';
|
||||
import { Icon } from './Icon';
|
||||
import { Image } from './Image';
|
||||
import { PlaceholderImage } from './PlaceholderImage';
|
||||
import { Stack } from './Stack';
|
||||
import { Text } from './Text';
|
||||
|
||||
interface TeamCardProps {
|
||||
name: string;
|
||||
description?: string;
|
||||
logo?: string;
|
||||
memberCount: number;
|
||||
isRecruiting?: boolean;
|
||||
performanceBadge?: ReactNode;
|
||||
specializationContent?: ReactNode;
|
||||
categoryBadge?: ReactNode;
|
||||
region?: string;
|
||||
languagesContent?: ReactNode;
|
||||
statsContent?: ReactNode;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function TeamCard({
|
||||
name,
|
||||
description,
|
||||
logo,
|
||||
memberCount,
|
||||
isRecruiting,
|
||||
performanceBadge,
|
||||
specializationContent,
|
||||
categoryBadge,
|
||||
region,
|
||||
languagesContent,
|
||||
statsContent,
|
||||
onClick,
|
||||
}: TeamCardProps) {
|
||||
return (
|
||||
<Box onClick={onClick} h="full" cursor={onClick ? 'pointer' : 'default'} className="group">
|
||||
<Card h="full" p={0} display="flex" flexDirection="col" overflow="hidden" className="bg-panel-gray/40 border-border-gray/50 hover:border-primary-accent/30 hover:bg-panel-gray/60 transition-all duration-300">
|
||||
{/* Header with Logo */}
|
||||
<Box p={5} pb={0}>
|
||||
<Stack direction="row" align="start" gap={4}>
|
||||
{/* Logo */}
|
||||
<Box
|
||||
w="16"
|
||||
h="16"
|
||||
rounded="none"
|
||||
bg="graphite-black"
|
||||
display="flex"
|
||||
center
|
||||
overflow="hidden"
|
||||
border
|
||||
borderColor="border-gray/50"
|
||||
className="relative"
|
||||
>
|
||||
{logo ? (
|
||||
<Image
|
||||
src={logo}
|
||||
alt={name}
|
||||
width={64}
|
||||
height={64}
|
||||
fullWidth
|
||||
fullHeight
|
||||
objectFit="cover"
|
||||
/>
|
||||
) : (
|
||||
<PlaceholderImage size={64} />
|
||||
)}
|
||||
<Box position="absolute" top="-1px" left="-1px" w="2" h="2" borderTop borderLeft borderColor="primary-accent/30" />
|
||||
</Box>
|
||||
|
||||
{/* Title & Badges */}
|
||||
<Box flexGrow={1} minWidth="0">
|
||||
<Stack direction="row" align="start" justify="between" gap={2}>
|
||||
<Heading level={4} weight="bold" fontSize="lg" className="tracking-tight group-hover:text-primary-accent transition-colors">
|
||||
{name}
|
||||
</Heading>
|
||||
{isRecruiting && (
|
||||
<Badge variant="success" size="xs">
|
||||
RECRUITING
|
||||
</Badge>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Performance Level & Category */}
|
||||
<Stack direction="row" align="center" gap={2} wrap mt={2}>
|
||||
{performanceBadge}
|
||||
{specializationContent}
|
||||
{categoryBadge}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Content */}
|
||||
<Box p={5} display="flex" flexDirection="col" flexGrow={1}>
|
||||
{/* Description */}
|
||||
<Text
|
||||
size="xs"
|
||||
color="text-gray-500"
|
||||
mb={4}
|
||||
lineClamp={2}
|
||||
block
|
||||
leading="relaxed"
|
||||
style={{ height: '2.5rem' }}
|
||||
>
|
||||
{description || 'No description available'}
|
||||
</Text>
|
||||
|
||||
{/* Region & Languages */}
|
||||
{(region || languagesContent) && (
|
||||
<Stack direction="row" align="center" gap={2} wrap mb={4}>
|
||||
{region && (
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={2}
|
||||
px={2}
|
||||
py={1}
|
||||
rounded="none"
|
||||
bg="panel-gray/20"
|
||||
border
|
||||
borderColor="border-gray/30"
|
||||
>
|
||||
<Icon icon={Globe} size={3} color="text-primary-accent" />
|
||||
<Text size="xs" color="text-gray-400" weight="bold" className="uppercase tracking-widest">{region}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{languagesContent}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* Spacer */}
|
||||
<Box flexGrow={1} />
|
||||
|
||||
{/* Footer */}
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="between"
|
||||
pt={4}
|
||||
borderTop
|
||||
borderColor="border-gray/30"
|
||||
mt="auto"
|
||||
>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={Users} size={3} color="text-gray-500" />
|
||||
<Text size="xs" color="text-gray-500" font="mono">
|
||||
{memberCount} {memberCount === 1 ? 'MEMBER' : 'MEMBERS'}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" align="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={ChevronRight} size={3} color="text-gray-500" className="transition-transform group-hover:translate-x-0.5" />
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Zap,
|
||||
Clock,
|
||||
Languages,
|
||||
Crown,
|
||||
Star,
|
||||
TrendingUp,
|
||||
Shield
|
||||
} from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Badge } from '@/ui/Badge';
|
||||
import { TeamCard as UiTeamCard } from '@/ui/TeamCard';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { TeamStatItem } from '@/ui/TeamStatItem';
|
||||
|
||||
interface TeamCardProps {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
logo?: string;
|
||||
memberCount: number;
|
||||
rating?: number | null;
|
||||
totalWins?: number;
|
||||
totalRaces?: number;
|
||||
performanceLevel?: 'beginner' | 'intermediate' | 'advanced' | 'pro';
|
||||
isRecruiting?: boolean;
|
||||
specialization?: 'endurance' | 'sprint' | 'mixed' | undefined;
|
||||
region?: string;
|
||||
languages?: string[] | undefined;
|
||||
leagues?: string[];
|
||||
category?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
function getPerformanceBadge(level?: string) {
|
||||
switch (level) {
|
||||
case 'pro':
|
||||
return { icon: Crown, label: 'Pro', variant: 'warning' as const };
|
||||
case 'advanced':
|
||||
return { icon: Star, label: 'Advanced', variant: 'primary' as const };
|
||||
case 'intermediate':
|
||||
return { icon: TrendingUp, label: 'Intermediate', variant: 'info' as const };
|
||||
case 'beginner':
|
||||
return { icon: Shield, label: 'Beginner', variant: 'success' as const };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getSpecializationBadge(specialization?: string) {
|
||||
switch (specialization) {
|
||||
case 'endurance':
|
||||
return { icon: Clock, label: 'Endurance', color: 'var(--warning-amber)' };
|
||||
case 'sprint':
|
||||
return { icon: Zap, label: 'Sprint', color: 'var(--neon-aqua)' };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function TeamCard({
|
||||
name,
|
||||
description,
|
||||
logo,
|
||||
memberCount,
|
||||
rating,
|
||||
totalWins,
|
||||
totalRaces,
|
||||
performanceLevel,
|
||||
isRecruiting,
|
||||
specialization,
|
||||
region,
|
||||
languages,
|
||||
category,
|
||||
onClick,
|
||||
}: TeamCardProps) {
|
||||
const performanceBadge = getPerformanceBadge(performanceLevel);
|
||||
const specializationBadge = getSpecializationBadge(specialization);
|
||||
|
||||
return (
|
||||
<UiTeamCard
|
||||
name={name}
|
||||
description={description}
|
||||
logo={logo}
|
||||
memberCount={memberCount}
|
||||
isRecruiting={isRecruiting}
|
||||
onClick={onClick}
|
||||
region={region}
|
||||
performanceBadge={performanceBadge && (
|
||||
<Badge variant={performanceBadge.variant} icon={performanceBadge.icon}>
|
||||
{performanceBadge.label}
|
||||
</Badge>
|
||||
)}
|
||||
specializationContent={specializationBadge && (
|
||||
<Stack direction="row" align="center" gap={1}>
|
||||
<Icon icon={specializationBadge.icon} size={3} color={specializationBadge.color} />
|
||||
<Text size="xs" color="text-gray-500">{specializationBadge.label}</Text>
|
||||
</Stack>
|
||||
)}
|
||||
categoryBadge={category && (
|
||||
<Badge variant="primary">
|
||||
<Box w="2" h="2" rounded="full" bg="bg-purple-500" mr={1.5} />
|
||||
{category}
|
||||
</Badge>
|
||||
)}
|
||||
languagesContent={languages && languages.length > 0 && (
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={1.5}
|
||||
px={2}
|
||||
py={1}
|
||||
rounded="md"
|
||||
bg="bg-iron-gray/50"
|
||||
border
|
||||
borderColor="border-charcoal-outline/30"
|
||||
>
|
||||
<Icon icon={Languages} size={3} color="var(--neon-purple)" />
|
||||
<Text size="xs" color="text-gray-400">
|
||||
{languages.slice(0, 2).join(', ')}
|
||||
{languages.length > 2 && ` +${languages.length - 2}`}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
statsContent={
|
||||
<>
|
||||
<TeamStatItem label="Rating" value={typeof rating === 'number' ? Math.round(rating).toLocaleString() : '—'} color="text-primary-blue" align="center" />
|
||||
<TeamStatItem label="Wins" value={totalWins ?? 0} color="text-performance-green" align="center" />
|
||||
<TeamStatItem label="Races" value={totalRaces ?? 0} color="text-white" align="center" />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Search, Star, Trophy, Percent, Hash, LucideIcon } from 'lucide-react';
|
||||
import { Button } from './Button';
|
||||
import { Input } from './Input';
|
||||
import { Stack } from './Stack';
|
||||
import { Box } from './Box';
|
||||
import { Text } from './Text';
|
||||
import { Icon } from './Icon';
|
||||
import { Badge } from './Badge';
|
||||
|
||||
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
|
||||
type SortBy = 'rating' | 'wins' | 'winRate' | 'races';
|
||||
|
||||
const SKILL_LEVELS: {
|
||||
id: SkillLevel;
|
||||
label: string;
|
||||
variant: 'warning' | 'primary' | 'info' | 'success';
|
||||
}[] = [
|
||||
{ id: 'pro', label: 'Pro', variant: 'warning' },
|
||||
{ id: 'advanced', label: 'Advanced', variant: 'primary' },
|
||||
{ id: 'intermediate', label: 'Intermediate', variant: 'info' },
|
||||
{ id: 'beginner', label: 'Beginner', variant: 'success' },
|
||||
];
|
||||
|
||||
const SORT_OPTIONS: { id: SortBy; label: string; icon: LucideIcon }[] = [
|
||||
{ id: 'rating', label: 'Rating', icon: Star },
|
||||
{ id: 'wins', label: 'Total Wins', icon: Trophy },
|
||||
{ id: 'winRate', label: 'Win Rate', icon: Percent },
|
||||
{ id: 'races', label: 'Races', icon: Hash },
|
||||
];
|
||||
|
||||
interface TeamFilterProps {
|
||||
searchQuery: string;
|
||||
onSearchChange: (query: string) => void;
|
||||
filterLevel: SkillLevel | 'all';
|
||||
onFilterLevelChange: (level: SkillLevel | 'all') => void;
|
||||
sortBy: SortBy;
|
||||
onSortChange: (sort: SortBy) => void;
|
||||
}
|
||||
|
||||
export function TeamFilter({
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
filterLevel,
|
||||
onFilterLevelChange,
|
||||
sortBy,
|
||||
onSortChange,
|
||||
}: TeamFilterProps) {
|
||||
return (
|
||||
<Stack mb={6} gap={4}>
|
||||
{/* Search and Level Filter Row */}
|
||||
<Stack direction="row" align="center" gap={4} wrap>
|
||||
<Box maxWidth="448px" fullWidth>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search teams..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
icon={<Icon icon={Search} size={5} color="text-gray-500" />}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Level Filter */}
|
||||
<Stack direction="row" align="center" gap={2} wrap>
|
||||
<Button
|
||||
variant={filterLevel === 'all' ? 'race-final' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => onFilterLevelChange('all')}
|
||||
>
|
||||
All Levels
|
||||
</Button>
|
||||
{SKILL_LEVELS.map((level) => {
|
||||
const isActive = filterLevel === level.id;
|
||||
return (
|
||||
<Button
|
||||
key={level.id}
|
||||
variant={isActive ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => onFilterLevelChange(level.id)}
|
||||
>
|
||||
{isActive ? (
|
||||
<Badge variant={level.variant}>{level.label}</Badge>
|
||||
) : (
|
||||
level.label
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{/* Sort Options */}
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Text size="sm" color="text-gray-400">Sort by:</Text>
|
||||
<Box p={1} rounded="lg" border={true} borderColor="border-charcoal-outline" bg="bg-iron-gray" bgOpacity={0.5}>
|
||||
<Stack direction="row" align="center" gap={1}>
|
||||
{SORT_OPTIONS.map((option) => {
|
||||
const isActive = sortBy === option.id;
|
||||
return (
|
||||
<Button
|
||||
key={option.id}
|
||||
variant={isActive ? 'race-final' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => onSortChange(option.id)}
|
||||
icon={<Icon icon={option.icon} size={3.5} />}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './Box';
|
||||
|
||||
interface TeamGridProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function TeamGrid({ children }: TeamGridProps) {
|
||||
return (
|
||||
<Box display="grid" gridCols={{ base: 1, md: 2, lg: 3 }} gap={4}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Stack } from './Stack';
|
||||
import { Heading } from './Heading';
|
||||
import { Text } from './Text';
|
||||
import { TeamLogo } from './TeamLogo';
|
||||
import { TeamTag } from './TeamTag';
|
||||
|
||||
interface TeamHeaderPanelProps {
|
||||
teamId: string;
|
||||
name: string;
|
||||
tag?: string | null;
|
||||
description?: string;
|
||||
memberCount: number;
|
||||
activeLeaguesCount?: number;
|
||||
foundedDate?: string;
|
||||
category?: string | null;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
export function TeamHeaderPanel({
|
||||
teamId,
|
||||
name,
|
||||
tag,
|
||||
description,
|
||||
memberCount,
|
||||
activeLeaguesCount,
|
||||
foundedDate,
|
||||
category,
|
||||
actions,
|
||||
}: TeamHeaderPanelProps) {
|
||||
return (
|
||||
<Box
|
||||
bg="surface-charcoal"
|
||||
border
|
||||
borderColor="border-steel-grey"
|
||||
p={6}
|
||||
className="relative overflow-hidden"
|
||||
>
|
||||
{/* Instrument-grade accent corner */}
|
||||
<Box position="absolute" top="-1px" left="-1px" w="4" h="4" borderTop borderLeft borderColor="primary-blue/40" />
|
||||
|
||||
<Stack direction="row" align="start" justify="between" wrap gap={6}>
|
||||
<Stack direction="row" align="start" gap={6} wrap flexGrow={1}>
|
||||
{/* Logo Container */}
|
||||
<Box
|
||||
w="24"
|
||||
h="24"
|
||||
bg="base-graphite"
|
||||
border
|
||||
borderColor="border-steel-grey"
|
||||
display="flex"
|
||||
center
|
||||
overflow="hidden"
|
||||
className="relative"
|
||||
>
|
||||
<TeamLogo teamId={teamId} alt={name} />
|
||||
{/* Corner detail */}
|
||||
<Box position="absolute" bottom="0" right="0" w="2" h="2" bg="primary-blue/20" />
|
||||
</Box>
|
||||
|
||||
<Box flexGrow={1} minWidth="0">
|
||||
<Stack direction="row" align="center" gap={3} mb={2}>
|
||||
<Heading level={1} weight="bold" className="tracking-tight">{name}</Heading>
|
||||
{tag && <TeamTag tag={tag} />}
|
||||
</Stack>
|
||||
|
||||
{description && (
|
||||
<Text color="text-gray-400" block mb={4} maxWidth="42rem" size="sm" leading="relaxed">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Stack direction="row" align="center" gap={4} wrap>
|
||||
<Box display="flex" alignItems="center" gap={1.5}>
|
||||
<Box w="1.5" h="1.5" bg="primary-blue" />
|
||||
<Text size="xs" color="text-gray-300" font="mono" className="uppercase tracking-wider">
|
||||
{memberCount} {memberCount === 1 ? 'Member' : 'Members'}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{category && (
|
||||
<Box display="flex" alignItems="center" gap={1.5}>
|
||||
<Box w="1.5" h="1.5" bg="telemetry-aqua" />
|
||||
<Text size="xs" color="text-gray-300" font="mono" className="uppercase tracking-wider">
|
||||
{category}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{activeLeaguesCount !== undefined && (
|
||||
<Box display="flex" alignItems="center" gap={1.5}>
|
||||
<Box w="1.5" h="1.5" bg="warning-amber" />
|
||||
<Text size="xs" color="text-gray-300" font="mono" className="uppercase tracking-wider">
|
||||
{activeLeaguesCount} {activeLeaguesCount === 1 ? 'League' : 'Leagues'}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{foundedDate && (
|
||||
<Text size="xs" color="text-gray-500" font="mono" className="uppercase tracking-wider">
|
||||
EST. {foundedDate}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{actions && (
|
||||
<Box>
|
||||
{actions}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import React from 'react';
|
||||
import { TableRow, TableCell } from './Table';
|
||||
import { Image } from './Image';
|
||||
import { Box } from './Box';
|
||||
import { Text } from './Text';
|
||||
|
||||
export interface TeamLadderRowProps {
|
||||
rank: number;
|
||||
teamId: string;
|
||||
teamName: string;
|
||||
logoUrl: string;
|
||||
memberCount: number;
|
||||
teamRating: number | null;
|
||||
totalWins: number;
|
||||
totalRaces: number;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function TeamLadderRow({
|
||||
rank,
|
||||
teamName,
|
||||
logoUrl,
|
||||
memberCount,
|
||||
teamRating,
|
||||
totalWins,
|
||||
totalRaces,
|
||||
onClick,
|
||||
}: TeamLadderRowProps) {
|
||||
return (
|
||||
<TableRow
|
||||
onClick={onClick}
|
||||
clickable
|
||||
>
|
||||
<TableCell>
|
||||
<Text size="sm" color="text-gray-300" weight="semibold">#{rank}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Box display="flex" alignItems="center" gap={3}>
|
||||
<Box w="8" h="8" rounded="md" overflow="hidden" bg="bg-deep-graphite" flexShrink={0}>
|
||||
<Image
|
||||
src={logoUrl}
|
||||
alt={teamName}
|
||||
width={32}
|
||||
height={32}
|
||||
objectFit="cover"
|
||||
/>
|
||||
</Box>
|
||||
<Box display="flex" flexDirection="col">
|
||||
<Text size="sm" weight="semibold" color="text-white" truncate block>
|
||||
{teamName}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text color="text-primary-blue" weight="semibold">
|
||||
{teamRating !== null ? Math.round(teamRating) : '—'}
|
||||
</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text color="text-performance-green" weight="semibold">{totalWins}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text color="text-white">{totalRaces}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text color="text-gray-300">
|
||||
{memberCount} {memberCount === 1 ? 'member' : 'members'}
|
||||
</Text>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Table, TableHead, TableBody, TableRow, TableHeader } from './Table';
|
||||
|
||||
interface TeamLadderTableProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function TeamLadderTable({ children }: TeamLadderTableProps) {
|
||||
return (
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader>Rank</TableHeader>
|
||||
<TableHeader>Team</TableHeader>
|
||||
<TableHeader>Rating</TableHeader>
|
||||
<TableHeader>Wins</TableHeader>
|
||||
<TableHeader>Races</TableHeader>
|
||||
<TableHeader>Members</TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{children}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
|
||||
|
||||
import { Crown, Trophy, Users } from 'lucide-react';
|
||||
import { Box } from './Box';
|
||||
import { Icon } from './Icon';
|
||||
import { Image } from './Image';
|
||||
import { Stack } from './Stack';
|
||||
import { Text } from './Text';
|
||||
|
||||
interface TeamLeaderboardItemProps {
|
||||
position: number;
|
||||
name: string;
|
||||
logoUrl: string;
|
||||
category?: string;
|
||||
memberCount: number;
|
||||
totalWins: number;
|
||||
isRecruiting: boolean;
|
||||
rating?: number;
|
||||
onClick?: () => void;
|
||||
medalColor: string;
|
||||
medalBg: string;
|
||||
medalBorder: string;
|
||||
}
|
||||
|
||||
export function TeamLeaderboardItem({
|
||||
position,
|
||||
name,
|
||||
logoUrl,
|
||||
category,
|
||||
memberCount,
|
||||
totalWins,
|
||||
isRecruiting,
|
||||
rating,
|
||||
onClick,
|
||||
medalColor,
|
||||
medalBg,
|
||||
medalBorder,
|
||||
}: TeamLeaderboardItemProps) {
|
||||
return (
|
||||
<Box
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={4}
|
||||
px={4}
|
||||
py={3}
|
||||
fullWidth
|
||||
textAlign="left"
|
||||
bg="transparent"
|
||||
border="none"
|
||||
cursor="pointer"
|
||||
borderBottom
|
||||
style={{ borderColor: 'rgba(38, 38, 38, 0.5)' }}
|
||||
className="last:border-0"
|
||||
>
|
||||
{/* Position */}
|
||||
<Box
|
||||
w="8"
|
||||
h="8"
|
||||
display="flex"
|
||||
center
|
||||
rounded="full"
|
||||
style={{
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 'bold',
|
||||
border: `1px solid ${medalBorder}`,
|
||||
backgroundColor: medalBg,
|
||||
color: medalColor
|
||||
}}
|
||||
>
|
||||
{position <= 3 ? (
|
||||
<Icon icon={Crown} size={3.5} />
|
||||
) : (
|
||||
position
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Team Info */}
|
||||
<Box w="9" h="9" rounded="md" overflow="hidden" bg="bg-deep-graphite" border style={{ borderColor: 'rgba(38, 38, 38, 0.8)' }} flexShrink={0}>
|
||||
<Image
|
||||
src={logoUrl}
|
||||
alt={name}
|
||||
width={36}
|
||||
height={36}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
</Box>
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text weight="medium" color="text-white" className="truncate" block>
|
||||
{name}
|
||||
</Text>
|
||||
<Stack direction="row" align="center" gap={2} wrap mt={0.5}>
|
||||
{category && (
|
||||
<Stack direction="row" align="center" gap={1}>
|
||||
<Box w="1.5" h="1.5" rounded="full" bg="bg-purple-500" />
|
||||
<Text size="xs" color="text-purple-400">{category}</Text>
|
||||
</Stack>
|
||||
)}
|
||||
<Stack direction="row" align="center" gap={1}>
|
||||
<Icon icon={Users} size={3} color="var(--text-gray-600)" />
|
||||
<Text size="xs" color="text-gray-500">{memberCount}</Text>
|
||||
</Stack>
|
||||
<Stack direction="row" align="center" gap={1}>
|
||||
<Icon icon={Trophy} size={3} color="var(--text-gray-600)" />
|
||||
<Text size="xs" color="text-gray-500">{totalWins} wins</Text>
|
||||
</Stack>
|
||||
{isRecruiting && (
|
||||
<Stack direction="row" align="center" gap={1}>
|
||||
<Box w="1.5" h="1.5" rounded="full" bg="bg-performance-green" />
|
||||
<Text size="xs" color="text-performance-green">Recruiting</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Rating */}
|
||||
<Box textAlign="right">
|
||||
<Text font="mono" weight="semibold" color="text-purple-400" block>
|
||||
{typeof rating === 'number' ? Math.round(rating).toLocaleString() : '—'}
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-500">Rating</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Stack } from './Stack';
|
||||
import { Text } from './Text';
|
||||
import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from './Table';
|
||||
import { TeamLogo } from './TeamLogo';
|
||||
import { RankBadge } from './RankBadge';
|
||||
|
||||
interface TeamLeaderboardPanelProps {
|
||||
teams: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
logoUrl?: string;
|
||||
rating: number;
|
||||
wins: number;
|
||||
races: number;
|
||||
memberCount: number;
|
||||
}>;
|
||||
onTeamClick: (id: string) => void;
|
||||
}
|
||||
|
||||
export function TeamLeaderboardPanel({ teams, onTeamClick }: TeamLeaderboardPanelProps) {
|
||||
return (
|
||||
<Box border borderColor="border-steel-grey" bg="surface-charcoal/50" overflow="hidden">
|
||||
<Table>
|
||||
<TableHead className="bg-base-graphite/50">
|
||||
<TableRow>
|
||||
<TableHeader className="w-16 text-center">Rank</TableHeader>
|
||||
<TableHeader>Team</TableHeader>
|
||||
<TableHeader className="text-center">Rating</TableHeader>
|
||||
<TableHeader className="text-center">Wins</TableHeader>
|
||||
<TableHeader className="text-center">Races</TableHeader>
|
||||
<TableHeader className="text-center">Members</TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{teams.map((team, index) => (
|
||||
<TableRow
|
||||
key={team.id}
|
||||
onClick={() => onTeamClick(team.id)}
|
||||
clickable
|
||||
className="group hover:bg-primary-blue/5 transition-colors border-b border-border-steel-grey/30 last:border-0"
|
||||
>
|
||||
<TableCell className="text-center">
|
||||
<RankBadge rank={index + 1} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Box w="8" h="8" bg="base-graphite" border borderColor="border-steel-grey" display="flex" center overflow="hidden">
|
||||
<TeamLogo teamId={team.id} alt={team.name} />
|
||||
</Box>
|
||||
<Text weight="bold" size="sm" color="text-white" className="group-hover:text-primary-blue transition-colors">
|
||||
{team.name}
|
||||
</Text>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Text font="mono" weight="bold" color="text-primary-blue">{team.rating}</Text>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Text font="mono" color="text-gray-300">{team.wins}</Text>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Text font="mono" color="text-gray-300">{team.races}</Text>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Text font="mono" color="text-gray-400" size="xs">{team.memberCount}</Text>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user