'use client'; import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel'; import { Heading } from '@/ui/Heading'; import { Icon } from '@/ui/Icon'; import { Input } from '@/ui/Input'; import { Stack } from '@/ui/Stack'; import { Text } from '@/ui/Text'; import { Check, HelpCircle, User, Users2, X } from 'lucide-react'; import type * as React from 'react'; import { useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; // ============================================================================ // INFO FLYOUT COMPONENT // ============================================================================ interface InfoFlyoutProps { isOpen: boolean; onClose: () => void; title: string; children: React.ReactNode; anchorRef: React.RefObject; } function InfoFlyout({ isOpen, onClose, title, children, anchorRef }: InfoFlyoutProps) { const [position, setPosition] = useState({ top: 0, left: 0 }); const [mounted, setMounted] = useState(false); const flyoutRef = useRef(null); useEffect(() => { setMounted(true); }, []); useEffect(() => { if (isOpen && anchorRef.current && mounted) { const rect = anchorRef.current.getBoundingClientRect(); const flyoutWidth = Math.min(320, window.innerWidth - 40); const flyoutHeight = 300; const padding = 16; let left = rect.right + 12; let top = rect.top; if (left + flyoutWidth > window.innerWidth - padding) { left = rect.left - flyoutWidth - 12; } if (left < padding) { left = Math.max(padding, (window.innerWidth - flyoutWidth) / 2); } top = rect.top - flyoutHeight / 3; if (top + flyoutHeight > window.innerHeight - padding) { top = window.innerHeight - flyoutHeight - padding; } if (top < padding) top = padding; left = Math.max(padding, Math.min(left, window.innerWidth - flyoutWidth - padding)); setPosition({ top, left }); } }, [isOpen, anchorRef, mounted]); useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; const handleClickOutside = (e: MouseEvent) => { if (flyoutRef.current && !flyoutRef.current.contains(e.target as Node)) { onClose(); } }; if (isOpen) { document.addEventListener('keydown', handleEscape); document.addEventListener('mousedown', handleClickOutside); } return () => { document.removeEventListener('keydown', handleEscape); document.removeEventListener('mousedown', handleClickOutside); }; }, [isOpen, onClose]); if (!isOpen || !mounted) return null; return createPortal( {title} {children} , document.body ); } interface LeagueStructureSectionProps { form: LeagueConfigFormModel; onChange?: (form: LeagueConfigFormModel) => void; readOnly?: boolean; } export function LeagueStructureSection({ form, onChange, readOnly, }: LeagueStructureSectionProps) { const disabled = readOnly || !onChange; const structure = form.structure; const updateStructure = ( patch: Partial, ) => { if (!onChange) return; const nextStructure = { ...structure, ...patch, }; let nextForm: LeagueConfigFormModel = { ...form, structure: nextStructure, }; if (nextStructure.mode === 'fixedTeams') { const maxTeams = typeof nextStructure.maxTeams === 'number' && nextStructure.maxTeams > 0 ? nextStructure.maxTeams : 1; const driversPerTeam = typeof nextStructure.driversPerTeam === 'number' && nextStructure.driversPerTeam > 0 ? nextStructure.driversPerTeam : 1; const maxDrivers = maxTeams * driversPerTeam; nextForm = { ...nextForm, structure: { ...nextStructure, maxTeams, driversPerTeam, maxDrivers, }, }; } if (nextStructure.mode === 'solo') { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { maxTeams, driversPerTeam, ...restStructure } = nextStructure; nextForm = { ...nextForm, structure: restStructure, }; } onChange(nextForm); }; const handleModeChange = (mode: 'solo' | 'fixedTeams') => { if (mode === structure.mode) return; if (mode === 'solo') { updateStructure({ mode: 'solo', maxDrivers: structure.maxDrivers || 24, }); } else { const maxTeams = structure.maxTeams ?? 12; const driversPerTeam = structure.driversPerTeam ?? 2; updateStructure({ mode: 'fixedTeams', maxTeams, driversPerTeam, maxDrivers: maxTeams * driversPerTeam, }); } }; const handleMaxDriversChange = (value: string) => { const parsed = parseInt(value, 10); updateStructure({ maxDrivers: Number.isNaN(parsed) ? 0 : parsed, }); }; const handleMaxTeamsChange = (value: string) => { const parsed = parseInt(value, 10); const maxTeams = Number.isNaN(parsed) ? 0 : parsed; const driversPerTeam = structure.driversPerTeam ?? 2; updateStructure({ maxTeams, driversPerTeam, maxDrivers: maxTeams > 0 && driversPerTeam > 0 ? maxTeams * driversPerTeam : structure.maxDrivers, }); }; const handleDriversPerTeamChange = (value: string) => { const parsed = parseInt(value, 10); const driversPerTeam = Number.isNaN(parsed) ? 0 : parsed; const maxTeams = structure.maxTeams ?? 12; updateStructure({ driversPerTeam, maxTeams, maxDrivers: maxTeams > 0 && driversPerTeam > 0 ? maxTeams * driversPerTeam : structure.maxDrivers, }); }; // Flyout state const [showSoloFlyout, setShowSoloFlyout] = useState(false); const [showTeamsFlyout, setShowTeamsFlyout] = useState(false); const soloInfoRef = useRef(null!); const teamsInfoRef = useRef(null!); const isSolo = structure.mode === 'solo'; // Get game-specific constraints const gameConstraints = useMemo( () => ({ minDrivers: 1, maxDrivers: 100, defaultMaxDrivers: 24, minTeams: 1, maxTeams: 50, minDriversPerTeam: 1, maxDriversPerTeam: 10, }), [] ); return ( {/* Emotional header */} How will your drivers compete? Choose your championship format — individual glory or team triumph. {/* Mode Selection Cards */} {/* Solo Mode Card */} handleModeChange('solo')} display="flex" flexDirection="col" gap={4} p={6} textAlign="left" rounded="xl" border borderColor={isSolo ? 'border-primary-blue' : 'border-charcoal-outline'} bg={isSolo ? 'bg-primary-blue/15' : 'bg-iron-gray/30'} w="full" position="relative" transition shadow={isSolo ? '0_0_30px_rgba(25,140,255,0.25)' : undefined} hoverBorderColor={!isSolo && !disabled ? 'border-gray-500' : undefined} hoverBg={!isSolo && !disabled ? 'bg-iron-gray/50' : undefined} opacity={disabled ? 0.6 : 1} cursor={disabled ? 'not-allowed' : 'pointer'} group > {/* Header */} Solo Drivers Individual competition {/* Radio indicator */} {isSolo && } {/* Emotional tagline */} Every driver for themselves. Pure skill, pure competition, pure glory. {/* Features */} Individual driver standings Simple, classic format Perfect for any grid size {/* Info button */} setShowSoloFlyout(true)} position="absolute" top="2" right="2" display="flex" h="6" w="6" alignItems="center" justifyContent="center" rounded="full" transition color="text-gray-500" hoverTextColor="text-primary-blue" hoverBg="bg-primary-blue/10" > {/* Solo Info Flyout */} setShowSoloFlyout(false)} title="Solo Drivers Mode" anchorRef={soloInfoRef} > In solo mode, each driver competes individually. Points are awarded based on finishing position, and standings track individual performance. Best For Traditional racing championships Smaller grids (10-30 drivers) Quick setup with minimal coordination {/* Teams Mode Card */} handleModeChange('fixedTeams')} display="flex" flexDirection="col" gap={4} p={6} textAlign="left" rounded="xl" border borderColor={!isSolo ? 'border-neon-aqua' : 'border-charcoal-outline'} bg={!isSolo ? 'bg-neon-aqua/15' : 'bg-iron-gray/30'} w="full" position="relative" transition shadow={!isSolo ? '0_0_30px_rgba(67,201,230,0.2)' : undefined} hoverBorderColor={isSolo && !disabled ? 'border-gray-500' : undefined} hoverBg={isSolo && !disabled ? 'bg-iron-gray/50' : undefined} opacity={disabled ? 0.6 : 1} cursor={disabled ? 'not-allowed' : 'pointer'} group > {/* Header */} Team Racing Shared destiny {/* Radio indicator */} {!isSolo && } {/* Emotional tagline */} Victory is sweeter together. Build a team, share the podium. {/* Features */} Team & driver standings Fixed roster per team Great for endurance & pro-am {/* Info button */} setShowTeamsFlyout(true)} position="absolute" top="2" right="2" display="flex" h="6" w="6" alignItems="center" justifyContent="center" rounded="full" transition color="text-gray-500" hoverTextColor="text-neon-aqua" hoverBg="bg-neon-aqua/10" > {/* Teams Info Flyout */} setShowTeamsFlyout(false)} title="Team Racing Mode" anchorRef={teamsInfoRef} > In team mode, drivers are grouped into fixed teams. Points contribute to both individual and team standings, creating deeper competition. Best For Endurance races with driver swaps Pro-Am style competitions Larger organized leagues {/* Configuration Panel */} {isSolo ? ( ) : ( )} {isSolo ? 'Grid size' : 'Team configuration'} {isSolo ? 'How many drivers can join your championship?' : 'Configure teams and roster sizes' } {/* Solo mode capacity */} {isSolo && ( Maximum drivers ) => handleMaxDriversChange(e.target.value)} disabled={disabled} min={gameConstraints.minDrivers} max={gameConstraints.maxDrivers} // eslint-disable-next-line gridpilot-rules/component-classification className="w-40" /> {form.basics.gameId.toUpperCase()} supports up to {gameConstraints.maxDrivers} drivers Quick select: handleMaxDriversChange('16')} disabled={disabled || 16 > gameConstraints.maxDrivers} p={2} px={3} rounded="lg" bg={structure.maxDrivers === 16 ? 'bg-primary-blue' : 'bg-iron-gray'} border borderColor={structure.maxDrivers === 16 ? 'border-primary-blue' : 'border-charcoal-outline'} color={structure.maxDrivers === 16 ? 'text-white' : 'text-gray-300'} hoverTextColor={structure.maxDrivers !== 16 ? 'text-primary-blue' : undefined} hoverBorderColor={structure.maxDrivers !== 16 ? 'border-primary-blue' : undefined} transition > Compact (16) handleMaxDriversChange('24')} disabled={disabled || 24 > gameConstraints.maxDrivers} p={2} px={3} rounded="lg" bg={structure.maxDrivers === 24 ? 'bg-primary-blue' : 'bg-iron-gray'} border borderColor={structure.maxDrivers === 24 ? 'border-primary-blue' : 'border-charcoal-outline'} color={structure.maxDrivers === 24 ? 'text-white' : 'text-gray-300'} hoverTextColor={structure.maxDrivers !== 24 ? 'text-primary-blue' : undefined} hoverBorderColor={structure.maxDrivers !== 24 ? 'border-primary-blue' : undefined} transition > Standard (24) handleMaxDriversChange('30')} disabled={disabled || 30 > gameConstraints.maxDrivers} p={2} px={3} rounded="lg" bg={structure.maxDrivers === 30 ? 'bg-primary-blue' : 'bg-iron-gray'} border borderColor={structure.maxDrivers === 30 ? 'border-primary-blue' : 'border-charcoal-outline'} color={structure.maxDrivers === 30 ? 'text-white' : 'text-gray-300'} hoverTextColor={structure.maxDrivers !== 30 ? 'text-primary-blue' : undefined} hoverBorderColor={structure.maxDrivers !== 30 ? 'border-primary-blue' : undefined} transition > Full Grid (30) handleMaxDriversChange('40')} disabled={disabled || 40 > gameConstraints.maxDrivers} p={2} px={3} rounded="lg" bg={structure.maxDrivers === 40 ? 'bg-primary-blue' : 'bg-iron-gray'} border borderColor={structure.maxDrivers === 40 ? 'border-primary-blue' : 'border-charcoal-outline'} color={structure.maxDrivers === 40 ? 'text-white' : 'text-gray-300'} hoverTextColor={structure.maxDrivers !== 40 ? 'text-primary-blue' : undefined} hoverBorderColor={structure.maxDrivers !== 40 ? 'border-primary-blue' : undefined} transition > Large (40) handleMaxDriversChange(String(gameConstraints.maxDrivers))} disabled={disabled} p={2} px={3} rounded="lg" bg={structure.maxDrivers === gameConstraints.maxDrivers ? 'bg-primary-blue' : 'bg-iron-gray'} border borderColor={structure.maxDrivers === gameConstraints.maxDrivers ? 'border-primary-blue' : 'border-charcoal-outline'} color={structure.maxDrivers === gameConstraints.maxDrivers ? 'text-white' : 'text-gray-300'} hoverTextColor={structure.maxDrivers !== gameConstraints.maxDrivers ? 'text-primary-blue' : undefined} hoverBorderColor={structure.maxDrivers !== gameConstraints.maxDrivers ? 'border-primary-blue' : undefined} transition > Max ({gameConstraints.maxDrivers}) )} {/* Teams mode capacity */} {!isSolo && ( {/* Quick presets */} Popular configurations: { handleMaxTeamsChange('10'); handleDriversPerTeamChange('2'); }} disabled={disabled} p={2} px={3} rounded="lg" bg={structure.maxTeams === 10 && structure.driversPerTeam === 2 ? 'bg-neon-aqua' : 'bg-iron-gray'} border borderColor={structure.maxTeams === 10 && structure.driversPerTeam === 2 ? 'border-neon-aqua' : 'border-charcoal-outline'} color={structure.maxTeams === 10 && structure.driversPerTeam === 2 ? 'text-deep-graphite' : 'text-gray-300'} hoverTextColor={!(structure.maxTeams === 10 && structure.driversPerTeam === 2) ? 'text-neon-aqua' : undefined} hoverBorderColor={!(structure.maxTeams === 10 && structure.driversPerTeam === 2) ? 'border-neon-aqua' : undefined} transition > 10 × 2 (20 grid) { handleMaxTeamsChange('12'); handleDriversPerTeamChange('2'); }} disabled={disabled} p={2} px={3} rounded="lg" bg={structure.maxTeams === 12 && structure.driversPerTeam === 2 ? 'bg-neon-aqua' : 'bg-iron-gray'} border borderColor={structure.maxTeams === 12 && structure.driversPerTeam === 2 ? 'border-neon-aqua' : 'border-charcoal-outline'} color={structure.maxTeams === 12 && structure.driversPerTeam === 2 ? 'text-deep-graphite' : 'text-gray-300'} hoverTextColor={!(structure.maxTeams === 12 && structure.driversPerTeam === 2) ? 'text-neon-aqua' : undefined} hoverBorderColor={!(structure.maxTeams === 12 && structure.driversPerTeam === 2) ? 'border-neon-aqua' : undefined} transition > 12 × 2 (24 grid) { handleMaxTeamsChange('8'); handleDriversPerTeamChange('3'); }} disabled={disabled} p={2} px={3} rounded="lg" bg={structure.maxTeams === 8 && structure.driversPerTeam === 3 ? 'bg-neon-aqua' : 'bg-iron-gray'} border borderColor={structure.maxTeams === 8 && structure.driversPerTeam === 3 ? 'border-neon-aqua' : 'border-charcoal-outline'} color={structure.maxTeams === 8 && structure.driversPerTeam === 3 ? 'text-deep-graphite' : 'text-gray-300'} hoverTextColor={!(structure.maxTeams === 8 && structure.driversPerTeam === 3) ? 'text-neon-aqua' : undefined} hoverBorderColor={!(structure.maxTeams === 8 && structure.driversPerTeam === 3) ? 'border-neon-aqua' : undefined} transition > 8 × 3 (24 grid) { handleMaxTeamsChange('15'); handleDriversPerTeamChange('2'); }} disabled={disabled} p={2} px={3} rounded="lg" bg={structure.maxTeams === 15 && structure.driversPerTeam === 2 ? 'bg-neon-aqua' : 'bg-iron-gray'} border borderColor={structure.maxTeams === 15 && structure.driversPerTeam === 2 ? 'border-neon-aqua' : 'border-charcoal-outline'} color={structure.maxTeams === 15 && structure.driversPerTeam === 2 ? 'text-deep-graphite' : 'text-gray-300'} hoverTextColor={!(structure.maxTeams === 15 && structure.driversPerTeam === 2) ? 'text-neon-aqua' : undefined} hoverBorderColor={!(structure.maxTeams === 15 && structure.driversPerTeam === 2) ? 'border-neon-aqua' : undefined} transition > 15 × 2 (30 grid) {/* Manual configuration */} Teams ) => handleMaxTeamsChange(e.target.value)} disabled={disabled} min={1} max={32} /> Drivers / team ) => handleDriversPerTeamChange(e.target.value)} disabled={disabled} min={1} max={6} /> Total grid {structure.maxDrivers ?? 0} drivers )} ); }