'use client'; import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel'; import type { LeagueScoringPresetViewModel } from '@/lib/view-models/LeagueScoringPresetViewModel'; import type { CustomPointsConfig } from '@/lib/view-models/ScoringConfigurationViewModel'; import { Button } from '@/ui/Button'; import { Heading } from '@/ui/Heading'; import { Icon } from '@/ui/Icon'; import { Grid } from '@/ui/Grid'; import { Stack } from '@/ui/Stack'; import { Surface } from '@/ui/Surface'; import { Text } from '@/ui/Text'; import { Award, Check, Globe, HelpCircle, Medal, Minus, Plus, RotateCcw, Settings, Trophy, X, Zap } from 'lucide-react'; import React, { useEffect, 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(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( {/* Header */} {title} {/* Content */} {children} , document.body ); } function InfoButton({ onClick, buttonRef }: { onClick: () => void; buttonRef: React.Ref }) { return ( ); } // ============================================================================ // 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 ( Position Points {positions.map((p) => ( P{p.pos} {p.pts} ))} ... down to P10 = 1 point ); } 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 ( {bonuses.map((b, i) => ( {b.emoji} {b.label} {b.desc} {b.pts} ))} ); } 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 ( Driver Championship {standings.map((s) => ( {s.pos} {s.name} {s.pts} {s.delta && ( {s.delta} )} ))} Points accumulated across all races ); } 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 = ; const championshipsProps: ChampionshipsSectionProps = { form, readOnly: !!readOnly, }; if (onChange) { championshipsProps.onChange = onChange; } const championshipsPanel = ; if (patternOnly) { return {patternPanel}; } if (championshipsOnly) { return {championshipsPanel}; } return ( {patternPanel} {championshipsPanel} ); } /** * 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(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, 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(null); const pointsInfoRef = useRef(null!); const bonusInfoRef = useRef(null!); const presetInfoRefs = useRef>({}); return ( {/* Section header */} Points System setShowPointsFlyout(true)} /> Choose how points are awarded {/* Points System Flyout */} setShowPointsFlyout(false)} title="How Points Work" anchorRef={pointsInfoRef} > Points are awarded based on finishing position. Higher positions earn more points, which accumulate across the season to determine championship standings. Example: F1-Style Points Pro tip: Sprint formats award points in both races, typically with reduced points for the sprint. {/* Two-column layout: Presets | Custom */} {/* Preset options */} {presets.length === 0 ? ( Loading presets... ) : ( presets.map((preset) => { const isSelected = !isCustom && scoring.patternId === preset.id; const presetInfo = getPresetInfoContent(preset.name); return ( {/* Preset Info Flyout */} setActivePresetFlyout(null)} title={presetInfo.title} anchorRef={{ current: presetInfoRefs.current[preset.id] ?? pointsInfoRef.current }} > {presetInfo.description} Key Features {presetInfo.details.map((detail, idx) => ( {detail} ))} {preset.bonusSummary && ( Bonus points: {preset.bonusSummary} )} ); }) )} {/* Custom scoring option */} {/* Error message */} {patternError && ( {patternError} )} {/* Custom scoring editor - inline, no placeholder */} {isCustom && ( {/* Header with reset button */} Custom Points Table setShowBonusFlyout(true)} /> {/* Bonus Points Flyout */} setShowBonusFlyout(false)} title="Bonus Points Explained" anchorRef={bonusInfoRef} > Bonus points reward exceptional performances beyond just finishing position. They add strategic depth and excitement to your championship. Available Bonuses Example: A driver finishing P1 with pole and fastest lap would earn 25 + 1 + 1 = 27 points. {/* Race position points */} Finish position points {customPoints.racePoints.map((pts, idx) => ( P{idx + 1} {pts} ))} {/* Bonus points */} {[ { 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) => ( {bonus.emoji} {bonus.label} {customPoints[bonus.key]} ))} )} ); } /** * Championships section - simple inline toggle list. * No collapsibles, flat and easy to scan. */ // Championship info content const CHAMPIONSHIP_INFO: Record = { 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(null); const champInfoRef = useRef(null!); const champItemRefs = useRef>({}); 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 ( {/* Section header */} Championships setShowChampFlyout(true)} /> What standings to track {/* Championships Flyout */} setShowChampFlyout(false)} title="Championship Standings" anchorRef={champInfoRef} > Championships track cumulative points across all races. You can enable multiple championship types to run different competitions simultaneously. Live Standings Example Championship Types {[ { 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) => ( {t.label} {t.desc} ))} {/* Inline toggle grid */} {championships.map((champ) => { const isEnabled = champ.enabled && champ.available; const champInfo = CHAMPIONSHIP_INFO[champ.key]; return ( {/* Championship Item Info Flyout */} {champInfo && ( setActiveChampFlyout(null)} title={champInfo.title} anchorRef={{ current: (champItemRefs.current[champ.key] as HTMLElement | null) ?? champInfoRef.current }} > {champInfo.description} How It Works {champInfo.details.map((detail, idx) => ( {detail} ))} {!champ.available && ( Note: {champ.unavailableHint}. Switch to Teams mode to enable this championship. )} )} ); })} ); }