This commit is contained in:
2025-12-05 14:26:54 +01:00
parent b6c2b4a422
commit 01a2c12feb
7 changed files with 3390 additions and 1709 deletions

View File

@@ -10,7 +10,11 @@ import {
Award, Award,
CheckCircle2, CheckCircle2,
ChevronLeft, ChevronLeft,
ChevronRight ChevronRight,
Loader2,
AlertCircle,
Sparkles,
Check,
} from 'lucide-react'; } from 'lucide-react';
import Card from '@/components/ui/Card'; import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
@@ -33,7 +37,7 @@ import {
import { LeagueDropSection } from './LeagueDropSection'; import { LeagueDropSection } from './LeagueDropSection';
import { LeagueTimingsSection } from './LeagueTimingsSection'; import { LeagueTimingsSection } from './LeagueTimingsSection';
type Step = 1 | 2 | 3 | 4 | 5 | 6; type Step = 1 | 2 | 3 | 4 | 5;
interface WizardErrors { interface WizardErrors {
basics?: { basics?: {
@@ -109,11 +113,6 @@ export default function CreateLeagueWizard() {
const [form, setForm] = useState<LeagueConfigFormModel>(() => const [form, setForm] = useState<LeagueConfigFormModel>(() =>
createDefaultForm(), createDefaultForm(),
); );
/**
* Local-only weekend template selection for Step 3.
* This does not touch domain models; it only seeds timing defaults.
*/
const [weekendTemplate, setWeekendTemplate] = useState<string>('');
useEffect(() => { useEffect(() => {
async function loadPresets() { async function loadPresets() {
@@ -229,7 +228,7 @@ export default function CreateLeagueWizard() {
if (!validateStep(step)) { if (!validateStep(step)) {
return; return;
} }
setStep((prev) => (prev < 6 ? ((prev + 1) as Step) : prev)); setStep((prev) => (prev < 5 ? ((prev + 1) as Step) : prev));
}; };
const goToPreviousStep = () => { const goToPreviousStep = () => {
@@ -244,8 +243,7 @@ export default function CreateLeagueWizard() {
!validateStep(1) || !validateStep(1) ||
!validateStep(2) || !validateStep(2) ||
!validateStep(3) || !validateStep(3) ||
!validateStep(4) || !validateStep(4)
!validateStep(5)
) { ) {
setStep(1); setStep(1);
return; return;
@@ -326,77 +324,77 @@ export default function CreateLeagueWizard() {
const currentPreset = const currentPreset =
presets.find((p) => p.id === form.scoring.patternId) ?? null; presets.find((p) => p.id === form.scoring.patternId) ?? null;
const handleWeekendTemplateChange = (template: string) => { // Handler for scoring preset selection - updates timing defaults based on preset
setWeekendTemplate(template); const handleScoringPresetChange = (patternId: string) => {
const lowerPresetId = patternId.toLowerCase();
setForm((prev) => { setForm((prev) => {
const timings = prev.timings ?? {}; const timings = prev.timings ?? {};
if (template === 'feature') { let updatedTimings = { ...timings };
return {
...prev, // Auto-configure session durations based on preset type
timings: { if (lowerPresetId.includes('sprint') || lowerPresetId.includes('double')) {
...timings, updatedTimings = {
practiceMinutes: 20, ...updatedTimings,
qualifyingMinutes: 30,
sprintRaceMinutes: undefined,
mainRaceMinutes: 40,
sessionCount: 1,
},
};
}
if (template === 'sprintFeature') {
return {
...prev,
timings: {
...timings,
practiceMinutes: 15, practiceMinutes: 15,
qualifyingMinutes: 20, qualifyingMinutes: 20,
sprintRaceMinutes: 20, sprintRaceMinutes: 20,
mainRaceMinutes: 35, mainRaceMinutes: 35,
sessionCount: 2, sessionCount: 2,
},
}; };
} } else if (lowerPresetId.includes('endurance') || lowerPresetId.includes('long')) {
if (template === 'endurance') { updatedTimings = {
return { ...updatedTimings,
...prev,
timings: {
...timings,
practiceMinutes: 30, practiceMinutes: 30,
qualifyingMinutes: 30, qualifyingMinutes: 30,
sprintRaceMinutes: undefined, sprintRaceMinutes: undefined,
mainRaceMinutes: 90, mainRaceMinutes: 90,
sessionCount: 1, sessionCount: 1,
}, };
} else {
// Standard/feature format
updatedTimings = {
...updatedTimings,
practiceMinutes: 20,
qualifyingMinutes: 30,
sprintRaceMinutes: undefined,
mainRaceMinutes: 40,
sessionCount: 1,
}; };
} }
return prev;
return {
...prev,
scoring: {
...prev.scoring,
patternId,
customScoringEnabled: false,
},
timings: updatedTimings,
};
}); });
}; };
const steps = [ const steps = [
{ id: 1 as Step, label: 'Basics', icon: FileText }, { id: 1 as Step, label: 'Basics', icon: FileText, shortLabel: 'Name' },
{ id: 2 as Step, label: 'Structure', icon: Users }, { id: 2 as Step, label: 'Structure', icon: Users, shortLabel: 'Mode' },
{ id: 3 as Step, label: 'Schedule', icon: Calendar }, { id: 3 as Step, label: 'Schedule', icon: Calendar, shortLabel: 'Time' },
{ id: 4 as Step, label: 'Scoring', icon: Trophy }, { id: 4 as Step, label: 'Scoring', icon: Trophy, shortLabel: 'Points' },
{ id: 5 as Step, label: 'Championships', icon: Award }, { id: 5 as Step, label: 'Review', icon: CheckCircle2, shortLabel: 'Done' },
{ id: 6 as Step, label: 'Review', icon: CheckCircle2 },
]; ];
const getStepTitle = (currentStep: Step): string => { const getStepTitle = (currentStep: Step): string => {
switch (currentStep) { switch (currentStep) {
case 1: case 1:
return 'Step 1 — Basics'; return 'Name your league';
case 2: case 2:
return 'Step 2 — Structure'; return 'Choose the structure';
case 3: case 3:
return 'Step 3 — Schedule & timings'; return 'Set the schedule';
case 4: case 4:
return 'Step 4 — Scoring pattern'; return 'Scoring & championships';
case 5: case 5:
return 'Step 5 — Championships & drops'; return 'Review & create';
case 6:
return 'Step 6 — Review & confirm';
default: default:
return ''; return '';
} }
@@ -405,167 +403,204 @@ export default function CreateLeagueWizard() {
const getStepSubtitle = (currentStep: Step): string => { const getStepSubtitle = (currentStep: Step): string => {
switch (currentStep) { switch (currentStep) {
case 1: case 1:
return 'Give your league a clear name, description, and visibility.'; return 'Give your league a memorable name and choose who can join.';
case 2: case 2:
return 'Choose whether this is a solo or team-based championship.'; return 'Will drivers compete individually or as part of teams?';
case 3: case 3:
return 'Roughly outline how long your weekends and season should run.'; return 'Configure session durations and plan your season calendar.';
case 4: case 4:
return 'Pick a scoring pattern that matches your weekends.'; return 'Select a scoring preset, enable championships, and set drop rules.';
case 5: case 5:
return 'Decide which championships to track and how drops behave.'; return 'Everything looks good? Launch your new league!';
case 6:
return 'Double-check the summary before creating your new league.';
default: default:
return ''; return '';
} }
}; };
const currentStepData = steps.find((s) => s.id === step);
const CurrentStepIcon = currentStepData?.icon ?? FileText;
return ( return (
<form onSubmit={handleSubmit} className="space-y-6 max-w-5xl mx-auto"> <form onSubmit={handleSubmit} className="max-w-4xl mx-auto pb-8">
{/* Header */} {/* Header with icon */}
<div className="text-center space-y-3"> <div className="mb-8">
<Heading level={1} className="mb-2"> <div className="flex items-center gap-3 mb-3">
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-gradient-to-br from-primary-blue/20 to-primary-blue/5 border border-primary-blue/20">
<Sparkles className="w-5 h-5 text-primary-blue" />
</div>
<div>
<Heading level={1} className="text-2xl sm:text-3xl">
Create a new league Create a new league
</Heading> </Heading>
<p className="text-sm text-gray-400"> <p className="text-sm text-gray-500">
Configure your league in {steps.length} simple steps Set up your racing series in {steps.length} easy steps
</p> </p>
</div> </div>
</div>
</div>
{/* Progress indicators */} {/* Desktop Progress Bar */}
<div className="hidden md:block mb-8">
<div className="relative"> <div className="relative">
<div className="flex items-center justify-between mb-8"> {/* Background track */}
{steps.map((wizardStep, index) => { <div className="absolute top-5 left-6 right-6 h-0.5 bg-charcoal-outline rounded-full" />
{/* Progress fill */}
<div
className="absolute top-5 left-6 h-0.5 bg-gradient-to-r from-primary-blue to-neon-aqua rounded-full transition-all duration-500 ease-out"
style={{ width: `calc(${((step - 1) / (steps.length - 1)) * 100}% - 48px)` }}
/>
<div className="relative flex justify-between">
{steps.map((wizardStep) => {
const isCompleted = wizardStep.id < step; const isCompleted = wizardStep.id < step;
const isCurrent = wizardStep.id === step; const isCurrent = wizardStep.id === step;
const StepIcon = wizardStep.icon; const StepIcon = wizardStep.icon;
return ( return (
<div key={wizardStep.id} className="flex flex-col items-center gap-2 flex-1"> <div key={wizardStep.id} className="flex flex-col items-center">
<div className="relative flex items-center justify-center">
{index > 0 && (
<div <div
className={`absolute right-1/2 top-1/2 -translate-y-1/2 h-0.5 transition-all duration-300 ${ className={`
isCompleted || isCurrent relative z-10 flex h-10 w-10 items-center justify-center rounded-full
? 'bg-primary-blue' transition-all duration-300 ease-out
: 'bg-charcoal-outline' ${isCurrent
}`} ? 'bg-primary-blue text-white shadow-[0_0_24px_rgba(25,140,255,0.5)] scale-110'
style={{ width: 'calc(100vw / 6 - 48px)', maxWidth: '120px' }}
/>
)}
<div
className={`relative z-10 flex h-12 w-12 items-center justify-center rounded-full transition-all duration-200 ${
isCurrent
? 'bg-primary-blue text-white shadow-[0_0_20px_rgba(25,140,255,0.5)] scale-110'
: isCompleted : isCompleted
? 'bg-primary-blue/20 border-2 border-primary-blue text-primary-blue' ? 'bg-primary-blue text-white'
: 'bg-iron-gray border-2 border-charcoal-outline text-gray-500' : 'bg-iron-gray text-gray-500 border-2 border-charcoal-outline'
}`} }
`}
> >
{isCompleted ? ( {isCompleted ? (
<CheckCircle2 className="w-5 h-5" /> <Check className="w-4 h-4" strokeWidth={3} />
) : ( ) : (
<StepIcon className="w-5 h-5" /> <StepIcon className="w-4 h-4" />
)} )}
</div> </div>
</div> <div className="mt-2 text-center">
<span <p
className={`text-xs font-medium transition-colors duration-200 ${ className={`text-xs font-medium transition-colors duration-200 ${
isCurrent isCurrent
? 'text-white' ? 'text-white'
: isCompleted : isCompleted
? 'text-gray-300' ? 'text-primary-blue'
: 'text-gray-500' : 'text-gray-500'
}`} }`}
> >
{wizardStep.label} {wizardStep.label}
</span> </p>
</div>
</div> </div>
); );
})} })}
</div> </div>
</div> </div>
{/* Main content card */}
<Card className="relative overflow-hidden">
{/* Decorative gradient */}
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-primary-blue/50 via-primary-blue to-primary-blue/50" />
<div className="space-y-6">
{/* Step header */}
<div className="space-y-2">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-blue/10">
{(() => {
const currentStepData = steps.find((s) => s.id === step);
if (!currentStepData) return null;
const Icon = currentStepData.icon;
return <Icon className="w-5 h-5 text-primary-blue" />;
})()}
</div> </div>
<div className="flex-1">
<Heading level={2} className="text-2xl text-white"> {/* Mobile Progress */}
<div className="md:hidden mb-6">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<CurrentStepIcon className="w-4 h-4 text-primary-blue" />
<span className="text-sm font-medium text-white">{currentStepData?.label}</span>
</div>
<span className="text-xs text-gray-500">
{step}/{steps.length}
</span>
</div>
<div className="h-1.5 bg-charcoal-outline rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-primary-blue to-neon-aqua rounded-full transition-all duration-500 ease-out"
style={{ width: `${(step / steps.length) * 100}%` }}
/>
</div>
{/* Step dots */}
<div className="flex justify-between mt-2 px-0.5">
{steps.map((s) => (
<div
key={s.id}
className={`
h-1.5 rounded-full transition-all duration-300
${s.id === step
? 'w-4 bg-primary-blue'
: s.id < step
? 'w-1.5 bg-primary-blue/60'
: 'w-1.5 bg-charcoal-outline'
}
`}
/>
))}
</div>
</div>
{/* Main Card */}
<Card className="relative overflow-hidden">
{/* Top gradient accent */}
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-transparent via-primary-blue to-transparent" />
{/* Step header */}
<div className="flex items-start gap-4 mb-6">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary-blue/10 shrink-0 transition-transform duration-300">
<CurrentStepIcon className="w-6 h-6 text-primary-blue" />
</div>
<div className="flex-1 min-w-0">
<Heading level={2} className="text-xl sm:text-2xl text-white leading-tight">
{getStepTitle(step)} {getStepTitle(step)}
</Heading> </Heading>
<p className="text-sm text-gray-400"> <p className="text-sm text-gray-400 mt-1">
{getStepSubtitle(step)} {getStepSubtitle(step)}
</p> </p>
</div> </div>
<span className="text-xs font-medium text-gray-500"> <div className="hidden sm:flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-deep-graphite border border-charcoal-outline">
Step {step} of {steps.length} <span className="text-xs text-gray-500">Step</span>
</span> <span className="text-sm font-semibold text-white">{step}</span>
<span className="text-xs text-gray-500">/ {steps.length}</span>
</div> </div>
</div> </div>
<hr className="border-charcoal-outline/40" /> {/* Divider */}
<div className="h-px bg-gradient-to-r from-transparent via-charcoal-outline to-transparent mb-6" />
{/* Step content */}
<div className="min-h-[400px]">
{/* Step content with min-height for consistency */}
<div className="min-h-[320px]">
{step === 1 && ( {step === 1 && (
<div className="animate-fade-in">
<LeagueBasicsSection <LeagueBasicsSection
form={form} form={form}
onChange={setForm} onChange={setForm}
errors={errors.basics} errors={errors.basics}
/> />
</div>
)} )}
{step === 2 && ( {step === 2 && (
<div className="animate-fade-in">
<LeagueStructureSection <LeagueStructureSection
form={form} form={form}
onChange={setForm} onChange={setForm}
readOnly={false} readOnly={false}
/> />
</div>
)} )}
{step === 3 && ( {step === 3 && (
<div className="animate-fade-in">
<LeagueTimingsSection <LeagueTimingsSection
form={form} form={form}
onChange={setForm} onChange={setForm}
errors={errors.timings} errors={errors.timings}
weekendTemplate={weekendTemplate}
onWeekendTemplateChange={handleWeekendTemplateChange}
/> />
</div>
)} )}
{step === 4 && ( {step === 4 && (
<div className="space-y-4"> <div className="animate-fade-in space-y-8">
{/* Scoring Pattern Selection */}
<ScoringPatternSection <ScoringPatternSection
scoring={form.scoring} scoring={form.scoring}
presets={presets} presets={presets}
readOnly={presetsLoading} readOnly={presetsLoading}
patternError={errors.scoring?.patternId} patternError={errors.scoring?.patternId}
onChangePatternId={(patternId) => onChangePatternId={handleScoringPresetChange}
setForm((prev) => ({
...prev,
scoring: {
...prev.scoring,
patternId,
customScoringEnabled: false,
},
}))
}
onToggleCustomScoring={() => onToggleCustomScoring={() =>
setForm((prev) => ({ setForm((prev) => ({
...prev, ...prev,
@@ -576,44 +611,41 @@ export default function CreateLeagueWizard() {
})) }))
} }
/> />
</div>
)}
{step === 5 && ( {/* Divider */}
<div className="space-y-6"> <div className="h-px bg-gradient-to-r from-transparent via-charcoal-outline to-transparent" />
<div className="space-y-6">
{/* Championships & Drop Rules side by side on larger screens */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<ChampionshipsSection form={form} onChange={setForm} readOnly={presetsLoading} /> <ChampionshipsSection form={form} onChange={setForm} readOnly={presetsLoading} />
</div>
<div className="space-y-3">
<LeagueDropSection form={form} onChange={setForm} readOnly={false} /> <LeagueDropSection form={form} onChange={setForm} readOnly={false} />
</div> </div>
{errors.submit && ( {errors.submit && (
<div className="rounded-lg bg-warning-amber/10 p-4 border border-warning-amber/20 text-sm text-warning-amber flex items-start gap-3"> <div className="flex items-start gap-3 rounded-lg bg-warning-amber/10 p-4 border border-warning-amber/20">
<div className="shrink-0 mt-0.5"></div> <AlertCircle className="w-5 h-5 text-warning-amber shrink-0 mt-0.5" />
<div>{errors.submit}</div> <p className="text-sm text-warning-amber">{errors.submit}</p>
</div> </div>
)} )}
</div> </div>
)} )}
{step === 6 && ( {step === 5 && (
<div className="space-y-6"> <div className="animate-fade-in space-y-6">
<LeagueReviewSummary form={form} presets={presets} /> <LeagueReviewSummary form={form} presets={presets} />
{errors.submit && ( {errors.submit && (
<div className="rounded-lg bg-warning-amber/10 p-4 border border-warning-amber/20 text-sm text-warning-amber flex items-start gap-3"> <div className="flex items-start gap-3 rounded-lg bg-warning-amber/10 p-4 border border-warning-amber/20">
<div className="shrink-0 mt-0.5"></div> <AlertCircle className="w-5 h-5 text-warning-amber shrink-0 mt-0.5" />
<div>{errors.submit}</div> <p className="text-sm text-warning-amber">{errors.submit}</p>
</div> </div>
)} )}
</div> </div>
)} )}
</div> </div>
</div>
</Card> </Card>
{/* Navigation buttons */} {/* Navigation */}
<div className="flex justify-between items-center pt-2"> <div className="flex justify-between items-center mt-6">
<Button <Button
type="button" type="button"
variant="secondary" variant="secondary"
@@ -622,10 +654,24 @@ export default function CreateLeagueWizard() {
className="flex items-center gap-2" className="flex items-center gap-2"
> >
<ChevronLeft className="w-4 h-4" /> <ChevronLeft className="w-4 h-4" />
Back <span className="hidden sm:inline">Back</span>
</Button> </Button>
<div className="flex gap-3">
{step < 6 && ( <div className="flex items-center gap-3">
{/* Mobile step dots */}
<div className="flex sm:hidden items-center gap-1">
{steps.map((s) => (
<div
key={s.id}
className={`
h-1.5 rounded-full transition-all duration-300
${s.id === step ? 'w-3 bg-primary-blue' : s.id < step ? 'w-1.5 bg-primary-blue/50' : 'w-1.5 bg-charcoal-outline'}
`}
/>
))}
</div>
{step < 5 ? (
<Button <Button
type="button" type="button"
variant="primary" variant="primary"
@@ -633,32 +679,36 @@ export default function CreateLeagueWizard() {
onClick={goToNextStep} onClick={goToNextStep}
className="flex items-center gap-2" className="flex items-center gap-2"
> >
Continue <span>Continue</span>
<ChevronRight className="w-4 h-4" /> <ChevronRight className="w-4 h-4" />
</Button> </Button>
)} ) : (
{step === 6 && (
<Button <Button
type="submit" type="submit"
variant="primary" variant="primary"
disabled={loading} disabled={loading}
className="flex items-center gap-2 min-w-[160px] justify-center" className="flex items-center gap-2 min-w-[150px] justify-center"
> >
{loading ? ( {loading ? (
<> <>
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> <Loader2 className="w-4 h-4 animate-spin" />
Creating <span>Creating</span>
</> </>
) : ( ) : (
<> <>
<CheckCircle2 className="w-4 h-4" /> <Sparkles className="w-4 h-4" />
Create league <span>Create League</span>
</> </>
)} )}
</Button> </Button>
)} )}
</div> </div>
</div> </div>
{/* Helper text */}
<p className="text-center text-xs text-gray-500 mt-4">
You can edit all settings after creating your league
</p>
</form> </form>
); );
} }

View File

@@ -1,9 +1,169 @@
'use client'; '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 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 { interface LeagueDropSectionProps {
form: LeagueConfigFormModel; form: LeagueConfigFormModel;
@@ -11,6 +171,74 @@ interface LeagueDropSectionProps {
readOnly?: boolean; 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({ export function LeagueDropSection({
form, form,
onChange, onChange,
@@ -18,183 +246,233 @@ export function LeagueDropSection({
}: LeagueDropSectionProps) { }: LeagueDropSectionProps) {
const disabled = readOnly || !onChange; const disabled = readOnly || !onChange;
const dropPolicy = form.dropPolicy; 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 = ( const handleStrategyChange = (strategy: DropStrategy) => {
patch: Partial<LeagueConfigFormModel['dropPolicy']>, if (disabled || !onChange) return;
) => {
if (!onChange) return; const option = DROP_OPTIONS.find((o) => o.value === strategy);
onChange({ onChange({
...form, ...form,
dropPolicy: { dropPolicy: {
...dropPolicy, strategy,
...patch, n: strategy === 'none' ? undefined : (dropPolicy.n ?? option?.defaultN),
}, },
}); });
}; };
const handleStrategyChange = ( const handleNChange = (delta: number) => {
strategy: LeagueConfigFormModel['dropPolicy']['strategy'], if (disabled || !onChange || dropPolicy.strategy === 'none') return;
) => { const current = dropPolicy.n ?? 1;
if (strategy === 'none') { const newValue = Math.max(1, current + delta);
updateDropPolicy({ strategy: 'none', n: undefined }); onChange({
} else if (strategy === 'bestNResults') { ...form,
const n = dropPolicy.n ?? 6; dropPolicy: {
updateDropPolicy({ strategy: 'bestNResults', n }); ...dropPolicy,
} else if (strategy === 'dropWorstN') { n: newValue,
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 getSuggestedN = () => { const needsN = dropPolicy.strategy !== 'none';
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();
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> {/* 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"> <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 Rules</h3>
<h3 className="text-sm font-semibold text-white">Drop rule</h3> <InfoButton buttonRef={dropInfoRef} onClick={() => setShowDropFlyout(true)} />
</div> </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"> <p className="text-xs text-gray-400">
Protect drivers from bad races by dropping worst results or counting only the best ones Drop rules allow drivers to exclude their worst results from championship calculations.
This protects against mechanical failures, bad luck, or occasional poor performances.
</p> </p>
<div>
<div className="text-[10px] text-gray-500 uppercase tracking-wide mb-2">Visual Example</div>
<DropRulesMockup />
</div> </div>
<div className="space-y-3"> <div className="space-y-2">
<SegmentedControl <div className="text-[10px] text-gray-500 uppercase tracking-wide">Drop Strategies</div>
options={[ <div className="space-y-2">
{ value: 'all', label: 'All count', description: 'Every race matters' }, <div className="flex items-start gap-2 p-2 rounded-lg bg-deep-graphite border border-charcoal-outline/30">
{ value: 'bestN', label: 'Best N', description: 'Keep best results' }, <span className="text-base"></span>
{ value: 'dropWorstN', label: 'Drop worst N', description: 'Ignore worst results' }, <div>
]} <div className="text-[10px] font-medium text-white">All Count</div>
value={currentStrategyValue} <div className="text-[9px] text-gray-500">Every race affects standings. Best for short seasons.</div>
onChange={(value) => { </div>
if (disabled) return; </div>
if (value === 'all') { <div className="flex items-start gap-2 p-2 rounded-lg bg-deep-graphite border border-charcoal-outline/30">
handleStrategyChange('none'); <span className="text-base">🏆</span>
} else if (value === 'bestN') { <div>
handleStrategyChange('bestNResults'); <div className="text-[10px] font-medium text-white">Best N Results</div>
} else if (value === 'dropWorstN') { <div className="text-[9px] text-gray-500">Only your top N races count. Extra races are optional.</div>
handleStrategyChange('dropWorstN'); </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>
{dropPolicy.strategy === 'none' && (
<div className="rounded-lg bg-primary-blue/5 border border-primary-blue/20 p-3"> <div className="rounded-lg bg-primary-blue/5 border border-primary-blue/20 p-3">
<p className="text-xs text-gray-300"> <div className="flex items-start gap-2">
<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. <Zap className="w-3.5 h-3.5 text-primary-blue shrink-0 mt-0.5" />
</p> <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>
</div>
</div>
</InfoFlyout>
{(dropPolicy.strategy === 'bestNResults' || {/* Strategy buttons + N stepper inline */}
dropPolicy.strategy === 'dropWorstN') && ( <div className="flex flex-wrap items-center gap-2">
<div className="space-y-3"> {DROP_OPTIONS.map((option) => {
{suggestedN && ( const isSelected = dropPolicy.strategy === option.value;
<div className="rounded-lg bg-primary-blue/5 border border-primary-blue/20 p-3"> const ruleInfo = DROP_RULE_INFO[option.value];
<p className="text-xs text-gray-300 mb-2"> return (
<span className="font-medium text-primary-blue">Suggested:</span> {suggestedN.explanation} <div key={option.value} className="relative">
</p>
<button <button
type="button" type="button"
onClick={() => handleNChange(String(suggestedN.value))}
disabled={disabled} 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" 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'}
`}
> >
Use suggested value ({suggestedN.value}) {/* 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> </button>
</div> </div>
)} )}
</div>
<div className="rounded-lg border border-charcoal-outline bg-iron-gray/30 p-4 space-y-3"> {/* Explanation text */}
<div className="flex items-start gap-2"> <p className="text-xs text-gray-500">
<Info className="w-4 h-4 text-primary-blue mt-0.5 shrink-0" /> {dropPolicy.strategy === 'none' && 'Every race result affects the championship standings.'}
<div className="flex-1"> {dropPolicy.strategy === 'bestNResults' && `Only your best ${dropPolicy.n ?? 1} results will count.`}
<label className="block text-sm font-medium text-gray-300 mb-2"> {dropPolicy.strategy === 'dropWorstN' && `Your worst ${dropPolicy.n ?? 1} results will be excluded.`}
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> </p>
</div> </div>
</div>
</div>
</div>
)}
<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>
</div>
</div>
); );
} }

View File

@@ -1,7 +1,26 @@
'use client'; 'use client';
import { FileText, Users, Calendar, Trophy, Award, Info } from 'lucide-react'; import {
import Card from '@/components/ui/Card'; FileText,
Users,
Calendar,
Trophy,
Award,
Rocket,
Eye,
EyeOff,
Gamepad2,
User,
UsersRound,
Clock,
Flag,
Zap,
Timer,
TrendingDown,
Check,
Globe,
Medal,
} from 'lucide-react';
import type { LeagueConfigFormModel } from '@gridpilot/racing/application'; import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
import type { LeagueScoringPresetDTO } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider'; import type { LeagueScoringPresetDTO } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider';
@@ -10,242 +29,325 @@ interface LeagueReviewSummaryProps {
presets: LeagueScoringPresetDTO[]; presets: LeagueScoringPresetDTO[];
} }
// Individual review card component
function ReviewCard({
icon: Icon,
iconColor = 'text-primary-blue',
bgColor = 'bg-primary-blue/10',
title,
children,
}: {
icon: React.ElementType;
iconColor?: string;
bgColor?: string;
title: string;
children: React.ReactNode;
}) {
return (
<div className="rounded-xl bg-iron-gray/50 border border-charcoal-outline/40 p-4 space-y-3">
<div className="flex items-center gap-3">
<div className={`flex h-9 w-9 items-center justify-center rounded-lg ${bgColor}`}>
<Icon className={`w-4 h-4 ${iconColor}`} />
</div>
<h3 className="text-sm font-semibold text-white">{title}</h3>
</div>
{children}
</div>
);
}
// Info row component for consistent layout
function InfoRow({
icon: Icon,
label,
value,
valueClass = '',
}: {
icon?: React.ElementType;
label: string;
value: React.ReactNode;
valueClass?: string;
}) {
return (
<div className="flex items-center justify-between py-2 border-b border-charcoal-outline/20 last:border-0">
<div className="flex items-center gap-2 text-xs text-gray-500">
{Icon && <Icon className="w-3.5 h-3.5" />}
<span>{label}</span>
</div>
<div className={`text-sm font-medium text-white ${valueClass}`}>{value}</div>
</div>
);
}
// Badge component for enabled features
function FeatureBadge({
icon: Icon,
label,
enabled,
color = 'primary-blue',
}: {
icon: React.ElementType;
label: string;
enabled: boolean;
color?: string;
}) {
if (!enabled) return null;
return (
<span className={`inline-flex items-center gap-1.5 rounded-full bg-${color}/10 px-3 py-1.5 text-xs font-medium text-${color}`}>
<Icon className="w-3 h-3" />
{label}
</span>
);
}
export default function LeagueReviewSummary({ form, presets }: LeagueReviewSummaryProps) { export default function LeagueReviewSummary({ form, presets }: LeagueReviewSummaryProps) {
const { basics, structure, timings, scoring, championships, dropPolicy } = form; const { basics, structure, timings, scoring, championships, dropPolicy } = form;
const modeLabel = const modeLabel =
structure.mode === 'solo' structure.mode === 'solo'
? 'Drivers only (solo)' ? 'Solo drivers'
: 'Teams with fixed drivers per team'; : 'Team-based';
const capacitySentence = (() => { const modeDescription =
structure.mode === 'solo'
? 'Individual competition'
: 'Teams with fixed rosters';
const capacityValue = (() => {
if (structure.mode === 'solo') { if (structure.mode === 'solo') {
if (typeof structure.maxDrivers === 'number') { return typeof structure.maxDrivers === 'number' ? structure.maxDrivers : '—';
return `Up to ${structure.maxDrivers} drivers`;
} }
return 'Capacity not fully specified'; return typeof structure.maxTeams === 'number' ? structure.maxTeams : '—';
}
const parts: string[] = [];
if (typeof structure.maxTeams === 'number') {
parts.push(`Teams: ${structure.maxTeams}`);
}
if (typeof structure.driversPerTeam === 'number') {
parts.push(`Drivers per team: ${structure.driversPerTeam}`);
}
if (typeof structure.maxDrivers === 'number') {
parts.push(`Max grid: ${structure.maxDrivers}`);
}
if (parts.length === 0) {
return '—';
}
return parts.join(', ');
})(); })();
const capacityLabel = structure.mode === 'solo' ? 'drivers' : 'teams';
const formatMinutes = (value: number | undefined) => { const formatMinutes = (value: number | undefined) => {
if (typeof value !== 'number' || value <= 0) return '—'; if (typeof value !== 'number' || value <= 0) return '—';
return `${value} min`; return `${value} min`;
}; };
const dropRuleSentence = (() => { const getDropRuleInfo = () => {
if (dropPolicy.strategy === 'none') { if (dropPolicy.strategy === 'none') {
return 'All results will count towards the championship.'; return { emoji: '✓', label: 'All count', description: 'Every race counts' };
} }
if (dropPolicy.strategy === 'bestNResults') { if (dropPolicy.strategy === 'bestNResults') {
if (typeof dropPolicy.n === 'number' && dropPolicy.n > 0) { return {
return `Best ${dropPolicy.n} results will count; others are ignored.`; emoji: '🏆',
} label: `Best ${dropPolicy.n ?? 'N'}`,
return 'Best N results will count; others are ignored.'; description: `Only best ${dropPolicy.n ?? 'N'} results count`,
};
} }
if (dropPolicy.strategy === 'dropWorstN') { if (dropPolicy.strategy === 'dropWorstN') {
if (typeof dropPolicy.n === 'number' && dropPolicy.n > 0) { return {
return `Worst ${dropPolicy.n} results will be dropped from the standings.`; emoji: '🗑️',
label: `Drop ${dropPolicy.n ?? 'N'}`,
description: `Worst ${dropPolicy.n ?? 'N'} dropped`,
};
} }
return 'Worst N results will be dropped from the standings.'; return { emoji: '✓', label: 'All count', description: 'Every race counts' };
} };
return 'All results will count towards the championship.';
})();
const preset = const dropRuleInfo = getDropRuleInfo();
presets.find((p) => p.id === scoring.patternId) ?? null;
const scoringPresetName = preset ? preset.name : scoring.patternId ? 'Preset not found' : '—'; const preset = presets.find((p) => p.id === scoring.patternId) ?? null;
const scoringPatternSummary = preset?.sessionSummary ?? '—';
const dropPolicySummary = dropRuleSentence;
const enabledChampionshipsLabels: string[] = []; const getScoringEmoji = () => {
if (championships.enableDriverChampionship) enabledChampionshipsLabels.push('Driver'); if (!preset) return '🏁';
if (championships.enableTeamChampionship) enabledChampionshipsLabels.push('Team'); const name = preset.name.toLowerCase();
if (championships.enableNationsChampionship) enabledChampionshipsLabels.push('Nations Cup'); if (name.includes('sprint') || name.includes('double')) return '⚡';
if (championships.enableTrophyChampionship) enabledChampionshipsLabels.push('Trophy'); if (name.includes('endurance') || name.includes('long')) return '🏆';
if (name.includes('club') || name.includes('casual')) return '🏅';
const championshipsSummary = return '🏁';
enabledChampionshipsLabels.length === 0 };
? 'None enabled yet.'
: enabledChampionshipsLabels.join(', ');
const visibilityIcon = basics.visibility === 'public' ? Eye : EyeOff;
const visibilityLabel = basics.visibility === 'public' ? 'Public' : 'Private'; const visibilityLabel = basics.visibility === 'public' ? 'Public' : 'Private';
const gameLabel = 'iRacing';
// Calculate total weekend duration
const totalWeekendMinutes = (timings.practiceMinutes ?? 0) +
(timings.qualifyingMinutes ?? 0) +
(timings.sprintRaceMinutes ?? 0) +
(timings.mainRaceMinutes ?? 0);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="rounded-lg bg-primary-blue/5 border border-primary-blue/20 p-4"> {/* Hero Banner */}
<div className="flex items-start gap-3"> <div className="relative rounded-2xl bg-gradient-to-br from-primary-blue/20 via-iron-gray to-iron-gray border border-primary-blue/30 p-6 overflow-hidden">
<Info className="w-5 h-5 text-primary-blue shrink-0 mt-0.5" /> {/* Background decoration */}
<div className="absolute top-0 right-0 w-32 h-32 bg-primary-blue/10 rounded-full blur-3xl" />
<div className="absolute bottom-0 left-0 w-24 h-24 bg-neon-aqua/5 rounded-full blur-2xl" />
<div className="relative flex items-start gap-4">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary-blue/20 border border-primary-blue/30 shrink-0">
<Rocket className="w-7 h-7 text-primary-blue" />
</div>
<div className="flex-1 min-w-0">
<h2 className="text-xl font-bold text-white mb-1 truncate">
{basics.name || 'Your New League'}
</h2>
<p className="text-sm text-gray-400 mb-3">
{basics.description || 'Ready to launch your racing series!'}
</p>
<div className="flex flex-wrap items-center gap-3">
<span className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium ${
basics.visibility === 'public'
? 'bg-performance-green/10 text-performance-green'
: 'bg-warning-amber/10 text-warning-amber'
}`}>
{basics.visibility === 'public' ? <Eye className="w-3 h-3" /> : <EyeOff className="w-3 h-3" />}
{visibilityLabel}
</span>
<span className="inline-flex items-center gap-1.5 rounded-full bg-charcoal-outline/50 px-3 py-1 text-xs font-medium text-gray-300">
<Gamepad2 className="w-3 h-3" />
iRacing
</span>
<span className="inline-flex items-center gap-1.5 rounded-full bg-charcoal-outline/50 px-3 py-1 text-xs font-medium text-gray-300">
{structure.mode === 'solo' ? <User className="w-3 h-3" /> : <UsersRound className="w-3 h-3" />}
{modeLabel}
</span>
</div>
</div>
</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{/* Capacity */}
<div className="rounded-xl bg-iron-gray/50 border border-charcoal-outline/40 p-4 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-blue/10 mx-auto mb-2">
<Users className="w-5 h-5 text-primary-blue" />
</div>
<div className="text-2xl font-bold text-white">{capacityValue}</div>
<div className="text-xs text-gray-500">{capacityLabel}</div>
</div>
{/* Rounds */}
<div className="rounded-xl bg-iron-gray/50 border border-charcoal-outline/40 p-4 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-performance-green/10 mx-auto mb-2">
<Flag className="w-5 h-5 text-performance-green" />
</div>
<div className="text-2xl font-bold text-white">{timings.roundsPlanned ?? '—'}</div>
<div className="text-xs text-gray-500">rounds</div>
</div>
{/* Weekend Duration */}
<div className="rounded-xl bg-iron-gray/50 border border-charcoal-outline/40 p-4 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-warning-amber/10 mx-auto mb-2">
<Timer className="w-5 h-5 text-warning-amber" />
</div>
<div className="text-2xl font-bold text-white">{totalWeekendMinutes > 0 ? `${totalWeekendMinutes}` : '—'}</div>
<div className="text-xs text-gray-500">min/weekend</div>
</div>
{/* Championships */}
<div className="rounded-xl bg-iron-gray/50 border border-charcoal-outline/40 p-4 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-neon-aqua/10 mx-auto mb-2">
<Award className="w-5 h-5 text-neon-aqua" />
</div>
<div className="text-2xl font-bold text-white">
{[championships.enableDriverChampionship, championships.enableTeamChampionship, championships.enableNationsChampionship, championships.enableTrophyChampionship].filter(Boolean).length}
</div>
<div className="text-xs text-gray-500">championships</div>
</div>
</div>
{/* Detail Cards Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Schedule Card */}
<ReviewCard icon={Calendar} title="Race Weekend">
<div className="space-y-1">
{timings.practiceMinutes && timings.practiceMinutes > 0 && (
<InfoRow icon={Clock} label="Practice" value={formatMinutes(timings.practiceMinutes)} />
)}
<InfoRow icon={Clock} label="Qualifying" value={formatMinutes(timings.qualifyingMinutes)} />
{timings.sprintRaceMinutes && timings.sprintRaceMinutes > 0 && (
<InfoRow icon={Zap} label="Sprint Race" value={formatMinutes(timings.sprintRaceMinutes)} />
)}
<InfoRow icon={Flag} label="Main Race" value={formatMinutes(timings.mainRaceMinutes)} />
</div>
</ReviewCard>
{/* Scoring Card */}
<ReviewCard icon={Trophy} iconColor="text-warning-amber" bgColor="bg-warning-amber/10" title="Scoring System">
<div className="space-y-3">
{/* Scoring Preset */}
<div className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline/30">
<span className="text-2xl">{getScoringEmoji()}</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-white">{preset?.name ?? 'Custom'}</div>
<div className="text-xs text-gray-500">{preset?.sessionSummary ?? 'Custom scoring enabled'}</div>
</div>
{scoring.customScoringEnabled && (
<span className="px-2 py-0.5 rounded bg-primary-blue/20 text-[10px] font-medium text-primary-blue">Custom</span>
)}
</div>
{/* Drop Rule */}
<div className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline/30">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-charcoal-outline/50">
<span className="text-base">{dropRuleInfo.emoji}</span>
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-white">{dropRuleInfo.label}</div>
<div className="text-xs text-gray-500">{dropRuleInfo.description}</div>
</div>
</div>
</div>
</ReviewCard>
</div>
{/* Championships Section */}
<ReviewCard icon={Award} iconColor="text-neon-aqua" bgColor="bg-neon-aqua/10" title="Active Championships">
<div className="flex flex-wrap gap-2">
{championships.enableDriverChampionship && (
<span className="inline-flex items-center gap-1.5 rounded-lg bg-primary-blue/10 border border-primary-blue/20 px-3 py-2 text-xs font-medium text-primary-blue">
<Trophy className="w-3.5 h-3.5" />
Driver Championship
<Check className="w-3 h-3 text-performance-green" />
</span>
)}
{championships.enableTeamChampionship && (
<span className="inline-flex items-center gap-1.5 rounded-lg bg-primary-blue/10 border border-primary-blue/20 px-3 py-2 text-xs font-medium text-primary-blue">
<Award className="w-3.5 h-3.5" />
Team Championship
<Check className="w-3 h-3 text-performance-green" />
</span>
)}
{championships.enableNationsChampionship && (
<span className="inline-flex items-center gap-1.5 rounded-lg bg-primary-blue/10 border border-primary-blue/20 px-3 py-2 text-xs font-medium text-primary-blue">
<Globe className="w-3.5 h-3.5" />
Nations Cup
<Check className="w-3 h-3 text-performance-green" />
</span>
)}
{championships.enableTrophyChampionship && (
<span className="inline-flex items-center gap-1.5 rounded-lg bg-primary-blue/10 border border-primary-blue/20 px-3 py-2 text-xs font-medium text-primary-blue">
<Medal className="w-3.5 h-3.5" />
Trophy Championship
<Check className="w-3 h-3 text-performance-green" />
</span>
)}
{![championships.enableDriverChampionship, championships.enableTeamChampionship, championships.enableNationsChampionship, championships.enableTrophyChampionship].some(Boolean) && (
<span className="text-sm text-gray-500">No championships enabled</span>
)}
</div>
</ReviewCard>
{/* Ready to launch message */}
<div className="rounded-xl bg-performance-green/5 border border-performance-green/20 p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-performance-green/20">
<Check className="w-5 h-5 text-performance-green" />
</div>
<div> <div>
<p className="text-sm font-medium text-white mb-1">Review your league configuration</p> <p className="text-sm font-medium text-white">Ready to launch!</p>
<p className="text-xs text-gray-400"> <p className="text-xs text-gray-400">
Double-check all settings before creating your league. You can modify most of these later. Click "Create League" to launch your racing series. You can modify all settings later.
</p> </p>
</div> </div>
</div> </div>
</div> </div>
<Card className="bg-iron-gray/80">
<div className="space-y-6 text-sm text-gray-200">
{/* 1. Basics & visibility */}
<section className="space-y-3">
<div className="flex items-center gap-2 pb-2 border-b border-charcoal-outline/40">
<FileText className="w-4 h-4 text-primary-blue" />
<h3 className="text-sm font-semibold text-white">
Basics & visibility
</h3>
</div>
<dl className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-1">
<dt className="text-xs text-gray-500">Name</dt>
<dd className="font-medium text-white">{basics.name || '—'}</dd>
</div>
<div className="space-y-1">
<dt className="text-xs text-gray-500">Visibility</dt>
<dd>{visibilityLabel}</dd>
</div>
<div className="space-y-1">
<dt className="text-xs text-gray-500">Game</dt>
<dd>{gameLabel}</dd>
</div>
{basics.description && (
<div className="space-y-1 md:col-span-2">
<dt className="text-xs text-gray-500">Description</dt>
<dd className="text-gray-300">{basics.description}</dd>
</div>
)}
</dl>
</section>
{/* 2. Structure & capacity */}
<section className="space-y-3">
<div className="flex items-center gap-2 pb-2 border-b border-charcoal-outline/40">
<Users className="w-4 h-4 text-primary-blue" />
<h3 className="text-sm font-semibold text-white">
Structure & capacity
</h3>
</div>
<dl className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-1">
<dt className="text-xs text-gray-500">Mode</dt>
<dd>{modeLabel}</dd>
</div>
<div className="space-y-1">
<dt className="text-xs text-gray-500">Capacity</dt>
<dd>{capacitySentence}</dd>
</div>
</dl>
</section>
{/* 3. Schedule & timings */}
<section className="space-y-3">
<div className="flex items-center gap-2 pb-2 border-b border-charcoal-outline/40">
<Calendar className="w-4 h-4 text-primary-blue" />
<h3 className="text-sm font-semibold text-white">
Schedule & timings
</h3>
</div>
<dl className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-1">
<dt className="text-xs text-gray-500">Planned rounds</dt>
<dd>{typeof timings.roundsPlanned === 'number' ? timings.roundsPlanned : '—'}</dd>
</div>
<div className="space-y-1">
<dt className="text-xs text-gray-500">Sessions per weekend</dt>
<dd>{timings.sessionCount ?? '—'}</dd>
</div>
<div className="space-y-1">
<dt className="text-xs text-gray-500">Practice</dt>
<dd>{formatMinutes(timings.practiceMinutes)}</dd>
</div>
<div className="space-y-1">
<dt className="text-xs text-gray-500">Qualifying</dt>
<dd>{formatMinutes(timings.qualifyingMinutes)}</dd>
</div>
<div className="space-y-1">
<dt className="text-xs text-gray-500">Sprint</dt>
<dd>{formatMinutes(timings.sprintRaceMinutes)}</dd>
</div>
<div className="space-y-1">
<dt className="text-xs text-gray-500">Main race</dt>
<dd>{formatMinutes(timings.mainRaceMinutes)}</dd>
</div>
</dl>
</section>
{/* 4. Scoring & drops */}
<section className="space-y-3">
<div className="flex items-center gap-2 pb-2 border-b border-charcoal-outline/40">
<Trophy className="w-4 h-4 text-primary-blue" />
<h3 className="text-sm font-semibold text-white">
Scoring & drops
</h3>
</div>
<dl className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-1">
<dt className="text-xs text-gray-500">Scoring pattern</dt>
<dd>{scoringPresetName}</dd>
</div>
<div className="space-y-1">
<dt className="text-xs text-gray-500">Pattern summary</dt>
<dd>{scoringPatternSummary}</dd>
</div>
<div className="space-y-1">
<dt className="text-xs text-gray-500">Drop rule</dt>
<dd>{dropPolicySummary}</dd>
</div>
{scoring.customScoringEnabled && (
<div className="space-y-1">
<dt className="text-xs text-gray-500">Custom scoring</dt>
<dd>Custom scoring flagged</dd>
</div>
)}
</dl>
</section>
{/* 5. Championships */}
<section className="space-y-3">
<div className="flex items-center gap-2 pb-2 border-b border-charcoal-outline/40">
<Award className="w-4 h-4 text-primary-blue" />
<h3 className="text-sm font-semibold text-white">
Championships
</h3>
</div>
<dl className="grid grid-cols-1 gap-4">
<div className="space-y-1">
<dt className="text-xs text-gray-500">Enabled championships</dt>
<dd className="flex flex-wrap gap-2">
{enabledChampionshipsLabels.length > 0 ? (
enabledChampionshipsLabels.map((label) => (
<span key={label} className="inline-flex items-center gap-1 rounded-full bg-primary-blue/10 px-3 py-1 text-xs font-medium text-primary-blue">
<Award className="w-3 h-3" />
{label}
</span>
))
) : (
<span className="text-gray-400">None enabled yet</span>
)}
</dd>
</div>
</dl>
</section>
</div>
</Card>
</div> </div>
); );
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import Input from '@/components/ui/Input'; import { useCallback, useRef, useState, useEffect } from 'react';
interface RangeFieldProps { interface RangeFieldProps {
label: string; label: string;
@@ -12,14 +12,12 @@ interface RangeFieldProps {
helperText?: string; helperText?: string;
error?: string; error?: string;
disabled?: boolean; disabled?: boolean;
/**
* Optional unit label, defaults to "min".
*/
unitLabel?: string; unitLabel?: string;
/**
* Optional override for the right-hand range hint.
*/
rangeHint?: string; rangeHint?: string;
/** Show large value display above slider */
showLargeValue?: boolean;
/** Compact mode - single line */
compact?: boolean;
} }
export default function RangeField({ export default function RangeField({
@@ -34,97 +32,240 @@ export default function RangeField({
disabled, disabled,
unitLabel = 'min', unitLabel = 'min',
rangeHint, rangeHint,
showLargeValue = false,
compact = false,
}: RangeFieldProps) { }: RangeFieldProps) {
const clampedValue = Number.isFinite(value) const [localValue, setLocalValue] = useState(value);
? Math.min(Math.max(value, min), max) const [isDragging, setIsDragging] = useState(false);
const sliderRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Sync local value with prop when not dragging
useEffect(() => {
if (!isDragging) {
setLocalValue(value);
}
}, [value, isDragging]);
const clampedValue = Number.isFinite(localValue)
? Math.min(Math.max(localValue, min), max)
: min; : min;
const handleSliderChange = (raw: string) => { const rangePercent = ((clampedValue - min) / Math.max(max - min, 1)) * 100;
const parsed = parseInt(raw, 10);
if (Number.isNaN(parsed)) {
return;
}
const next = Math.min(Math.max(parsed, min), max);
onChange(next);
};
const handleNumberChange = (raw: string) => {
if (raw.trim() === '') {
// Allow the field to clear without jumping the slider;
// keep the previous value until the user types a number.
return;
}
const parsed = parseInt(raw, 10);
if (Number.isNaN(parsed)) {
return;
}
const next = Math.min(Math.max(parsed, min), max);
onChange(next);
};
const rangePercent =
((clampedValue - min) / Math.max(max - min, 1)) * 100;
const effectiveRangeHint = const effectiveRangeHint =
rangeHint ?? rangeHint ?? (min === 0 ? `Up to ${max} ${unitLabel}` : `${min}${max} ${unitLabel}`);
(min === 0
? `Up to ${max} ${unitLabel}`
: `${min}${max} ${unitLabel}`);
const calculateValueFromPosition = useCallback(
(clientX: number) => {
if (!sliderRef.current) return clampedValue;
const rect = sliderRef.current.getBoundingClientRect();
const percent = Math.min(Math.max((clientX - rect.left) / rect.width, 0), 1);
const rawValue = min + percent * (max - min);
const steppedValue = Math.round(rawValue / step) * step;
return Math.min(Math.max(steppedValue, min), max);
},
[min, max, step, clampedValue]
);
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
if (disabled) return;
e.preventDefault();
setIsDragging(true);
const newValue = calculateValueFromPosition(e.clientX);
setLocalValue(newValue);
onChange(newValue);
(e.target as HTMLElement).setPointerCapture(e.pointerId);
},
[disabled, calculateValueFromPosition, onChange]
);
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
if (!isDragging || disabled) return;
const newValue = calculateValueFromPosition(e.clientX);
setLocalValue(newValue);
onChange(newValue);
},
[isDragging, disabled, calculateValueFromPosition, onChange]
);
const handlePointerUp = useCallback(() => {
setIsDragging(false);
}, []);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const raw = e.target.value;
if (raw === '') {
setLocalValue(min);
return;
}
const parsed = parseInt(raw, 10);
if (!Number.isNaN(parsed)) {
const clamped = Math.min(Math.max(parsed, min), max);
setLocalValue(clamped);
onChange(clamped);
}
};
const handleInputBlur = () => {
// Ensure value is synced on blur
onChange(clampedValue);
};
// Quick preset buttons for common values
const quickPresets = [
Math.round(min + (max - min) * 0.25),
Math.round(min + (max - min) * 0.5),
Math.round(min + (max - min) * 0.75),
].filter((v, i, arr) => arr.indexOf(v) === i && v !== clampedValue);
if (compact) {
return ( return (
<div className="space-y-2"> <div className="space-y-1.5">
<div className="flex items-baseline justify-between gap-2"> <div className="flex items-center justify-between gap-3">
<label className="block text-sm font-medium text-gray-300"> <label className="text-xs font-medium text-gray-400 shrink-0">{label}</label>
{label} <div className="flex items-center gap-2 flex-1 max-w-[200px]">
</label>
<p className="text-[11px] text-gray-500">
{effectiveRangeHint}
</p>
</div>
<div className="space-y-2">
<div className="relative">
<div className="pointer-events-none absolute inset-y-0 left-0 right-0 rounded-full bg-charcoal-outline/60" />
<div <div
className="pointer-events-none absolute inset-y-0 left-0 rounded-full bg-primary-blue" ref={sliderRef}
className={`relative flex-1 h-6 cursor-pointer touch-none ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
>
{/* Track background */}
<div className="absolute top-1/2 -translate-y-1/2 left-0 right-0 h-1.5 rounded-full bg-charcoal-outline" />
{/* Track fill */}
<div
className="absolute top-1/2 -translate-y-1/2 left-0 h-1.5 rounded-full bg-primary-blue transition-all duration-75"
style={{ width: `${rangePercent}%` }} style={{ width: `${rangePercent}%` }}
/> />
{/* Thumb */}
<div
className={`
absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-4 h-4 rounded-full
bg-white border-2 border-primary-blue shadow-md
transition-transform duration-75
${isDragging ? 'scale-125 shadow-[0_0_12px_rgba(25,140,255,0.5)]' : ''}
`}
style={{ left: `${rangePercent}%` }}
/>
</div>
<div className="flex items-center gap-1 shrink-0">
<span className="text-sm font-semibold text-white w-8 text-right">{clampedValue}</span>
<span className="text-[10px] text-gray-500">{unitLabel}</span>
</div>
</div>
</div>
{error && <p className="text-[10px] text-warning-amber">{error}</p>}
</div>
);
}
return (
<div className="space-y-3">
<div className="flex items-baseline justify-between gap-2">
<label className="block text-sm font-medium text-gray-300">{label}</label>
<span className="text-[10px] text-gray-500">{effectiveRangeHint}</span>
</div>
{showLargeValue && (
<div className="flex items-baseline gap-1">
<span className="text-3xl font-bold text-white tabular-nums">{clampedValue}</span>
<span className="text-sm text-gray-400">{unitLabel}</span>
</div>
)}
{/* Custom slider */}
<div
ref={sliderRef}
className={`relative h-8 cursor-pointer touch-none select-none ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
>
{/* Track background */}
<div className="absolute top-1/2 -translate-y-1/2 left-0 right-0 h-2 rounded-full bg-charcoal-outline/80" />
{/* Track fill with gradient */}
<div
className="absolute top-1/2 -translate-y-1/2 left-0 h-2 rounded-full bg-gradient-to-r from-primary-blue to-neon-aqua transition-all duration-75"
style={{ width: `${rangePercent}%` }}
/>
{/* Tick marks */}
<div className="absolute top-1/2 -translate-y-1/2 left-0 right-0 flex justify-between px-1">
{[0, 25, 50, 75, 100].map((tick) => (
<div
key={tick}
className={`w-0.5 h-1 rounded-full transition-colors ${
rangePercent >= tick ? 'bg-white/40' : 'bg-charcoal-outline'
}`}
/>
))}
</div>
{/* Thumb */}
<div
className={`
absolute top-1/2 -translate-y-1/2 -translate-x-1/2
w-5 h-5 rounded-full bg-white border-2 border-primary-blue
shadow-[0_2px_8px_rgba(0,0,0,0.3)]
transition-all duration-75
${isDragging ? 'scale-125 shadow-[0_0_16px_rgba(25,140,255,0.6)]' : 'hover:scale-110'}
`}
style={{ left: `${rangePercent}%` }}
/>
</div>
{/* Value input and quick presets */}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<input <input
type="range" ref={inputRef}
type="number"
min={min} min={min}
max={max} max={max}
step={step} step={step}
value={clampedValue} value={clampedValue}
onChange={(e) => handleSliderChange(e.target.value)} onChange={handleInputChange}
onBlur={handleInputBlur}
disabled={disabled} disabled={disabled}
className="relative z-10 h-2 w-full appearance-none bg-transparent focus:outline-none accent-primary-blue" className={`
w-16 px-2 py-1.5 text-sm font-medium text-center rounded-lg
bg-iron-gray border border-charcoal-outline text-white
focus:border-primary-blue focus:ring-1 focus:ring-primary-blue focus:outline-none
transition-colors
${error ? 'border-warning-amber' : ''}
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
`}
/> />
</div>
<div className="flex items-center gap-2">
<div className="max-w-[96px]">
<Input
type="number"
value={Number.isFinite(value) ? String(clampedValue) : ''}
onChange={(e) => handleNumberChange(e.target.value)}
min={min}
max={max}
step={step}
disabled={disabled}
className="px-3 py-2 text-sm"
error={!!error}
/>
</div>
<span className="text-xs text-gray-400">{unitLabel}</span> <span className="text-xs text-gray-400">{unitLabel}</span>
</div> </div>
{quickPresets.length > 0 && (
<div className="flex gap-1">
{quickPresets.slice(0, 3).map((preset) => (
<button
key={preset}
type="button"
onClick={() => {
setLocalValue(preset);
onChange(preset);
}}
disabled={disabled}
className="px-2 py-1 text-[10px] rounded bg-charcoal-outline/50 text-gray-400 hover:bg-charcoal-outline hover:text-white transition-colors"
>
{preset}
</button>
))}
</div>
)}
</div> </div>
{helperText && ( {helperText && <p className="text-xs text-gray-500">{helperText}</p>}
<p className="text-xs text-gray-500">{helperText}</p> {error && <p className="text-xs text-warning-amber">{error}</p>}
)}
{error && (
<p className="text-xs text-warning-amber mt-1">{error}</p>
)}
</div> </div>
); );
} }

View File

@@ -35,8 +35,9 @@ export interface LeagueTimingsFormDTO {
roundsPlanned?: number; roundsPlanned?: number;
seasonStartDate?: string; // ISO date YYYY-MM-DD seasonStartDate?: string; // ISO date YYYY-MM-DD
seasonEndDate?: string; // ISO date YYYY-MM-DD
raceStartTime?: string; // "HH:MM" 24h raceStartTime?: string; // "HH:MM" 24h
timezoneId?: string; // IANA ID, e.g. "Europe/Berlin" timezoneId?: string; // IANA ID, e.g. "Europe/Berlin", or "track" for track local time
recurrenceStrategy?: 'weekly' | 'everyNWeeks' | 'monthlyNthWeekday'; recurrenceStrategy?: 'weekly' | 'everyNWeeks' | 'monthlyNthWeekday';
intervalWeeks?: number; intervalWeeks?: number;
weekdays?: import('../../domain/value-objects/Weekday').Weekday[]; weekdays?: import('../../domain/value-objects/Weekday').Weekday[];