1440 lines
55 KiB
TypeScript
1440 lines
55 KiB
TypeScript
'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>
|
||
);
|
||
}
|