664 lines
20 KiB
TypeScript
664 lines
20 KiB
TypeScript
'use client';
|
||
|
||
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 '@/components/leagues/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', 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 => {
|
||
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 max-w-5xl mx-auto">
|
||
{/* Header */}
|
||
<div className="text-center space-y-3">
|
||
<Heading level={1} className="mb-2">
|
||
Create a new league
|
||
</Heading>
|
||
<p className="text-sm text-gray-400">
|
||
Configure your league in {steps.length} simple steps
|
||
</p>
|
||
</div>
|
||
|
||
{/* Progress indicators */}
|
||
<div className="relative">
|
||
<div className="flex items-center justify-between mb-8">
|
||
{steps.map((wizardStep, index) => {
|
||
const isCompleted = wizardStep.id < step;
|
||
const isCurrent = wizardStep.id === step;
|
||
const StepIcon = wizardStep.icon;
|
||
|
||
return (
|
||
<div key={wizardStep.id} className="flex flex-col items-center gap-2 flex-1">
|
||
<div className="relative flex items-center justify-center">
|
||
{index > 0 && (
|
||
<div
|
||
className={`absolute right-1/2 top-1/2 -translate-y-1/2 h-0.5 transition-all duration-300 ${
|
||
isCompleted || isCurrent
|
||
? 'bg-primary-blue'
|
||
: 'bg-charcoal-outline'
|
||
}`}
|
||
style={{ width: 'calc(100vw / 6 - 48px)', maxWidth: '120px' }}
|
||
/>
|
||
)}
|
||
<div
|
||
className={`relative z-10 flex h-12 w-12 items-center justify-center rounded-full transition-all duration-200 ${
|
||
isCurrent
|
||
? 'bg-primary-blue text-white shadow-[0_0_20px_rgba(25,140,255,0.5)] scale-110'
|
||
: isCompleted
|
||
? 'bg-primary-blue/20 border-2 border-primary-blue text-primary-blue'
|
||
: 'bg-iron-gray border-2 border-charcoal-outline text-gray-500'
|
||
}`}
|
||
>
|
||
{isCompleted ? (
|
||
<CheckCircle2 className="w-5 h-5" />
|
||
) : (
|
||
<StepIcon className="w-5 h-5" />
|
||
)}
|
||
</div>
|
||
</div>
|
||
<span
|
||
className={`text-xs font-medium transition-colors duration-200 ${
|
||
isCurrent
|
||
? 'text-white'
|
||
: isCompleted
|
||
? 'text-gray-300'
|
||
: 'text-gray-500'
|
||
}`}
|
||
>
|
||
{wizardStep.label}
|
||
</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Main content card */}
|
||
<Card className="relative overflow-hidden">
|
||
{/* Decorative gradient */}
|
||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-primary-blue/50 via-primary-blue to-primary-blue/50" />
|
||
|
||
<div className="space-y-6">
|
||
{/* Step header */}
|
||
<div className="space-y-2">
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-blue/10">
|
||
{(() => {
|
||
const currentStepData = steps.find((s) => s.id === step);
|
||
if (!currentStepData) return null;
|
||
const Icon = currentStepData.icon;
|
||
return <Icon className="w-5 h-5 text-primary-blue" />;
|
||
})()}
|
||
</div>
|
||
<div className="flex-1">
|
||
<Heading level={2} className="text-2xl text-white">
|
||
{getStepTitle(step)}
|
||
</Heading>
|
||
<p className="text-sm text-gray-400">
|
||
{getStepSubtitle(step)}
|
||
</p>
|
||
</div>
|
||
<span className="text-xs font-medium text-gray-500">
|
||
Step {step} of {steps.length}
|
||
</span>
|
||
</div>
|
||
</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>
|
||
</Card>
|
||
|
||
{/* 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-3">
|
||
{step < 6 && (
|
||
<Button
|
||
type="button"
|
||
variant="primary"
|
||
disabled={loading}
|
||
onClick={goToNextStep}
|
||
className="flex items-center gap-2"
|
||
>
|
||
Continue
|
||
<ChevronRight className="w-4 h-4" />
|
||
</Button>
|
||
)}
|
||
{step === 6 && (
|
||
<Button
|
||
type="submit"
|
||
variant="primary"
|
||
disabled={loading}
|
||
className="flex items-center gap-2 min-w-[160px] justify-center"
|
||
>
|
||
{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>
|
||
</div>
|
||
</form>
|
||
);
|
||
} |