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

View File

@@ -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>
);
}

View File

@@ -1,7 +1,26 @@
'use client';
import { FileText, Users, Calendar, Trophy, Award, Info } from 'lucide-react';
import Card from '@/components/ui/Card';
import {
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 { LeagueScoringPresetDTO } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider';
@@ -9,243 +28,326 @@ interface LeagueReviewSummaryProps {
form: LeagueConfigFormModel;
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) {
const { basics, structure, timings, scoring, championships, dropPolicy } = form;
const modeLabel =
structure.mode === 'solo'
? 'Drivers only (solo)'
: 'Teams with fixed drivers per team';
? 'Solo drivers'
: 'Team-based';
const capacitySentence = (() => {
const modeDescription =
structure.mode === 'solo'
? 'Individual competition'
: 'Teams with fixed rosters';
const capacityValue = (() => {
if (structure.mode === 'solo') {
if (typeof structure.maxDrivers === 'number') {
return `Up to ${structure.maxDrivers} drivers`;
}
return 'Capacity not fully specified';
return typeof structure.maxDrivers === 'number' ? structure.maxDrivers : '—';
}
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(', ');
return typeof structure.maxTeams === 'number' ? structure.maxTeams : '—';
})();
const capacityLabel = structure.mode === 'solo' ? 'drivers' : 'teams';
const formatMinutes = (value: number | undefined) => {
if (typeof value !== 'number' || value <= 0) return '—';
return `${value} min`;
};
const dropRuleSentence = (() => {
const getDropRuleInfo = () => {
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 (typeof dropPolicy.n === 'number' && dropPolicy.n > 0) {
return `Best ${dropPolicy.n} results will count; others are ignored.`;
}
return 'Best N results will count; others are ignored.';
return {
emoji: '🏆',
label: `Best ${dropPolicy.n ?? 'N'}`,
description: `Only best ${dropPolicy.n ?? 'N'} results count`,
};
}
if (dropPolicy.strategy === 'dropWorstN') {
if (typeof dropPolicy.n === 'number' && dropPolicy.n > 0) {
return `Worst ${dropPolicy.n} results will be dropped from the standings.`;
}
return 'Worst N results will be dropped from the standings.';
return {
emoji: '🗑️',
label: `Drop ${dropPolicy.n ?? 'N'}`,
description: `Worst ${dropPolicy.n ?? 'N'} dropped`,
};
}
return 'All results will count towards the championship.';
})();
return { emoji: '✓', label: 'All count', description: 'Every race counts' };
};
const dropRuleInfo = getDropRuleInfo();
const preset =
presets.find((p) => p.id === scoring.patternId) ?? null;
const preset = presets.find((p) => p.id === scoring.patternId) ?? null;
const scoringPresetName = preset ? preset.name : scoring.patternId ? 'Preset not found' : '—';
const scoringPatternSummary = preset?.sessionSummary ?? '';
const dropPolicySummary = dropRuleSentence;
const enabledChampionshipsLabels: string[] = [];
if (championships.enableDriverChampionship) enabledChampionshipsLabels.push('Driver');
if (championships.enableTeamChampionship) enabledChampionshipsLabels.push('Team');
if (championships.enableNationsChampionship) enabledChampionshipsLabels.push('Nations Cup');
if (championships.enableTrophyChampionship) enabledChampionshipsLabels.push('Trophy');
const championshipsSummary =
enabledChampionshipsLabels.length === 0
? 'None enabled yet.'
: enabledChampionshipsLabels.join(', ');
const getScoringEmoji = () => {
if (!preset) return '🏁';
const name = preset.name.toLowerCase();
if (name.includes('sprint') || name.includes('double')) return '⚡';
if (name.includes('endurance') || name.includes('long')) return '🏆';
if (name.includes('club') || name.includes('casual')) return '🏅';
return '🏁';
};
const visibilityIcon = basics.visibility === 'public' ? Eye : EyeOff;
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 (
<div className="space-y-6">
<div className="rounded-lg bg-primary-blue/5 border border-primary-blue/20 p-4">
<div className="flex items-start gap-3">
<Info className="w-5 h-5 text-primary-blue shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-white mb-1">Review your league configuration</p>
<p className="text-xs text-gray-400">
Double-check all settings before creating your league. You can modify most of these later.
{/* Hero Banner */}
<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">
{/* 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>
<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>
{/* 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>
</Card>
{/* 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>
<p className="text-sm font-medium text-white">Ready to launch!</p>
<p className="text-xs text-gray-400">
Click "Create League" to launch your racing series. You can modify all settings later.
</p>
</div>
</div>
</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';
import Input from '@/components/ui/Input';
import { useCallback, useRef, useState, useEffect } from 'react';
interface RangeFieldProps {
label: string;
@@ -12,14 +12,12 @@ interface RangeFieldProps {
helperText?: string;
error?: string;
disabled?: boolean;
/**
* Optional unit label, defaults to "min".
*/
unitLabel?: string;
/**
* Optional override for the right-hand range hint.
*/
rangeHint?: string;
/** Show large value display above slider */
showLargeValue?: boolean;
/** Compact mode - single line */
compact?: boolean;
}
export default function RangeField({
@@ -34,97 +32,240 @@ export default function RangeField({
disabled,
unitLabel = 'min',
rangeHint,
showLargeValue = false,
compact = false,
}: RangeFieldProps) {
const clampedValue = Number.isFinite(value)
? Math.min(Math.max(value, min), max)
const [localValue, setLocalValue] = useState(value);
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;
const handleSliderChange = (raw: string) => {
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 rangePercent = ((clampedValue - min) / Math.max(max - min, 1)) * 100;
const effectiveRangeHint =
rangeHint ??
(min === 0
? `Up to ${max} ${unitLabel}`
: `${min}${max} ${unitLabel}`);
rangeHint ?? (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 (
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-3">
<label className="text-xs font-medium text-gray-400 shrink-0">{label}</label>
<div className="flex items-center gap-2 flex-1 max-w-[200px]">
<div
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}%` }}
/>
{/* 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-2">
<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>
<p className="text-[11px] text-gray-500">
{effectiveRangeHint}
</p>
<label className="block text-sm font-medium text-gray-300">{label}</label>
<span className="text-[10px] text-gray-500">{effectiveRangeHint}</span>
</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
className="pointer-events-none absolute inset-y-0 left-0 rounded-full bg-primary-blue"
style={{ width: `${rangePercent}%` }}
/>
{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
type="range"
ref={inputRef}
type="number"
min={min}
max={max}
step={step}
value={clampedValue}
onChange={(e) => handleSliderChange(e.target.value)}
onChange={handleInputChange}
onBlur={handleInputBlur}
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>
</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>
{helperText && (
<p className="text-xs text-gray-500">{helperText}</p>
)}
{error && (
<p className="text-xs text-warning-amber mt-1">{error}</p>
)}
{helperText && <p className="text-xs text-gray-500">{helperText}</p>}
{error && <p className="text-xs text-warning-amber">{error}</p>}
</div>
);
}

View File

@@ -35,8 +35,9 @@ export interface LeagueTimingsFormDTO {
roundsPlanned?: number;
seasonStartDate?: string; // ISO date YYYY-MM-DD
seasonEndDate?: string; // ISO date YYYY-MM-DD
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';
intervalWeeks?: number;
weekdays?: import('../../domain/value-objects/Weekday').Weekday[];