Files
gridpilot.gg/apps/website/components/leagues/LeagueScoringSection.tsx
2025-12-11 21:06:25 +01:00

1160 lines
45 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 { LeagueScoringPresetDTO } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider';
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
// ============================================================================
// INFO FLYOUT COMPONENT
// ============================================================================
interface InfoFlyoutProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
anchorRef: React.RefObject<HTMLElement | null>;
}
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(
<div
ref={flyoutRef}
className="fixed z-50 w-[380px] max-h-[80vh] overflow-y-auto bg-iron-gray border border-charcoal-outline rounded-xl shadow-2xl animate-fade-in"
style={{ top: position.top, left: position.left }}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-charcoal-outline/50 sticky top-0 bg-iron-gray z-10">
<div className="flex items-center gap-2">
<HelpCircle className="w-4 h-4 text-primary-blue" />
<span className="text-sm font-semibold text-white">{title}</span>
</div>
<button
type="button"
onClick={onClose}
className="flex h-6 w-6 items-center justify-center rounded-md hover:bg-charcoal-outline transition-colors"
>
<X className="w-4 h-4 text-gray-400" />
</button>
</div>
{/* Content */}
<div className="p-4">
{children}
</div>
</div>,
document.body
);
}
function InfoButton({ onClick, buttonRef }: { onClick: () => void; buttonRef: React.RefObject<HTMLButtonElement> }) {
return (
<button
ref={buttonRef}
type="button"
onClick={onClick}
className="flex h-5 w-5 items-center justify-center rounded-full text-gray-500 hover:text-primary-blue hover:bg-primary-blue/10 transition-colors"
>
<HelpCircle className="w-3.5 h-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 (
<div className="bg-deep-graphite rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between text-[10px] text-gray-500 uppercase tracking-wide px-1">
<span>Position</span>
<span>Points</span>
</div>
{positions.map((p) => (
<div key={p.pos} className="flex items-center gap-3">
<div className={`h-8 w-8 rounded-lg ${p.color} flex items-center justify-center text-sm font-bold ${p.pos <= 3 ? 'text-deep-graphite' : 'text-gray-400'}`}>
P{p.pos}
</div>
<div className="flex-1 h-2 bg-charcoal-outline/50 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-primary-blue to-neon-aqua rounded-full"
style={{ width: `${(p.pts / 25) * 100}%` }}
/>
</div>
<div className="w-8 text-right">
<span className="text-sm font-mono font-semibold text-white">{p.pts}</span>
</div>
</div>
))}
<div className="flex items-center justify-center gap-1 pt-2 text-[10px] text-gray-500">
<span>...</span>
<span>down to P10 = 1 point</span>
</div>
</div>
);
}
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 (
<div className="bg-deep-graphite rounded-lg p-4 space-y-2">
{bonuses.map((b, i) => (
<div key={i} className="flex items-center gap-3 p-2 rounded-lg bg-iron-gray/50 border border-charcoal-outline/30">
<span className="text-xl">{b.emoji}</span>
<div className="flex-1">
<div className="text-xs font-medium text-white">{b.label}</div>
<div className="text-[10px] text-gray-500">{b.desc}</div>
</div>
<span className="text-sm font-mono font-semibold text-performance-green">{b.pts}</span>
</div>
))}
</div>
);
}
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 (
<div className="bg-deep-graphite rounded-lg p-4">
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-charcoal-outline/50">
<Trophy className="w-4 h-4 text-yellow-500" />
<span className="text-xs font-semibold text-white">Driver Championship</span>
</div>
<div className="space-y-2">
{standings.map((s) => (
<div key={s.pos} className="flex items-center gap-2">
<div className={`h-6 w-6 rounded-full flex items-center justify-center text-[10px] font-bold ${
s.pos === 1 ? 'bg-yellow-500 text-deep-graphite' : 'bg-charcoal-outline text-gray-400'
}`}>
{s.pos}
</div>
<div className="flex-1 text-xs text-white truncate">{s.name}</div>
<div className="text-xs font-mono font-semibold text-white">{s.pts}</div>
{s.delta && (
<div className="text-[10px] font-mono text-gray-500">{s.delta}</div>
)}
</div>
))}
</div>
<div className="mt-3 pt-2 border-t border-charcoal-outline/50 text-[10px] text-gray-500 text-center">
Points accumulated across all races
</div>
</div>
);
}
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 (
<div className="bg-deep-graphite rounded-lg p-4">
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-charcoal-outline/50">
<span className="text-xs font-semibold text-white">Best 4 of 6 Results</span>
</div>
<div className="flex gap-1 mb-3">
{results.map((r, i) => (
<div
key={i}
className={`flex-1 p-2 rounded-lg text-center border transition-all ${
r.dropped
? 'bg-charcoal-outline/20 border-dashed border-charcoal-outline/50 opacity-50'
: 'bg-performance-green/10 border-performance-green/30'
}`}
>
<div className="text-[9px] text-gray-500">{r.round}</div>
<div className={`text-xs font-mono font-semibold ${r.dropped ? 'text-gray-500 line-through' : 'text-white'}`}>
{r.pts}
</div>
</div>
))}
</div>
<div className="flex justify-between items-center text-xs">
<span className="text-gray-500">Total counted:</span>
<span className="font-mono font-semibold text-performance-green">{total} pts</span>
</div>
<div className="flex justify-between items-center text-[10px] text-gray-500 mt-1">
<span>Without drops:</span>
<span className="font-mono">{wouldBe} pts</span>
</div>
</div>
);
}
interface LeagueScoringSectionProps {
form: LeagueConfigFormModel;
presets: LeagueScoringPresetDTO[];
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: LeagueScoringPresetDTO[];
readOnly?: boolean;
patternError?: string;
onChangePatternId?: (patternId: string) => void;
onToggleCustomScoring?: () => void;
onUpdateCustomPoints?: (points: CustomPointsConfig) => void;
}
// Custom points configuration for inline editor
export interface CustomPointsConfig {
racePoints: number[];
poleBonusPoints: number;
fastestLapPoints: number;
leaderLapPoints: number;
}
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;
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 <div>{patternPanel}</div>;
}
if (championshipsOnly) {
return <div>{championshipsPanel}</div>;
}
return (
<div className="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1.4fr)]">
{patternPanel}
{championshipsPanel}
</div>
);
}
/**
* 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 currentPreset = presets.find((p) => p.id === scoring.patternId) ?? null;
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: LeagueScoringPresetDTO) => {
const name = preset.name.toLowerCase();
if (name.includes('sprint') || name.includes('double')) return '⚡';
if (name.includes('endurance') || name.includes('long')) return '🏆';
if (name.includes('club') || name.includes('casual')) return '🏅';
return '🏁';
};
const getPresetDescription = (preset: LeagueScoringPresetDTO) => {
const name = preset.name.toLowerCase();
if (name.includes('sprint')) return 'Sprint + Feature race';
if (name.includes('endurance')) return 'Long-form endurance';
if (name.includes('club')) return 'Casual league format';
return preset.sessionSummary;
};
// 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 (
<div className="space-y-5">
{/* Section header */}
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary-blue/10">
<Trophy className="w-5 h-5 text-primary-blue" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-white">Points System</h3>
<InfoButton buttonRef={pointsInfoRef} onClick={() => setShowPointsFlyout(true)} />
</div>
<p className="text-xs text-gray-500">Choose how points are awarded</p>
</div>
</div>
{/* Points System Flyout */}
<InfoFlyout
isOpen={showPointsFlyout}
onClose={() => setShowPointsFlyout(false)}
title="How Points Work"
anchorRef={pointsInfoRef}
>
<div className="space-y-4">
<p className="text-xs text-gray-400">
Points are awarded based on finishing position. Higher positions earn more points,
which accumulate across the season to determine championship standings.
</p>
<div>
<div className="text-[10px] text-gray-500 uppercase tracking-wide mb-2">Example: F1-Style Points</div>
<PointsSystemMockup />
</div>
<div className="rounded-lg bg-primary-blue/5 border border-primary-blue/20 p-3">
<div className="flex items-start gap-2">
<Zap className="w-3.5 h-3.5 text-primary-blue shrink-0 mt-0.5" />
<div className="text-[11px] text-gray-400">
<span className="font-medium text-primary-blue">Pro tip:</span> Sprint formats
award points in both races, typically with reduced points for the sprint.
</div>
</div>
</div>
</div>
</InfoFlyout>
{/* Two-column layout: Presets | Custom */}
<div className="grid grid-cols-1 lg:grid-cols-[1fr_auto] gap-4">
{/* Preset options */}
<div className="space-y-2">
{presets.length === 0 ? (
<p className="text-sm text-gray-400 p-4 border border-dashed border-charcoal-outline rounded-lg">
Loading presets...
</p>
) : (
presets.map((preset) => {
const isSelected = !isCustom && scoring.patternId === preset.id;
const presetInfo = getPresetInfoContent(preset.name);
return (
<div key={preset.id} className="relative">
<button
type="button"
onClick={() => onChangePatternId?.(preset.id)}
disabled={disabled}
className={`
w-full flex items-center gap-3 p-3 rounded-xl border-2 text-left transition-all duration-200
${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'
}
${disabled ? 'cursor-default opacity-60' : 'cursor-pointer'}
`}
>
{/* Radio indicator */}
<div className={`
flex h-5 w-5 items-center justify-center rounded-full border-2 shrink-0 transition-colors
${isSelected ? 'border-primary-blue bg-primary-blue' : 'border-gray-500'}
`}>
{isSelected && <Check className="w-3 h-3 text-white" />}
</div>
{/* Emoji */}
<span className="text-xl">{getPresetEmoji(preset)}</span>
{/* Text */}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-white">{preset.name}</div>
<div className="text-xs text-gray-500">{getPresetDescription(preset)}</div>
</div>
{/* Bonus badge */}
{preset.bonusSummary && (
<span className="hidden sm:inline-flex items-center gap-1 px-2 py-1 rounded-full bg-charcoal-outline/30 text-[10px] text-gray-400">
<Zap className="w-3 h-3" />
{preset.bonusSummary}
</span>
)}
{/* Info button */}
<div
ref={(el) => { presetInfoRefs.current[preset.id] = el; }}
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
setActivePresetFlyout(activePresetFlyout === preset.id ? null : preset.id);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
setActivePresetFlyout(activePresetFlyout === preset.id ? null : preset.id);
}
}}
className="flex h-6 w-6 items-center justify-center rounded-full text-gray-500 hover:text-primary-blue hover:bg-primary-blue/10 transition-colors shrink-0"
>
<HelpCircle className="w-3.5 h-3.5" />
</div>
</button>
{/* Preset Info Flyout */}
<InfoFlyout
isOpen={activePresetFlyout === preset.id}
onClose={() => setActivePresetFlyout(null)}
title={presetInfo.title}
anchorRef={{ current: presetInfoRefs.current[preset.id] ?? null }}
>
<div className="space-y-4">
<p className="text-xs text-gray-400">{presetInfo.description}</p>
<div className="space-y-2">
<div className="text-[10px] text-gray-500 uppercase tracking-wide">Key Features</div>
<ul className="space-y-1.5">
{presetInfo.details.map((detail, idx) => (
<li key={idx} className="flex items-start gap-2 text-xs text-gray-400">
<Check className="w-3 h-3 text-performance-green shrink-0 mt-0.5" />
<span>{detail}</span>
</li>
))}
</ul>
</div>
{preset.bonusSummary && (
<div className="rounded-lg bg-primary-blue/5 border border-primary-blue/20 p-3">
<div className="flex items-start gap-2">
<Zap className="w-3.5 h-3.5 text-primary-blue shrink-0 mt-0.5" />
<div className="text-[11px] text-gray-400">
<span className="font-medium text-primary-blue">Bonus points:</span> {preset.bonusSummary}
</div>
</div>
</div>
)}
</div>
</InfoFlyout>
</div>
);
})
)}
</div>
{/* Custom scoring option */}
<div className="lg:w-48">
<button
type="button"
onClick={onToggleCustomScoring}
disabled={!onToggleCustomScoring || readOnly}
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'
}
${readOnly ? 'cursor-default opacity-60' : 'cursor-pointer'}
`}
>
<div className={`
flex h-10 w-10 items-center justify-center rounded-xl transition-colors
${isCustom ? 'bg-primary-blue/20' : 'bg-charcoal-outline/30'}
`}>
<Settings className={`w-5 h-5 ${isCustom ? 'text-primary-blue' : 'text-gray-500'}`} />
</div>
<div className="text-center">
<div className={`text-sm font-medium ${isCustom ? 'text-white' : 'text-gray-400'}`}>
Custom
</div>
<div className="text-[10px] text-gray-500">
Define your own
</div>
</div>
{isCustom && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary-blue/20 text-[10px] font-medium text-primary-blue">
<Check className="w-2.5 h-2.5" />
Active
</span>
)}
</button>
</div>
</div>
{/* Error message */}
{patternError && (
<p className="text-xs text-warning-amber">{patternError}</p>
)}
{/* Custom scoring editor - inline, no placeholder */}
{isCustom && (
<div className="space-y-4 p-4 rounded-xl bg-iron-gray/30 border border-charcoal-outline/50">
{/* Header with reset button */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Settings className="w-4 h-4 text-primary-blue" />
<span className="text-sm font-medium text-white">Custom Points Table</span>
<InfoButton buttonRef={bonusInfoRef} onClick={() => setShowBonusFlyout(true)} />
</div>
{/* Bonus Points Flyout */}
<InfoFlyout
isOpen={showBonusFlyout}
onClose={() => setShowBonusFlyout(false)}
title="Bonus Points Explained"
anchorRef={bonusInfoRef}
>
<div className="space-y-4">
<p className="text-xs text-gray-400">
Bonus points reward exceptional performances beyond just finishing position.
They add strategic depth and excitement to your championship.
</p>
<div>
<div className="text-[10px] text-gray-500 uppercase tracking-wide mb-2">Available Bonuses</div>
<BonusPointsMockup />
</div>
<div className="rounded-lg bg-primary-blue/5 border border-primary-blue/20 p-3">
<div className="flex items-start gap-2">
<Zap className="w-3.5 h-3.5 text-primary-blue shrink-0 mt-0.5" />
<div className="text-[11px] text-gray-400">
<span className="font-medium text-primary-blue">Example:</span> A driver finishing
P1 with pole and fastest lap would earn 25 + 1 + 1 = 27 points.
</div>
</div>
</div>
</div>
</InfoFlyout>
<button
type="button"
onClick={resetToDefaults}
disabled={readOnly}
className="flex items-center gap-1 px-2 py-1 rounded-md text-[10px] text-gray-400 hover:text-primary-blue hover:bg-primary-blue/10 transition-colors"
>
<RotateCcw className="w-3 h-3" />
Reset
</button>
</div>
{/* Race position points */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-gray-400">Finish position points</span>
<div className="flex items-center gap-1">
<button
type="button"
onClick={removePosition}
disabled={readOnly || customPoints.racePoints.length <= 3}
className="flex h-5 w-5 items-center justify-center rounded bg-iron-gray border border-charcoal-outline text-gray-500 hover:text-white hover:border-primary-blue disabled:opacity-30 transition-colors"
>
<Minus className="w-3 h-3" />
</button>
<button
type="button"
onClick={addPosition}
disabled={readOnly || customPoints.racePoints.length >= 20}
className="flex h-5 w-5 items-center justify-center rounded bg-iron-gray border border-charcoal-outline text-gray-500 hover:text-white hover:border-primary-blue disabled:opacity-30 transition-colors"
>
<Plus className="w-3 h-3" />
</button>
</div>
</div>
<div className="flex flex-wrap gap-1">
{customPoints.racePoints.map((pts, idx) => (
<div key={idx} className="flex flex-col items-center">
<span className="text-[9px] text-gray-500 mb-0.5">P{idx + 1}</span>
<div className="flex items-center gap-0.5">
<button
type="button"
onClick={() => updateRacePoints(idx, -1)}
disabled={readOnly || pts <= 0}
className="flex h-5 w-4 items-center justify-center rounded-l bg-iron-gray border border-charcoal-outline text-gray-500 hover:text-white disabled:opacity-30 text-[10px] transition-colors"
>
</button>
<div className="flex h-5 w-6 items-center justify-center bg-deep-graphite border-y border-charcoal-outline">
<span className="text-[10px] font-medium text-white">{pts}</span>
</div>
<button
type="button"
onClick={() => updateRacePoints(idx, 1)}
disabled={readOnly}
className="flex h-5 w-4 items-center justify-center rounded-r bg-iron-gray border border-charcoal-outline text-gray-500 hover:text-white disabled:opacity-30 text-[10px] transition-colors"
>
+
</button>
</div>
</div>
))}
</div>
</div>
{/* Bonus points */}
<div className="grid 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) => (
<div key={bonus.key} className="flex flex-col items-center gap-1">
<span className="text-[10px] text-gray-500">{bonus.emoji} {bonus.label}</span>
<div className="flex items-center gap-0.5">
<button
type="button"
onClick={() => updateBonus(bonus.key, -1)}
disabled={readOnly || customPoints[bonus.key] <= 0}
className="flex h-6 w-5 items-center justify-center rounded-l bg-iron-gray border border-charcoal-outline text-gray-500 hover:text-white disabled:opacity-30 transition-colors"
>
</button>
<div className="flex h-6 w-7 items-center justify-center bg-deep-graphite border-y border-charcoal-outline">
<span className="text-xs font-medium text-white">{customPoints[bonus.key]}</span>
</div>
<button
type="button"
onClick={() => updateBonus(bonus.key, 1)}
disabled={readOnly}
className="flex h-6 w-5 items-center justify-center rounded-r bg-iron-gray border border-charcoal-outline text-gray-500 hover:text-white disabled:opacity-30 transition-colors"
>
+
</button>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}
/**
* 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 (
<div className="space-y-4">
{/* Section header */}
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary-blue/10">
<Award className="w-5 h-5 text-primary-blue" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-white">Championships</h3>
<InfoButton buttonRef={champInfoRef} onClick={() => setShowChampFlyout(true)} />
</div>
<p className="text-xs text-gray-500">What standings to track</p>
</div>
</div>
{/* Championships Flyout */}
<InfoFlyout
isOpen={showChampFlyout}
onClose={() => setShowChampFlyout(false)}
title="Championship Standings"
anchorRef={champInfoRef}
>
<div className="space-y-4">
<p className="text-xs text-gray-400">
Championships track cumulative points across all races. You can enable multiple
championship types to run different competitions simultaneously.
</p>
<div>
<div className="text-[10px] text-gray-500 uppercase tracking-wide mb-2">Live Standings Example</div>
<ChampionshipMockup />
</div>
<div className="space-y-2">
<div className="text-[10px] text-gray-500 uppercase tracking-wide">Championship Types</div>
<div className="grid 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) => (
<div key={i} className="flex items-center gap-2 p-2 rounded-lg bg-deep-graphite border border-charcoal-outline/30">
<t.icon className="w-3.5 h-3.5 text-primary-blue" />
<div>
<div className="text-[10px] font-medium text-white">{t.label}</div>
<div className="text-[9px] text-gray-500">{t.desc}</div>
</div>
</div>
))}
</div>
</div>
</div>
</InfoFlyout>
{/* Inline toggle grid */}
<div className="grid grid-cols-2 gap-2">
{championships.map((champ) => {
const Icon = champ.icon;
const isEnabled = champ.enabled && champ.available;
const champInfo = CHAMPIONSHIP_INFO[champ.key];
return (
<div key={champ.key} className="relative">
<button
type="button"
disabled={disabled || !champ.available}
onClick={() => champ.available && updateChampionship(champ.key, !champ.enabled)}
className={`
w-full flex items-center gap-3 p-3 rounded-xl border text-left transition-all duration-200
${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 */}
<div className={`
flex h-5 w-5 items-center justify-center rounded-md shrink-0 transition-colors
${isEnabled ? 'bg-primary-blue' : 'bg-charcoal-outline/50'}
`}>
{isEnabled && <Check className="w-3 h-3 text-white" />}
</div>
{/* Icon */}
<Icon className={`w-4 h-4 shrink-0 ${isEnabled ? 'text-primary-blue' : 'text-gray-500'}`} />
{/* Text */}
<div className="flex-1 min-w-0">
<div className={`text-xs font-medium truncate ${isEnabled ? 'text-white' : 'text-gray-400'}`}>
{champ.label}
</div>
{!champ.available && champ.unavailableHint && (
<div className="text-[10px] text-warning-amber/70">{champ.unavailableHint}</div>
)}
</div>
{/* Info button */}
<div
ref={(el) => { champItemRefs.current[champ.key] = el; }}
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
setActiveChampFlyout(activeChampFlyout === champ.key ? null : champ.key);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
setActiveChampFlyout(activeChampFlyout === champ.key ? null : champ.key);
}
}}
className="flex h-5 w-5 items-center justify-center rounded-full text-gray-500 hover:text-primary-blue hover:bg-primary-blue/10 transition-colors shrink-0"
>
<HelpCircle className="w-3 h-3" />
</div>
</button>
{/* Championship Item Info Flyout */}
{champInfo && (
<InfoFlyout
isOpen={activeChampFlyout === champ.key}
onClose={() => setActiveChampFlyout(null)}
title={champInfo.title}
anchorRef={{ current: champItemRefs.current[champ.key] ?? null }}
>
<div className="space-y-4">
<p className="text-xs text-gray-400">{champInfo.description}</p>
<div className="space-y-2">
<div className="text-[10px] text-gray-500 uppercase tracking-wide">How It Works</div>
<ul className="space-y-1.5">
{champInfo.details.map((detail, idx) => (
<li key={idx} className="flex items-start gap-2 text-xs text-gray-400">
<Check className="w-3 h-3 text-performance-green shrink-0 mt-0.5" />
<span>{detail}</span>
</li>
))}
</ul>
</div>
{!champ.available && (
<div className="rounded-lg bg-warning-amber/5 border border-warning-amber/20 p-3">
<div className="flex items-start gap-2">
<Zap className="w-3.5 h-3.5 text-warning-amber shrink-0 mt-0.5" />
<div className="text-[11px] text-gray-400">
<span className="font-medium text-warning-amber">Note:</span> {champ.unavailableHint}. Switch to Teams mode to enable this championship.
</div>
</div>
</div>
)}
</div>
</InfoFlyout>
)}
</div>
);
})}
</div>
</div>
);
}