website refactor

This commit is contained in:
2026-01-18 22:55:55 +01:00
parent b43a23a48c
commit aeaa43f4d3
179 changed files with 4736 additions and 6832 deletions

View File

@@ -1,6 +1,7 @@
import { Trophy, Sparkles, LucideIcon } from 'lucide-react';
import { Card } from '@/ui/Card';
import { EmptyState as UiEmptyState } from '@/components/shared/state/EmptyState';
import { EmptyState as UiEmptyState } from '@/ui/EmptyState';
import React from 'react';
interface EmptyStateProps {
title: string;
@@ -32,8 +33,10 @@ export function EmptyState({
onClick: onAction,
icon: actionIcon,
} : undefined}
/>
{children}
variant="minimal"
>
{children}
</UiEmptyState>
</Card>
);
}

View File

@@ -1,9 +1,13 @@
import { Button } from '@/ui/Button';
import { Heading } from '@/ui/Heading';
'use client';
import { IconButton } from '@/ui/IconButton';
import { Panel } from '@/ui/Panel';
import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/primitives/Stack';
import { Text } from '@/ui/Text';
import { Check, Clock, X } from 'lucide-react';
import { ListItem, ListItemInfo, ListItemActions } from '@/ui/ListItem';
import { EmptyState } from '@/ui/EmptyState';
import { Check, Clock, X, UserPlus } from 'lucide-react';
import React from 'react';
interface JoinRequestsPanelProps {
requests: Array<{
@@ -20,67 +24,49 @@ interface JoinRequestsPanelProps {
export function JoinRequestsPanel({ requests, onAccept, onDecline }: JoinRequestsPanelProps) {
if (requests.length === 0) {
return (
<Stack 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>
</Stack>
<EmptyState
icon={UserPlus}
title="No pending join requests"
variant="minimal"
/>
);
}
return (
<Stack border borderColor="border-steel-grey" bg="surface-charcoal/50" overflow="hidden">
<Stack 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>
</Stack>
<Stack gap={0} className="divide-y divide-border-steel-grey/30">
<Panel title={`Pending Requests (${requests.length})`}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{requests.map((request) => (
<Stack 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}>
<Stack 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>
</Stack>
<Stack>
<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>
</Stack>
</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 && (
<Stack mt={3} p={3} bg="base-graphite/30" borderLeft borderPrimary borderColor="primary-blue/40">
<Text size="xs" color="text-gray-400" italic leading="relaxed">
&ldquo;{request.message}&rdquo;
</Text>
</Stack>
)}
</Stack>
<ListItem key={request.id}>
<ListItemInfo
title={request.driverName}
description={request.message}
meta={
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
<Icon icon={Clock} size={3} intent="low" />
<Text size="xs" variant="low" font="mono">{request.requestedAt}</Text>
</div>
}
/>
<ListItemActions>
<IconButton
variant="secondary"
size="sm"
onClick={() => onDecline(request.id)}
icon={X}
intent="critical"
title="Decline"
/>
<IconButton
variant="primary"
size="sm"
onClick={() => onAccept(request.id)}
icon={Check}
title="Accept"
/>
</ListItemActions>
</ListItem>
))}
</Stack>
</Stack>
</div>
</Panel>
);
}

View File

@@ -1,13 +1,13 @@
'use client';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Image } from '@/ui/Image';
import { PlaceholderImage } from '@/ui/PlaceholderImage';
import { Stack } from '@/ui/primitives/Stack';
import { Text } from '@/ui/Text';
import { Calendar as LucideCalendar, ChevronRight as LucideChevronRight } from 'lucide-react';
import { ReactNode } from 'react';
import { LeagueCard as UILeagueCard, LeagueCardStats, LeagueCardFooter } from '@/ui/LeagueCard';
import { Calendar as LucideCalendar } from 'lucide-react';
import React, { ReactNode } from 'react';
interface LeagueCardProps {
name: string;
@@ -42,151 +42,68 @@ export function LeagueCard({
fillPercentage,
hasOpenSlots,
openSlotsCount,
isTeamLeague: _isTeamLeague,
usedDriverSlots: _usedDriverSlots,
maxDrivers: _maxDrivers,
timingSummary,
onClick,
}: LeagueCardProps) {
return (
<Stack
position="relative"
cursor={onClick ? 'pointer' : 'default'}
h="full"
<UILeagueCard
onClick={onClick}
className="group"
coverUrl={coverUrl}
logo={
<div style={{ width: '3rem', height: '3rem', borderRadius: '0.5rem', overflow: 'hidden', border: '1px solid var(--ui-color-border-default)', backgroundColor: 'var(--ui-color-bg-base)' }}>
{logoUrl ? (
<Image
src={logoUrl}
alt={`${name} logo`}
width={48}
height={48}
objectFit="cover"
/>
) : (
<PlaceholderImage size={48} />
)}
</div>
}
badges={
<React.Fragment>
{badges}
{championshipBadge}
</React.Fragment>
}
>
{/* Card Container */}
<Stack
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 */}
<Stack 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 */}
<Stack position="absolute" inset="0" bg="linear-gradient(to top, #0C0D0F, transparent)" />
{/* Badges - Top Left */}
<Stack position="absolute" top="3" left="3" display="flex" alignItems="center" gap={2}>
{badges}
</Stack>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
<div style={{ width: '0.25rem', height: '1rem', backgroundColor: 'var(--ui-color-intent-primary)' }} />
<Heading level={3} weight="bold" truncate>{name}</Heading>
</div>
<Text size="xs" variant="low" lineClamp={2} style={{ height: '2.5rem', marginBottom: '1rem' }} block leading="relaxed">
{description || 'No description available'}
</Text>
{/* Championship Type Badge - Top Right */}
<Stack position="absolute" top="3" right="3">
{championshipBadge}
</Stack>
<LeagueCardStats
label={slotLabel}
value={`${usedSlots}/${maxSlots || '∞'}`}
percentage={fillPercentage}
intent={fillPercentage >= 90 ? 'warning' : fillPercentage >= 70 ? 'primary' : 'success'}
/>
{/* Logo */}
<Stack position="absolute" left="4" bottom="-6" zIndex={10}>
<Stack 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} />
)}
</Stack>
</Stack>
</Stack>
{hasOpenSlots && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem', padding: '0.25rem 0.5rem', backgroundColor: 'rgba(25, 140, 255, 0.05)', border: '1px solid rgba(25, 140, 255, 0.2)', borderRadius: 'var(--ui-radius-sm)', width: 'fit-content', marginBottom: '1rem' }}>
<div style={{ width: '0.375rem', height: '0.375rem', borderRadius: '9999px', backgroundColor: 'var(--ui-color-intent-primary)' }} />
<Text size="xs" variant="primary" weight="bold" uppercase>{openSlotsCount} OPEN</Text>
</div>
)}
{/* Content */}
<Stack pt={8} px={4} pb={4} display="flex" flexDirection="col" fullHeight>
{/* Title & Description */}
<Stack direction="row" align="center" gap={2} mb={1}>
<Stack 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 */}
<Stack display="flex" alignItems="center" gap={3} mb={4}>
{/* Primary Slots (Drivers/Teams/Nations) */}
<Stack flexGrow={1}>
<Stack 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>
</Stack>
<Stack h="1" rounded="none" bg="border-gray/30" overflow="hidden">
<Stack
h="full"
rounded="none"
transition
bg={
fillPercentage >= 90
? 'warning-amber'
: fillPercentage >= 70
? 'primary-accent'
: 'success-green'
}
style={{ width: `${Math.min(fillPercentage, 100)}%` }}
/>
</Stack>
</Stack>
{/* Open Slots Badge */}
{hasOpenSlots && (
<Stack display="flex" alignItems="center" gap={1.5} px={2} py={1} rounded="none" bg="primary-accent/5" border borderColor="primary-accent/20">
<Stack 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>
</Stack>
)}
</Stack>
{/* Spacer to push footer to bottom */}
<Stack flexGrow={1} />
{/* Footer Info */}
<Stack display="flex" alignItems="center" justifyContent="between" pt={3} borderTop borderColor="border-gray/30" mt="auto">
<Stack display="flex" alignItems="center" gap={3}>
{timingSummary && (
<Stack 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>
</Stack>
)}
</Stack>
{/* View Arrow */}
<Stack 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" />
</Stack>
</Stack>
</Stack>
</Stack>
</Stack>
<LeagueCardFooter>
{timingSummary && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<Icon icon={LucideCalendar} size={3} intent="low" />
<Text size="xs" variant="low" font="mono">
{timingSummary.split('•')[1]?.trim() || timingSummary}
</Text>
</div>
)}
</LeagueCardFooter>
</UILeagueCard>
);
}

