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

664 lines
20 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
);
}