This commit is contained in:
2025-12-05 12:47:20 +01:00
parent 5a9cd28d5b
commit b6c2b4a422
7 changed files with 1093 additions and 713 deletions

View File

@@ -2,10 +2,20 @@
import { useEffect, useState, FormEvent } from 'react'; import { useEffect, useState, FormEvent } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import {
FileText,
Users,
Calendar,
Trophy,
Award,
CheckCircle2,
ChevronLeft,
ChevronRight
} from 'lucide-react';
import Card from '@/components/ui/Card'; import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import Heading from '@/components/ui/Heading'; import Heading from '@/components/ui/Heading';
import LeagueReviewSummary from './LeagueReviewSummary'; import LeagueReviewSummary from '@/components/leagues/LeagueReviewSummary';
import { import {
getDriverRepository, getDriverRepository,
getListLeagueScoringPresetsQuery, getListLeagueScoringPresetsQuery,
@@ -365,12 +375,12 @@ export default function CreateLeagueWizard() {
}; };
const steps = [ const steps = [
{ id: 1 as Step, label: 'Basics' }, { id: 1 as Step, label: 'Basics', icon: FileText },
{ id: 2 as Step, label: 'Structure' }, { id: 2 as Step, label: 'Structure', icon: Users },
{ id: 3 as Step, label: 'Schedule & timings' }, { id: 3 as Step, label: 'Schedule', icon: Calendar },
{ id: 4 as Step, label: 'Scoring pattern' }, { id: 4 as Step, label: 'Scoring', icon: Trophy },
{ id: 5 as Step, label: 'Championships & drops' }, { id: 5 as Step, label: 'Championships', icon: Award },
{ id: 6 as Step, label: 'Review & confirm' }, { id: 6 as Step, label: 'Review', icon: CheckCircle2 },
]; ];
const getStepTitle = (currentStep: Step): string => { const getStepTitle = (currentStep: Step): string => {
@@ -412,35 +422,56 @@ export default function CreateLeagueWizard() {
}; };
return ( return (
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6 max-w-5xl mx-auto">
<Heading level={1} className="mb-2"> {/* Header */}
Create a new league <div className="text-center space-y-3">
</Heading> <Heading level={1} className="mb-2">
<p className="text-sm text-gray-400 mb-4"> Create a new league
Configure basics, structure, schedule, scoring, and drop rules in a few </Heading>
simple steps. <p className="text-sm text-gray-400">
</p> Configure your league in {steps.length} simple steps
</p>
</div>
<div className="mb-4 flex flex-col gap-2"> {/* Progress indicators */}
<div className="flex flex-wrap gap-3"> <div className="relative">
<div className="flex items-center justify-between mb-8">
{steps.map((wizardStep, index) => { {steps.map((wizardStep, index) => {
const isCompleted = wizardStep.id < step; const isCompleted = wizardStep.id < step;
const isCurrent = wizardStep.id === step; const isCurrent = wizardStep.id === step;
const baseCircleClasses = const StepIcon = wizardStep.icon;
'flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold';
const circleClasses = isCurrent
? 'bg-primary-blue text-white'
: isCompleted
? 'bg-primary-blue/20 border border-primary-blue text-primary-blue'
: 'bg-iron-gray border border-charcoal-outline text-gray-400';
return ( return (
<div key={wizardStep.id} className="flex items-center gap-2"> <div key={wizardStep.id} className="flex flex-col items-center gap-2 flex-1">
<div className={baseCircleClasses + ' ' + circleClasses}> <div className="relative flex items-center justify-center">
{isCompleted ? '✓' : wizardStep.id} {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> </div>
<span <span
className={`text-xs ${ className={`text-xs font-medium transition-colors duration-200 ${
isCurrent isCurrent
? 'text-white' ? 'text-white'
: isCompleted : isCompleted
@@ -450,134 +481,180 @@ export default function CreateLeagueWizard() {
> >
{wizardStep.label} {wizardStep.label}
</span> </span>
{index < steps.length - 1 && (
<span className="mx-1 h-px w-6 bg-charcoal-outline/70" />
)}
</div> </div>
); );
})} })}
</div> </div>
</div> </div>
<Card> {/* Main content card */}
<div> <Card className="relative overflow-hidden">
<Heading level={2} className="text-2xl text-white"> {/* Decorative gradient */}
{getStepTitle(step)} <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" />
</Heading>
<p className="mt-1 text-sm text-gray-400"> <div className="space-y-6">
{getStepSubtitle(step)} {/* Step header */}
</p> <div className="space-y-2">
<hr className="my-4 border-charcoal-outline/40" /> <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>
</div>
<hr className="border-charcoal-outline/40" />
{/* Step content */}
<div className="min-h-[400px]">
{step === 1 && (
<LeagueBasicsSection
form={form}
onChange={setForm}
errors={errors.basics}
/>
)}
{step === 2 && (
<LeagueStructureSection
form={form}
onChange={setForm}
readOnly={false}
/>
)}
{step === 3 && (
<LeagueTimingsSection
form={form}
onChange={setForm}
errors={errors.timings}
weekendTemplate={weekendTemplate}
onWeekendTemplateChange={handleWeekendTemplateChange}
/>
)}
{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,
},
}))
}
/>
</div>
)}
{step === 5 && (
<div className="space-y-6">
<div className="space-y-6">
<ChampionshipsSection form={form} onChange={setForm} readOnly={presetsLoading} />
</div>
<div className="space-y-3">
<LeagueDropSection form={form} onChange={setForm} readOnly={false} />
</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>
{step === 1 && (
<LeagueBasicsSection
form={form}
onChange={setForm}
errors={errors.basics}
/>
)}
{step === 2 && (
<LeagueStructureSection
form={form}
onChange={setForm}
readOnly={false}
/>
)}
{step === 3 && (
<LeagueTimingsSection
form={form}
onChange={setForm}
errors={errors.timings}
weekendTemplate={weekendTemplate}
onWeekendTemplateChange={handleWeekendTemplateChange}
/>
)}
{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,
},
}))
}
/>
</div>
)}
{step === 5 && (
<div className="space-y-6">
<div className="space-y-6">
<ChampionshipsSection form={form} onChange={setForm} readOnly={presetsLoading} />
</div>
<div className="space-y-3">
<LeagueDropSection form={form} onChange={setForm} readOnly={false} />
</div>
{errors.submit && (
<div className="rounded-md bg-warning-amber/10 p-3 border border-warning-amber/20 text-xs text-warning-amber">
{errors.submit}
</div>
)}
</div>
)}
{step === 6 && (
<div className="space-y-6">
<LeagueReviewSummary form={form} presets={presets} />
{errors.submit && (
<div className="rounded-md bg-warning-amber/10 p-3 border border-warning-amber/20 text-xs text-warning-amber">
{errors.submit}
</div>
)}
</div>
)}
</Card> </Card>
<div className="flex justify-between items-center"> {/* Navigation buttons */}
<div className="flex justify-between items-center pt-2">
<Button <Button
type="button" type="button"
variant="secondary" variant="secondary"
disabled={step === 1 || loading} disabled={step === 1 || loading}
onClick={goToPreviousStep} onClick={goToPreviousStep}
className="flex items-center gap-2"
> >
<ChevronLeft className="w-4 h-4" />
Back Back
</Button> </Button>
<div className="flex gap-2"> <div className="flex gap-3">
{step < 6 && ( {step < 6 && (
<Button <Button
type="button" type="button"
variant="primary" variant="primary"
disabled={loading} disabled={loading}
onClick={goToNextStep} onClick={goToNextStep}
className="flex items-center gap-2"
> >
Next Continue
<ChevronRight className="w-4 h-4" />
</Button> </Button>
)} )}
{step === 6 && ( {step === 6 && (
<Button type="submit" variant="primary" disabled={loading}> <Button
{loading ? 'Creating…' : 'Create league'} type="submit"
variant="primary"
disabled={loading}
className="flex items-center gap-2 min-w-[160px] justify-center"
>
{loading ? (
<>
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Creating
</>
) : (
<>
<CheckCircle2 className="w-4 h-4" />
Create league
</>
)}
</Button> </Button>
)} )}
</div> </div>