View File

@@ -3,237 +3,13 @@
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/primitives/Stack';
import { Text } from '@/ui/Text';
import { Check, HelpCircle, TrendingDown, X, Zap } from 'lucide-react';
import React, { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
// ============================================================================
// INFO FLYOUT (duplicated for self-contained component)
// ============================================================================
interface InfoFlyoutProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
anchorRef: React.RefObject<HTMLElement>;
}
function InfoFlyout({ isOpen, onClose, title, children, anchorRef }: InfoFlyoutProps) {
const [position, setPosition] = useState({ top: 0, left: 0 });
const [mounted, setMounted] = useState(false);
const flyoutRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (isOpen && anchorRef.current && mounted) {
const rect = anchorRef.current.getBoundingClientRect();
const flyoutWidth = Math.min(380, window.innerWidth - 40);
const flyoutHeight = 450;
const padding = 16;
let left = rect.right + 12;
let top = rect.top;
if (left + flyoutWidth > window.innerWidth - padding) {
left = rect.left - flyoutWidth - 12;
}
if (left < padding) {
left = Math.max(padding, (window.innerWidth - flyoutWidth) / 2);
}
top = rect.top - flyoutHeight / 3;
if (top + flyoutHeight > window.innerHeight - padding) {
top = window.innerHeight - flyoutHeight - padding;
}
if (top < padding) top = padding;
left = Math.max(padding, Math.min(left, window.innerWidth - flyoutWidth - padding));
setPosition({ top, left });
}
}, [isOpen, anchorRef, mounted]);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
const handleClickOutside = (e: MouseEvent) => {
if (flyoutRef.current && !flyoutRef.current.contains(e.target as Node)) {
onClose();
}
};
if (isOpen) {
document.addEventListener('keydown', handleEscape);
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('keydown', handleEscape);
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return createPortal(
<Stack
ref={flyoutRef}
position="fixed"
zIndex={50}
w="380px"
bg="bg-iron-gray"
border
borderColor="border-charcoal-outline"
rounded="xl"
shadow="2xl"
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ top: position.top, left: position.left, maxHeight: '80vh', overflowY: 'auto' }}
>
<Stack
display="flex"
alignItems="center"
justifyContent="between"
p={4}
borderBottom
borderColor="border-charcoal-outline/50"
position="sticky"
top="0"
bg="bg-iron-gray"
zIndex={10}
>
<Stack direction="row" align="center" gap={2}>
<Icon icon={HelpCircle} size={4} color="text-primary-blue" />
<Text size="sm" weight="semibold" color="text-white">{title}</Text>
</Stack>
<Stack
as="button"
type="button"
onClick={onClose}
display="flex"
h="6"
w="6"
alignItems="center"
justifyContent="center"
rounded="md"
transition
hoverBg="bg-charcoal-outline"
>
<Icon icon={X} size={4} color="text-gray-400" />
</Stack>
</Stack>
<Stack p={4}>
{children}
</Stack>
</Stack>,
document.body
);
}
function InfoButton({ onClick, buttonRef }: { onClick: () => void; buttonRef: React.Ref<HTMLButtonElement> }) {
return (
<Stack
as="button"
ref={buttonRef}
type="button"
onClick={onClick}
display="flex"
h="5"
w="5"
alignItems="center"
justifyContent="center"
rounded="full"
transition
color="text-gray-500"
hoverTextColor="text-primary-blue"
hoverBg="bg-primary-blue/10"
>
<Icon icon={HelpCircle} size={3.5} />
</Stack>
);
}
// Drop Rules Mockup
function DropRulesMockup() {
const results = [
{ round: 'R1', pts: 25, dropped: false },
{ round: 'R2', pts: 18, dropped: false },
{ round: 'R3', pts: 4, dropped: true },
{ round: 'R4', pts: 15, dropped: false },
{ round: 'R5', pts: 12, dropped: false },
{ round: 'R6', pts: 0, dropped: true },
];
const total = results.filter(r => !r.dropped).reduce((sum, r) => sum + r.pts, 0);
const wouldBe = results.reduce((sum, r) => sum + r.pts, 0);
return (
<Stack bg="bg-deep-graphite" rounded="lg" p={4}>
<Stack display="flex" alignItems="center" gap={2} mb={3} pb={2} borderBottom borderColor="border-charcoal-outline/50" opacity={0.5}>
<Text size="xs" weight="semibold" color="text-white">Best 4 of 6 Results</Text>
</Stack>
<Stack display="flex" gap={1} mb={3}>
{results.map((r, i) => (
<Stack
key={i}
flexGrow={1}
p={2}
rounded="lg"
textAlign="center"
border
transition
bg={r.dropped ? 'bg-charcoal-outline/20' : 'bg-performance-green/10'}
borderColor={r.dropped ? 'border-charcoal-outline/50' : 'border-performance-green/30'}
opacity={r.dropped ? 0.5 : 1}
// eslint-disable-next-line gridpilot-rules/component-classification
className={r.dropped ? 'border-dashed' : ''}
>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '9px' }}
color="text-gray-500"
block
>
{r.round}
</Text>
<Text font="mono" weight="semibold" size="xs" color={r.dropped ? 'text-gray-500' : 'text-white'}
// eslint-disable-next-line gridpilot-rules/component-classification
className={r.dropped ? 'line-through' : ''}
block
>
{r.pts}
</Text>
</Stack>
))}
</Stack>
<Stack display="flex" justifyContent="between" alignItems="center">
<Text size="xs" color="text-gray-500">Total counted:</Text>
<Text font="mono" weight="semibold" color="text-performance-green" size="xs">{total} pts</Text>
</Stack>
<Stack display="flex" justifyContent="between" alignItems="center" mt={1}>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
>
Without drops:
</Text>
<Text font="mono"
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
>
{wouldBe} pts
</Text>
</Stack>
</Stack>
);
}
import { InfoFlyout } from '@/ui/InfoFlyout';
import { Stepper } from '@/ui/Stepper';
import { Button } from '@/ui/Button';
import { IconButton } from '@/ui/IconButton';
import { Check, HelpCircle, TrendingDown, Zap } from 'lucide-react';
import React, { useRef, useState } from 'react';
interface LeagueDropSectionProps {
form: LeagueConfigFormModel;
@@ -243,43 +19,6 @@ interface LeagueDropSectionProps {
type DropStrategy = 'none' | 'bestNResults' | 'dropWorstN';
// Drop rule info content
const DROP_RULE_INFO: Record<DropStrategy, { title: string; description: string; details: string[]; example: string }> = {
none: {
title: 'All Results Count',
description: 'Every race result affects the championship standings with no exceptions.',
details: [
'All race results count toward final standings',
'No safety net for bad races or DNFs',
'Rewards consistency across entire season',
'Best for shorter seasons (4-6 races)',
],
example: '8 races × your points = your total',
},
bestNResults: {
title: 'Best N Results',
description: 'Only your top N race results count toward the championship.',
details: [
'Choose how many of your best races count',
'Extra races become "bonus" opportunities',
'Protects against occasional bad days',
'Encourages trying even when behind',
],
example: 'Best 6 of 8 races count',
},
dropWorstN: {
title: 'Drop Worst N Results',
description: 'Your N worst race results are excluded from championship calculations.',
details: [
'Automatically removes your worst performances',
'Great for handling DNFs or incidents',
'All other races count normally',
'Common in real-world championships',
],
example: 'Drop 2 worst → 6 of 8 count',
},
};
const DROP_OPTIONS: Array<{
value: DropStrategy;
label: string;
@@ -317,13 +56,7 @@ export function LeagueDropSection({
const disabled = readOnly || !onChange;
const dropPolicy = form.dropPolicy || { strategy: 'none' as const };
const [showDropFlyout, setShowDropFlyout] = useState(false);
const [activeDropRuleFlyout, setActiveDropRuleFlyout] = useState<DropStrategy | null>(null);
const dropInfoRef = useRef<HTMLButtonElement>(null!);
const dropRuleRefs = useRef<Record<DropStrategy, HTMLButtonElement | null>>({
none: null,
bestNResults: null,
dropWorstN: null,
});
const handleStrategyChange = (strategy: DropStrategy) => {
if (disabled || !onChange) return;
@@ -344,10 +77,8 @@ export function LeagueDropSection({
onChange(next);
};
const handleNChange = (delta: number) => {
const handleNChange = (newValue: number) => {
if (disabled || !onChange || dropPolicy.strategy === 'none') return;
const current = dropPolicy.n ?? 1;
const newValue = Math.max(1, current + delta);
onChange({
...form,
dropPolicy: {
@@ -360,328 +91,79 @@ export function LeagueDropSection({
const needsN = dropPolicy.strategy !== 'none';
return (
<Stack gap={4}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{/* Section header */}
<Stack display="flex" alignItems="center" gap={3}>
<Stack display="flex" h="10" w="10" alignItems="center" justifyContent="center" rounded="xl" bg="bg-primary-blue/10">
<Icon icon={TrendingDown} size={5} color="text-primary-blue" />
</Stack>
<Stack flexGrow={1}>
<Stack display="flex" alignItems="center" gap={2}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<div style={{ display: 'flex', width: '2.5rem', height: '2.5rem', alignItems: 'center', justifyContent: 'center', borderRadius: '0.75rem', backgroundColor: 'rgba(25, 140, 255, 0.1)' }}>
<Icon icon={TrendingDown} size={5} intent="primary" />
</div>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<Heading level={3}>Drop Rules</Heading>
<InfoButton buttonRef={dropInfoRef} onClick={() => setShowDropFlyout(true)} />
</Stack>
<Text size="xs" color="text-gray-500">Protect from bad races</Text>
</Stack>
</Stack>
<IconButton
ref={dropInfoRef}
icon={HelpCircle}
size="sm"
variant="ghost"
onClick={() => setShowDropFlyout(true)}
title="Help"
/>
</div>
<Text size="xs" variant="low">Protect from bad races</Text>
</div>
</div>
{/* Drop Rules Flyout */}
<InfoFlyout
isOpen={showDropFlyout}
onClose={() => setShowDropFlyout(false)}
title="Drop Rules Explained"
anchorRef={dropInfoRef}
>
<Stack gap={4}>
<Text size="xs" color="text-gray-400" block>
Drop rules allow drivers to exclude their worst results from championship calculations.
This protects against mechanical failures, bad luck, or occasional poor performances.
</Text>
<Stack>
<Text size="xs" weight="bold" color="text-gray-500" transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
block
mb={2}
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
>
Visual Example
</Text>
<DropRulesMockup />
</Stack>
<Stack gap={2}>
<Text size="xs" weight="bold" color="text-gray-500" transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
block
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
>
Drop Strategies
</Text>
<Stack gap={2}>
<Stack display="flex" alignItems="start" gap={2} p={2} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline/30">
<Text size="base"></Text>
<Stack>
<Text size="xs" weight="medium" color="text-white" block
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
>
All Count
</Text>
<Text size="xs" color="text-gray-500" block
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '9px' }}
>
Every race affects standings. Best for short seasons.
</Text>
</Stack>
</Stack>
<Stack display="flex" alignItems="start" gap={2} p={2} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline/30">
<Text size="base">🏆</Text>
<Stack>
<Text size="xs" weight="medium" color="text-white" block
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
>
Best N Results
</Text>
<Text size="xs" color="text-gray-500" block
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '9px' }}
>
Only your top N races count. Extra races are optional.
</Text>
</Stack>
</Stack>
<Stack display="flex" alignItems="start" gap={2} p={2} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline/30">
<Text size="base">🗑</Text>
<Stack>
<Text size="xs" weight="medium" color="text-white" block
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
>
Drop Worst N
</Text>
<Text size="xs" color="text-gray-500" block
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '9px' }}
>
Exclude your N worst results. Forgives bad days.
</Text>
</Stack>
</Stack>
</Stack>
</Stack>
<Stack rounded="lg" bg="bg-primary-blue/5" border borderColor="border-primary-blue/20" p={3}>
<Stack display="flex" alignItems="start" gap={2}>
<Icon icon={Zap} size={3.5} color="text-primary-blue" flexShrink={0} mt={0.5} />
<Stack>
<Text size="xs" color="text-gray-400"
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '11px' }}
>
<Text weight="medium" color="text-primary-blue">Pro tip:</Text> For an 8-round season,
&quot;Best 6&quot; or &quot;Drop 2&quot; are popular choices.
</Text>
</Stack>
</Stack>
</Stack>
</Stack>
<Text size="xs" variant="low" block>
Drop rules allow drivers to exclude their worst results from championship calculations.
This protects against mechanical failures, bad luck, or occasional poor performances.
</Text>
</InfoFlyout>
{/* Strategy buttons + N stepper inline */}
<Stack display="flex" flexWrap="wrap" alignItems="center" gap={2}>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: '0.5rem' }}>
{DROP_OPTIONS.map((option) => {
const isSelected = dropPolicy.strategy === option.value;
const ruleInfo = DROP_RULE_INFO[option.value];
return (
<Stack key={option.value} display="flex" alignItems="center" position="relative">
<Stack
as="button"
type="button"
disabled={disabled}
onClick={() => handleStrategyChange(option.value)}
display="flex"
alignItems="center"
gap={2}
px={3}
py={2}
rounded="lg"
border
borderWidth="2px"
transition
borderColor={isSelected ? 'border-primary-blue' : 'border-charcoal-outline/40'}
bg={isSelected ? 'bg-primary-blue/10' : 'bg-iron-gray/20'}
hoverBorderColor={!isSelected && !disabled ? 'border-charcoal-outline' : undefined}
hoverBg={!isSelected && !disabled ? 'bg-iron-gray/30' : undefined}
cursor={disabled ? 'default' : 'pointer'}
opacity={disabled ? 0.6 : 1}
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ borderRightWidth: 0, borderTopRightRadius: 0, borderBottomRightRadius: 0 }}
>
{/* Radio indicator */}
<Stack
display="flex"
h="4"
w="4"
alignItems="center"
justifyContent="center"
rounded="full"
border
borderColor={isSelected ? 'border-primary-blue' : 'border-gray-500'}
bg={isSelected ? 'bg-primary-blue' : ''}
flexShrink={0}
transition
>
{isSelected && <Icon icon={Check} size={2.5} color="text-white" />}
</Stack>
<Text size="sm">{option.emoji}</Text>
<Text size="sm" weight="medium" color={isSelected ? 'text-white' : 'text-gray-400'}>
{option.label}
</Text>
</Stack>
{/* Info button - separate from main button */}
<Stack
as="button"
ref={(el: HTMLButtonElement | null) => { dropRuleRefs.current[option.value] = el; }}
type="button"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
setActiveDropRuleFlyout(activeDropRuleFlyout === option.value ? null : option.value);
}}
display="flex"
alignItems="center"
justifyContent="center"
px={2}
py={2}
rounded="lg"
border
borderWidth="2px"
transition
borderColor={isSelected ? 'border-primary-blue' : 'border-charcoal-outline/40'}
bg={isSelected ? 'bg-primary-blue/10' : 'bg-iron-gray/20'}
hoverBorderColor={!isSelected && !disabled ? 'border-charcoal-outline' : undefined}
hoverBg={!isSelected && !disabled ? 'bg-iron-gray/30' : undefined}
cursor={disabled ? 'default' : 'pointer'}
opacity={disabled ? 0.6 : 1}
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ borderLeftWidth: 0, borderTopLeftRadius: 0, borderBottomLeftRadius: 0, height: '100%' }}
>
<Icon icon={HelpCircle} size={3.5} color="text-gray-500"
// eslint-disable-next-line gridpilot-rules/component-classification
className="hover:text-primary-blue transition-colors"
/>
</Stack>
{/* Drop Rule Info Flyout */}
<InfoFlyout
isOpen={activeDropRuleFlyout === option.value}
onClose={() => setActiveDropRuleFlyout(null)}
title={ruleInfo.title}
anchorRef={{ current: (dropRuleRefs.current[option.value] as HTMLElement | null) ?? dropInfoRef.current }}
>
<Stack gap={4}>
<Text size="xs" color="text-gray-400" block>{ruleInfo.description}</Text>
<Stack gap={2}>
<Text size="xs" weight="bold" color="text-gray-500" transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
block
mb={2}
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
>
How It Works
</Text>
<Stack gap={1.5}>
{ruleInfo.details.map((detail, idx) => (
<Stack key={idx} display="flex" alignItems="start" gap={2}>
<Icon icon={Check} size={3} color="text-performance-green" flexShrink={0} mt={0.5} />
<Text size="xs" color="text-gray-400">{detail}</Text>
</Stack>
))}
</Stack>
</Stack>
<Stack rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline/30" p={3}>
<Stack display="flex" alignItems="center" gap={2}>
<Text size="base">{option.emoji}</Text>
<Stack>
<Text size="xs" color="text-gray-400" block
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
>
Example
</Text>
<Text size="xs" weight="medium" color="text-white" block>{ruleInfo.example}</Text>
</Stack>
</Stack>
</Stack>
</Stack>
</InfoFlyout>
</Stack>
<Button
key={option.value}
variant={isSelected ? 'primary' : 'secondary'}
size="sm"
onClick={() => handleStrategyChange(option.value)}
disabled={disabled}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
{isSelected && <Icon icon={Check} size={3} />}
<span>{option.emoji}</span>
<span>{option.label}</span>
</div>
</Button>
);
})}
{/* N Stepper - only show when needed */}
{needsN && (
<Stack display="flex" alignItems="center" gap={1} ml={2}>
<Text size="xs" color="text-gray-500" mr={1}>N =</Text>
<Stack
as="button"
type="button"
disabled={disabled || (dropPolicy.n ?? 1) <= 1}
onClick={() => handleNChange(-1)}
display="flex"
h="7"
w="7"
alignItems="center"
justifyContent="center"
rounded="md"
bg="bg-iron-gray"
border
borderColor="border-charcoal-outline"
color="text-gray-400"
transition
hoverTextColor={!disabled ? 'text-white' : undefined}
hoverBorderColor={!disabled ? 'border-primary-blue' : undefined}
opacity={disabled || (dropPolicy.n ?? 1) <= 1 ? 0.4 : 1}
>
</Stack>
<Stack display="flex" h="7" w="10" alignItems="center" justifyContent="center" rounded="md" bg="bg-iron-gray/50" border borderColor="border-charcoal-outline/50">
<Text size="sm" weight="semibold" color="text-white">{dropPolicy.n ?? 1}</Text>
</Stack>
<Stack
as="button"
type="button"
<div style={{ marginLeft: '0.5rem' }}>
<Stepper
value={dropPolicy.n ?? 1}
onChange={handleNChange}
label="N ="
disabled={disabled}
onClick={() => handleNChange(1)}
display="flex"
h="7"
w="7"
alignItems="center"
justifyContent="center"
rounded="md"
bg="bg-iron-gray"
border
borderColor="border-charcoal-outline"
color="text-gray-400"
transition
hoverTextColor={!disabled ? 'text-white' : undefined}
hoverBorderColor={!disabled ? 'border-primary-blue' : undefined}
opacity={disabled ? 0.4 : 1}
>
+
</Stack>
</Stack>
/>
</div>
)}
</Stack>
</div>
{/* Explanation text */}
<Text size="xs" color="text-gray-500" block>
<Text size="xs" variant="low" block>
{dropPolicy.strategy === 'none' && 'Every race result affects the championship standings.'}
{dropPolicy.strategy === 'bestNResults' && `Only your best ${dropPolicy.n ?? 1} results will count.`}
{dropPolicy.strategy === 'dropWorstN' && `Your worst ${dropPolicy.n ?? 1} results will be excluded.`}
</Text>
</Stack>
</div>
);
}

