'use client'; import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel'; import { Box } from '@/ui/Box'; import { Heading } from '@/ui/Heading'; import { Icon } from '@/ui/Icon'; import { Stack } from '@/ui/Stack'; import { Text } from '@/ui/Text'; import { Check, HelpCircle, TrendingDown, X, Zap } from 'lucide-react'; import React, { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; // ============================================================================ // INFO FLYOUT (duplicated for self-contained component) // ============================================================================ interface InfoFlyoutProps { isOpen: boolean; onClose: () => void; title: string; children: React.ReactNode; anchorRef: React.RefObject; } 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(380, window.innerWidth - 40); const flyoutHeight = 450; const padding = 16; let left = rect.right + 12; let top = rect.top; if (left + flyoutWidth > window.innerWidth - padding) { left = rect.left - flyoutWidth - 12; } if (left < padding) { left = Math.max(padding, (window.innerWidth - flyoutWidth) / 2); } top = rect.top - flyoutHeight / 3; if (top + flyoutHeight > window.innerHeight - padding) { top = window.innerHeight - flyoutHeight - padding; } if (top < padding) top = padding; left = Math.max(padding, Math.min(left, window.innerWidth - flyoutWidth - padding)); setPosition({ top, left }); } }, [isOpen, anchorRef, mounted]); useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; const handleClickOutside = (e: MouseEvent) => { if (flyoutRef.current && !flyoutRef.current.contains(e.target as Node)) { onClose(); } }; if (isOpen) { document.addEventListener('keydown', handleEscape); document.addEventListener('mousedown', handleClickOutside); } return () => { document.removeEventListener('keydown', handleEscape); document.removeEventListener('mousedown', handleClickOutside); }; }, [isOpen, onClose]); if (!isOpen) return null; return createPortal( {title} {children} , document.body ); } function InfoButton({ onClick, buttonRef }: { onClick: () => void; buttonRef: React.Ref }) { return ( ); } // Drop Rules Mockup function DropRulesMockup() { const results = [ { round: 'R1', pts: 25, dropped: false }, { round: 'R2', pts: 18, dropped: false }, { round: 'R3', pts: 4, dropped: true }, { round: 'R4', pts: 15, dropped: false }, { round: 'R5', pts: 12, dropped: false }, { round: 'R6', pts: 0, dropped: true }, ]; const total = results.filter(r => !r.dropped).reduce((sum, r) => sum + r.pts, 0); const wouldBe = results.reduce((sum, r) => sum + r.pts, 0); return ( Best 4 of 6 Results {results.map((r, i) => ( {r.round} {r.pts} ))} Total counted: {total} pts Without drops: {wouldBe} pts ); } interface LeagueDropSectionProps { form: LeagueConfigFormModel; onChange?: (form: LeagueConfigFormModel) => void; readOnly?: boolean; } type DropStrategy = 'none' | 'bestNResults' | 'dropWorstN'; // Drop rule info content const DROP_RULE_INFO: Record = { none: { title: 'All Results Count', description: 'Every race result affects the championship standings with no exceptions.', details: [ 'All race results count toward final standings', 'No safety net for bad races or DNFs', 'Rewards consistency across entire season', 'Best for shorter seasons (4-6 races)', ], example: '8 races × your points = your total', }, bestNResults: { title: 'Best N Results', description: 'Only your top N race results count toward the championship.', details: [ 'Choose how many of your best races count', 'Extra races become "bonus" opportunities', 'Protects against occasional bad days', 'Encourages trying even when behind', ], example: 'Best 6 of 8 races count', }, dropWorstN: { title: 'Drop Worst N Results', description: 'Your N worst race results are excluded from championship calculations.', details: [ 'Automatically removes your worst performances', 'Great for handling DNFs or incidents', 'All other races count normally', 'Common in real-world championships', ], example: 'Drop 2 worst → 6 of 8 count', }, }; const DROP_OPTIONS: Array<{ value: DropStrategy; label: string; emoji: string; description: string; defaultN?: number; }> = [ { value: 'none', label: 'All count', emoji: '✓', description: 'Every race counts', }, { value: 'bestNResults', label: 'Best N', emoji: '🏆', description: 'Only best results', defaultN: 6, }, { value: 'dropWorstN', label: 'Drop worst', emoji: '🗑️', description: 'Exclude worst races', defaultN: 2, }, ]; export function LeagueDropSection({ form, onChange, readOnly, }: LeagueDropSectionProps) { const disabled = readOnly || !onChange; const dropPolicy = form.dropPolicy || { strategy: 'none' as const }; const [showDropFlyout, setShowDropFlyout] = useState(false); const [activeDropRuleFlyout, setActiveDropRuleFlyout] = useState(null); const dropInfoRef = useRef(null!); const dropRuleRefs = useRef>({ none: null, bestNResults: null, dropWorstN: null, }); const handleStrategyChange = (strategy: DropStrategy) => { if (disabled || !onChange) return; const option = DROP_OPTIONS.find((o) => o.value === strategy); const next: LeagueConfigFormModel = { ...form, dropPolicy: strategy === 'none' ? { strategy, } : { strategy, n: dropPolicy.n ?? option?.defaultN ?? 1, }, }; onChange(next); }; const handleNChange = (delta: number) => { if (disabled || !onChange || dropPolicy.strategy === 'none') return; const current = dropPolicy.n ?? 1; const newValue = Math.max(1, current + delta); onChange({ ...form, dropPolicy: { ...dropPolicy, n: newValue, }, }); }; const needsN = dropPolicy.strategy !== 'none'; return ( {/* Section header */} Drop Rules setShowDropFlyout(true)} /> Protect from bad races {/* Drop Rules Flyout */} setShowDropFlyout(false)} title="Drop Rules Explained" anchorRef={dropInfoRef} > Drop rules allow drivers to exclude their worst results from championship calculations. This protects against mechanical failures, bad luck, or occasional poor performances. Visual Example Drop Strategies All Count Every race affects standings. Best for short seasons. 🏆 Best N Results Only your top N races count. Extra races are optional. 🗑️ Drop Worst N Exclude your N worst results. Forgives bad days. Pro tip: For an 8-round season, "Best 6" or "Drop 2" are popular choices. {/* Strategy buttons + N stepper inline */} {DROP_OPTIONS.map((option) => { const isSelected = dropPolicy.strategy === option.value; const ruleInfo = DROP_RULE_INFO[option.value]; return ( handleStrategyChange(option.value)} display="flex" alignItems="center" gap={2} px={3} py={2} rounded="lg" border borderWidth="2px" transition borderColor={isSelected ? 'border-primary-blue' : 'border-charcoal-outline/40'} bg={isSelected ? 'bg-primary-blue/10' : 'bg-iron-gray/20'} hoverBorderColor={!isSelected && !disabled ? 'border-charcoal-outline' : undefined} hoverBg={!isSelected && !disabled ? 'bg-iron-gray/30' : undefined} cursor={disabled ? 'default' : 'pointer'} opacity={disabled ? 0.6 : 1} // eslint-disable-next-line gridpilot-rules/component-classification style={{ borderRightWidth: 0, borderTopRightRadius: 0, borderBottomRightRadius: 0 }} > {/* Radio indicator */} {isSelected && } {option.emoji} {option.label} {/* Info button - separate from main button */} { dropRuleRefs.current[option.value] = el; }} type="button" onClick={(e: React.MouseEvent) => { e.stopPropagation(); setActiveDropRuleFlyout(activeDropRuleFlyout === option.value ? null : option.value); }} display="flex" alignItems="center" justifyContent="center" px={2} py={2} rounded="lg" border borderWidth="2px" transition borderColor={isSelected ? 'border-primary-blue' : 'border-charcoal-outline/40'} bg={isSelected ? 'bg-primary-blue/10' : 'bg-iron-gray/20'} hoverBorderColor={!isSelected && !disabled ? 'border-charcoal-outline' : undefined} hoverBg={!isSelected && !disabled ? 'bg-iron-gray/30' : undefined} cursor={disabled ? 'default' : 'pointer'} opacity={disabled ? 0.6 : 1} // eslint-disable-next-line gridpilot-rules/component-classification style={{ borderLeftWidth: 0, borderTopLeftRadius: 0, borderBottomLeftRadius: 0, height: '100%' }} > {/* Drop Rule Info Flyout */} setActiveDropRuleFlyout(null)} title={ruleInfo.title} anchorRef={{ current: (dropRuleRefs.current[option.value] as HTMLElement | null) ?? dropInfoRef.current }} > {ruleInfo.description} How It Works {ruleInfo.details.map((detail, idx) => ( {detail} ))} {option.emoji} Example {ruleInfo.example} ); })} {/* N Stepper - only show when needed */} {needsN && ( N = handleNChange(-1)} display="flex" h="7" w="7" alignItems="center" justifyContent="center" rounded="md" bg="bg-iron-gray" border borderColor="border-charcoal-outline" color="text-gray-400" transition hoverTextColor={!disabled ? 'text-white' : undefined} hoverBorderColor={!disabled ? 'border-primary-blue' : undefined} opacity={disabled || (dropPolicy.n ?? 1) <= 1 ? 0.4 : 1} > − {dropPolicy.n ?? 1} handleNChange(1)} display="flex" h="7" w="7" alignItems="center" justifyContent="center" rounded="md" bg="bg-iron-gray" border borderColor="border-charcoal-outline" color="text-gray-400" transition hoverTextColor={!disabled ? 'text-white' : undefined} hoverBorderColor={!disabled ? 'border-primary-blue' : undefined} opacity={disabled ? 0.4 : 1} > + )} {/* Explanation text */} {dropPolicy.strategy === 'none' && 'Every race result affects the championship standings.'} {dropPolicy.strategy === 'bestNResults' && `Only your best ${dropPolicy.n ?? 1} results will count.`} {dropPolicy.strategy === 'dropWorstN' && `Your worst ${dropPolicy.n ?? 1} results will be excluded.`} ); }