wip
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user