Files
gridpilot.gg/apps/website/components/leagues/LeagueScoringSection.tsx
2026-01-15 01:26:30 +01:00

1217 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import React, { useState, useRef, useEffect } from 'react';
import { Trophy, Award, Check, Zap, Settings, Globe, Medal, Plus, Minus, RotateCcw, HelpCircle, X, LucideIcon } from 'lucide-react';
import { createPortal } from 'react-dom';
import type { LeagueScoringPresetViewModel } from '@/lib/view-models/LeagueScoringPresetViewModel';
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import type { CustomPointsConfig } from '@/lib/view-models/ScoringConfigurationViewModel';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Grid } from '@/ui/Grid';
import { Surface } from '@/ui/Surface';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
// ============================================================================
// INFO FLYOUT 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;
// Try to position to the right first
let left = rect.right + 12;
let top = rect.top;
// If goes off right edge, try left side
if (left + flyoutWidth > window.innerWidth - padding) {
left = rect.left - flyoutWidth - 12;
}
// If still off screen (left side), center horizontally
if (left < padding) {
left = Math.max(padding, (window.innerWidth - flyoutWidth) / 2);
}
// Vertical positioning - try to center on button, but stay in viewport
top = rect.top - flyoutHeight / 3;
// Adjust if goes off bottom edge
if (top + flyoutHeight > window.innerHeight - padding) {
top = window.innerHeight - flyoutHeight - padding;
}
// Ensure not above viewport
if (top < padding) top = padding;
// Final bounds check
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(
<Box
ref={flyoutRef}
position="fixed"
zIndex={50}
width="380px"
backgroundColor="iron-gray"
border
borderColor="charcoal-outline"
rounded="xl"
className="max-h-[80vh] overflow-y-auto shadow-2xl animate-fade-in"
style={{ top: position.top, left: position.left }}
>
{/* Header */}
<Box display="flex" align="center" justify="between" padding={4} border borderBottom borderColor="charcoal-outline" position="sticky" top={0} backgroundColor="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>
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="h-6 w-6 p-0"
icon={<Icon icon={X} size={4} color="text-gray-400" />}
>
{null}
</Button>
</Box>
{/* Content */}
<Box padding={4}>
{children}
</Box>
</Box>,
document.body
);
}
function InfoButton({ onClick, buttonRef }: { onClick: () => void; buttonRef: React.Ref<HTMLButtonElement> }) {
return (
<Button
ref={buttonRef}
variant="ghost"
size="sm"
onClick={onClick}
className="h-5 w-5 p-0 rounded-full text-gray-500 hover:text-primary-blue hover:bg-primary-blue/10"
icon={<Icon icon={HelpCircle} size={3.5} />}
>
{null}
</Button>
);
}
// ============================================================================
// MOCKUP COMPONENTS FOR FLYOUTS
// ============================================================================
function PointsSystemMockup() {
const positions = [
{ pos: 1, pts: 25, color: 'bg-yellow-500' },
{ pos: 2, pts: 18, color: 'bg-gray-300' },
{ pos: 3, pts: 15, color: 'bg-amber-600' },
{ pos: 4, pts: 12, color: 'bg-charcoal-outline' },
{ pos: 5, pts: 10, color: 'bg-charcoal-outline' },
];
return (
<Surface variant="dark" rounded="lg" padding={4}>
<Stack gap={3}>
<Box display="flex" align="center" justify="between" className="text-[10px] text-gray-500 uppercase tracking-wide px-1">
<Text>Position</Text>
<Text>Points</Text>
</Box>
{positions.map((p) => (
<Stack key={p.pos} direction="row" align="center" gap={3}>
<Box width={8} height={8} rounded="lg" className={p.color} display="flex" center>
<Text size="sm" weight="bold" className={p.pos <= 3 ? 'text-deep-graphite' : 'text-gray-400'}>P{p.pos}</Text>
</Box>
<Box flex={1} height={2} backgroundColor="charcoal-outline" rounded="full" className="overflow-hidden opacity-50">
<Box
height="full"
className="bg-gradient-to-r from-primary-blue to-neon-aqua rounded-full"
style={{ width: `${(p.pts / 25) * 100}%` }}
/>
</Box>
<Box width={8} textAlign="right">
<Text size="sm" font="mono" weight="semibold" color="text-white">{p.pts}</Text>
</Box>
</Stack>
))}
<Box display="flex" align="center" justify="center" gap={1} pt={2} className="text-[10px] text-gray-500">
<Text>...</Text>
<Text>down to P10 = 1 point</Text>
</Box>
</Stack>
</Surface>
);
}
function BonusPointsMockup() {
const bonuses = [
{ emoji: '🏎️', label: 'Pole Position', pts: '+1', desc: 'Fastest in qualifying' },
{ emoji: '⚡', label: 'Fastest Lap', pts: '+1', desc: 'Best race lap time' },
{ emoji: '🥇', label: 'Led a Lap', pts: '+1', desc: 'Led at least one lap' },
];
return (
<Surface variant="dark" rounded="lg" padding={4}>
<Stack gap={2}>
{bonuses.map((b, i) => (
<Surface key={i} variant="muted" border rounded="lg" padding={2}>
<Stack direction="row" align="center" gap={3}>
<Text size="xl">{b.emoji}</Text>
<Box flex={1}>
<Text size="xs" weight="medium" color="text-white" block>{b.label}</Text>
<Text className="text-[10px] text-gray-500" block>{b.desc}</Text>
</Box>
<Text size="sm" font="mono" weight="semibold" color="text-performance-green">{b.pts}</Text>
</Stack>
</Surface>
))}
</Stack>
</Surface>
);
}
function ChampionshipMockup() {
const standings = [
{ pos: 1, name: 'M. Verstappen', pts: 575, delta: null },
{ pos: 2, name: 'L. Norris', pts: 412, delta: -163 },
{ pos: 3, name: 'C. Leclerc', pts: 389, delta: -186 },
];
return (
<Surface variant="dark" rounded="lg" padding={4}>
<Box display="flex" align="center" gap={2} mb={3} pb={2} border borderBottom borderColor="charcoal-outline" className="opacity-50">
<Icon icon={Trophy} size={4} color="text-yellow-500" />
<Text size="xs" weight="semibold" color="text-white">Driver Championship</Text>
</Box>
<Stack gap={2}>
{standings.map((s) => (
<Stack key={s.pos} direction="row" align="center" gap={2}>
<Box width={6} height={6} rounded="full" display="flex" center className={s.pos === 1 ? 'bg-yellow-500 text-deep-graphite' : 'bg-charcoal-outline text-gray-400'}>
<Text className="text-[10px]" weight="bold">{s.pos}</Text>
</Box>
<Box flex={1}>
<Text size="xs" color="text-white" className="truncate" block>{s.name}</Text>
</Box>
<Text size="xs" font="mono" weight="semibold" color="text-white">{s.pts}</Text>
{s.delta && (
<Text className="text-[10px] font-mono text-gray-500">{s.delta}</Text>
)}
</Stack>
))}
</Stack>
<Box mt={3} pt={2} border borderTop borderColor="charcoal-outline" className="text-[10px] text-gray-500 opacity-50" textAlign="center">
<Text>Points accumulated across all races</Text>
</Box>
</Surface>
);
}
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 (
<Surface variant="dark" rounded="lg" padding={4}>
<Box mb={3} pb={2} border borderBottom borderColor="charcoal-outline" className="opacity-50">
<Text size="xs" weight="semibold" color="text-white">Best 4 of 6 Results</Text>
</Box>
<Stack direction="row" gap={1} mb={3}>
{results.map((r, i) => (
<Box
key={i}
flex={1}
padding={2}
rounded="lg"
textAlign="center"
border
borderColor={r.dropped ? 'charcoal-outline' : 'performance-green'}
backgroundColor={r.dropped ? 'transparent' : 'performance-green'}
opacity={r.dropped ? 0.5 : 0.1}
className="transition-all"
>
<Text className="text-[9px] text-gray-500" block>{r.round}</Text>
<Text size="xs" font="mono" weight="semibold" color={r.dropped ? 'text-gray-500' : 'text-white'} className={r.dropped ? 'line-through' : ''} block>
{r.pts}
</Text>
</Box>
))}
</Stack>
<Box display="flex" justify="between" align="center">
<Text size="xs" color="text-gray-500">Total counted:</Text>
<Text font="mono" weight="semibold" color="text-performance-green">{total} pts</Text>
</Box>
<Box display="flex" justify="between" align="center" mt={1} className="text-[10px] text-gray-500">
<Text>Without drops:</Text>
<Text font="mono">{wouldBe} pts</Text>
</Box>
</Surface>
);
}
interface LeagueScoringSectionProps {
form: LeagueConfigFormModel;
presets: LeagueScoringPresetViewModel[];
onChange?: (form: LeagueConfigFormModel) => void;
readOnly?: boolean;
/**
* When true, only render the scoring pattern panel.
*/
patternOnly?: boolean;
/**
* When true, only render the championships panel.
*/
championshipsOnly?: boolean;
}
interface ScoringPatternSectionProps {
scoring: LeagueConfigFormModel['scoring'];
presets: LeagueScoringPresetViewModel[];
readOnly?: boolean;
patternError?: string;
onChangePatternId?: (patternId: string) => void;
onToggleCustomScoring?: () => void;
onUpdateCustomPoints?: (points: CustomPointsConfig) => void;
}
const DEFAULT_CUSTOM_POINTS: CustomPointsConfig = {
racePoints: [25, 18, 15, 12, 10, 8, 6, 4, 2, 1],
poleBonusPoints: 1,
fastestLapPoints: 1,
leaderLapPoints: 0,
};
interface ChampionshipsSectionProps {
form: LeagueConfigFormModel;
onChange?: (form: LeagueConfigFormModel) => void;
readOnly?: boolean;
}
export function LeagueScoringSection({
form,
presets,
onChange,
readOnly,
patternOnly,
championshipsOnly,
}: LeagueScoringSectionProps) {
const disabled = readOnly || !onChange;
const handleSelectPreset = (presetId: string) => {
if (disabled || !onChange) return;
// Logic moved to parent component
onChange({
...form,
scoring: {
...form.scoring,
patternId: presetId,
customScoringEnabled: false,
}
});
};
const handleToggleCustomScoring = () => {
if (disabled || !onChange) return;
onChange({
...form,
scoring: {
...form.scoring,
customScoringEnabled: !form.scoring.customScoringEnabled,
}
});
};
const patternProps: ScoringPatternSectionProps = {
scoring: form.scoring,
presets,
readOnly: !!readOnly,
};
if (!readOnly && onChange) {
patternProps.onChangePatternId = handleSelectPreset;
}
if (!disabled) {
patternProps.onToggleCustomScoring = handleToggleCustomScoring;
}
const patternPanel = <ScoringPatternSection {...patternProps} />;
const championshipsProps: ChampionshipsSectionProps = {
form,
readOnly: !!readOnly,
};
if (onChange) {
championshipsProps.onChange = onChange;
}
const championshipsPanel = <ChampionshipsSection {...championshipsProps} />;
if (patternOnly) {
return <Box>{patternPanel}</Box>;
}
if (championshipsOnly) {
return <Box>{championshipsPanel}</Box>;
}
return (
<Grid cols={2} gap={6} className="lg:grid-cols-[minmax(0,2fr)_minmax(0,1.4fr)]">
{patternPanel}
{championshipsPanel}
</Grid>
);
}
/**
* Step 4 Super simple scoring preset picker.
* Flat layout, no collapsibles, easy to understand.
*/
// Preset info content for individual presets
function getPresetInfoContent(presetName: string): { title: string; description: string; details: string[] } {
const name = presetName.toLowerCase();
if (name.includes('sprint')) {
return {
title: 'Sprint + Feature Format',
description: 'A two-race weekend format with a shorter sprint race and a longer feature race.',
details: [
'Sprint race typically awards reduced points (e.g., 8-6-4-3-2-1)',
'Feature race awards full points (e.g., 25-18-15-12-10-8-6-4-2-1)',
'Grid for feature often based on sprint results',
'Great for competitive leagues with time for multiple races',
],
};
}
if (name.includes('endurance') || name.includes('long')) {
return {
title: 'Endurance Format',
description: 'Long-form racing focused on consistency and strategy over raw pace.',
details: [
'Single race per weekend, longer duration (60-90+ minutes)',
'Higher points for finishing (rewards reliability)',
'Often includes mandatory pit stops',
'Best for serious leagues with dedicated racers',
],
};
}
if (name.includes('club') || name.includes('casual')) {
return {
title: 'Club/Casual Format',
description: 'Relaxed format perfect for community leagues and casual racing.',
details: [
'Simple points structure, easy to understand',
'Typically single race per weekend',
'Lower stakes, focus on participation',
'Great for beginners or mixed-skill leagues',
],
};
}
return {
title: 'Standard Race Format',
description: 'Traditional single-race weekend with standard F1-style points.',
details: [
'Points: 25-18-15-12-10-8-6-4-2-1 for top 10',
'Bonus points for pole position and fastest lap',
'One race per weekend',
'The most common format used in sim racing',
],
};
}
export function ScoringPatternSection({
scoring,
presets,
readOnly,
patternError,
onChangePatternId,
onToggleCustomScoring,
onUpdateCustomPoints,
}: ScoringPatternSectionProps) {
const disabled = readOnly || !onChangePatternId;
const isCustom = scoring.customScoringEnabled;
// Local state for custom points editor
const [customPoints, setCustomPoints] = useState<CustomPointsConfig>(DEFAULT_CUSTOM_POINTS);
const updateRacePoints = (index: number, delta: number) => {
setCustomPoints((prev) => {
const newPoints = [...prev.racePoints];
newPoints[index] = Math.max(0, (newPoints[index] ?? 0) + delta);
const updated = { ...prev, racePoints: newPoints };
onUpdateCustomPoints?.(updated);
return updated;
});
};
const addPosition = () => {
setCustomPoints((prev) => {
const lastPoints = prev.racePoints[prev.racePoints.length - 1] ?? 0;
const updated = { ...prev, racePoints: [...prev.racePoints, Math.max(0, lastPoints - 1)] };
onUpdateCustomPoints?.(updated);
return updated;
});
};
const removePosition = () => {
if (customPoints.racePoints.length <= 3) return;
setCustomPoints((prev) => {
const updated = { ...prev, racePoints: prev.racePoints.slice(0, -1) };
onUpdateCustomPoints?.(updated);
return updated;
});
};
const updateBonus = (key: keyof Omit<CustomPointsConfig, 'racePoints'>, delta: number) => {
setCustomPoints((prev) => {
const updated = { ...prev, [key]: Math.max(0, prev[key] + delta) };
onUpdateCustomPoints?.(updated);
return updated;
});
};
const resetToDefaults = () => {
setCustomPoints(DEFAULT_CUSTOM_POINTS);
onUpdateCustomPoints?.(DEFAULT_CUSTOM_POINTS);
};
const getPresetEmoji = (preset: LeagueScoringPresetViewModel) => {
const name = preset.name.toLowerCase();
if (name.includes('sprint')) return '🏁';
if (name.includes('endurance')) return '🔋';
if (name.includes('club')) return '🤝';
return '🏆';
};
const getPresetDescription = (preset: LeagueScoringPresetViewModel) => {
return preset.sessionSummary || 'Standard scoring format';
};
// Flyout state
const [showPointsFlyout, setShowPointsFlyout] = useState(false);
const [showBonusFlyout, setShowBonusFlyout] = useState(false);
const [activePresetFlyout, setActivePresetFlyout] = useState<string | null>(null);
const pointsInfoRef = useRef<HTMLButtonElement>(null!);
const bonusInfoRef = useRef<HTMLButtonElement>(null!);
const presetInfoRefs = useRef<Record<string, HTMLElement | null>>({});
return (
<Stack gap={5}>
{/* Section header */}
<Stack direction="row" align="center" gap={3}>
<Box width={10} height={10} display="flex" center rounded="xl" backgroundColor="primary-blue" opacity={0.1}>
<Icon icon={Trophy} size={5} color="text-primary-blue" />
</Box>
<Box flex={1}>
<Stack direction="row" align="center" gap={2}>
<Heading level={3}>Points System</Heading>
<InfoButton buttonRef={pointsInfoRef} onClick={() => setShowPointsFlyout(true)} />
</Stack>
<Text size="xs" color="text-gray-500">Choose how points are awarded</Text>
</Box>
</Stack>
{/* Points System Flyout */}
<InfoFlyout
isOpen={showPointsFlyout}
onClose={() => setShowPointsFlyout(false)}
title="How Points Work"
anchorRef={pointsInfoRef}
>
<Stack gap={4}>
<Text size="xs" color="text-gray-400">
Points are awarded based on finishing position. Higher positions earn more points,
which accumulate across the season to determine championship standings.
</Text>
<Box>
<Box mb={2} className="text-[10px] text-gray-500 uppercase tracking-wide">
<Text>Example: F1-Style Points</Text>
</Box>
<PointsSystemMockup />
</Box>
<Surface variant="muted" border rounded="lg" padding={3}>
<Stack direction="row" align="start" gap={2}>
<Icon icon={Zap} size={3.5} color="text-primary-blue" className="mt-0.5" />
<Text className="text-[11px] text-gray-400">
<Text weight="medium" color="text-primary-blue">Pro tip:</Text> Sprint formats
award points in both races, typically with reduced points for the sprint.
</Text>
</Stack>
</Surface>
</Stack>
</InfoFlyout>
{/* Two-column layout: Presets | Custom */}
<Grid cols={2} gap={4} className="lg:grid-cols-[1fr_auto]">
{/* Preset options */}
<Stack gap={2}>
{presets.length === 0 ? (
<Box padding={4} border borderStyle="dashed" borderColor="charcoal-outline" rounded="lg">
<Text size="sm" color="text-gray-400">Loading presets...</Text>
</Box>
) : (
presets.map((preset) => {
const isSelected = !isCustom && scoring.patternId === preset.id;
const presetInfo = getPresetInfoContent(preset.name);
return (
<Box key={preset.id} position="relative">
<Button
variant="ghost"
onClick={() => onChangePatternId?.(preset.id)}
disabled={disabled}
className={`
w-full flex items-center gap-3 p-3 rounded-xl border-2 text-left transition-all duration-200 h-auto
${isSelected
? 'border-primary-blue bg-primary-blue/10'
: 'border-charcoal-outline/40 bg-iron-gray/20 hover:border-charcoal-outline hover:bg-iron-gray/40'
}
`}
>
{/* Radio indicator */}
<Box
width={5}
height={5}
display="flex"
center
rounded="full"
border
borderColor={isSelected ? 'primary-blue' : 'gray-500'}
backgroundColor={isSelected ? 'primary-blue' : 'transparent'}
className="shrink-0 transition-colors"
>
{isSelected && <Icon icon={Check} size={3} color="text-white" />}
</Box>
{/* Emoji */}
<Text size="xl">{getPresetEmoji(preset)}</Text>
{/* Text */}
<Box flex={1} className="min-w-0">
<Text size="sm" weight="medium" color="text-white" block>{preset.name}</Text>
<Text size="xs" color="text-gray-500" block>{getPresetDescription(preset)}</Text>
</Box>
{/* Bonus badge */}
{preset.bonusSummary && (
<Box className="hidden sm:inline-flex items-center gap-1 px-2 py-1 rounded-full bg-charcoal-outline/30 text-[10px] text-gray-400">
<Icon icon={Zap} size={3} />
<Text>{preset.bonusSummary}</Text>
</Box>
)}
{/* Info button */}
<Box
ref={(el: any) => { presetInfoRefs.current[preset.id] = el; }}
role="button"
tabIndex={0}
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
setActivePresetFlyout(activePresetFlyout === preset.id ? null : preset.id);
}}
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
setActivePresetFlyout(activePresetFlyout === preset.id ? null : preset.id);
}
}}
className="flex h-6 w-6 items-center justify-center rounded-full text-gray-500 hover:text-primary-blue hover:bg-primary-blue/10 transition-colors shrink-0"
>
<Icon icon={HelpCircle} size={3.5} />
</Box>
</Button>
{/* Preset Info Flyout */}
<InfoFlyout
isOpen={activePresetFlyout === preset.id}
onClose={() => setActivePresetFlyout(null)}
title={presetInfo.title}
anchorRef={{ current: presetInfoRefs.current[preset.id] ?? pointsInfoRef.current }}
>
<Stack gap={4}>
<Text size="xs" color="text-gray-400">{presetInfo.description}</Text>
<Stack gap={2}>
<Box className="text-[10px] text-gray-500 uppercase tracking-wide">
<Text>Key Features</Text>
</Box>
<Box as="ul" className="space-y-1.5">
{presetInfo.details.map((detail, idx) => (
<Box as="li" key={idx} display="flex" align="start" gap={2}>
<Icon icon={Check} size={3} color="text-performance-green" className="mt-0.5" />
<Text size="xs" color="text-gray-400">{detail}</Text>
</Box>
))}
</Box>
</Stack>
{preset.bonusSummary && (
<Surface variant="muted" border rounded="lg" padding={3}>
<Stack direction="row" align="start" gap={2}>
<Icon icon={Zap} size={3.5} color="text-primary-blue" className="mt-0.5" />
<Text className="text-[11px] text-gray-400">
<Text weight="medium" color="text-primary-blue">Bonus points:</Text> {preset.bonusSummary}
</Text>
</Stack>
</Surface>
)}
</Stack>
</InfoFlyout>
</Box>
);
})
)}
</Stack>
{/* Custom scoring option */}
<Box width="full" className="lg:w-48">
<Button
variant="ghost"
onClick={onToggleCustomScoring}
disabled={!onToggleCustomScoring || readOnly}
className={`
w-full h-full min-h-[100px] flex flex-col items-center justify-center gap-2 p-4 rounded-xl border-2 transition-all duration-200
${isCustom
? 'border-primary-blue bg-primary-blue/10'
: 'border-dashed border-charcoal-outline/50 bg-iron-gray/10 hover:border-charcoal-outline hover:bg-iron-gray/20'
}
`}
>
<Box
width={10}
height={10}
display="flex"
center
rounded="xl"
backgroundColor={isCustom ? 'primary-blue' : 'charcoal-outline'}
opacity={isCustom ? 0.2 : 0.3}
className="transition-colors"
>
<Icon icon={Settings} size={5} color={isCustom ? 'text-primary-blue' : 'text-gray-500'} />
</Box>
<Box textAlign="center">
<Text size="sm" weight="medium" color={isCustom ? 'text-white' : 'text-gray-400'} block>Custom</Text>
<Text className="text-[10px] text-gray-500" block>Define your own</Text>
</Box>
{isCustom && (
<Box display="flex" align="center" gap={1} px={2} py={0.5} rounded="full" backgroundColor="primary-blue" opacity={0.2}>
<Icon icon={Check} size={2.5} color="text-primary-blue" />
<Text className="text-[10px]" weight="medium" color="text-primary-blue">Active</Text>
</Box>
)}
</Button>
</Box>
</Grid>
{/* Error message */}
{patternError && (
<Text size="xs" color="text-warning-amber">{patternError}</Text>
)}
{/* Custom scoring editor - inline, no placeholder */}
{isCustom && (
<Surface variant="muted" border rounded="xl" padding={4}>
<Stack gap={4}>
{/* Header with reset button */}
<Box display="flex" align="center" justify="between">
<Stack direction="row" align="center" gap={2}>
<Icon icon={Settings} size={4} color="text-primary-blue" />
<Text size="sm" weight="medium" color="text-white">Custom Points Table</Text>
<InfoButton buttonRef={bonusInfoRef} onClick={() => setShowBonusFlyout(true)} />
</Stack>
{/* Bonus Points Flyout */}
<InfoFlyout
isOpen={showBonusFlyout}
onClose={() => setShowBonusFlyout(false)}
title="Bonus Points Explained"
anchorRef={bonusInfoRef}
>
<Stack gap={4}>
<Text size="xs" color="text-gray-400">
Bonus points reward exceptional performances beyond just finishing position.
They add strategic depth and excitement to your championship.
</Text>
<Box>
<Box mb={2} className="text-[10px] text-gray-500 uppercase tracking-wide">
<Text>Available Bonuses</Text>
</Box>
<BonusPointsMockup />
</Box>
<Surface variant="muted" border rounded="lg" padding={3}>
<Stack direction="row" align="start" gap={2}>
<Icon icon={Zap} size={3.5} color="text-primary-blue" className="mt-0.5" />
<Text className="text-[11px] text-gray-400">
<Text weight="medium" color="text-primary-blue">Example:</Text> A driver finishing
P1 with pole and fastest lap would earn 25 + 1 + 1 = 27 points.
</Text>
</Stack>
</Surface>
</Stack>
</InfoFlyout>
<Button
variant="ghost"
size="sm"
onClick={resetToDefaults}
disabled={readOnly}
className="h-auto py-1 px-2 text-[10px] text-gray-400 hover:text-primary-blue hover:bg-primary-blue/10"
icon={<Icon icon={RotateCcw} size={3} />}
>
Reset
</Button>
</Box>
{/* Race position points */}
<Stack gap={2}>
<Box display="flex" align="center" justify="between">
<Text size="xs" color="text-gray-400">Finish position points</Text>
<Stack direction="row" align="center" gap={1}>
<Button
variant="secondary"
size="sm"
onClick={removePosition}
disabled={readOnly || customPoints.racePoints.length <= 3}
className="h-5 w-5 p-0"
icon={<Icon icon={Minus} size={3} />}
>
{null}
</Button>
<Button
variant="secondary"
size="sm"
onClick={addPosition}
disabled={readOnly || customPoints.racePoints.length >= 20}
className="h-5 w-5 p-0"
icon={<Icon icon={Plus} size={3} />}
>
{null}
</Button>
</Stack>
</Box>
<Stack direction="row" wrap gap={1}>
{customPoints.racePoints.map((pts, idx) => (
<Stack key={idx} align="center">
<Text className="text-[9px] text-gray-500" mb={0.5}>P{idx + 1}</Text>
<Stack direction="row" align="center" gap={0.5}>
<Button
variant="secondary"
size="sm"
onClick={() => updateRacePoints(idx, -1)}
disabled={readOnly || pts <= 0}
className="h-5 w-4 p-0 rounded-r-none text-[10px]"
>
</Button>
<Box width={6} height={5} display="flex" center backgroundColor="deep-graphite" border borderTop borderBottom borderColor="charcoal-outline">
<Text className="text-[10px]" weight="medium" color="text-white">{pts}</Text>
</Box>
<Button
variant="secondary"
size="sm"
onClick={() => updateRacePoints(idx, 1)}
disabled={readOnly}
className="h-5 w-4 p-0 rounded-l-none text-[10px]"
>
+
</Button>
</Stack>
</Stack>
))}
</Stack>
</Stack>
{/* Bonus points */}
<Grid cols={3} gap={3}>
{[
{ key: 'poleBonusPoints' as const, label: 'Pole', emoji: '🏎️' },
{ key: 'fastestLapPoints' as const, label: 'Fast lap', emoji: '⚡' },
{ key: 'leaderLapPoints' as const, label: 'Led lap', emoji: '🥇' },
].map((bonus) => (
<Stack key={bonus.key} align="center" gap={1}>
<Text className="text-[10px] text-gray-500">{bonus.emoji} {bonus.label}</Text>
<Stack direction="row" align="center" gap={0.5}>
<Button
variant="secondary"
size="sm"
onClick={() => updateBonus(bonus.key, -1)}
disabled={readOnly || customPoints[bonus.key] <= 0}
className="h-6 w-5 p-0 rounded-r-none"
>
</Button>
<Box width={7} height={6} display="flex" center backgroundColor="deep-graphite" border borderTop borderBottom borderColor="charcoal-outline">
<Text size="xs" weight="medium" color="text-white">{customPoints[bonus.key]}</Text>
</Box>
<Button
variant="secondary"
size="sm"
onClick={() => updateBonus(bonus.key, 1)}
disabled={readOnly}
className="h-6 w-5 p-0 rounded-l-none"
>
+
</Button>
</Stack>
</Stack>
))}
</Grid>
</Stack>
</Surface>
)}
</Stack>
);
}
/**
* Championships section - simple inline toggle list.
* No collapsibles, flat and easy to scan.
*/
// Championship info content
const CHAMPIONSHIP_INFO: Record<string, { title: string; description: string; details: string[] }> = {
enableDriverChampionship: {
title: 'Driver Championship',
description: 'Track individual driver performance across all races in the season.',
details: [
'Each driver accumulates points based on race finishes',
'The driver with most points at season end wins',
'Standard in all racing leagues',
'Shows overall driver skill and consistency',
],
},
enableTeamChampionship: {
title: 'Team Championship',
description: 'Combine points from all drivers within a team for team standings.',
details: [
'All drivers\' points count toward team total',
'Rewards having consistent performers across the roster',
'Creates team strategy opportunities',
'Only available in Teams mode leagues',
],
},
enableNationsChampionship: {
title: 'Nations Cup',
description: 'Group drivers by nationality for international competition.',
details: [
'Drivers represent their country automatically',
'Points pooled by nationality',
'Adds international rivalry element',
'Great for diverse, international leagues',
],
},
enableTrophyChampionship: {
title: 'Trophy Championship',
description: 'A special category championship for specific classes or groups.',
details: [
'Custom category you define (e.g., Am drivers, rookies)',
'Separate standings from main championship',
'Encourages participation from all skill levels',
'Can be used for gentleman drivers, newcomers, etc.',
],
},
};
export function ChampionshipsSection({
form,
onChange,
readOnly,
}: ChampionshipsSectionProps) {
const disabled = readOnly || !onChange;
const isTeamsMode = form.structure.mode === 'fixedTeams';
const [showChampFlyout, setShowChampFlyout] = useState(false);
const [activeChampFlyout, setActiveChampFlyout] = useState<string | null>(null);
const champInfoRef = useRef<HTMLButtonElement>(null!);
const champItemRefs = useRef<Record<string, HTMLElement | null>>({});
const updateChampionship = (key: keyof LeagueConfigFormModel['championships'], value: boolean) => {
if (!onChange) return;
onChange({
...form,
championships: {
...form.championships,
[key]: value
}
});
};
const championships = [
{
key: 'enableDriverChampionship' as const,
icon: Trophy,
label: 'Driver Standings',
description: 'Track individual driver points',
enabled: form.championships.enableDriverChampionship,
available: true,
},
{
key: 'enableTeamChampionship' as const,
icon: Award,
label: 'Team Standings',
description: 'Combined team points',
enabled: form.championships.enableTeamChampionship,
available: isTeamsMode,
unavailableHint: 'Teams mode only',
},
{
key: 'enableNationsChampionship' as const,
icon: Globe,
label: 'Nations Cup',
description: 'By nationality',
enabled: form.championships.enableNationsChampionship,
available: true,
},
{
key: 'enableTrophyChampionship' as const,
icon: Medal,
label: 'Trophy Cup',
description: 'Special category',
enabled: form.championships.enableTrophyChampionship,
available: true,
},
];
return (
<Stack gap={4}>
{/* Section header */}
<Stack direction="row" align="center" gap={3}>
<Box width={10} height={10} display="flex" center rounded="xl" backgroundColor="primary-blue" opacity={0.1}>
<Icon icon={Award} size={5} color="text-primary-blue" />
</Box>
<Box flex={1}>
<Stack direction="row" align="center" gap={2}>
<Heading level={3}>Championships</Heading>
<InfoButton buttonRef={champInfoRef} onClick={() => setShowChampFlyout(true)} />
</Stack>
<Text size="xs" color="text-gray-500">What standings to track</Text>
</Box>
</Stack>
{/* Championships Flyout */}
<InfoFlyout
isOpen={showChampFlyout}
onClose={() => setShowChampFlyout(false)}
title="Championship Standings"
anchorRef={champInfoRef}
>
<Stack gap={4}>
<Text size="xs" color="text-gray-400">
Championships track cumulative points across all races. You can enable multiple
championship types to run different competitions simultaneously.
</Text>
<Box>
<Box mb={2} className="text-[10px] text-gray-500 uppercase tracking-wide">
<Text>Live Standings Example</Text>
</Box>
<ChampionshipMockup />
</Box>
<Stack gap={2}>
<Box className="text-[10px] text-gray-500 uppercase tracking-wide">
<Text>Championship Types</Text>
</Box>
<Grid cols={2} gap={2}>
{[
{ icon: Trophy, label: 'Driver', desc: 'Individual points' },
{ icon: Award, label: 'Team', desc: 'Combined drivers' },
{ icon: Globe, label: 'Nations', desc: 'By country' },
{ icon: Medal, label: 'Trophy', desc: 'Special class' },
].map((t, i) => (
<Surface key={i} variant="dark" border rounded="lg" padding={2}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={t.icon} size={3.5} color="text-primary-blue" />
<Box>
<Text className="text-[10px]" weight="medium" color="text-white" block>{t.label}</Text>
<Text className="text-[9px] text-gray-500" block>{t.desc}</Text>
</Box>
</Stack>
</Surface>
))}
</Grid>
</Stack>
</Stack>
</InfoFlyout>
{/* Inline toggle grid */}
<Grid cols={2} gap={2}>
{championships.map((champ) => {
const isEnabled = champ.enabled && champ.available;
const champInfo = CHAMPIONSHIP_INFO[champ.key];
return (
<Box key={champ.key} position="relative">
<Button
variant="ghost"
disabled={disabled || !champ.available}
onClick={() => champ.available && updateChampionship(champ.key, !champ.enabled)}
className={`
w-full flex items-center gap-3 p-3 rounded-xl border-2 text-left transition-all duration-200 h-auto
${isEnabled
? 'border-primary-blue/40 bg-primary-blue/10'
: champ.available
? 'border-charcoal-outline/40 bg-iron-gray/20 hover:border-charcoal-outline hover:bg-iron-gray/30'
: 'border-charcoal-outline/20 bg-iron-gray/10 opacity-50 cursor-not-allowed'
}
`}
>
{/* Toggle indicator */}
<Box
width={5}
height={5}
display="flex"
center
rounded="md"
backgroundColor={isEnabled ? 'primary-blue' : 'charcoal-outline'}
opacity={isEnabled ? 1 : 0.5}
className="shrink-0 transition-colors"
>
{isEnabled && <Icon icon={Check} size={3} color="text-white" />}
</Box>
{/* Icon */}
<Icon icon={champ.icon} size={4} color={isEnabled ? 'text-primary-blue' : 'text-gray-500'} className="shrink-0" />
{/* Text */}
<Box flex={1} className="min-w-0">
<Text className={`text-xs font-medium truncate ${isEnabled ? 'text-white' : 'text-gray-400'}`} block>
{champ.label}
</Text>
{!champ.available && champ.unavailableHint && (
<Text className="text-[10px] text-warning-amber/70" block>{champ.unavailableHint}</Text>
)}
</Box>
{/* Info button */}
<Box
ref={(el: any) => { champItemRefs.current[champ.key] = el; }}
role="button"
tabIndex={0}
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
setActiveChampFlyout(activeChampFlyout === champ.key ? null : champ.key);
}}
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
setActiveChampFlyout(activeChampFlyout === champ.key ? null : champ.key);
}
}}
className="flex h-5 w-5 items-center justify-center rounded-full text-gray-500 hover:text-primary-blue hover:bg-primary-blue/10 transition-colors shrink-0"
>
<Icon icon={HelpCircle} size={3} />
</Box>
</Button>
{/* Championship Item Info Flyout */}
{champInfo && (
<InfoFlyout
isOpen={activeChampFlyout === champ.key}
onClose={() => setActiveChampFlyout(null)}
title={champInfo.title}
anchorRef={{ current: champItemRefs.current[champ.key] ?? champInfoRef.current }}
>
<Stack gap={4}>
<Text size="xs" color="text-gray-400">{champInfo.description}</Text>
<Stack gap={2}>
<Box className="text-[10px] text-gray-500 uppercase tracking-wide">
<Text>How It Works</Text>
</Box>
<Box as="ul" className="space-y-1.5">
{champInfo.details.map((detail, idx) => (
<Box as="li" key={idx} display="flex" align="start" gap={2}>
<Icon icon={Check} size={3} color="text-performance-green" className="mt-0.5" />
<Text size="xs" color="text-gray-400">{detail}</Text>
</Box>
))}
</Box>
</Stack>
{!champ.available && (
<Surface variant="muted" border rounded="lg" padding={3} className="bg-warning-amber/5 border-warning-amber/20">
<Stack direction="row" align="start" gap={2}>
<Icon icon={Zap} size={3.5} color="text-warning-amber" className="mt-0.5" />
<Text className="text-[11px] text-gray-400">
<Text weight="medium" color="text-warning-amber">Note:</Text> {champ.unavailableHint}. Switch to Teams mode to enable this championship.
</Text>
</Stack>
</Surface>
)}
</Stack>
</InfoFlyout>
)}
</Box>
);
})}
</Grid>
</Stack>
);
}