View File

@@ -1,5 +1,6 @@
'use client'; 'use client';
import { FileText, Globe, Lock, Gamepad2 } from 'lucide-react';
import Input from '@/components/ui/Input'; import Input from '@/components/ui/Input';
import type { import type {
LeagueConfigFormModel, LeagueConfigFormModel,
@@ -37,91 +38,143 @@ export function LeagueBasicsSection({
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<h2 className="text-lg font-semibold text-white">Step 1 Basics</h2> {/* League name */}
<div className="space-y-4"> <div className="space-y-2">
<div> <label className="flex items-center gap-2 text-sm font-medium text-gray-300">
<label className="block text-sm font-medium text-gray-300 mb-2"> <FileText className="w-4 h-4 text-primary-blue" />
League name * League name *
</label> </label>
<Input <Input
value={basics.name} value={basics.name}
onChange={(e) => updateBasics({ name: e.target.value })} onChange={(e) => updateBasics({ name: e.target.value })}
placeholder="GridPilot Sprint Series" placeholder="e.g., GridPilot Sprint Series"
error={!!errors?.name} error={!!errors?.name}
errorMessage={errors?.name} errorMessage={errors?.name}
disabled={disabled} disabled={disabled}
/> autoFocus
</div> />
<div className="space-y-1">
<div> <p className="text-xs text-gray-500">
<label className="block text-sm font-medium text-gray-300 mb-2"> Choose a clear, memorable name that describes your league
Description </p>
</label> <div className="flex flex-wrap gap-2">
<textarea <button
value={basics.description ?? ''} type="button"
onChange={(e) => onClick={() => updateBasics({ name: 'Weekly Sprint Championship' })}
updateBasics({ className="text-xs text-primary-blue hover:text-primary-blue/80 transition-colors"
description: e.target.value, disabled={disabled}
}) >
} Example: Weekly Sprint Championship
rows={3} </button>
disabled={disabled} <span className="text-xs text-gray-600"></span>
className="block w-full rounded-md border-0 px-4 py-3 bg-iron-gray text-white shadow-sm ring-1 ring-inset ring-charcoal-outline placeholder:text-gray-500 focus:ring-2 focus:ring-inset focus:ring-primary-blue text-sm disabled:opacity-60 disabled:cursor-not-allowed" <button
placeholder="Weekly league with structured championships and live standings." type="button"
/> onClick={() => updateBasics({ name: 'Sunday Evening Endurance' })}
</div> className="text-xs text-primary-blue hover:text-primary-blue/80 transition-colors"
disabled={disabled}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> >
<div> Example: Sunday Evening Endurance
<span className="block text-sm font-medium text-gray-300 mb-2"> </button>
Visibility *
</span>
<div className="flex gap-3">
<button
type="button"
disabled={disabled}
onClick={() =>
updateBasics({
visibility: 'public',
})
}
className={`flex-1 px-3 py-2 text-xs rounded-md border ${
basics.visibility === 'public'
? 'border-primary-blue bg-primary-blue/10 text-primary-blue'
: 'border-charcoal-outline bg-iron-gray text-gray-300'
} ${disabled ? 'opacity-60 cursor-not-allowed' : ''}`}
>
Public
</button>
<button
type="button"
disabled={disabled}
onClick={() =>
updateBasics({
visibility: 'private',
})
}
className={`flex-1 px-3 py-2 text-xs rounded-md border ${
basics.visibility === 'private'
? 'border-primary-blue bg-primary-blue/10 text-primary-blue'
: 'border-charcoal-outline bg-iron-gray text-gray-300'
} ${disabled ? 'opacity-60 cursor-not-allowed' : ''}`}
>
Private
</button>
</div>
{errors?.visibility && (
<p className="mt-1 text-xs text-warning-amber">
{errors.visibility}
</p>
)}
</div> </div>
</div>
</div>
<div> {/* Description */}
<label className="block text-sm font-medium text-gray-300 mb-2"> <div className="space-y-2">
Game <label className="flex items-center gap-2 text-sm font-medium text-gray-300">
</label> <FileText className="w-4 h-4 text-gray-400" />
Description (optional)
</label>
<textarea
value={basics.description ?? ''}
onChange={(e) =>
updateBasics({
description: e.target.value,
})
}
rows={4}
disabled={disabled}
className="block w-full rounded-md border-0 px-4 py-3 bg-iron-gray text-white shadow-sm ring-1 ring-inset ring-charcoal-outline placeholder:text-gray-500 focus:ring-2 focus:ring-inset focus:ring-primary-blue text-sm disabled:opacity-60 disabled:cursor-not-allowed transition-all duration-150"
placeholder="Example: A competitive sprint racing series held every Sunday at 19:00 CET. We focus on close racing and fair play. All skill levels welcome!"
/>
<div className="space-y-1">
<p className="text-xs text-gray-500">
Help potential members understand your league's style, schedule, and community
</p>
<div className="text-xs text-gray-500">
<span className="font-medium text-gray-400">Tip:</span> Mention your racing style, typical schedule, and skill level expectations
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Visibility */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-gray-300">
<Globe className="w-4 h-4 text-primary-blue" />
Visibility *
</label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
disabled={disabled}
onClick={() =>
updateBasics({
visibility: 'public',
})
}
className={`group relative flex flex-col items-center gap-2 px-4 py-4 text-sm rounded-lg border transition-all duration-200 ${
basics.visibility === 'public'
? 'border-primary-blue bg-primary-blue/10 text-primary-blue shadow-[0_0_15px_rgba(25,140,255,0.2)]'
: 'border-charcoal-outline bg-iron-gray/50 text-gray-300 hover:border-gray-500 hover:bg-iron-gray'
} ${disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}`}
>
<Globe className={`w-5 h-5 ${basics.visibility === 'public' ? 'text-primary-blue' : 'text-gray-400'}`} />
<div className="text-center">
<div className="font-medium">Public</div>
<div className="text-xs text-gray-500 mt-0.5">Anyone can join</div>
</div>
</button>
<button
type="button"
disabled={disabled}
onClick={() =>
updateBasics({
visibility: 'private',
})
}
className={`group relative flex flex-col items-center gap-2 px-4 py-4 text-sm rounded-lg border transition-all duration-200 ${
basics.visibility === 'private'
? 'border-primary-blue bg-primary-blue/10 text-primary-blue shadow-[0_0_15px_rgba(25,140,255,0.2)]'
: 'border-charcoal-outline bg-iron-gray/50 text-gray-300 hover:border-gray-500 hover:bg-iron-gray'
} ${disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}`}
>
<Lock className={`w-5 h-5 ${basics.visibility === 'private' ? 'text-primary-blue' : 'text-gray-400'}`} />
<div className="text-center">
<div className="font-medium">Private</div>
<div className="text-xs text-gray-500 mt-0.5">Invite only</div>
</div>
</button>
</div>
{errors?.visibility && (
<p className="mt-1 text-xs text-warning-amber flex items-center gap-1">
<span>⚠️</span>
{errors.visibility}
</p>
)}
</div>
{/* Game */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-gray-300">
<Gamepad2 className="w-4 h-4 text-gray-400" />
Game platform
</label>
<div className="relative">
<Input value="iRacing" disabled /> <Input value="iRacing" disabled />
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-gray-500">
More platforms soon
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,6 @@
'use client'; 'use client';
import { TrendingDown, Info } from 'lucide-react';
import type { LeagueConfigFormModel } from '@gridpilot/racing/application'; import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
import Input from '@/components/ui/Input'; import Input from '@/components/ui/Input';
import SegmentedControl from '@/components/ui/SegmentedControl'; import SegmentedControl from '@/components/ui/SegmentedControl';
@@ -52,6 +53,22 @@ export function LeagueDropSection({
}); });
}; };
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 = () => { const computeSummary = () => {
if (dropPolicy.strategy === 'none') { if (dropPolicy.strategy === 'none') {
return 'All results will count towards the championship.'; return 'All results will count towards the championship.';
@@ -80,58 +97,104 @@ export function LeagueDropSection({
? 'bestN' ? 'bestN'
: 'dropWorstN'; : 'dropWorstN';
const suggestedN = getSuggestedN();
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-sm font-semibold text-white">Drop rule</h3> <div className="space-y-2">
<p className="text-xs text-gray-400"> <div className="flex items-center gap-2">
Decide whether to count every round or ignore a few worst results. <TrendingDown className="w-4 h-4 text-primary-blue" />
</p> <h3 className="text-sm font-semibold text-white">Drop rule</h3>
</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>
<SegmentedControl <div className="space-y-3">
options={[ <SegmentedControl
{ value: 'all', label: 'All count' }, options={[
{ value: 'bestN', label: 'Best N' }, { value: 'all', label: 'All count', description: 'Every race matters' },
{ value: 'dropWorstN', label: 'Drop worst N' }, { value: 'bestN', label: 'Best N', description: 'Keep best results' },
]} { value: 'dropWorstN', label: 'Drop worst N', description: 'Ignore worst results' },
value={currentStrategyValue} ]}
onChange={(value) => { value={currentStrategyValue}
if (disabled) return; onChange={(value) => {
if (value === 'all') { if (disabled) return;
handleStrategyChange('none'); if (value === 'all') {
} else if (value === 'bestN') { handleStrategyChange('none');
handleStrategyChange('bestNResults'); } else if (value === 'bestN') {
} else if (value === 'dropWorstN') { handleStrategyChange('bestNResults');
handleStrategyChange('dropWorstN'); } 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>
)}
</div>
{(dropPolicy.strategy === 'bestNResults' || {(dropPolicy.strategy === 'bestNResults' ||
dropPolicy.strategy === 'dropWorstN') && ( dropPolicy.strategy === 'dropWorstN') && (
<div className="mt-2 max-w-[140px]"> <div className="space-y-3">
<label className="mb-1 block text-xs font-medium text-gray-300"> {suggestedN && (
N <div className="rounded-lg bg-primary-blue/5 border border-primary-blue/20 p-3">
</label> <p className="text-xs text-gray-300 mb-2">
<Input <span className="font-medium text-primary-blue">Suggested:</span> {suggestedN.explanation}
type="number" </p>
value={ <button
typeof dropPolicy.n === 'number' && dropPolicy.n > 0 type="button"
? String(dropPolicy.n) 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"
onChange={(e) => handleNChange(e.target.value)} >
disabled={disabled} Use suggested value ({suggestedN.value})
min={1} </button>
/> </div>
<p className="mt-1 text-[11px] text-gray-500"> )}
{dropPolicy.strategy === 'bestNResults'
? 'For example, best 6 of 10 rounds count.' <div className="rounded-lg border border-charcoal-outline bg-iron-gray/30 p-4 space-y-3">
: 'For example, drop the worst 2 results.'} <div className="flex items-start gap-2">
</p> <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>
</div>
</div>
</div>
</div> </div>
)} )}
<p className="mt-3 text-xs text-gray-400">{computeSummary()}</p> <div className="rounded-lg border border-charcoal-outline/50 bg-iron-gray/30 p-4">
<div className="flex items-start gap-2">
<Info className="w-4 h-4 text-primary-blue mt-0.5 shrink-0" />
<div className="text-xs text-gray-300">{computeSummary()}</div>
</div>
</div>
</div> </div>
); );
} }

View File

@@ -1,5 +1,6 @@
'use client'; 'use client';
import { FileText, Users, Calendar, Trophy, Award, Info } from 'lucide-react';
import Card from '@/components/ui/Card'; import Card from '@/components/ui/Card';
import type { LeagueConfigFormModel } from '@gridpilot/racing/application'; import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
import type { LeagueScoringPresetDTO } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider'; import type { LeagueScoringPresetDTO } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider';
@@ -86,13 +87,29 @@ export default function LeagueReviewSummary({ form, presets }: LeagueReviewSumma
const gameLabel = 'iRacing'; const gameLabel = 'iRacing';
return ( return (
<Card className="bg-iron-gray/80"> <div className="space-y-6">
<div className="space-y-6 text-sm text-gray-200"> <div className="rounded-lg bg-primary-blue/5 border border-primary-blue/20 p-4">
{/* 1. Basics & visibility */} <div className="flex items-start gap-3">
<section className="space-y-3"> <Info className="w-5 h-5 text-primary-blue shrink-0 mt-0.5" />
<h3 className="text-[11px] font-semibold text-gray-400 uppercase tracking-wide"> <div>
Basics & visibility <p className="text-sm font-medium text-white mb-1">Review your league configuration</p>
</h3> <p className="text-xs text-gray-400">
Double-check all settings before creating your league. You can modify most of these later.
</p>
</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"> <dl className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-1"> <div className="space-y-1">
<dt className="text-xs text-gray-500">Name</dt> <dt className="text-xs text-gray-500">Name</dt>
@@ -115,11 +132,14 @@ export default function LeagueReviewSummary({ form, presets }: LeagueReviewSumma
</dl> </dl>
</section> </section>
{/* 2. Structure & capacity */} {/* 2. Structure & capacity */}
<section className="space-y-3"> <section className="space-y-3">
<h3 className="text-[11px] font-semibold text-gray-400 uppercase tracking-wide"> <div className="flex items-center gap-2 pb-2 border-b border-charcoal-outline/40">
Structure & capacity <Users className="w-4 h-4 text-primary-blue" />
</h3> <h3 className="text-sm font-semibold text-white">
Structure & capacity
</h3>
</div>
<dl className="grid grid-cols-1 gap-4 md:grid-cols-2"> <dl className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-1"> <div className="space-y-1">
<dt className="text-xs text-gray-500">Mode</dt> <dt className="text-xs text-gray-500">Mode</dt>
@@ -132,11 +152,14 @@ export default function LeagueReviewSummary({ form, presets }: LeagueReviewSumma
</dl> </dl>
</section> </section>
{/* 3. Schedule & timings */} {/* 3. Schedule & timings */}
<section className="space-y-3"> <section className="space-y-3">
<h3 className="text-[11px] font-semibold text-gray-400 uppercase tracking-wide"> <div className="flex items-center gap-2 pb-2 border-b border-charcoal-outline/40">
Schedule & timings <Calendar className="w-4 h-4 text-primary-blue" />
</h3> <h3 className="text-sm font-semibold text-white">
Schedule & timings
</h3>
</div>
<dl className="grid grid-cols-1 gap-4 md:grid-cols-2"> <dl className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-1"> <div className="space-y-1">
<dt className="text-xs text-gray-500">Planned rounds</dt> <dt className="text-xs text-gray-500">Planned rounds</dt>
@@ -165,11 +188,14 @@ export default function LeagueReviewSummary({ form, presets }: LeagueReviewSumma
</dl> </dl>
</section> </section>
{/* 4. Scoring & drops */} {/* 4. Scoring & drops */}
<section className="space-y-3"> <section className="space-y-3">
<h3 className="text-[11px] font-semibold text-gray-400 uppercase tracking-wide"> <div className="flex items-center gap-2 pb-2 border-b border-charcoal-outline/40">
Scoring & drops <Trophy className="w-4 h-4 text-primary-blue" />
</h3> <h3 className="text-sm font-semibold text-white">
Scoring & drops
</h3>
</div>
<dl className="grid grid-cols-1 gap-4 md:grid-cols-2"> <dl className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-1"> <div className="space-y-1">
<dt className="text-xs text-gray-500">Scoring pattern</dt> <dt className="text-xs text-gray-500">Scoring pattern</dt>
@@ -192,19 +218,34 @@ export default function LeagueReviewSummary({ form, presets }: LeagueReviewSumma
</dl> </dl>
</section> </section>
{/* 5. Championships */} {/* 5. Championships */}
<section className="space-y-3"> <section className="space-y-3">
<h3 className="text-[11px] font-semibold text-gray-400 uppercase tracking-wide"> <div className="flex items-center gap-2 pb-2 border-b border-charcoal-outline/40">
Championships <Award className="w-4 h-4 text-primary-blue" />
</h3> <h3 className="text-sm font-semibold text-white">
<dl className="grid grid-cols-1 gap-4"> Championships
<div className="space-y-1"> </h3>
<dt className="text-xs text-gray-500">Enabled championships</dt>
<dd>{championshipsSummary}</dd>
</div> </div>
</dl> <dl className="grid grid-cols-1 gap-4">
</section> <div className="space-y-1">
</div> <dt className="text-xs text-gray-500">Enabled championships</dt>
</Card> <dd className="flex flex-wrap gap-2">
{enabledChampionshipsLabels.length > 0 ? (
enabledChampionshipsLabels.map((label) => (
<span key={label} className="inline-flex items-center gap-1 rounded-full bg-primary-blue/10 px-3 py-1 text-xs font-medium text-primary-blue">
<Award className="w-3 h-3" />
{label}
</span>
))
) : (
<span className="text-gray-400">None enabled yet</span>
)}
</dd>
</div>
</dl>
</section>
</div>
</Card>
</div>
); );
} }

View File

@@ -1,5 +1,6 @@
'use client'; 'use client';
import { Trophy, Award, Star, Target } from 'lucide-react';
import type { LeagueScoringPresetDTO } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider'; import type { LeagueScoringPresetDTO } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider';
import type { LeagueConfigFormModel } from '@gridpilot/racing/application'; import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
@@ -185,10 +186,13 @@ export function ScoringPatternSection({
return ( return (
<section className="space-y-4"> <section className="space-y-4">
<header className="space-y-1"> <header className="space-y-2">
<h3 className="text-sm font-semibold text-white">Scoring pattern</h3> <div className="flex items-center gap-2">
<Trophy className="w-4 h-4 text-primary-blue" />
<h3 className="text-sm font-semibold text-white">Scoring pattern</h3>
</div>
<p className="text-xs text-gray-400"> <p className="text-xs text-gray-400">
Pick an overall scoring style; details can evolve later. Choose a preset that matches your race weekend format
</p> </p>
</header> </header>
@@ -219,25 +223,44 @@ export function ScoringPatternSection({
<p className="text-xs text-warning-amber">{patternError}</p> <p className="text-xs text-warning-amber">{patternError}</p>
)} )}
<div className="mt-3 space-y-2 rounded-lg border border-charcoal-outline/70 bg-deep-graphite/70 p-3 text-xs text-gray-300"> <div className="mt-3 space-y-3 rounded-lg border border-charcoal-outline/70 bg-iron-gray/40 p-4">
<div className="font-semibold text-gray-200">Selected pattern</div> <div className="flex items-center gap-2">
<Star className="w-4 h-4 text-primary-blue" />
<div className="font-semibold text-gray-200">Selected pattern</div>
</div>
{currentPreset ? ( {currentPreset ? (
<div className="mt-1 space-y-1 text-[11px]"> <div className="space-y-2 text-xs">
<div className="inline-flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="inline-flex rounded-full bg-primary-blue/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-primary-blue"> <span className="inline-flex rounded-full bg-primary-blue/10 px-3 py-1 text-xs font-medium text-primary-blue">
{currentPreset.name} {currentPreset.name}
</span> </span>
</div> </div>
<p className="text-gray-300">Sessions: {currentPreset.sessionSummary}</p> <div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-xs">
<p className="text-gray-300"> <div className="flex items-start gap-2">
Points focus: {currentPreset ? renderPrimaryLabel(currentPreset) : '—'} <Target className="w-3 h-3 mt-0.5 text-gray-400 shrink-0" />
</p> <div>
<p className="text-gray-300"> <div className="text-gray-500">Sessions</div>
Default drops: {currentPreset.dropPolicySummary} <div className="text-gray-200">{currentPreset.sessionSummary}</div>
</p> </div>
</div>
<div className="flex items-start gap-2">
<Trophy className="w-3 h-3 mt-0.5 text-gray-400 shrink-0" />
<div>
<div className="text-gray-500">Points focus</div>
<div className="text-gray-200">{renderPrimaryLabel(currentPreset)}</div>
</div>
</div>
<div className="flex items-start gap-2">
<Award className="w-3 h-3 mt-0.5 text-gray-400 shrink-0" />
<div>
<div className="text-gray-500">Default drops</div>
<div className="text-gray-200">{currentPreset.dropPolicySummary}</div>
</div>
</div>
</div>
</div> </div>
) : ( ) : (
<p className="text-[11px] text-gray-500"> <p className="text-xs text-gray-500">
No pattern selected yet. Pick a card above to define your scoring style. No pattern selected yet. Pick a card above to define your scoring style.
</p> </p>
)} )}
@@ -307,21 +330,29 @@ export function ChampionshipsSection({
return ( return (
<section className="space-y-4"> <section className="space-y-4">
<header className="space-y-1"> <header className="space-y-2">
<h3 className="text-sm font-semibold text-white">Championships</h3> <div className="flex items-center gap-2">
<Award className="w-4 h-4 text-primary-blue" />
<h3 className="text-sm font-semibold text-white">Championships</h3>
</div>
<p className="text-xs text-gray-400"> <p className="text-xs text-gray-400">
Pick which standings you want to maintain for this season. Select which championship standings to track this season
</p> </p>
</header> </header>
<div className="space-y-3 text-xs text-gray-300"> <div className="space-y-3 text-xs text-gray-300">
{/* Driver championship */} {/* Driver championship */}
<div className="flex items-start justify-between gap-3 rounded-lg bg-deep-graphite/40 p-3"> <div className="flex items-start justify-between gap-3 rounded-lg border border-charcoal-outline/50 bg-iron-gray/30 p-4 transition-all duration-200 hover:border-primary-blue/30">
<div className="space-y-0.5"> <div className="flex items-start gap-3">
<div className="text-xs font-medium text-gray-100">Driver championship</div> <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary-blue/10 shrink-0">
<p className="text-[11px] text-gray-500"> <Trophy className="w-4 h-4 text-primary-blue" />
Per-driver season standings across all points-scoring sessions. </div>
</p> <div className="space-y-1">
<div className="text-sm font-medium text-gray-100">Driver championship</div>
<p className="text-xs text-gray-500">
Per-driver season standings across all points-scoring sessions.
</p>
</div>
</div> </div>
<div className="shrink-0"> <div className="shrink-0">
{readOnly ? ( {readOnly ? (
@@ -354,17 +385,22 @@ export function ChampionshipsSection({
</div> </div>
{/* Team championship */} {/* Team championship */}
<div className="flex items-start justify-between gap-3 rounded-lg bg-deep-graphite/40 p-3"> <div className={`flex items-start justify-between gap-3 rounded-lg border border-charcoal-outline/50 bg-iron-gray/30 p-4 transition-all duration-200 ${isTeamsMode ? 'hover:border-primary-blue/30' : 'opacity-60'}`}>
<div className="space-y-0.5"> <div className="flex items-start gap-3">
<div className="text-xs font-medium text-gray-100">Team championship</div> <div className={`flex h-8 w-8 items-center justify-center rounded-lg shrink-0 ${isTeamsMode ? 'bg-primary-blue/10' : 'bg-gray-500/10'}`}>
<p className="text-[11px] text-gray-500"> <Award className={`w-4 h-4 ${isTeamsMode ? 'text-primary-blue' : 'text-gray-500'}`} />
Aggregated season standings for fixed teams. </div>
</p> <div className="space-y-1">
{!isTeamsMode && ( <div className="text-sm font-medium text-gray-100">Team championship</div>
<p className="text-[10px] text-gray-500"> <p className="text-xs text-gray-500">
Enable team mode in Structure to turn this on. Aggregated season standings for fixed teams.
</p> </p>
)} {!isTeamsMode && (
<p className="text-xs text-warning-amber/80">
Enable team mode in Structure to activate this
</p>
)}
</div>
</div> </div>
<div className="shrink-0"> <div className="shrink-0">
{readOnly ? ( {readOnly ? (
@@ -405,12 +441,17 @@ export function ChampionshipsSection({
</div> </div>
{/* Nations championship */} {/* Nations championship */}
<div className="flex items-start justify-between gap-3 rounded-lg bg-deep-graphite/40 p-3"> <div className="flex items-start justify-between gap-3 rounded-lg border border-charcoal-outline/50 bg-iron-gray/30 p-4 transition-all duration-200 hover:border-primary-blue/30">
<div className="space-y-0.5"> <div className="flex items-start gap-3">
<div className="text-xs font-medium text-gray-100">Nations Cup</div> <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary-blue/10 shrink-0">
<p className="text-[11px] text-gray-500"> <Star className="w-4 h-4 text-primary-blue" />
Standings grouped by drivers' nationality or country flag. </div>
</p> <div className="space-y-1">
<div className="text-sm font-medium text-gray-100">Nations Cup</div>
<p className="text-xs text-gray-500">
Standings grouped by drivers' nationality or country flag.
</p>
</div>
</div> </div>
<div className="shrink-0"> <div className="shrink-0">
{readOnly ? ( {readOnly ? (
@@ -443,12 +484,17 @@ export function ChampionshipsSection({
</div> </div>
{/* Trophy championship */} {/* Trophy championship */}
<div className="flex items-start justify-between gap-3 rounded-lg bg-deep-graphite/40 p-3"> <div className="flex items-start justify-between gap-3 rounded-lg border border-charcoal-outline/50 bg-iron-gray/30 p-4 transition-all duration-200 hover:border-primary-blue/30">
<div className="space-y-0.5"> <div className="flex items-start gap-3">
<div className="text-xs font-medium text-gray-100">Trophy / cup</div> <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary-blue/10 shrink-0">
<p className="text-[11px] text-gray-500"> <Award className="w-4 h-4 text-primary-blue" />
Extra cup-style standings for special categories or invite-only groups. </div>
</p> <div className="space-y-1">
<div className="text-sm font-medium text-gray-100">Trophy / cup</div>
<p className="text-xs text-gray-500">
Extra cup-style standings for special categories or invite-only groups.
</p>
</div>
</div> </div>
<div className="shrink-0"> <div className="shrink-0">
{readOnly ? ( {readOnly ? (
@@ -480,9 +526,12 @@ export function ChampionshipsSection({
</div> </div>
</div> </div>
<p className="pt-1 text-[10px] text-gray-500"> <div className="pt-2 rounded-md bg-deep-graphite/40 p-3 text-xs text-gray-500 border border-charcoal-outline/30">
For this alpha slice, only driver standings are fully calculated, but these toggles express intent for future seasons. <p className="flex items-start gap-2">
</p> <span className="shrink-0"></span>
<span>For this alpha, only driver standings are fully calculated. These toggles express intent for future seasons.</span>
</p>
</div>
</div> </div>
</section> </section>
); );

View File

@@ -1,5 +1,6 @@
'use client'; 'use client';
import { User, Users2, Info } from 'lucide-react';
import Input from '@/components/ui/Input'; import Input from '@/components/ui/Input';
import SegmentedControl from '@/components/ui/SegmentedControl'; import SegmentedControl from '@/components/ui/SegmentedControl';
import type { LeagueConfigFormModel } from '@gridpilot/racing/application'; import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
@@ -128,71 +129,162 @@ export function LeagueStructureSection({
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<h2 className="text-lg font-semibold text-white"> {/* League structure selection */}
Step 2 Structure &amp; capacity <div className="space-y-3">
</h2> <label className="flex items-center gap-2 text-sm font-medium text-gray-300">
<Users2 className="w-4 h-4 text-primary-blue" />
League structure
</label>
<SegmentedControl
options={[
{
value: 'solo',
label: 'Drivers only (Solo)',
description: 'Individual drivers score points.',
},
{
value: 'fixedTeams',
label: 'Teams',
description: 'Teams with fixed drivers per team.',
},
]}
value={structure.mode}
onChange={
disabled
? undefined
: (mode) => handleModeChange(mode as 'solo' | 'fixedTeams')
}
/>
</div>
<div className="space-y-5"> {/* Solo mode capacity */}
<div> {structure.mode === 'solo' && (
<span className="block text-sm font-medium text-gray-300 mb-2"> <div className="rounded-lg border border-charcoal-outline bg-iron-gray/30 p-5 space-y-4">
League structure <div className="flex items-start gap-3">
</span> <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-blue/10 shrink-0">
<SegmentedControl <User className="w-5 h-5 text-primary-blue" />
options={[ </div>
{ <div className="flex-1">
value: 'solo', <h3 className="text-sm font-semibold text-white mb-1">Driver capacity</h3>
label: 'Drivers only (Solo)', <p className="text-xs text-gray-500">
description: 'Individual drivers score points.', Set the maximum number of drivers who can join your league
}, </p>
{ </div>
value: 'fixedTeams', </div>
label: 'Teams',
description: 'Teams with fixed drivers per team.',
},
]}
value={structure.mode}
onChange={
disabled
? undefined
: (mode) => handleModeChange(mode as 'solo' | 'fixedTeams')
}
/>
</div>
{structure.mode === 'solo' && ( <div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="space-y-2">
<div>
<label className="block text-sm font-medium text-gray-300"> <label className="block text-sm font-medium text-gray-300">
Max drivers Max drivers
</label> </label>
<p className="text-xs text-gray-500"> <Input
Typical club leagues use 2030 type="number"
</p> value={structure.maxDrivers ?? 24}
<div className="mt-2"> onChange={(e) => handleMaxDriversChange(e.target.value)}
<Input disabled={disabled}
type="number" min={1}
value={structure.maxDrivers ?? 24} max={64}
onChange={(e) => handleMaxDriversChange(e.target.value)} className="w-32"
disabled={disabled} />
min={1} <div className="space-y-2">
max={64} <p className="text-xs text-gray-500 flex items-start gap-1.5">
className="w-28" <Info className="w-3 h-3 mt-0.5 shrink-0" />
/> <span>Typical club leagues use 2030 drivers</span>
</p>
<div className="flex flex-wrap gap-2 text-xs">
<button
type="button"
onClick={() => handleMaxDriversChange('20')}
disabled={disabled}
className="px-2 py-1 rounded bg-iron-gray border border-charcoal-outline text-gray-300 hover:border-primary-blue hover:text-primary-blue transition-colors"
>
Small (20)
</button>
<button
type="button"
onClick={() => handleMaxDriversChange('24')}
disabled={disabled}
className="px-2 py-1 rounded bg-iron-gray border border-charcoal-outline text-gray-300 hover:border-primary-blue hover:text-primary-blue transition-colors"
>
Medium (24)
</button>
<button
type="button"
onClick={() => handleMaxDriversChange('30')}
disabled={disabled}
className="px-2 py-1 rounded bg-iron-gray border border-charcoal-outline text-gray-300 hover:border-primary-blue hover:text-primary-blue transition-colors"
>
Large (30)
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
)} </div>
)}
{structure.mode === 'fixedTeams' && ( {/* Teams mode capacity */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> {structure.mode === 'fixedTeams' && (
<div> <div className="rounded-lg border border-charcoal-outline bg-iron-gray/30 p-5 space-y-4">
<label className="block text-sm font-medium text-gray-300"> <div className="flex items-start gap-3">
Max teams <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-blue/10 shrink-0">
</label> <Users2 className="w-5 h-5 text-primary-blue" />
</div>
<div className="flex-1">
<h3 className="text-sm font-semibold text-white mb-1">Team structure</h3>
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
Roughly how many teams you expect. Configure the team composition and maximum grid size
</p> </p>
<div className="mt-2"> </div>
</div>
<div className="space-y-4">
<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">Quick setup:</span> Choose a common configuration
</p>
<div className="flex flex-wrap gap-2 mt-2">
<button
type="button"
onClick={() => {
handleMaxTeamsChange('10');
handleDriversPerTeamChange('2');
}}
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"
>
10 teams × 2 drivers (20 grid)
</button>
<button
type="button"
onClick={() => {
handleMaxTeamsChange('12');
handleDriversPerTeamChange('2');
}}
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"
>
12 teams × 2 drivers (24 grid)
</button>
<button
type="button"
onClick={() => {
handleMaxTeamsChange('8');
handleDriversPerTeamChange('3');
}}
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"
>
8 teams × 3 drivers (24 grid)
</button>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-300">
Max teams
</label>
<Input <Input
type="number" type="number"
value={structure.maxTeams ?? 12} value={structure.maxTeams ?? 12}
@@ -200,18 +292,18 @@ export function LeagueStructureSection({
disabled={disabled} disabled={disabled}
min={1} min={1}
max={32} max={32}
className="w-28" className="w-32"
/> />
<p className="text-xs text-gray-500 flex items-start gap-1.5">
<Info className="w-3 h-3 mt-0.5 shrink-0" />
<span>Total competing teams</span>
</p>
</div> </div>
</div>
<div> <div className="space-y-2">
<label className="block text-sm font-medium text-gray-300"> <label className="block text-sm font-medium text-gray-300">
Drivers per team Drivers per team
</label> </label>
<p className="text-xs text-gray-500">
Common values are 23 drivers.
</p>
<div className="mt-2">
<Input <Input
type="number" type="number"
value={structure.driversPerTeam ?? 2} value={structure.driversPerTeam ?? 2}
@@ -219,28 +311,36 @@ export function LeagueStructureSection({
disabled={disabled} disabled={disabled}
min={1} min={1}
max={6} max={6}
className="w-28" className="w-32"
/> />
<p className="text-xs text-gray-500 flex items-start gap-1.5">
<Info className="w-3 h-3 mt-0.5 shrink-0" />
<span>Common: 23 drivers</span>
</p>
</div> </div>
</div>
<div> <div className="space-y-2">
<label className="block text-sm font-medium text-gray-300"> <label className="block text-sm font-medium text-gray-300">
Max drivers (derived) Total grid size
</label> </label>
<p className="text-xs text-gray-500"> <div className="flex items-center gap-2">
Calculated as teams × drivers per team. <Input
</p> type="number"
<div className="mt-2 max-w-[7rem]"> value={structure.maxDrivers ?? 0}
<Input disabled
type="number" className="w-32"
value={structure.maxDrivers ?? 0} />
disabled <div className="text-xs text-gray-500">drivers</div>
/> </div>
<p className="text-xs text-gray-500 flex items-start gap-1.5">
<Info className="w-3 h-3 mt-0.5 shrink-0" />
<span>Auto-calculated from teams × drivers</span>
</p>
</div> </div>
</div> </div>
</div> </div>
)} </div>
</div> )}
</div> </div>
); );
} }

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Calendar, Clock, MapPin, Zap, Info, Loader2 } from 'lucide-react';
import type { import type {
LeagueConfigFormModel, LeagueConfigFormModel,
LeagueSchedulePreviewDTO, LeagueSchedulePreviewDTO,
@@ -312,66 +313,110 @@ export function LeagueTimingsSection({
const weekendTemplateValue = weekendTemplate ?? ''; const weekendTemplateValue = weekendTemplate ?? '';
return ( return (
<div className="space-y-6"> <div className="space-y-8">
<Heading level={3} className="text-lg font-semibold text-white"> {/* Step intro */}
{title ?? 'Schedule & timings'} <div className="rounded-lg bg-primary-blue/5 border border-primary-blue/20 p-4">
</Heading> <div className="flex items-start gap-2">
<Info className="w-4 h-4 text-primary-blue shrink-0 mt-0.5" />
<p className="text-sm text-gray-300">
<span className="font-medium text-primary-blue">Quick setup:</span> Pick a weekend template and season length. The detailed schedule configuration is optionalyou can set it now or schedule races manually later.
</p>
</div>
</div>
<div className="space-y-6"> {/* 1. Weekend template - FIRST */}
{/* Season length block */} <section className="space-y-4">
<section className="space-y-3"> <div className="flex items-center gap-2">
<h4 className="text-xs font-semibold uppercase tracking-wide text-gray-400"> <Zap className="w-4 h-4 text-primary-blue" />
Season length <h4 className="text-sm font-semibold text-white">
1. Choose your race weekend format
</h4> </h4>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2"> </div>
<div> <p className="text-xs text-gray-500">
<label className="mb-1 block text-sm font-medium text-gray-300"> This determines session counts and sets sensible duration defaults
Planned rounds </p>
</label> <SegmentedControl
<div className="w-24"> options={[
<Input { value: 'feature', label: 'Feature only', description: '1 race per weekend' },
type="number" { value: 'sprintFeature', label: 'Sprint + Feature', description: '2 races per weekend' },
value={ { value: 'endurance', label: 'Endurance', description: 'Longer races' },
typeof timings.roundsPlanned === 'number' ]}
? String(timings.roundsPlanned) value={weekendTemplateValue}
: '' onChange={onWeekendTemplateChange}
} />
onChange={(e) => handleRoundsChange(e.target.value)} </section>
min={1}
error={!!errors?.roundsPlanned} {/* 2. Season length */}
errorMessage={errors?.roundsPlanned} <section className="space-y-4">
/> <div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-primary-blue" />
<h4 className="text-sm font-semibold text-white">
2. How many race rounds?
</h4>
</div>
<div className="space-y-2">
<Input
type="number"
value={
typeof timings.roundsPlanned === 'number'
? String(timings.roundsPlanned)
: ''
}
onChange={(e) => handleRoundsChange(e.target.value)}
min={1}
error={!!errors?.roundsPlanned}
errorMessage={errors?.roundsPlanned}
className="w-32"
placeholder="e.g., 10"
/>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => handleRoundsChange('6')}
className="px-3 py-1.5 rounded text-xs bg-iron-gray border border-charcoal-outline text-gray-300 hover:border-primary-blue hover:text-primary-blue transition-colors"
>
Short season (6)
</button>
<button
type="button"
onClick={() => handleRoundsChange('10')}
className="px-3 py-1.5 rounded text-xs bg-iron-gray border border-charcoal-outline text-gray-300 hover:border-primary-blue hover:text-primary-blue transition-colors"
>
Medium season (10)
</button>
<button
type="button"
onClick={() => handleRoundsChange('16')}
className="px-3 py-1.5 rounded text-xs bg-iron-gray border border-charcoal-outline text-gray-300 hover:border-primary-blue hover:text-primary-blue transition-colors"
>
Long season (16)
</button>
</div>
<p className="text-xs text-gray-500">
Used for drop rule suggestions. Can be approximateyou can always add or remove rounds.
</p>
</div>
</section>
{/* 3. Optional: Detailed schedule */}
<section className="space-y-4">
<div className="rounded-lg border-2 border-dashed border-charcoal-outline p-4 space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<Clock className="w-4 h-4 text-gray-400" />
<h4 className="text-sm font-semibold text-white">
3. Automatic schedule (optional)
</h4>
</div> </div>
<p className="mt-1 text-xs text-gray-500"> <p className="text-xs text-gray-500">
Used for planning and drop hints; can be approximate. Configure when races happen automatically, or skip this and schedule rounds manually later
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-300">
Sessions per weekend
</label>
<div className="w-24">
<Input
type="number"
value={String(timings.sessionCount)}
onChange={(e) => handleSessionCountChange(e.target.value)}
min={1}
/>
</div>
<p className="mt-1 text-xs text-gray-500">
Typically 1 for feature-only; 2 for sprint + feature.
</p> </p>
</div> </div>
</div> </div>
</section>
{/* Race schedule block */} <div className="space-y-4">
<section className="space-y-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<h4 className="text-xs font-semibold uppercase tracking-wide text-gray-400">
Race schedule
</h4>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div> <div>
<label className="mb-1 block text-sm font-medium text-gray-300"> <label className="mb-1 block text-sm font-medium text-gray-300">
Season start date Season start date
@@ -428,56 +473,47 @@ export function LeagueTimingsSection({
</select> </select>
</div> </div>
</div> </div>
</div> </div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-[minmax(0,2fr)_minmax(0,3fr)] items-start">
<div className="space-y-3"> <div className="space-y-3">
<div> <label className="block text-sm font-medium text-gray-300">
<label className="mb-1 block text-sm font-medium text-gray-300"> How often do races occur?
Cadence </label>
</label> <SegmentedControl
<SegmentedControl options={[
options={[ { value: 'weekly', label: 'Weekly' },
{ value: 'weekly', label: 'Weekly' }, { value: 'everyNWeeks', label: 'Bi-weekly' },
{ value: 'everyNWeeks', label: 'Every N weeks' }, ]}
{ value={recurrenceStrategy}
value: 'monthlyNthWeekday', onChange={handleRecurrenceChange}
label: 'Monthly (beta)', />
disabled: true,
},
]}
value={recurrenceStrategy}
onChange={handleRecurrenceChange}
/>
</div>
{recurrenceStrategy === 'everyNWeeks' && ( {recurrenceStrategy === 'everyNWeeks' && (
<div className="flex items-center gap-2 text-sm text-gray-300"> <div className="flex items-center gap-2 text-sm">
<span>Every</span> <span className="text-gray-300">Every</span>
<div className="w-20"> <Input
<Input type="number"
type="number" min={1}
min={1} value={
value={ typeof timings.intervalWeeks === 'number'
typeof timings.intervalWeeks === 'number' ? String(timings.intervalWeeks)
? String(timings.intervalWeeks) : '2'
: '2' }
onChange={(e) => {
const raw = e.target.value.trim();
if (raw === '') {
updateTimings({ intervalWeeks: undefined });
return;
} }
onChange={(e) => { const parsed = parseInt(raw, 10);
const raw = e.target.value.trim(); updateTimings({
if (raw === '') { intervalWeeks:
updateTimings({ intervalWeeks: undefined }); Number.isNaN(parsed) || parsed <= 0 ? 2 : parsed,
return; });
} }}
const parsed = parseInt(raw, 10); className="w-20"
updateTimings({ />
intervalWeeks: <span className="text-gray-300">weeks</span>
Number.isNaN(parsed) || parsed <= 0 ? 2 : parsed,
});
}}
/>
</div>
<span>weeks</span>
</div> </div>
)} )}
</div> </div>
@@ -485,11 +521,11 @@ export function LeagueTimingsSection({
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<label className="block text-sm font-medium text-gray-300"> <label className="block text-sm font-medium text-gray-300">
Race days in a week Which day(s)?
</label> </label>
{requiresWeekdaySelection && ( {requiresWeekdaySelection && (
<span className="text-[11px] text-warning-amber"> <span className="text-xs text-warning-amber">
Select at least one weekday. Pick at least one
</span> </span>
)} )}
</div> </div>
@@ -502,10 +538,10 @@ export function LeagueTimingsSection({
key={day} key={day}
type="button" type="button"
onClick={() => handleWeekdayToggle(day)} onClick={() => handleWeekdayToggle(day)}
className={`px-3 py-1 text-xs font-medium rounded-full border transition-colors ${ className={`px-3 py-1.5 text-xs font-medium rounded-full border transition-all duration-200 ${
isActive isActive
? 'bg-primary-blue text-white border-primary-blue' ? 'bg-primary-blue text-white border-primary-blue shadow-[0_0_10px_rgba(25,140,255,0.3)]'
: 'bg-iron-gray/80 text-gray-300 border-charcoal-outline hover:bg-charcoal-outline/80 hover:text-white' : 'bg-iron-gray/80 text-gray-300 border-charcoal-outline hover:bg-charcoal-outline hover:text-white hover:border-gray-500'
}`} }`}
> >
{day} {day}
@@ -517,235 +553,196 @@ export function LeagueTimingsSection({
</div> </div>
</div> </div>
<div className="space-y-2 rounded-md border border-charcoal-outline bg-iron-gray/40 p-3"> {/* Schedule preview */}
{(timings.seasonStartDate && timings.raceStartTime && weekdays.length > 0) && (
<div className="space-y-3 rounded-lg border border-charcoal-outline bg-iron-gray/40 p-4">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div> <div className="flex items-center gap-2">
<p className="text-xs font-medium text-gray-200"> <Calendar className="w-4 h-4 text-primary-blue" />
Schedule summary <div>
</p> <p className="text-sm font-medium text-gray-200">
<p className="text-xs text-gray-400"> Schedule preview
{schedulePreview?.summary ?? </p>
'Set a start date, time, and at least one weekday to preview the schedule.'} <p className="text-xs text-gray-400">
</p> {schedulePreview?.summary ??
'Set a start date, time, and at least one weekday to preview the schedule.'}
</p>
</div>
</div> </div>
{isSchedulePreviewLoading && ( {isSchedulePreviewLoading && (
<span className="text-[11px] text-gray-400">Updating…</span> <Loader2 className="w-4 h-4 text-primary-blue animate-spin" />
)} )}
</div> </div>
<div className="mt-2 space-y-1"> <div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-gray-300">
Schedule preview
</p>
<p className="text-[11px] text-gray-500">
First few rounds with your current settings.
</p>
</div>
{schedulePreviewError && ( {schedulePreviewError && (
<p className="text-[11px] text-warning-amber"> <div className="rounded-md bg-warning-amber/10 p-3 border border-warning-amber/20 text-xs text-warning-amber flex items-start gap-2">
{schedulePreviewError} <span className="shrink-0">⚠️</span>
</p> <span>{schedulePreviewError}</span>
</div>
)} )}
{!schedulePreview && !schedulePreviewError && ( {!schedulePreview && !schedulePreviewError && (
<p className="text-[11px] text-gray-500"> <p className="text-xs text-gray-500 flex items-start gap-1.5">
Adjust the fields above to see a preview of your calendar. <Info className="w-3 h-3 mt-0.5 shrink-0" />
<span>Adjust the fields above to see a preview of your calendar.</span>
</p> </p>
)} )}
{schedulePreview && ( {schedulePreview && (
<div className="mt-1 space-y-1.5 text-xs text-gray-200"> <div className="space-y-2">
{schedulePreview.rounds.map((round) => { <p className="text-xs text-gray-500">
const date = new Date(round.scheduledAt); First few rounds with your current settings:
const dateStr = date.toLocaleDateString(undefined, { </p>
weekday: 'short', <div className="space-y-1.5">
day: 'numeric', {schedulePreview.rounds.map((round) => {
month: 'short', const date = new Date(round.scheduledAt);
}); const dateStr = date.toLocaleDateString(undefined, {
const timeStr = date.toLocaleTimeString([], { weekday: 'short',
hour: '2-digit', day: 'numeric',
minute: '2-digit', month: 'short',
}); });
const timeStr = date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
return ( return (
<div <div
key={round.roundNumber} key={round.roundNumber}
className="flex items-center justify-between gap-2" className="flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-deep-graphite/40 text-xs"
> >
<span className="text-gray-300"> <span className="font-medium text-gray-300">
Round {round.roundNumber} Round {round.roundNumber}
</span>
<span className="text-gray-200">
{dateStr}, {timeStr}{' '}
<span className="text-gray-500">
{round.timezoneId}
</span> </span>
</span> <span className="text-gray-200">
</div> {dateStr}, {timeStr}{' '}
); <span className="text-gray-500">
})} {round.timezoneId}
</span>
</span>
</div>
);
})}
{typeof timings.roundsPlanned === 'number' && {typeof timings.roundsPlanned === 'number' &&
timings.roundsPlanned > schedulePreview.rounds.length && ( timings.roundsPlanned > schedulePreview.rounds.length && (
<p className="pt-1 text-[11px] text-gray-500"> <p className="pt-1 text-xs text-gray-500 text-center">
+ + {timings.roundsPlanned - schedulePreview.rounds.length} more rounds scheduled
{timings.roundsPlanned - schedulePreview.rounds.length}{' '} </p>
more rounds scheduled. )}
</p> </div>
)}
</div> </div>
)} )}
</div> </div>
</div> </div>
</section> </div>
)}
</div>
</section>
{/* Weekend template block */} {/* 4. Optional: Session duration overrides */}
<section className="space-y-3"> <details className="group">
<h4 className="text-xs font-semibold uppercase tracking-wide text-gray-400"> <summary className="cursor-pointer list-none">
Weekend template <div className="flex items-center justify-between p-4 rounded-lg border border-charcoal-outline bg-iron-gray/20 hover:border-primary-blue/50 transition-colors">
</h4> <div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-gray-400" />
<h4 className="text-sm font-semibold text-white">
4. Customize session durations (optional)
</h4>
</div>
<span className="text-xs text-gray-500 group-open:hidden">
Click to customize
</span>
</div>
</summary>
<div className="mt-4 pl-6 space-y-4">
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
Pick a typical weekend; you can fine-tune durations below. Your weekend template already set reasonable defaults. Only change these if you need specific timings.
</p> </p>
<SegmentedControl
options={[ <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
{ value: 'feature', label: 'Feature only' }, <div className="space-y-2">
{ value: 'sprintFeature', label: 'Sprint + feature' }, <label className="block text-xs font-medium text-gray-300">
{ value: 'endurance', label: 'Endurance' }, Practice (min)
]}
value={weekendTemplateValue}
onChange={onWeekendTemplateChange}
/>
<p className="text-[11px] text-gray-500">
Templates set starting values only; you can override any number.
</p>
</section>
{/* Session durations block */}
<section className="space-y-3">
<h4 className="text-xs font-semibold uppercase tracking-wide text-gray-400">
Session durations
</h4>
<p className="text-xs text-gray-500">
Rough lengths for each session type; used for planning only.
</p>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label className="mb-1 block text-sm font-medium text-gray-300">
Practice duration (optional)
</label> </label>
<div className="w-24"> <Input
<Input type="number"
type="number" value={
value={ typeof timings.practiceMinutes === 'number' && timings.practiceMinutes > 0
typeof timings.practiceMinutes === 'number' && ? String(timings.practiceMinutes)
timings.practiceMinutes > 0 : ''
? String(timings.practiceMinutes) }
: '' onChange={(e) => handleNumericMinutesChange('practiceMinutes', e.target.value)}
} min={0}
onChange={(e) => className="w-24"
handleNumericMinutesChange( placeholder="20"
'practiceMinutes', />
e.target.value,
)
}
min={0}
/>
</div>
<p className="mt-1 text-xs text-gray-500">
Set to 0 or leave empty if you dont plan dedicated practice.
</p>
</div> </div>
<div> <div className="space-y-2">
<label className="mb-1 block text-sm font-medium text-gray-300"> <label className="block text-xs font-medium text-gray-300">
Qualifying duration Qualifying (min)
</label> </label>
<div className="w-24"> <Input
<Input type="number"
type="number" value={
value={ typeof timings.qualifyingMinutes === 'number' && timings.qualifyingMinutes > 0
typeof timings.qualifyingMinutes === 'number' && ? String(timings.qualifyingMinutes)
timings.qualifyingMinutes > 0 : ''
? String(timings.qualifyingMinutes) }
: '' onChange={(e) => handleNumericMinutesChange('qualifyingMinutes', e.target.value)}
} min={5}
onChange={(e) => error={!!errors?.qualifyingMinutes}
handleNumericMinutesChange( errorMessage={errors?.qualifyingMinutes}
'qualifyingMinutes', className="w-24"
e.target.value, placeholder="30"
) />
}
min={5}
error={!!errors?.qualifyingMinutes}
errorMessage={errors?.qualifyingMinutes}
/>
</div>
</div> </div>
{showSprint && ( {showSprint && (
<div> <div className="space-y-2">
<label className="mb-1 block text-sm font-medium text-gray-300"> <label className="block text-xs font-medium text-gray-300">
Sprint duration Sprint (min)
</label> </label>
<div className="w-24">
<Input
type="number"
value={
typeof timings.sprintRaceMinutes === 'number' &&
timings.sprintRaceMinutes > 0
? String(timings.sprintRaceMinutes)
: ''
}
onChange={(e) =>
handleNumericMinutesChange(
'sprintRaceMinutes',
e.target.value,
)
}
min={0}
/>
</div>
<p className="mt-1 text-xs text-gray-500">
Only shown when your scoring pattern includes a sprint race.
</p>
</div>
)}
<div>
<label className="mb-1 block text-sm font-medium text-gray-300">
Main race duration
</label>
<div className="w-24">
<Input <Input
type="number" type="number"
value={ value={
typeof timings.mainRaceMinutes === 'number' && typeof timings.sprintRaceMinutes === 'number' && timings.sprintRaceMinutes > 0
timings.mainRaceMinutes > 0 ? String(timings.sprintRaceMinutes)
? String(timings.mainRaceMinutes)
: '' : ''
} }
onChange={(e) => onChange={(e) => handleNumericMinutesChange('sprintRaceMinutes', e.target.value)}
handleNumericMinutesChange( min={0}
'mainRaceMinutes', className="w-24"
e.target.value, placeholder="20"
)
}
min={10}
error={!!errors?.mainRaceMinutes}
errorMessage={errors?.mainRaceMinutes}
/> />
</div> </div>
<p className="mt-1 text-xs text-gray-500"> )}
Approximate length of your main race.
</p> <div className="space-y-2">
<label className="block text-xs font-medium text-gray-300">
Main race (min)
</label>
<Input
type="number"
value={
typeof timings.mainRaceMinutes === 'number' && timings.mainRaceMinutes > 0
? String(timings.mainRaceMinutes)
: ''
}
onChange={(e) => handleNumericMinutesChange('mainRaceMinutes', e.target.value)}
min={10}
error={!!errors?.mainRaceMinutes}
errorMessage={errors?.mainRaceMinutes}
className="w-24"
placeholder="40"
/>
</div> </div>
</div> </div>
</section> </div>
</div> </details>
</div> </div>
); );
} }