View File

@@ -1,8 +1,10 @@
'use client';
import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import { Link } from '@/ui/Link';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/primitives/Stack';
import { ListItem, ListItemInfo, ListItemActions } from '@/ui/ListItem';
import React from 'react';
interface League {
leagueId: string;
@@ -18,40 +20,34 @@ interface LeagueListItemProps {
export function LeagueListItem({ league, isAdmin }: LeagueListItemProps) {
return (
<Card
variant="outline"
p={4}
className="bg-graphite-black border-[#262626]"
>
<Stack direction="row" align="center" justify="between" fullWidth>
<Stack 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}>
<ListItem>
<ListItemInfo
title={league.name}
description={league.description}
meta={
league.membershipRole && (
<Text size="xs" variant="low">
Your role:{' '}
<Text color="text-gray-400" style={{ textTransform: 'capitalize' }}>{league.membershipRole}</Text>
<Text as="span" variant="med" style={{ textTransform: 'capitalize' }}>{league.membershipRole}</Text>
</Text>
)}
</Stack>
<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>
)
}
/>
<ListItemActions>
<Link
href={`/leagues/${league.leagueId}`}
variant="ghost"
>
<Text size="sm" variant="med">View</Text>
</Link>
{isAdmin && (
<Link href={`/leagues/${league.leagueId}?tab=admin`} variant="ghost">
<Button variant="primary" size="sm">
Manage
</Button>
</Link>
{isAdmin && (
<Link href={`/leagues/${league.leagueId}?tab=admin`} variant="ghost">
<Button variant="primary" size="sm">
Manage
</Button>
</Link>
)}
</Stack>
</Stack>
</Card>
)}
</ListItemActions>
</ListItem>
);
}

