Files
gridpilot.gg/apps/website/components/leagues/CreateLeagueWizard.tsx
2025-12-05 12:24:38 +01:00

587 lines
17 KiB
TypeScript

'use client';
import { useEffect, useState, FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Heading from '@/components/ui/Heading';
import LeagueReviewSummary from './LeagueReviewSummary';
import {
getDriverRepository,
getListLeagueScoringPresetsQuery,
getCreateLeagueWithSeasonAndScoringUseCase,
} from '@/lib/di-container';
import type { LeagueScoringPresetDTO } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider';
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
import { LeagueBasicsSection } from './LeagueBasicsSection';
import { LeagueStructureSection } from './LeagueStructureSection';
import {
LeagueScoringSection,
ScoringPatternSection,
ChampionshipsSection,
} from './LeagueScoringSection';
import { LeagueDropSection } from './LeagueDropSection';
import { LeagueTimingsSection } from './LeagueTimingsSection';
type Step = 1 | 2 | 3 | 4 | 5 | 6;
interface WizardErrors {
basics?: {
name?: string;
visibility?: string;
};
structure?: {
maxDrivers?: string;
maxTeams?: string;
driversPerTeam?: string;
};
timings?: {
qualifyingMinutes?: string;
mainRaceMinutes?: string;
roundsPlanned?: string;
};
scoring?: {
patternId?: string;
};
submit?: string;
}
function createDefaultForm(): LeagueConfigFormModel {
const defaultPatternId = 'sprint-main-driver';
return {
basics: {
name: '',
description: '',
visibility: 'public',
gameId: 'iracing',
},
structure: {
mode: 'solo',
maxDrivers: 24,
maxTeams: undefined,
driversPerTeam: undefined,
multiClassEnabled: false,
},
championships: {
enableDriverChampionship: true,
enableTeamChampionship: false,
enableNationsChampionship: false,
enableTrophyChampionship: false,
},
scoring: {
patternId: defaultPatternId,
customScoringEnabled: false,
},
dropPolicy: {
strategy: 'bestNResults',
n: 6,
},
timings: {
practiceMinutes: 20,
qualifyingMinutes: 30,
sprintRaceMinutes: defaultPatternId === 'sprint-main-driver' ? 20 : undefined,
mainRaceMinutes: 40,
sessionCount: 2,
roundsPlanned: 8,
},
};
}
export default function CreateLeagueWizard() {
const router = useRouter();
const [step, setStep] = useState<Step>(1);
const [loading, setLoading] = useState(false);
const [presetsLoading, setPresetsLoading] = useState(true);
const [presets, setPresets] = useState<LeagueScoringPresetDTO[]>([]);
const [errors, setErrors] = useState<WizardErrors>({});
const [form, setForm] = useState<LeagueConfigFormModel>(() =>
createDefaultForm(),
);
/**
* Local-only weekend template selection for Step 3.
* This does not touch domain models; it only seeds timing defaults.
*/
const [weekendTemplate, setWeekendTemplate] = useState<string>('');
useEffect(() => {
async function loadPresets() {
try {
const query = getListLeagueScoringPresetsQuery();
const result = await query.execute();
setPresets(result);
if (result.length > 0) {
setForm((prev) => ({
...prev,
scoring: {
...prev.scoring,
patternId: prev.scoring.patternId || result[0].id,
customScoringEnabled: prev.scoring.customScoringEnabled ?? false,
},
}));
}
} catch (err) {
setErrors((prev) => ({
...prev,
submit:
err instanceof Error
? err.message
: 'Failed to load scoring presets',
}));
} finally {
setPresetsLoading(false);
}
}
loadPresets();
}, []);
const validateStep = (currentStep: Step): boolean => {
const nextErrors: WizardErrors = {};
if (currentStep === 1) {
const basicsErrors: WizardErrors['basics'] = {};
if (!form.basics.name.trim()) {
basicsErrors.name = 'Name is required';
}
if (!form.basics.visibility) {
basicsErrors.visibility = 'Visibility is required';
}
if (Object.keys(basicsErrors).length > 0) {
nextErrors.basics = basicsErrors;
}
}
if (currentStep === 2) {
const structureErrors: WizardErrors['structure'] = {};
if (form.structure.mode === 'solo') {
if (!form.structure.maxDrivers || form.structure.maxDrivers <= 0) {
structureErrors.maxDrivers =
'Max drivers must be greater than 0 for solo leagues';
}
} else if (form.structure.mode === 'fixedTeams') {
if (
!form.structure.maxTeams ||
form.structure.maxTeams <= 0
) {
structureErrors.maxTeams =
'Max teams must be greater than 0 for team leagues';
}
if (
!form.structure.driversPerTeam ||
form.structure.driversPerTeam <= 0
) {
structureErrors.driversPerTeam =
'Drivers per team must be greater than 0';
}
}
if (Object.keys(structureErrors).length > 0) {
nextErrors.structure = structureErrors;
}
}
if (currentStep === 3) {
const timingsErrors: WizardErrors['timings'] = {};
if (!form.timings.qualifyingMinutes || form.timings.qualifyingMinutes <= 0) {
timingsErrors.qualifyingMinutes =
'Qualifying duration must be greater than 0 minutes';
}
if (!form.timings.mainRaceMinutes || form.timings.mainRaceMinutes <= 0) {
timingsErrors.mainRaceMinutes =
'Main race duration must be greater than 0 minutes';
}
if (Object.keys(timingsErrors).length > 0) {
nextErrors.timings = timingsErrors;
}
}
if (currentStep === 4) {
const scoringErrors: WizardErrors['scoring'] = {};
if (!form.scoring.patternId && !form.scoring.customScoringEnabled) {
scoringErrors.patternId =
'Select a scoring preset or enable custom scoring';
}
if (Object.keys(scoringErrors).length > 0) {
nextErrors.scoring = scoringErrors;
}
}
setErrors((prev) => ({
...prev,
...nextErrors,
}));
return Object.keys(nextErrors).length === 0;
};
const goToNextStep = () => {
if (!validateStep(step)) {
return;
}
setStep((prev) => (prev < 6 ? ((prev + 1) as Step) : prev));
};
const goToPreviousStep = () => {
setStep((prev) => (prev > 1 ? ((prev - 1) as Step) : prev));
};
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
if (loading) return;
if (
!validateStep(1) ||
!validateStep(2) ||
!validateStep(3) ||
!validateStep(4) ||
!validateStep(5)
) {
setStep(1);
return;
}
setLoading(true);
setErrors((prev) => ({ ...prev, submit: undefined }));
try {
const driverRepo = getDriverRepository();
const drivers = await driverRepo.findAll();
const currentDriver = drivers[0];
if (!currentDriver) {
setErrors((prev) => ({
...prev,
submit:
'No driver profile found. Please create a driver profile first.',
}));
setLoading(false);
return;
}
const createUseCase = getCreateLeagueWithSeasonAndScoringUseCase();
const structure = form.structure;
let maxDrivers: number | undefined;
let maxTeams: number | undefined;
if (structure.mode === 'solo') {
maxDrivers =
typeof structure.maxDrivers === 'number'
? structure.maxDrivers
: undefined;
maxTeams = undefined;
} else {
const teams =
typeof structure.maxTeams === 'number' ? structure.maxTeams : 0;
const perTeam =
typeof structure.driversPerTeam === 'number'
? structure.driversPerTeam
: 0;
maxTeams = teams > 0 ? teams : undefined;
maxDrivers =
teams > 0 && perTeam > 0 ? teams * perTeam : undefined;
}
const command = {
name: form.basics.name.trim(),
description: form.basics.description?.trim() || undefined,
visibility: form.basics.visibility,
ownerId: currentDriver.id,
gameId: form.basics.gameId,
maxDrivers,
maxTeams,
enableDriverChampionship: form.championships.enableDriverChampionship,
enableTeamChampionship: form.championships.enableTeamChampionship,
enableNationsChampionship:
form.championships.enableNationsChampionship,
enableTrophyChampionship:
form.championships.enableTrophyChampionship,
scoringPresetId: form.scoring.patternId || undefined,
} as const;
const result = await createUseCase.execute(command);
router.push(`/leagues/${result.leagueId}`);
} catch (err) {
setErrors((prev) => ({
...prev,
submit:
err instanceof Error ? err.message : 'Failed to create league',
}));
setLoading(false);
}
};
const currentPreset =
presets.find((p) => p.id === form.scoring.patternId) ?? null;
const handleWeekendTemplateChange = (template: string) => {
setWeekendTemplate(template);
setForm((prev) => {
const timings = prev.timings ?? {};
if (template === 'feature') {
return {
...prev,
timings: {
...timings,
practiceMinutes: 20,
qualifyingMinutes: 30,
sprintRaceMinutes: undefined,
mainRaceMinutes: 40,
sessionCount: 1,
},
};
}
if (template === 'sprintFeature') {
return {
...prev,
timings: {
...timings,
practiceMinutes: 15,
qualifyingMinutes: 20,
sprintRaceMinutes: 20,
mainRaceMinutes: 35,
sessionCount: 2,
},
};
}
if (template === 'endurance') {
return {
...prev,
timings: {
...timings,
practiceMinutes: 30,
qualifyingMinutes: 30,
sprintRaceMinutes: undefined,
mainRaceMinutes: 90,
sessionCount: 1,
},
};
}
return prev;
});
};
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' },
];
const getStepTitle = (currentStep: Step): string => {
switch (currentStep) {
case 1:
return 'Step 1 — Basics';
case 2:
return 'Step 2 — Structure';
case 3:
return 'Step 3 — Schedule & timings';
case 4:
return 'Step 4 — Scoring pattern';
case 5:
return 'Step 5 — Championships & drops';
case 6:
return 'Step 6 — Review & confirm';
default:
return '';
}
};
const getStepSubtitle = (currentStep: Step): string => {
switch (currentStep) {
case 1:
return 'Give your league a clear name, description, and visibility.';
case 2:
return 'Choose whether this is a solo or team-based championship.';
case 3:
return 'Roughly outline how long your weekends and season should run.';
case 4:
return 'Pick a scoring pattern that matches your weekends.';
case 5:
return 'Decide which championships to track and how drops behave.';
case 6:
return 'Double-check the summary before creating your new league.';
default:
return '';
}
};
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>
<div className="mb-4 flex flex-col gap-2">
<div className="flex flex-wrap gap-3">
{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';
return (
<div key={wizardStep.id} className="flex items-center gap-2">
<div className={baseCircleClasses + ' ' + circleClasses}>
{isCompleted ? '✓' : wizardStep.id}
</div>
<span
className={`text-xs ${
isCurrent
? 'text-white'
: isCompleted
? 'text-gray-300'
: 'text-gray-500'
}`}
>
{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" />
</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">
<Button
type="button"
variant="secondary"
disabled={step === 1 || loading}
onClick={goToPreviousStep}
>
Back
</Button>
<div className="flex gap-2">
{step < 6 && (
<Button
type="button"
variant="primary"
disabled={loading}
onClick={goToNextStep}
>
Next
</Button>
)}
{step === 6 && (
<Button type="submit" variant="primary" disabled={loading}>
{loading ? 'Creating…' : 'Create league'}
</Button>
)}
</div>
</div>
</form>
);
}