Files
gridpilot.gg/apps/website/components/leagues/LeagueDropSection.tsx
2025-12-11 00:57:32 +01:00

485 lines
18 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { TrendingDown, Check, HelpCircle, X, Zap } from 'lucide-react';
import { createPortal } from 'react-dom';
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
// ============================================================================
// INFO FLYOUT (duplicated for self-contained 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;
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(
<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 }}
>
<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>
<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>
);
}
// 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 (
<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 LeagueDropSectionProps {
form: LeagueConfigFormModel;
onChange?: (form: LeagueConfigFormModel) => void;
readOnly?: boolean;
}
type DropStrategy = 'none' | 'bestNResults' | 'dropWorstN';
// Drop rule info content
const DROP_RULE_INFO: Record<DropStrategy, { title: string; description: string; details: string[]; example: string }> = {
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;
const [showDropFlyout, setShowDropFlyout] = useState(false);
const [activeDropRuleFlyout, setActiveDropRuleFlyout] = useState<DropStrategy | null>(null);
const dropInfoRef = useRef<HTMLButtonElement>(null);
const dropRuleRefs = useRef<Record<DropStrategy, HTMLButtonElement | null>>({
none: null,
bestNResults: null,
dropWorstN: null,
});
const handleStrategyChange = (strategy: DropStrategy) => {
if (disabled || !onChange) return;
const option = DROP_OPTIONS.find((o) => o.value === strategy);
onChange({
...form,
dropPolicy: {
strategy,
n: strategy === 'none' ? undefined : (dropPolicy.n ?? option?.defaultN),
},
});
};
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 (
<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">
<TrendingDown 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">Drop Rules</h3>
<InfoButton buttonRef={dropInfoRef} onClick={() => setShowDropFlyout(true)} />
</div>
<p className="text-xs text-gray-500">Protect from bad races</p>
</div>
</div>
{/* Drop Rules Flyout */}
<InfoFlyout
isOpen={showDropFlyout}
onClose={() => setShowDropFlyout(false)}
title="Drop Rules Explained"
anchorRef={dropInfoRef}
>
<div className="space-y-4">
<p className="text-xs text-gray-400">
Drop rules allow drivers to exclude their worst results from championship calculations.
This protects against mechanical failures, bad luck, or occasional poor performances.
</p>
<div>
<div className="text-[10px] text-gray-500 uppercase tracking-wide mb-2">Visual Example</div>
<DropRulesMockup />
</div>
<div className="space-y-2">
<div className="text-[10px] text-gray-500 uppercase tracking-wide">Drop Strategies</div>
<div className="space-y-2">
<div className="flex items-start gap-2 p-2 rounded-lg bg-deep-graphite border border-charcoal-outline/30">
<span className="text-base"></span>
<div>
<div className="text-[10px] font-medium text-white">All Count</div>
<div className="text-[9px] text-gray-500">Every race affects standings. Best for short seasons.</div>
</div>
</div>
<div className="flex items-start gap-2 p-2 rounded-lg bg-deep-graphite border border-charcoal-outline/30">
<span className="text-base">🏆</span>
<div>
<div className="text-[10px] font-medium text-white">Best N Results</div>
<div className="text-[9px] text-gray-500">Only your top N races count. Extra races are optional.</div>
</div>
</div>
<div className="flex items-start gap-2 p-2 rounded-lg bg-deep-graphite border border-charcoal-outline/30">
<span className="text-base">🗑</span>
<div>
<div className="text-[10px] font-medium text-white">Drop Worst N</div>
<div className="text-[9px] text-gray-500">Exclude your N worst results. Forgives bad days.</div>
</div>
</div>
</div>
</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> For an 8-round season,
"Best 6" or "Drop 2" are popular choices.
</div>
</div>
</div>
</div>
</InfoFlyout>
{/* Strategy buttons + N stepper inline */}
<div className="flex flex-wrap items-center gap-2">
{DROP_OPTIONS.map((option) => {
const isSelected = dropPolicy.strategy === option.value;
const ruleInfo = DROP_RULE_INFO[option.value];
return (
<div key={option.value} className="relative flex items-center">
<button
type="button"
disabled={disabled}
onClick={() => handleStrategyChange(option.value)}
className={`
flex items-center gap-2 px-3 py-2 rounded-l-lg border-2 border-r-0 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/30'
}
${disabled ? 'cursor-default opacity-60' : 'cursor-pointer'}
`}
>
{/* Radio indicator */}
<div className={`
flex h-4 w-4 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-2.5 h-2.5 text-white" />}
</div>
<span className="text-sm">{option.emoji}</span>
<span className={`text-sm font-medium ${isSelected ? 'text-white' : 'text-gray-400'}`}>
{option.label}
</span>
</button>
{/* Info button - separate from main button */}
<button
ref={(el) => { dropRuleRefs.current[option.value] = el; }}
type="button"
onClick={(e) => {
e.stopPropagation();
setActiveDropRuleFlyout(activeDropRuleFlyout === option.value ? null : option.value);
}}
className={`
flex h-full items-center justify-center px-2 py-2 rounded-r-lg border-2 border-l-0 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/30'
}
${disabled ? 'cursor-default opacity-60' : 'cursor-pointer'}
`}
>
<HelpCircle className="w-3.5 h-3.5 text-gray-500 hover:text-primary-blue transition-colors" />
</button>
{/* Drop Rule Info Flyout */}
<InfoFlyout
isOpen={activeDropRuleFlyout === option.value}
onClose={() => setActiveDropRuleFlyout(null)}
title={ruleInfo.title}
anchorRef={{ current: dropRuleRefs.current[option.value] }}
>
<div className="space-y-4">
<p className="text-xs text-gray-400">{ruleInfo.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">
{ruleInfo.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>
<div className="rounded-lg bg-deep-graphite border border-charcoal-outline/30 p-3">
<div className="flex items-center gap-2">
<span className="text-base">{option.emoji}</span>
<div>
<div className="text-[10px] text-gray-500">Example</div>
<div className="text-xs font-medium text-white">{ruleInfo.example}</div>
</div>
</div>
</div>
</div>
</InfoFlyout>
</div>
);
})}
{/* N Stepper - only show when needed */}
{needsN && (
<div className="flex items-center gap-1 ml-2">
<span className="text-xs text-gray-500 mr-1">N =</span>
<button
type="button"
disabled={disabled || (dropPolicy.n ?? 1) <= 1}
onClick={() => handleNChange(-1)}
className="flex h-7 w-7 items-center justify-center rounded-md bg-iron-gray border border-charcoal-outline text-gray-400 hover:text-white hover:border-primary-blue disabled:opacity-40 transition-colors"
>
</button>
<div className="flex h-7 w-10 items-center justify-center rounded-md bg-iron-gray/50 border border-charcoal-outline/50">
<span className="text-sm font-semibold text-white">{dropPolicy.n ?? 1}</span>
</div>
<button
type="button"
disabled={disabled}
onClick={() => handleNChange(1)}
className="flex h-7 w-7 items-center justify-center rounded-md bg-iron-gray border border-charcoal-outline text-gray-400 hover:text-white hover:border-primary-blue disabled:opacity-40 transition-colors"
>
+
</button>
</div>
)}
</div>
{/* Explanation text */}
<p className="text-xs text-gray-500">
{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.`}
</p>
</div>
);
}