View File

@@ -1,10 +1,10 @@
import { DriverIdentity } from '@/components/drivers/DriverIdentity';
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import { Badge } from '@/ui/Badge';
import { Box } from '@/ui/primitives/Box';
import { TableCell, TableRow } from '@/ui/Table';
import { Text } from '@/ui/Text';
import { ReactNode } from 'react';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
import React, { ReactNode } from 'react';
interface LeagueMemberRowProps {
driver?: DriverViewModel;
@@ -41,7 +41,7 @@ export function LeagueMemberRow({
return (
<TableRow variant={isTopPerformer ? 'highlight' : 'default'}>
<TableCell>
<Box display="flex" alignItems="center" gap={2}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
{driver ? (
<DriverIdentity
driver={driver}
@@ -51,28 +51,28 @@ export function LeagueMemberRow({
size="md"
/>
) : (
<Text color="text-white">Unknown Driver</Text>
<Text variant="high">Unknown Driver</Text>
)}
{isCurrentUser && (
<Text size="xs" color="text-gray-500">(You)</Text>
<Text size="xs" variant="low">(You)</Text>
)}
{isTopPerformer && (
<Text size="xs"></Text>
)}
</Box>
</div>
</TableCell>
<TableCell>
<Text color="text-primary-blue" weight="medium">
<Text variant="primary" weight="medium">
{rating || '—'}
</Text>
</TableCell>
<TableCell>
<Text color="text-gray-300">
<Text variant="med">
#{rank || '—'}
</Text>
</TableCell>
<TableCell>
<Text color="text-green-400" weight="medium">
<Text variant="success" weight="medium">
{wins || 0}
</Text>
</TableCell>
@@ -82,12 +82,8 @@ export function LeagueMemberRow({
</Badge>
</TableCell>
<TableCell>
<Text color="text-white" size="sm">
{new Date(joinedAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
<Text variant="high" size="sm">
{DateDisplay.formatShort(joinedAt)}
</Text>
</TableCell>
{actions && (

View File

@@ -2,7 +2,8 @@
import { LeagueMemberRow } from '@/components/leagues/LeagueMemberRow';
import { LeagueMemberTable } from '@/components/leagues/LeagueMemberTable';
import { MinimalEmptyState } from '@/components/shared/state/EmptyState';
import { EmptyState } from '@/ui/EmptyState';
import { LoadingWrapper } from '@/ui/LoadingWrapper';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { useInject } from '@/lib/di/hooks/useInject';
import { DRIVER_SERVICE_TOKEN, LEAGUE_MEMBERSHIP_SERVICE_TOKEN } from '@/lib/di/tokens';
@@ -11,10 +12,11 @@ import type { LeagueMembership } from '@/lib/types/LeagueMembership';
import type { MembershipRole } from '@/lib/types/MembershipRole';
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import { Button } from '@/ui/Button';
import { Box } from '@/ui/primitives/Box';
import { Select } from '@/ui/Select';
import { Text } from '@/ui/Text';
import { ControlBar } from '@/ui/ControlBar';
import { useCallback, useEffect, useState } from 'react';
import React from 'react';
interface LeagueMembersProps {
leagueId: string;
@@ -83,7 +85,6 @@ export function LeagueMembers({
return null;
};
// eslint-disable-next-line gridpilot-rules/component-no-data-manipulation
const sortedMembers = [...members].sort((a, b) => {
switch (sortBy) {
case 'role':
@@ -120,31 +121,30 @@ export function LeagueMembers({
};
if (loading) {
return (
<Box textAlign="center" py={8}>
<Text color="text-gray-400">Loading members...</Text>
</Box>
);
return <LoadingWrapper variant="spinner" message="Loading members..." />;
}
if (members.length === 0) {
return (
<MinimalEmptyState
<EmptyState
title="No members found"
description="This league doesn't have any members yet."
variant="minimal"
/>
);
}
return (
<Box>
{/* Sort Controls */}
<Box display="flex" alignItems="center" justifyContent="between" mb={4}>
<Text size="sm" color="text-gray-400">
{members.length} {members.length === 1 ? 'member' : 'members'}
</Text>
<Box display="flex" alignItems="center" gap={2}>
<Text as="label" size="sm" color="text-gray-400">Sort by:</Text>
<div>
<ControlBar
leftContent={
<Text size="sm" variant="low">
{members.length} {members.length === 1 ? 'member' : 'members'}
</Text>
}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<Text size="sm" variant="low">Sort by:</Text>
<Select
value={sortBy}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setSortBy(e.target.value as typeof sortBy)}
@@ -158,11 +158,10 @@ export function LeagueMembers({
]}
fullWidth={false}
/>
</Box>
</Box>
</div>
</ControlBar>
{/* Members Table */}
<Box overflow="auto">
<div style={{ overflowX: 'auto', marginTop: '1rem' }}>
<LeagueMemberTable showActions={showActions}>
{sortedMembers.map((member, index) => {
const isCurrentUser = member.driverId === currentDriverId;
@@ -191,7 +190,7 @@ export function LeagueMembers({
href={routes.driver.detail(member.driverId)}
meta={ratingAndWinsMeta}
actions={showActions && !cannotModify && !isCurrentUser ? (
<Box display="flex" alignItems="center" justifyContent="end" gap={2}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '0.5rem' }}>
{onUpdateRole && (
<Select
value={member.role}
@@ -202,8 +201,6 @@ export function LeagueMembers({
{ value: 'admin', label: 'Admin' },
]}
fullWidth={false}
// eslint-disable-next-line gridpilot-rules/component-classification
className="text-xs py-1 px-2"
/>
)}
{onRemoveMember && (
@@ -211,18 +208,17 @@ export function LeagueMembers({
variant="ghost"
onClick={() => onRemoveMember(member.driverId)}
size="sm"
color="text-error-red"
>
Remove
<Text variant="critical">Remove</Text>
</Button>
)}
</Box>
) : (showActions && cannotModify ? <Text size="xs" color="text-gray-500"></Text> : undefined)}
</div>
) : (showActions && cannotModify ? <Text size="xs" variant="low"></Text> : undefined)}
/>
);
})}
</LeagueMemberTable>
</Box>
</Box>
</div>
</div>
);
}

View File

@@ -1,12 +1,7 @@
'use client';
import { Badge } from '@/ui/Badge';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/primitives/Stack';
import { Surface } from '@/ui/primitives/Surface';
import { Text } from '@/ui/Text';
import { DollarSign } from 'lucide-react';
import { SponsorshipCard } from '@/ui/SponsorshipCard';
import React from 'react';
interface SponsorshipSlot {
id: string;
@@ -26,42 +21,12 @@ interface SponsorshipSlotCardProps {
export function SponsorshipSlotCard({ slot }: SponsorshipSlotCardProps) {
return (
<Surface
variant="muted"
rounded="lg"
border
padding={4}
// eslint-disable-next-line gridpilot-rules/component-classification
style={{
backgroundColor: slot.isAvailable ? 'rgba(16, 185, 129, 0.05)' : 'rgba(38, 38, 38, 0.3)',
borderColor: slot.isAvailable ? '#10b981' : '#262626'
}}
>
<Stack gap={3}>
<Stack direction="row" align="start" justify="between">
<Heading level={4}>{slot.name}</Heading>
<Badge variant={slot.isAvailable ? 'success' : 'default'}>
{slot.isAvailable ? 'Available' : 'Taken'}
</Badge>
</Stack>
<Text size="sm" color="text-gray-300" block>{slot.description}</Text>
<Stack direction="row" align="center" gap={2}>
<Icon icon={DollarSign} size={4} color="#9ca3af" />
<Text weight="semibold" color="text-white">
{slot.price} {slot.currency}
</Text>
</Stack>
{!slot.isAvailable && slot.sponsoredBy && (
// eslint-disable-next-line gridpilot-rules/component-classification
<Stack pt={3} style={{ borderTop: '1px solid #262626' }}>
<Text size="xs" color="text-gray-400" block mb={1}>Sponsored by</Text>
<Text size="sm" weight="medium" color="text-white">{slot.sponsoredBy.name}</Text>
</Stack>
)}
</Stack>
</Surface>
<SponsorshipCard
name={slot.name}
description={slot.description}
price={`${slot.price} ${slot.currency}`}
isAvailable={slot.isAvailable}
sponsoredBy={slot.sponsoredBy?.name}
/>
);
}

View File

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