website refactor
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
“{request.message}”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
"Best 6" or "Drop 2" 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user