Files
gridpilot.gg/apps/website/components/leagues/LeagueScoringSection.tsx
2026-01-15 17:12:24 +01:00

1440 lines
55 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 } 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 { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
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}
w="380px"
bg="bg-iron-gray"
border
borderColor="border-charcoal-outline"
rounded="xl"
// eslint-disable-next-line gridpilot-rules/component-classification
className="max-h-[80vh] overflow-y-auto shadow-2xl animate-fade-in"
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ top: position.top, left: position.left }}
>
{/* Header */}
<Box display="flex" alignItems="center" justifyContent="between" p={4} borderBottom borderColor="border-charcoal-outline" 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>
<Button
variant="ghost"
size="sm"
onClick={onClose}
// eslint-disable-next-line gridpilot-rules/component-classification
className="h-6 w-6 p-0"
>
<Icon icon={X} size={4} color="text-gray-400" />
</Button>
</Box>
{/* Content */}
<Box p={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}
// eslint-disable-next-line gridpilot-rules/component-classification
className="h-5 w-5 p-0 rounded-full text-gray-500 hover:text-primary-blue hover:bg-primary-blue/10"
>
<Icon icon={HelpCircle} size={3.5} />
</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" p={4}>
<Stack gap={3}>
<Box display="flex" alignItems="center" justifyContent="between" px={1}>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
>
Position
</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
>
Points
</Text>
</Box>
{positions.map((p) => (
<Stack key={p.pos} direction="row" align="center" gap={3}>
<Box w="8" h="8" rounded="lg" bg={p.color} display="flex" alignItems="center" justifyContent="center">
<Text size="sm" weight="bold" color={p.pos <= 3 ? 'text-deep-graphite' : 'text-gray-400'}>P{p.pos}</Text>
</Box>
<Box flexGrow={1} h="2" bg="bg-charcoal-outline" rounded="full" overflow="hidden" opacity={0.5}>
<Box
h="full"
bg="bg-gradient-to-r from-primary-blue to-neon-aqua"
rounded="full"
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ width: `${(p.pts / 25) * 100}%` }}
/>
</Box>
<Box w="8" textAlign="right">
<Text size="sm" font="mono" weight="semibold" color="text-white">{p.pts}</Text>
</Box>
</Stack>
))}
<Box display="flex" alignItems="center" justifyContent="center" gap={1} pt={2}>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
>
...
</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
>
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" p={4}>
<Stack gap={2}>
{bonuses.map((b, i) => (
<Surface key={i} variant="muted" border rounded="lg" p={2}>
<Stack direction="row" align="center" gap={3}>
<Text size="xl">{b.emoji}</Text>
<Box flexGrow={1}>
<Text size="xs" weight="medium" color="text-white" block>{b.label}</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="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" p={4}>
<Box display="flex" alignItems="center" gap={2} mb={3} pb={2} borderBottom borderColor="border-charcoal-outline" opacity={0.5}>
<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 w="6" h="6" rounded="full" display="flex" alignItems="center" justifyContent="center" bg={s.pos === 1 ? 'bg-yellow-500' : 'bg-charcoal-outline'} color={s.pos === 1 ? 'text-deep-graphite' : 'text-gray-400'}>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
weight="bold"
>
{s.pos}
</Text>
</Box>
<Box flexGrow={1}>
<Text size="xs" color="text-white" truncate block>{s.name}</Text>
</Box>
<Text size="xs" font="mono" weight="semibold" color="text-white">{s.pts}</Text>
{s.delta && (
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
font="mono"
color="text-gray-500"
>
{s.delta}
</Text>
)}
</Stack>
))}
</Stack>
<Box mt={3} pt={2} borderTop borderColor="border-charcoal-outline" opacity={0.5} textAlign="center">
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
>
Points accumulated across all races
</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}
// eslint-disable-next-line gridpilot-rules/component-classification
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 w="10" h="10" display="flex" alignItems="center" justifyContent="center" rounded="xl" bg="bg-primary-blue" opacity={0.1}>
<Icon icon={Trophy} size={5} color="text-primary-blue" />
</Box>
<Box flexGrow={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}>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
>
Example: F1-Style Points
</Text>
</Box>
<PointsSystemMockup />
</Box>
<Surface variant="muted" border rounded="lg" p={3}>
<Stack direction="row" align="start" gap={2}>
<Icon icon={Zap} size={3.5} color="text-primary-blue"
// eslint-disable-next-line gridpilot-rules/component-classification
className="mt-0.5"
/>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '11px' }}
color="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}
// eslint-disable-next-line gridpilot-rules/component-classification
className="lg:grid-cols-[1fr_auto]"
>
{/* Preset options */}
<Stack gap={2}>
{presets.length === 0 ? (
<Box p={4} border borderStyle="dashed" borderColor="border-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}
// eslint-disable-next-line gridpilot-rules/component-classification
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
w="5"
h="5"
display="flex"
alignItems="center"
justifyContent="center"
rounded="full"
border
borderColor={isSelected ? 'border-primary-blue' : 'border-gray-500'}
bg={isSelected ? 'bg-primary-blue' : 'transparent'}
transition
flexShrink={0}
>
{isSelected && <Icon icon={Check} size={3} color="text-white" />}
</Box>
{/* Emoji */}
<Text size="xl">{getPresetEmoji(preset)}</Text>
{/* Text */}
<Box flexGrow={1}
// eslint-disable-next-line gridpilot-rules/component-classification
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
// eslint-disable-next-line gridpilot-rules/component-classification
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: HTMLElement | null) => { 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);
}
}}
display="flex"
h="6"
w="6"
alignItems="center"
justifyContent="center"
rounded="full"
color="text-gray-500"
hoverTextColor="text-primary-blue"
hoverBg="bg-primary-blue/10"
transition
flexShrink={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>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
>
Key Features
</Text>
</Box>
<Box as="ul"
// eslint-disable-next-line gridpilot-rules/component-classification
className="space-y-1.5"
>
{presetInfo.details.map((detail, idx) => (
<Box as="li" key={idx} display="flex" alignItems="start" gap={2}>
<Icon icon={Check} size={3} color="text-performance-green"
// eslint-disable-next-line gridpilot-rules/component-classification
className="mt-0.5"
/>
<Text size="xs" color="text-gray-400">{detail}</Text>
</Box>
))}
</Box>
</Stack>
{preset.bonusSummary && (
<Surface variant="muted" border rounded="lg" p={3}>
<Stack direction="row" align="start" gap={2}>
<Icon icon={Zap} size={3.5} color="text-primary-blue"
// eslint-disable-next-line gridpilot-rules/component-classification
className="mt-0.5"
/>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '11px' }}
color="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 w="full"
// eslint-disable-next-line gridpilot-rules/component-classification
className="lg:w-48"
>
<Button
variant="ghost"
onClick={onToggleCustomScoring}
disabled={!onToggleCustomScoring || readOnly}
// eslint-disable-next-line gridpilot-rules/component-classification
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
w="10"
h="10"
display="flex"
alignItems="center"
justifyContent="center"
rounded="xl"
bg={isCustom ? 'bg-primary-blue' : 'bg-charcoal-outline'}
opacity={isCustom ? 0.2 : 0.3}
transition
>
<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
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
block
>
Define your own
</Text>
</Box>
{isCustom && (
<Box display="flex" alignItems="center" gap={1} px={2} py={0.5} rounded="full" bg="bg-primary-blue" opacity={0.2}>
<Icon icon={Check} size={2.5} color="text-primary-blue" />
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '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" p={4}>
<Stack gap={4}>
{/* Header with reset button */}
<Box display="flex" alignItems="center" justifyContent="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}>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
>
Available Bonuses
</Text>
</Box>
<BonusPointsMockup />
</Box>
<Surface variant="muted" border rounded="lg" p={3}>
<Stack direction="row" align="start" gap={2}>
<Icon icon={Zap} size={3.5} color="text-primary-blue"
// eslint-disable-next-line gridpilot-rules/component-classification
className="mt-0.5"
/>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '11px' }}
color="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}
// eslint-disable-next-line gridpilot-rules/component-classification
className="h-auto py-1 px-2 text-[10px] text-gray-400 hover:text-primary-blue hover:bg-primary-blue/10"
>
<Stack direction="row" align="center" gap={1}>
<Icon icon={RotateCcw} size={3} />
<Text>Reset</Text>
</Stack>
</Button>
</Box>
{/* Race position points */}
<Stack gap={2}>
<Box display="flex" alignItems="center" justifyContent="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}
// eslint-disable-next-line gridpilot-rules/component-classification
className="h-5 w-5 p-0"
>
<Icon icon={Minus} size={3} />
</Button>
<Button
variant="secondary"
size="sm"
onClick={addPosition}
disabled={readOnly || customPoints.racePoints.length >= 20}
// eslint-disable-next-line gridpilot-rules/component-classification
className="h-5 w-5 p-0"
>
<Icon icon={Plus} size={3} />
</Button>
</Stack>
</Box>
<Box display="flex" flexWrap="wrap" gap={1}>
{customPoints.racePoints.map((pts, idx) => (
<Stack key={idx} align="center">
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '9px' }}
color="text-gray-500"
mb={0.5}
>
P{idx + 1}
</Text>
<Box display="flex" alignItems="center">
<Button
variant="secondary"
size="sm"
onClick={() => updateRacePoints(idx, -1)}
disabled={readOnly || pts <= 0}
// eslint-disable-next-line gridpilot-rules/component-classification
className="h-5 w-4 p-0 rounded-r-none text-[10px]"
>
</Button>
<Box w="6" h="5" display="flex" alignItems="center" justifyContent="center" bg="bg-deep-graphite" border borderTop borderBottom borderColor="border-charcoal-outline">
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
weight="medium"
color="text-white"
>
{pts}
</Text>
</Box>
<Button
variant="secondary"
size="sm"
onClick={() => updateRacePoints(idx, 1)}
disabled={readOnly}
// eslint-disable-next-line gridpilot-rules/component-classification
className="h-5 w-4 p-0 rounded-l-none text-[10px]"
>
+
</Button>
</Box>
</Stack>
))}
</Box>
</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
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
>
{bonus.emoji} {bonus.label}
</Text>
<Box display="flex" alignItems="center">
<Button
variant="secondary"
size="sm"
onClick={() => updateBonus(bonus.key, -1)}
disabled={readOnly || customPoints[bonus.key] <= 0}
// eslint-disable-next-line gridpilot-rules/component-classification
className="h-6 w-5 p-0 rounded-r-none"
>
</Button>
<Box w="7" h="6" display="flex" alignItems="center" justifyContent="center" bg="bg-deep-graphite" border borderTop borderBottom borderColor="border-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}
// eslint-disable-next-line gridpilot-rules/component-classification
className="h-6 w-5 p-0 rounded-l-none"
>
+
</Button>
</Box>
</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 w="10" h="10" display="flex" alignItems="center" justifyContent="center" rounded="xl" bg="bg-primary-blue" opacity={0.1}>
<Icon icon={Award} size={5} color="text-primary-blue" />
</Box>
<Box flexGrow={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}>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
>
Live Standings Example
</Text>
</Box>
<ChampionshipMockup />
</Box>
<Stack gap={2}>
<Box>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
>
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" p={2}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={t.icon} size={3.5} color="text-primary-blue" />
<Box>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
weight="medium"
color="text-white"
block
>
{t.label}
</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '9px' }}
color="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)}
// eslint-disable-next-line gridpilot-rules/component-classification
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
w="5"
h="5"
display="flex"
alignItems="center"
justifyContent="center"
rounded="md"
bg={isEnabled ? 'bg-primary-blue' : 'bg-charcoal-outline'}
opacity={isEnabled ? 1 : 0.5}
transition
flexShrink={0}
>
{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'}
// eslint-disable-next-line gridpilot-rules/component-classification
className="shrink-0"
/>
{/* Text */}
<Box flexGrow={1}
// eslint-disable-next-line gridpilot-rules/component-classification
className="min-w-0"
>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
className={`text-xs font-medium truncate ${isEnabled ? 'text-white' : 'text-gray-400'}`}
block
>
{champ.label}
</Text>
{!champ.available && champ.unavailableHint && (
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-warning-amber"
opacity={0.7}
block
>
{champ.unavailableHint}
</Text>
)}
</Box>
{/* Info button */}
<Box
ref={(el: HTMLElement | null) => { 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);
}
}}
display="flex"
h="5"
w="5"
alignItems="center"
justifyContent="center"
rounded="full"
color="text-gray-500"
hoverTextColor="text-primary-blue"
hoverBg="bg-primary-blue/10"
transition
flexShrink={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] as HTMLElement | null) ?? champInfoRef.current }}
>
<Stack gap={4}>
<Text size="xs" color="text-gray-400">{champInfo.description}</Text>
<Stack gap={2}>
<Box>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
>
How It Works
</Text>
</Box>
<Box as="ul"
// eslint-disable-next-line gridpilot-rules/component-classification
className="space-y-1.5"
>
{champInfo.details.map((detail, idx) => (
<Box as="li" key={idx} display="flex" alignItems="start" gap={2}>
<Icon icon={Check} size={3} color="text-performance-green"
// eslint-disable-next-line gridpilot-rules/component-classification
className="mt-0.5"
/>
<Text size="xs" color="text-gray-400">{detail}</Text>
</Box>
))}
</Box>
</Stack>
{!champ.available && (
<Surface variant="muted" border rounded="lg" p={3}
// eslint-disable-next-line gridpilot-rules/component-classification
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"
// eslint-disable-next-line gridpilot-rules/component-classification
className="mt-0.5"
/>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '11px' }}
color="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>
);
}