wip
This commit is contained in:
@@ -2,10 +2,20 @@
|
||||
|
||||
import { useEffect, useState, FormEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
FileText,
|
||||
Users,
|
||||
Calendar,
|
||||
Trophy,
|
||||
Award,
|
||||
CheckCircle2,
|
||||
ChevronLeft,
|
||||
ChevronRight
|
||||
} from 'lucide-react';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import LeagueReviewSummary from './LeagueReviewSummary';
|
||||
import LeagueReviewSummary from '@/components/leagues/LeagueReviewSummary';
|
||||
import {
|
||||
getDriverRepository,
|
||||
getListLeagueScoringPresetsQuery,
|
||||
@@ -365,12 +375,12 @@ export default function CreateLeagueWizard() {
|
||||
};
|
||||
|
||||
const steps = [
|
||||
{ id: 1 as Step, label: 'Basics' },
|
||||
{ id: 2 as Step, label: 'Structure' },
|
||||
{ id: 3 as Step, label: 'Schedule & timings' },
|
||||
{ id: 4 as Step, label: 'Scoring pattern' },
|
||||
{ id: 5 as Step, label: 'Championships & drops' },
|
||||
{ id: 6 as Step, label: 'Review & confirm' },
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
const getStepTitle = (currentStep: Step): string => {
|
||||
@@ -412,35 +422,56 @@ export default function CreateLeagueWizard() {
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<Heading level={1} className="mb-2">
|
||||
Create a new league
|
||||
</Heading>
|
||||
<p className="text-sm text-gray-400 mb-4">
|
||||
Configure basics, structure, schedule, scoring, and drop rules in a few
|
||||
simple steps.
|
||||
</p>
|
||||
<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>
|
||||
|
||||
<div className="mb-4 flex flex-col gap-2">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{/* 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 baseCircleClasses =
|
||||
'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';
|
||||
const StepIcon = wizardStep.icon;
|
||||
|
||||
return (
|
||||
<div key={wizardStep.id} className="flex items-center gap-2">
|
||||
<div className={baseCircleClasses + ' ' + circleClasses}>
|
||||
{isCompleted ? '✓' : wizardStep.id}
|
||||
<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 ${
|
||||
className={`text-xs font-medium transition-colors duration-200 ${
|
||||
isCurrent
|
||||
? 'text-white'
|
||||
: isCompleted
|
||||
@@ -450,134 +481,180 @@ export default function CreateLeagueWizard() {
|
||||
>
|
||||
{wizardStep.label}
|
||||
</span>
|
||||
{index < steps.length - 1 && (
|
||||
<span className="mx-1 h-px w-6 bg-charcoal-outline/70" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div>
|
||||
<Heading level={2} className="text-2xl text-white">
|
||||
{getStepTitle(step)}
|
||||
</Heading>
|
||||
<p className="mt-1 text-sm text-gray-400">
|
||||
{getStepSubtitle(step)}
|
||||
</p>
|
||||
<hr className="my-4 border-charcoal-outline/40" />
|
||||
{/* 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>
|
||||
</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>
|
||||
|
||||
{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>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
{/* Navigation buttons */}
|
||||
<div className="flex justify-between items-center pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={step === 1 || loading}
|
||||
onClick={goToPreviousStep}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-3">
|
||||
{step < 6 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
disabled={loading}
|
||||
onClick={goToNextStep}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
Next
|
||||
Continue
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
{step === 6 && (
|
||||
<Button type="submit" variant="primary" disabled={loading}>
|
||||
{loading ? 'Creating…' : 'Create league'}
|
||||
<Button
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { FileText, Globe, Lock, Gamepad2 } from 'lucide-react';
|
||||
import Input from '@/components/ui/Input';
|
||||
import type {
|
||||
LeagueConfigFormModel,
|
||||
@@ -37,91 +38,143 @@ export function LeagueBasicsSection({
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-lg font-semibold text-white">Step 1 — Basics</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
League name *
|
||||
</label>
|
||||
<Input
|
||||
value={basics.name}
|
||||
onChange={(e) => updateBasics({ name: e.target.value })}
|
||||
placeholder="GridPilot Sprint Series"
|
||||
error={!!errors?.name}
|
||||
errorMessage={errors?.name}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={basics.description ?? ''}
|
||||
onChange={(e) =>
|
||||
updateBasics({
|
||||
description: e.target.value,
|
||||
})
|
||||
}
|
||||
rows={3}
|
||||
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"
|
||||
placeholder="Weekly league with structured championships and live standings."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span className="block text-sm font-medium text-gray-300 mb-2">
|
||||
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>
|
||||
)}
|
||||
{/* League name */}
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-300">
|
||||
<FileText className="w-4 h-4 text-primary-blue" />
|
||||
League name *
|
||||
</label>
|
||||
<Input
|
||||
value={basics.name}
|
||||
onChange={(e) => updateBasics({ name: e.target.value })}
|
||||
placeholder="e.g., GridPilot Sprint Series"
|
||||
error={!!errors?.name}
|
||||
errorMessage={errors?.name}
|
||||
disabled={disabled}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-gray-500">
|
||||
Choose a clear, memorable name that describes your league
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateBasics({ name: 'Weekly Sprint Championship' })}
|
||||
className="text-xs text-primary-blue hover:text-primary-blue/80 transition-colors"
|
||||
disabled={disabled}
|
||||
>
|
||||
Example: Weekly Sprint Championship
|
||||
</button>
|
||||
<span className="text-xs text-gray-600">•</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateBasics({ name: 'Sunday Evening Endurance' })}
|
||||
className="text-xs text-primary-blue hover:text-primary-blue/80 transition-colors"
|
||||
disabled={disabled}
|
||||
>
|
||||
Example: Sunday Evening Endurance
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Game
|
||||
</label>
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-300">
|
||||
<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 />
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-gray-500">
|
||||
More platforms soon
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { TrendingDown, Info } from 'lucide-react';
|
||||
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
|
||||
import Input from '@/components/ui/Input';
|
||||
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 = () => {
|
||||
if (dropPolicy.strategy === 'none') {
|
||||
return 'All results will count towards the championship.';
|
||||
@@ -80,58 +97,104 @@ export function LeagueDropSection({
|
||||
? 'bestN'
|
||||
: 'dropWorstN';
|
||||
|
||||
const suggestedN = getSuggestedN();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-white">Drop rule</h3>
|
||||
<p className="text-xs text-gray-400">
|
||||
Decide whether to count every round or ignore a few worst results.
|
||||
</p>
|
||||
<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>
|
||||
</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
|
||||
options={[
|
||||
{ value: 'all', label: 'All count' },
|
||||
{ value: 'bestN', label: 'Best N' },
|
||||
{ value: 'dropWorstN', label: 'Drop worst N' },
|
||||
]}
|
||||
value={currentStrategyValue}
|
||||
onChange={(value) => {
|
||||
if (disabled) return;
|
||||
if (value === 'all') {
|
||||
handleStrategyChange('none');
|
||||
} else if (value === 'bestN') {
|
||||
handleStrategyChange('bestNResults');
|
||||
} else if (value === 'dropWorstN') {
|
||||
handleStrategyChange('dropWorstN');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(dropPolicy.strategy === 'bestNResults' ||
|
||||
dropPolicy.strategy === 'dropWorstN') && (
|
||||
<div className="mt-2 max-w-[140px]">
|
||||
<label className="mb-1 block text-xs font-medium text-gray-300">
|
||||
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}
|
||||
/>
|
||||
<p className="mt-1 text-[11px] text-gray-500">
|
||||
{dropPolicy.strategy === 'bestNResults'
|
||||
? 'For example, best 6 of 10 rounds count.'
|
||||
: 'For example, drop the worst 2 results.'}
|
||||
</p>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border border-charcoal-outline bg-iron-gray/30 p-4 space-y-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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { FileText, Users, Calendar, Trophy, Award, Info } from 'lucide-react';
|
||||
import Card from '@/components/ui/Card';
|
||||
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
|
||||
import type { LeagueScoringPresetDTO } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider';
|
||||
@@ -86,13 +87,29 @@ export default function LeagueReviewSummary({ form, presets }: LeagueReviewSumma
|
||||
const gameLabel = 'iRacing';
|
||||
|
||||
return (
|
||||
<Card className="bg-iron-gray/80">
|
||||
<div className="space-y-6 text-sm text-gray-200">
|
||||
{/* 1. Basics & visibility */}
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-[11px] font-semibold text-gray-400 uppercase tracking-wide">
|
||||
Basics & visibility
|
||||
</h3>
|
||||
<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.
|
||||
</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">
|
||||
<div className="space-y-1">
|
||||
<dt className="text-xs text-gray-500">Name</dt>
|
||||
@@ -115,11 +132,14 @@ export default function LeagueReviewSummary({ form, presets }: LeagueReviewSumma
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{/* 2. Structure & capacity */}
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-[11px] font-semibold text-gray-400 uppercase tracking-wide">
|
||||
Structure & capacity
|
||||
</h3>
|
||||
{/* 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>
|
||||
@@ -132,11 +152,14 @@ export default function LeagueReviewSummary({ form, presets }: LeagueReviewSumma
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{/* 3. Schedule & timings */}
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-[11px] font-semibold text-gray-400 uppercase tracking-wide">
|
||||
Schedule & timings
|
||||
</h3>
|
||||
{/* 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>
|
||||
@@ -165,11 +188,14 @@ export default function LeagueReviewSummary({ form, presets }: LeagueReviewSumma
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{/* 4. Scoring & drops */}
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-[11px] font-semibold text-gray-400 uppercase tracking-wide">
|
||||
Scoring & drops
|
||||
</h3>
|
||||
{/* 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>
|
||||
@@ -192,19 +218,34 @@ export default function LeagueReviewSummary({ form, presets }: LeagueReviewSumma
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{/* 5. Championships */}
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-[11px] font-semibold text-gray-400 uppercase tracking-wide">
|
||||
Championships
|
||||
</h3>
|
||||
<dl className="grid grid-cols-1 gap-4">
|
||||
<div className="space-y-1">
|
||||
<dt className="text-xs text-gray-500">Enabled championships</dt>
|
||||
<dd>{championshipsSummary}</dd>
|
||||
{/* 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>
|
||||
</section>
|
||||
</div>
|
||||
</Card>
|
||||
<dl className="grid grid-cols-1 gap-4">
|
||||
<div className="space-y-1">
|
||||
<dt className="text-xs text-gray-500">Enabled championships</dt>
|
||||
<dd className="flex flex-wrap gap-2">
|
||||
{enabledChampionshipsLabels.length > 0 ? (
|
||||
enabledChampionshipsLabels.map((label) => (
|
||||
<span key={label} className="inline-flex items-center gap-1 rounded-full bg-primary-blue/10 px-3 py-1 text-xs font-medium text-primary-blue">
|
||||
<Award className="w-3 h-3" />
|
||||
{label}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-gray-400">None enabled yet</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { Trophy, Award, Star, Target } from 'lucide-react';
|
||||
import type { LeagueScoringPresetDTO } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider';
|
||||
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
|
||||
import Button from '@/components/ui/Button';
|
||||
@@ -185,10 +186,13 @@ export function ScoringPatternSection({
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<header className="space-y-1">
|
||||
<h3 className="text-sm font-semibold text-white">Scoring pattern</h3>
|
||||
<header className="space-y-2">
|
||||
<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">
|
||||
Pick an overall scoring style; details can evolve later.
|
||||
Choose a preset that matches your race weekend format
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -219,25 +223,44 @@ export function ScoringPatternSection({
|
||||
<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="font-semibold text-gray-200">Selected pattern</div>
|
||||
<div className="mt-3 space-y-3 rounded-lg border border-charcoal-outline/70 bg-iron-gray/40 p-4">
|
||||
<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 ? (
|
||||
<div className="mt-1 space-y-1 text-[11px]">
|
||||
<div className="inline-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">
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex rounded-full bg-primary-blue/10 px-3 py-1 text-xs font-medium text-primary-blue">
|
||||
{currentPreset.name}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-300">Sessions: {currentPreset.sessionSummary}</p>
|
||||
<p className="text-gray-300">
|
||||
Points focus: {currentPreset ? renderPrimaryLabel(currentPreset) : '—'}
|
||||
</p>
|
||||
<p className="text-gray-300">
|
||||
Default drops: {currentPreset.dropPolicySummary}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-xs">
|
||||
<div className="flex items-start gap-2">
|
||||
<Target className="w-3 h-3 mt-0.5 text-gray-400 shrink-0" />
|
||||
<div>
|
||||
<div className="text-gray-500">Sessions</div>
|
||||
<div className="text-gray-200">{currentPreset.sessionSummary}</div>
|
||||
</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>
|
||||
) : (
|
||||
<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.
|
||||
</p>
|
||||
)}
|
||||
@@ -307,21 +330,29 @@ export function ChampionshipsSection({
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<header className="space-y-1">
|
||||
<h3 className="text-sm font-semibold text-white">Championships</h3>
|
||||
<header className="space-y-2">
|
||||
<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">
|
||||
Pick which standings you want to maintain for this season.
|
||||
Select which championship standings to track this season
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="space-y-3 text-xs text-gray-300">
|
||||
{/* Driver championship */}
|
||||
<div className="flex items-start justify-between gap-3 rounded-lg bg-deep-graphite/40 p-3">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-xs font-medium text-gray-100">Driver championship</div>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
Per-driver season standings across all points-scoring sessions.
|
||||
</p>
|
||||
<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="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary-blue/10 shrink-0">
|
||||
<Trophy className="w-4 h-4 text-primary-blue" />
|
||||
</div>
|
||||
<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 className="shrink-0">
|
||||
{readOnly ? (
|
||||
@@ -354,17 +385,22 @@ export function ChampionshipsSection({
|
||||
</div>
|
||||
|
||||
{/* Team championship */}
|
||||
<div className="flex items-start justify-between gap-3 rounded-lg bg-deep-graphite/40 p-3">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-xs font-medium text-gray-100">Team championship</div>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
Aggregated season standings for fixed teams.
|
||||
</p>
|
||||
{!isTeamsMode && (
|
||||
<p className="text-[10px] text-gray-500">
|
||||
Enable team mode in Structure to turn this on.
|
||||
<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="flex items-start gap-3">
|
||||
<div className={`flex h-8 w-8 items-center justify-center rounded-lg shrink-0 ${isTeamsMode ? 'bg-primary-blue/10' : 'bg-gray-500/10'}`}>
|
||||
<Award className={`w-4 h-4 ${isTeamsMode ? 'text-primary-blue' : 'text-gray-500'}`} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium text-gray-100">Team championship</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
Aggregated season standings for fixed teams.
|
||||
</p>
|
||||
)}
|
||||
{!isTeamsMode && (
|
||||
<p className="text-xs text-warning-amber/80">
|
||||
Enable team mode in Structure to activate this
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{readOnly ? (
|
||||
@@ -405,12 +441,17 @@ export function ChampionshipsSection({
|
||||
</div>
|
||||
|
||||
{/* Nations championship */}
|
||||
<div className="flex items-start justify-between gap-3 rounded-lg bg-deep-graphite/40 p-3">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-xs font-medium text-gray-100">Nations Cup</div>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
Standings grouped by drivers' nationality or country flag.
|
||||
</p>
|
||||
<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="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary-blue/10 shrink-0">
|
||||
<Star className="w-4 h-4 text-primary-blue" />
|
||||
</div>
|
||||
<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 className="shrink-0">
|
||||
{readOnly ? (
|
||||
@@ -443,12 +484,17 @@ export function ChampionshipsSection({
|
||||
</div>
|
||||
|
||||
{/* Trophy championship */}
|
||||
<div className="flex items-start justify-between gap-3 rounded-lg bg-deep-graphite/40 p-3">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-xs font-medium text-gray-100">Trophy / cup</div>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
Extra cup-style standings for special categories or invite-only groups.
|
||||
</p>
|
||||
<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="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary-blue/10 shrink-0">
|
||||
<Award className="w-4 h-4 text-primary-blue" />
|
||||
</div>
|
||||
<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 className="shrink-0">
|
||||
{readOnly ? (
|
||||
@@ -480,9 +526,12 @@ export function ChampionshipsSection({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="pt-1 text-[10px] text-gray-500">
|
||||
For this alpha slice, only driver standings are fully calculated, but these toggles express intent for future seasons.
|
||||
</p>
|
||||
<div className="pt-2 rounded-md bg-deep-graphite/40 p-3 text-xs text-gray-500 border border-charcoal-outline/30">
|
||||
<p className="flex items-start gap-2">
|
||||
<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>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { User, Users2, Info } from 'lucide-react';
|
||||
import Input from '@/components/ui/Input';
|
||||
import SegmentedControl from '@/components/ui/SegmentedControl';
|
||||
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
|
||||
@@ -128,71 +129,162 @@ export function LeagueStructureSection({
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
Step 2 — Structure & capacity
|
||||
</h2>
|
||||
{/* League structure selection */}
|
||||
<div className="space-y-3">
|
||||
<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">
|
||||
<div>
|
||||
<span className="block text-sm font-medium text-gray-300 mb-2">
|
||||
League structure
|
||||
</span>
|
||||
<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>
|
||||
{/* Solo mode capacity */}
|
||||
{structure.mode === 'solo' && (
|
||||
<div className="rounded-lg border border-charcoal-outline bg-iron-gray/30 p-5 space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-blue/10 shrink-0">
|
||||
<User className="w-5 h-5 text-primary-blue" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-semibold text-white mb-1">Driver capacity</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
Set the maximum number of drivers who can join your league
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{structure.mode === 'solo' && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-300">
|
||||
Max drivers
|
||||
</label>
|
||||
<p className="text-xs text-gray-500">
|
||||
Typical club leagues use 20–30
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={structure.maxDrivers ?? 24}
|
||||
onChange={(e) => handleMaxDriversChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
min={1}
|
||||
max={64}
|
||||
className="w-28"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={structure.maxDrivers ?? 24}
|
||||
onChange={(e) => handleMaxDriversChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
min={1}
|
||||
max={64}
|
||||
className="w-32"
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<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>Typical club leagues use 20–30 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>
|
||||
)}
|
||||
|
||||
{structure.mode === 'fixedTeams' && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300">
|
||||
Max teams
|
||||
</label>
|
||||
{/* Teams mode capacity */}
|
||||
{structure.mode === 'fixedTeams' && (
|
||||
<div className="rounded-lg border border-charcoal-outline bg-iron-gray/30 p-5 space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-blue/10 shrink-0">
|
||||
<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">
|
||||
Roughly how many teams you expect.
|
||||
Configure the team composition and maximum grid size
|
||||
</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
|
||||
type="number"
|
||||
value={structure.maxTeams ?? 12}
|
||||
@@ -200,18 +292,18 @@ export function LeagueStructureSection({
|
||||
disabled={disabled}
|
||||
min={1}
|
||||
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>
|
||||
<label className="block text-sm font-medium text-gray-300">
|
||||
Drivers per team
|
||||
</label>
|
||||
<p className="text-xs text-gray-500">
|
||||
Common values are 2–3 drivers.
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-300">
|
||||
Drivers per team
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={structure.driversPerTeam ?? 2}
|
||||
@@ -219,28 +311,36 @@ export function LeagueStructureSection({
|
||||
disabled={disabled}
|
||||
min={1}
|
||||
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: 2–3 drivers</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300">
|
||||
Max drivers (derived)
|
||||
</label>
|
||||
<p className="text-xs text-gray-500">
|
||||
Calculated as teams × drivers per team.
|
||||
</p>
|
||||
<div className="mt-2 max-w-[7rem]">
|
||||
<Input
|
||||
type="number"
|
||||
value={structure.maxDrivers ?? 0}
|
||||
disabled
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-300">
|
||||
Total grid size
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={structure.maxDrivers ?? 0}
|
||||
disabled
|
||||
className="w-32"
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Calendar, Clock, MapPin, Zap, Info, Loader2 } from 'lucide-react';
|
||||
import type {
|
||||
LeagueConfigFormModel,
|
||||
LeagueSchedulePreviewDTO,
|
||||
@@ -312,66 +313,110 @@ export function LeagueTimingsSection({
|
||||
const weekendTemplateValue = weekendTemplate ?? '';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Heading level={3} className="text-lg font-semibold text-white">
|
||||
{title ?? 'Schedule & timings'}
|
||||
</Heading>
|
||||
<div className="space-y-8">
|
||||
{/* Step intro */}
|
||||
<div className="rounded-lg bg-primary-blue/5 border border-primary-blue/20 p-4">
|
||||
<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 optional—you can set it now or schedule races manually later.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Season length block */}
|
||||
<section className="space-y-3">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-gray-400">
|
||||
Season length
|
||||
{/* 1. Weekend template - FIRST */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-primary-blue" />
|
||||
<h4 className="text-sm font-semibold text-white">
|
||||
1. Choose your race weekend format
|
||||
</h4>
|
||||
<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">
|
||||
Planned rounds
|
||||
</label>
|
||||
<div className="w-24">
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
This determines session counts and sets sensible duration defaults
|
||||
</p>
|
||||
<SegmentedControl
|
||||
options={[
|
||||
{ value: 'feature', label: 'Feature only', description: '1 race per weekend' },
|
||||
{ value: 'sprintFeature', label: 'Sprint + Feature', description: '2 races per weekend' },
|
||||
{ value: 'endurance', label: 'Endurance', description: 'Longer races' },
|
||||
]}
|
||||
value={weekendTemplateValue}
|
||||
onChange={onWeekendTemplateChange}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* 2. Season length */}
|
||||
<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 approximate—you 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>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Used for planning and drop hints; can be approximate.
|
||||
</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 className="text-xs text-gray-500">
|
||||
Configure when races happen automatically, or skip this and schedule rounds manually later
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Race schedule block */}
|
||||
<section className="space-y-4">
|
||||
<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 className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-300">
|
||||
Season start date
|
||||
@@ -428,56 +473,47 @@ export function LeagueTimingsSection({
|
||||
</select>
|
||||
</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>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-300">
|
||||
Cadence
|
||||
</label>
|
||||
<SegmentedControl
|
||||
options={[
|
||||
{ value: 'weekly', label: 'Weekly' },
|
||||
{ value: 'everyNWeeks', label: 'Every N weeks' },
|
||||
{
|
||||
value: 'monthlyNthWeekday',
|
||||
label: 'Monthly (beta)',
|
||||
disabled: true,
|
||||
},
|
||||
]}
|
||||
value={recurrenceStrategy}
|
||||
onChange={handleRecurrenceChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="block text-sm font-medium text-gray-300">
|
||||
How often do races occur?
|
||||
</label>
|
||||
<SegmentedControl
|
||||
options={[
|
||||
{ value: 'weekly', label: 'Weekly' },
|
||||
{ value: 'everyNWeeks', label: 'Bi-weekly' },
|
||||
]}
|
||||
value={recurrenceStrategy}
|
||||
onChange={handleRecurrenceChange}
|
||||
/>
|
||||
|
||||
{recurrenceStrategy === 'everyNWeeks' && (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-300">
|
||||
<span>Every</span>
|
||||
<div className="w-20">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={
|
||||
typeof timings.intervalWeeks === 'number'
|
||||
? String(timings.intervalWeeks)
|
||||
: '2'
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-gray-300">Every</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={
|
||||
typeof timings.intervalWeeks === 'number'
|
||||
? String(timings.intervalWeeks)
|
||||
: '2'
|
||||
}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value.trim();
|
||||
if (raw === '') {
|
||||
updateTimings({ intervalWeeks: undefined });
|
||||
return;
|
||||
}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value.trim();
|
||||
if (raw === '') {
|
||||
updateTimings({ intervalWeeks: undefined });
|
||||
return;
|
||||
}
|
||||
const parsed = parseInt(raw, 10);
|
||||
updateTimings({
|
||||
intervalWeeks:
|
||||
Number.isNaN(parsed) || parsed <= 0 ? 2 : parsed,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span>weeks</span>
|
||||
const parsed = parseInt(raw, 10);
|
||||
updateTimings({
|
||||
intervalWeeks:
|
||||
Number.isNaN(parsed) || parsed <= 0 ? 2 : parsed,
|
||||
});
|
||||
}}
|
||||
className="w-20"
|
||||
/>
|
||||
<span className="text-gray-300">weeks</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -485,11 +521,11 @@ export function LeagueTimingsSection({
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label className="block text-sm font-medium text-gray-300">
|
||||
Race days in a week
|
||||
Which day(s)?
|
||||
</label>
|
||||
{requiresWeekdaySelection && (
|
||||
<span className="text-[11px] text-warning-amber">
|
||||
Select at least one weekday.
|
||||
<span className="text-xs text-warning-amber">
|
||||
Pick at least one
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -502,10 +538,10 @@ export function LeagueTimingsSection({
|
||||
key={day}
|
||||
type="button"
|
||||
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
|
||||
? 'bg-primary-blue text-white border-primary-blue'
|
||||
: 'bg-iron-gray/80 text-gray-300 border-charcoal-outline hover:bg-charcoal-outline/80 hover:text-white'
|
||||
? '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 hover:text-white hover:border-gray-500'
|
||||
}`}
|
||||
>
|
||||
{day}
|
||||
@@ -517,235 +553,196 @@ export function LeagueTimingsSection({
|
||||
</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>
|
||||
<p className="text-xs font-medium text-gray-200">
|
||||
Schedule summary
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{schedulePreview?.summary ??
|
||||
'Set a start date, time, and at least one weekday to preview the schedule.'}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-primary-blue" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-200">
|
||||
Schedule preview
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{schedulePreview?.summary ??
|
||||
'Set a start date, time, and at least one weekday to preview the schedule.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{isSchedulePreviewLoading && (
|
||||
<span className="text-[11px] text-gray-400">Updating…</span>
|
||||
<Loader2 className="w-4 h-4 text-primary-blue animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 space-y-1">
|
||||
<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>
|
||||
|
||||
<div className="space-y-2">
|
||||
{schedulePreviewError && (
|
||||
<p className="text-[11px] text-warning-amber">
|
||||
{schedulePreviewError}
|
||||
</p>
|
||||
<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">
|
||||
<span className="shrink-0">⚠️</span>
|
||||
<span>{schedulePreviewError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!schedulePreview && !schedulePreviewError && (
|
||||
<p className="text-[11px] text-gray-500">
|
||||
Adjust the fields above to see a preview of your calendar.
|
||||
<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>Adjust the fields above to see a preview of your calendar.</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{schedulePreview && (
|
||||
<div className="mt-1 space-y-1.5 text-xs text-gray-200">
|
||||
{schedulePreview.rounds.map((round) => {
|
||||
const date = new Date(round.scheduledAt);
|
||||
const dateStr = date.toLocaleDateString(undefined, {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
});
|
||||
const timeStr = date.toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-gray-500">
|
||||
First few rounds with your current settings:
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{schedulePreview.rounds.map((round) => {
|
||||
const date = new Date(round.scheduledAt);
|
||||
const dateStr = date.toLocaleDateString(undefined, {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
});
|
||||
const timeStr = date.toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
key={round.roundNumber}
|
||||
className="flex items-center justify-between gap-2"
|
||||
>
|
||||
<span className="text-gray-300">
|
||||
Round {round.roundNumber}
|
||||
</span>
|
||||
<span className="text-gray-200">
|
||||
{dateStr}, {timeStr}{' '}
|
||||
<span className="text-gray-500">
|
||||
{round.timezoneId}
|
||||
return (
|
||||
<div
|
||||
key={round.roundNumber}
|
||||
className="flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-deep-graphite/40 text-xs"
|
||||
>
|
||||
<span className="font-medium text-gray-300">
|
||||
Round {round.roundNumber}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<span className="text-gray-200">
|
||||
{dateStr}, {timeStr}{' '}
|
||||
<span className="text-gray-500">
|
||||
{round.timezoneId}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{typeof timings.roundsPlanned === 'number' &&
|
||||
timings.roundsPlanned > schedulePreview.rounds.length && (
|
||||
<p className="pt-1 text-[11px] text-gray-500">
|
||||
+
|
||||
{timings.roundsPlanned - schedulePreview.rounds.length}{' '}
|
||||
more rounds scheduled.
|
||||
</p>
|
||||
)}
|
||||
{typeof timings.roundsPlanned === 'number' &&
|
||||
timings.roundsPlanned > schedulePreview.rounds.length && (
|
||||
<p className="pt-1 text-xs text-gray-500 text-center">
|
||||
+ {timings.roundsPlanned - schedulePreview.rounds.length} more rounds scheduled
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Weekend template block */}
|
||||
<section className="space-y-3">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-gray-400">
|
||||
Weekend template
|
||||
</h4>
|
||||
{/* 4. Optional: Session duration overrides */}
|
||||
<details className="group">
|
||||
<summary className="cursor-pointer list-none">
|
||||
<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">
|
||||
<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">
|
||||
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>
|
||||
<SegmentedControl
|
||||
options={[
|
||||
{ value: 'feature', label: 'Feature only' },
|
||||
{ value: 'sprintFeature', label: 'Sprint + feature' },
|
||||
{ value: 'endurance', label: 'Endurance' },
|
||||
]}
|
||||
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)
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs font-medium text-gray-300">
|
||||
Practice (min)
|
||||
</label>
|
||||
<div className="w-24">
|
||||
<Input
|
||||
type="number"
|
||||
value={
|
||||
typeof timings.practiceMinutes === 'number' &&
|
||||
timings.practiceMinutes > 0
|
||||
? String(timings.practiceMinutes)
|
||||
: ''
|
||||
}
|
||||
onChange={(e) =>
|
||||
handleNumericMinutesChange(
|
||||
'practiceMinutes',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
min={0}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Set to 0 or leave empty if you don’t plan dedicated practice.
|
||||
</p>
|
||||
<Input
|
||||
type="number"
|
||||
value={
|
||||
typeof timings.practiceMinutes === 'number' && timings.practiceMinutes > 0
|
||||
? String(timings.practiceMinutes)
|
||||
: ''
|
||||
}
|
||||
onChange={(e) => handleNumericMinutesChange('practiceMinutes', e.target.value)}
|
||||
min={0}
|
||||
className="w-24"
|
||||
placeholder="20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-300">
|
||||
Qualifying duration
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs font-medium text-gray-300">
|
||||
Qualifying (min)
|
||||
</label>
|
||||
<div className="w-24">
|
||||
<Input
|
||||
type="number"
|
||||
value={
|
||||
typeof timings.qualifyingMinutes === 'number' &&
|
||||
timings.qualifyingMinutes > 0
|
||||
? String(timings.qualifyingMinutes)
|
||||
: ''
|
||||
}
|
||||
onChange={(e) =>
|
||||
handleNumericMinutesChange(
|
||||
'qualifyingMinutes',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
min={5}
|
||||
error={!!errors?.qualifyingMinutes}
|
||||
errorMessage={errors?.qualifyingMinutes}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
value={
|
||||
typeof timings.qualifyingMinutes === 'number' && timings.qualifyingMinutes > 0
|
||||
? String(timings.qualifyingMinutes)
|
||||
: ''
|
||||
}
|
||||
onChange={(e) => handleNumericMinutesChange('qualifyingMinutes', e.target.value)}
|
||||
min={5}
|
||||
error={!!errors?.qualifyingMinutes}
|
||||
errorMessage={errors?.qualifyingMinutes}
|
||||
className="w-24"
|
||||
placeholder="30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showSprint && (
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-300">
|
||||
Sprint duration
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs font-medium text-gray-300">
|
||||
Sprint (min)
|
||||
</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
|
||||
type="number"
|
||||
value={
|
||||
typeof timings.mainRaceMinutes === 'number' &&
|
||||
timings.mainRaceMinutes > 0
|
||||
? String(timings.mainRaceMinutes)
|
||||
typeof timings.sprintRaceMinutes === 'number' && timings.sprintRaceMinutes > 0
|
||||
? String(timings.sprintRaceMinutes)
|
||||
: ''
|
||||
}
|
||||
onChange={(e) =>
|
||||
handleNumericMinutesChange(
|
||||
'mainRaceMinutes',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
min={10}
|
||||
error={!!errors?.mainRaceMinutes}
|
||||
errorMessage={errors?.mainRaceMinutes}
|
||||
onChange={(e) => handleNumericMinutesChange('sprintRaceMinutes', e.target.value)}
|
||||
min={0}
|
||||
className="w-24"
|
||||
placeholder="20"
|
||||
/>
|
||||
</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>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user