wip
This commit is contained in:
@@ -1,9 +1,169 @@
|
||||
'use client';
|
||||
|
||||
import { TrendingDown, Info } from 'lucide-react';
|
||||
import { 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';
|
||||
import Input from '@/components/ui/Input';
|
||||
import SegmentedControl from '@/components/ui/SegmentedControl';
|
||||
|
||||
// ============================================================================
|
||||
// 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 | null> }) {
|
||||
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;
|
||||
@@ -11,6 +171,74 @@ interface LeagueDropSectionProps {
|
||||
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,
|
||||
@@ -18,183 +246,233 @@ export function LeagueDropSection({
|
||||
}: 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 updateDropPolicy = (
|
||||
patch: Partial<LeagueConfigFormModel['dropPolicy']>,
|
||||
) => {
|
||||
if (!onChange) return;
|
||||
const handleStrategyChange = (strategy: DropStrategy) => {
|
||||
if (disabled || !onChange) return;
|
||||
|
||||
const option = DROP_OPTIONS.find((o) => o.value === strategy);
|
||||
onChange({
|
||||
...form,
|
||||
dropPolicy: {
|
||||
...dropPolicy,
|
||||
...patch,
|
||||
strategy,
|
||||
n: strategy === 'none' ? undefined : (dropPolicy.n ?? option?.defaultN),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleStrategyChange = (
|
||||
strategy: LeagueConfigFormModel['dropPolicy']['strategy'],
|
||||
) => {
|
||||
if (strategy === 'none') {
|
||||
updateDropPolicy({ strategy: 'none', n: undefined });
|
||||
} else if (strategy === 'bestNResults') {
|
||||
const n = dropPolicy.n ?? 6;
|
||||
updateDropPolicy({ strategy: 'bestNResults', n });
|
||||
} else if (strategy === 'dropWorstN') {
|
||||
const n = dropPolicy.n ?? 2;
|
||||
updateDropPolicy({ strategy: 'dropWorstN', n });
|
||||
}
|
||||
};
|
||||
|
||||
const handleNChange = (value: string) => {
|
||||
const parsed = parseInt(value, 10);
|
||||
updateDropPolicy({
|
||||
n: Number.isNaN(parsed) || parsed <= 0 ? undefined : parsed,
|
||||
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 getSuggestedN = () => {
|
||||
const rounds = form.timings.roundsPlanned;
|
||||
if (!rounds || rounds <= 0) return null;
|
||||
|
||||
if (dropPolicy.strategy === 'bestNResults') {
|
||||
// Suggest keeping 70-80% of rounds
|
||||
const suggestion = Math.max(1, Math.floor(rounds * 0.75));
|
||||
return { value: suggestion, explanation: `Keep best ${suggestion} of ${rounds} rounds (75%)` };
|
||||
} else if (dropPolicy.strategy === 'dropWorstN') {
|
||||
// Suggest dropping 1-2 rounds for every 8-10 rounds
|
||||
const suggestion = Math.max(1, Math.floor(rounds / 8));
|
||||
return { value: suggestion, explanation: `Drop worst ${suggestion} of ${rounds} rounds` };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const computeSummary = () => {
|
||||
if (dropPolicy.strategy === 'none') {
|
||||
return 'All results will count towards the championship.';
|
||||
}
|
||||
if (dropPolicy.strategy === 'bestNResults') {
|
||||
const n = dropPolicy.n;
|
||||
if (typeof n === 'number' && n > 0) {
|
||||
return `Best ${n} results will count; others are ignored.`;
|
||||
}
|
||||
return 'Best N results will count; others are ignored.';
|
||||
}
|
||||
if (dropPolicy.strategy === 'dropWorstN') {
|
||||
const n = dropPolicy.n;
|
||||
if (typeof n === 'number' && n > 0) {
|
||||
return `Worst ${n} results will be dropped from the standings.`;
|
||||
}
|
||||
return 'Worst N results will be dropped from the standings.';
|
||||
}
|
||||
return 'All results will count towards the championship.';
|
||||
};
|
||||
|
||||
const currentStrategyValue =
|
||||
dropPolicy.strategy === 'none'
|
||||
? 'all'
|
||||
: dropPolicy.strategy === 'bestNResults'
|
||||
? 'bestN'
|
||||
: 'dropWorstN';
|
||||
|
||||
const suggestedN = getSuggestedN();
|
||||
const needsN = dropPolicy.strategy !== 'none';
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingDown className="w-4 h-4 text-primary-blue" />
|
||||
<h3 className="text-sm font-semibold text-white">Drop rule</h3>
|
||||
{/* 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>
|
||||
<p className="text-xs text-gray-400">
|
||||
Protect drivers from bad races by dropping worst results or counting only the best ones
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<SegmentedControl
|
||||
options={[
|
||||
{ value: 'all', label: 'All count', description: 'Every race matters' },
|
||||
{ value: 'bestN', label: 'Best N', description: 'Keep best results' },
|
||||
{ value: 'dropWorstN', label: 'Drop worst N', description: 'Ignore worst results' },
|
||||
]}
|
||||
value={currentStrategyValue}
|
||||
onChange={(value) => {
|
||||
if (disabled) return;
|
||||
if (value === 'all') {
|
||||
handleStrategyChange('none');
|
||||
} else if (value === 'bestN') {
|
||||
handleStrategyChange('bestNResults');
|
||||
} else if (value === 'dropWorstN') {
|
||||
handleStrategyChange('dropWorstN');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{dropPolicy.strategy === 'none' && (
|
||||
<div className="rounded-lg bg-primary-blue/5 border border-primary-blue/20 p-3">
|
||||
<p className="text-xs text-gray-300">
|
||||
<span className="font-medium text-primary-blue">All count:</span> Every race result affects the championship. Best for shorter seasons or when consistency is key.
|
||||
</p>
|
||||
<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>
|
||||
|
||||
{(dropPolicy.strategy === 'bestNResults' ||
|
||||
dropPolicy.strategy === 'dropWorstN') && (
|
||||
<div className="space-y-3">
|
||||
{suggestedN && (
|
||||
<div className="rounded-lg bg-primary-blue/5 border border-primary-blue/20 p-3">
|
||||
<p className="text-xs text-gray-300 mb-2">
|
||||
<span className="font-medium text-primary-blue">Suggested:</span> {suggestedN.explanation}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleNChange(String(suggestedN.value))}
|
||||
disabled={disabled}
|
||||
className="px-3 py-1.5 rounded bg-iron-gray border border-charcoal-outline text-xs text-gray-300 hover:border-primary-blue hover:text-primary-blue transition-colors"
|
||||
>
|
||||
Use suggested value ({suggestedN.value})
|
||||
</button>
|
||||
</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="rounded-lg border border-charcoal-outline bg-iron-gray/30 p-4 space-y-3">
|
||||
<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">
|
||||
<Info className="w-4 h-4 text-primary-blue mt-0.5 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Number of rounds (N)
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={
|
||||
typeof dropPolicy.n === 'number' && dropPolicy.n > 0
|
||||
? String(dropPolicy.n)
|
||||
: ''
|
||||
}
|
||||
onChange={(e) => handleNChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
min={1}
|
||||
className="max-w-[140px]"
|
||||
/>
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
{dropPolicy.strategy === 'bestNResults'
|
||||
? 'Only your best N results will count towards the championship. Great for long seasons.'
|
||||
: 'Your worst N results will be excluded from the championship. Helps forgive bad days.'}
|
||||
</p>
|
||||
<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>
|
||||
|
||||
<div className="rounded-lg border border-charcoal-outline/50 bg-iron-gray/30 p-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<Info className="w-4 h-4 text-primary-blue mt-0.5 shrink-0" />
|
||||
<div className="text-xs text-gray-300">{computeSummary()}</div>
|
||||
</div>
|
||||
{/* 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">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => handleStrategyChange(option.value)}
|
||||
className={`
|
||||
flex items-center gap-2 px-3 py-2 rounded-lg border-2 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>
|
||||
|
||||
{/* Info 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-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 ml-1"
|
||||
>
|
||||
<HelpCircle className="w-3 h-3" />
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user