wip
@@ -1,14 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import CreateLeagueWizard from '@/components/leagues/CreateLeagueWizard';
|
||||
import Section from '@/components/ui/Section';
|
||||
import Container from '@/components/ui/Container';
|
||||
|
||||
type StepName = 'basics' | 'structure' | 'schedule' | 'scoring' | 'review';
|
||||
|
||||
function normalizeStepName(raw: string | null): StepName {
|
||||
switch (raw) {
|
||||
case 'basics':
|
||||
case 'structure':
|
||||
case 'schedule':
|
||||
case 'scoring':
|
||||
case 'review':
|
||||
return raw;
|
||||
default:
|
||||
return 'basics';
|
||||
}
|
||||
}
|
||||
|
||||
export default function CreateLeaguePage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const currentStepName = normalizeStepName(searchParams.get('step'));
|
||||
|
||||
const handleStepChange = (stepName: StepName) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('step', stepName);
|
||||
const query = params.toString();
|
||||
const href = query ? `/leagues/create?${query}` : '/leagues/create';
|
||||
router.push(href);
|
||||
};
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<Container size="md">
|
||||
<CreateLeagueWizard />
|
||||
<CreateLeagueWizard stepName={currentStepName} onStepChange={handleStepChange} />
|
||||
</Container>
|
||||
</Section>
|
||||
);
|
||||
|
||||
@@ -21,10 +21,15 @@ 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 {
|
||||
validateLeagueWizardStep,
|
||||
validateAllLeagueWizardSteps,
|
||||
hasWizardErrors,
|
||||
createLeagueFromConfig,
|
||||
applyScoringPresetToConfig,
|
||||
} from '@/lib/leagueWizardService';
|
||||
import type { LeagueScoringPresetDTO } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider';
|
||||
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
|
||||
import { LeagueBasicsSection } from './LeagueBasicsSection';
|
||||
@@ -39,27 +44,45 @@ import { LeagueTimingsSection } from './LeagueTimingsSection';
|
||||
|
||||
type Step = 1 | 2 | 3 | 4 | 5;
|
||||
|
||||
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;
|
||||
type StepName = 'basics' | 'structure' | 'schedule' | 'scoring' | 'review';
|
||||
|
||||
interface CreateLeagueWizardProps {
|
||||
stepName: StepName;
|
||||
onStepChange: (stepName: StepName) => void;
|
||||
}
|
||||
|
||||
function stepNameToStep(stepName: StepName): Step {
|
||||
switch (stepName) {
|
||||
case 'basics':
|
||||
return 1;
|
||||
case 'structure':
|
||||
return 2;
|
||||
case 'schedule':
|
||||
return 3;
|
||||
case 'scoring':
|
||||
return 4;
|
||||
case 'review':
|
||||
return 5;
|
||||
}
|
||||
}
|
||||
|
||||
function stepToStepName(step: Step): StepName {
|
||||
switch (step) {
|
||||
case 1:
|
||||
return 'basics';
|
||||
case 2:
|
||||
return 'structure';
|
||||
case 3:
|
||||
return 'schedule';
|
||||
case 4:
|
||||
return 'scoring';
|
||||
case 5:
|
||||
return 'review';
|
||||
}
|
||||
}
|
||||
|
||||
import type { WizardErrors } from '@/lib/leagueWizardService';
|
||||
|
||||
function createDefaultForm(): LeagueConfigFormModel {
|
||||
const defaultPatternId = 'sprint-main-driver';
|
||||
|
||||
@@ -102,10 +125,10 @@ function createDefaultForm(): LeagueConfigFormModel {
|
||||
};
|
||||
}
|
||||
|
||||
export default function CreateLeagueWizard() {
|
||||
export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLeagueWizardProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const [step, setStep] = useState<Step>(1);
|
||||
|
||||
const step = stepNameToStep(stepName);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [presetsLoading, setPresetsLoading] = useState(true);
|
||||
const [presets, setPresets] = useState<LeagueScoringPresetDTO[]>([]);
|
||||
@@ -147,105 +170,39 @@ export default function CreateLeagueWizard() {
|
||||
}, []);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
const stepErrors = validateLeagueWizardStep(form, currentStep);
|
||||
setErrors((prev) => ({
|
||||
...prev,
|
||||
...nextErrors,
|
||||
...stepErrors,
|
||||
}));
|
||||
|
||||
return Object.keys(nextErrors).length === 0;
|
||||
return !hasWizardErrors(stepErrors);
|
||||
};
|
||||
|
||||
const goToNextStep = () => {
|
||||
if (!validateStep(step)) {
|
||||
return;
|
||||
}
|
||||
setStep((prev) => (prev < 5 ? ((prev + 1) as Step) : prev));
|
||||
const nextStep = (step < 5 ? ((step + 1) as Step) : step);
|
||||
onStepChange(stepToStepName(nextStep));
|
||||
};
|
||||
|
||||
const goToPreviousStep = () => {
|
||||
setStep((prev) => (prev > 1 ? ((prev - 1) as Step) : prev));
|
||||
const prevStep = (step > 1 ? ((step - 1) as Step) : step);
|
||||
onStepChange(stepToStepName(prevStep));
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (loading) return;
|
||||
|
||||
if (
|
||||
!validateStep(1) ||
|
||||
!validateStep(2) ||
|
||||
!validateStep(3) ||
|
||||
!validateStep(4)
|
||||
) {
|
||||
setStep(1);
|
||||
const allErrors = validateAllLeagueWizardSteps(form);
|
||||
setErrors((prev) => ({
|
||||
...prev,
|
||||
...allErrors,
|
||||
}));
|
||||
|
||||
if (hasWizardErrors(allErrors)) {
|
||||
onStepChange('basics');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -253,69 +210,14 @@ export default function CreateLeagueWizard() {
|
||||
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);
|
||||
|
||||
const result = await createLeagueFromConfig(form);
|
||||
router.push(`/leagues/${result.leagueId}`);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : 'Failed to create league';
|
||||
setErrors((prev) => ({
|
||||
...prev,
|
||||
submit:
|
||||
err instanceof Error ? err.message : 'Failed to create league',
|
||||
submit: message,
|
||||
}));
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -324,55 +226,9 @@ export default function CreateLeagueWizard() {
|
||||
const currentPreset =
|
||||
presets.find((p) => p.id === form.scoring.patternId) ?? null;
|
||||
|
||||
// Handler for scoring preset selection - updates timing defaults based on preset
|
||||
// Handler for scoring preset selection - delegates to application-level config helper
|
||||
const handleScoringPresetChange = (patternId: string) => {
|
||||
const lowerPresetId = patternId.toLowerCase();
|
||||
|
||||
setForm((prev) => {
|
||||
const timings = prev.timings ?? {};
|
||||
let updatedTimings = { ...timings };
|
||||
|
||||
// Auto-configure session durations based on preset type
|
||||
if (lowerPresetId.includes('sprint') || lowerPresetId.includes('double')) {
|
||||
updatedTimings = {
|
||||
...updatedTimings,
|
||||
practiceMinutes: 15,
|
||||
qualifyingMinutes: 20,
|
||||
sprintRaceMinutes: 20,
|
||||
mainRaceMinutes: 35,
|
||||
sessionCount: 2,
|
||||
};
|
||||
} else if (lowerPresetId.includes('endurance') || lowerPresetId.includes('long')) {
|
||||
updatedTimings = {
|
||||
...updatedTimings,
|
||||
practiceMinutes: 30,
|
||||
qualifyingMinutes: 30,
|
||||
sprintRaceMinutes: undefined,
|
||||
mainRaceMinutes: 90,
|
||||
sessionCount: 1,
|
||||
};
|
||||
} else {
|
||||
// Standard/feature format
|
||||
updatedTimings = {
|
||||
...updatedTimings,
|
||||
practiceMinutes: 20,
|
||||
qualifyingMinutes: 30,
|
||||
sprintRaceMinutes: undefined,
|
||||
mainRaceMinutes: 40,
|
||||
sessionCount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
scoring: {
|
||||
...prev.scoring,
|
||||
patternId,
|
||||
customScoringEnabled: false,
|
||||
},
|
||||
timings: updatedTimings,
|
||||
};
|
||||
});
|
||||
setForm((prev) => applyScoringPresetToConfig(prev, patternId));
|
||||
};
|
||||
|
||||
const steps = [
|
||||
|
||||
@@ -522,7 +522,7 @@ export function ScoringPatternSection({
|
||||
const [activePresetFlyout, setActivePresetFlyout] = useState<string | null>(null);
|
||||
const pointsInfoRef = useRef<HTMLButtonElement>(null);
|
||||
const bonusInfoRef = useRef<HTMLButtonElement>(null);
|
||||
const presetInfoRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
const presetInfoRefs = useRef<Record<string, HTMLElement | null>>({});
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@@ -623,17 +623,25 @@ export function ScoringPatternSection({
|
||||
)}
|
||||
|
||||
{/* Info button */}
|
||||
<button
|
||||
<div
|
||||
ref={(el) => { presetInfoRefs.current[preset.id] = el; }}
|
||||
type="button"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setActivePresetFlyout(activePresetFlyout === preset.id ? null : preset.id);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setActivePresetFlyout(activePresetFlyout === preset.id ? null : preset.id);
|
||||
}
|
||||
}}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full text-gray-500 hover:text-primary-blue hover:bg-primary-blue/10 transition-colors shrink-0"
|
||||
>
|
||||
<HelpCircle className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Preset Info Flyout */}
|
||||
@@ -923,7 +931,7 @@ export function ChampionshipsSection({
|
||||
const [showChampFlyout, setShowChampFlyout] = useState(false);
|
||||
const [activeChampFlyout, setActiveChampFlyout] = useState<string | null>(null);
|
||||
const champInfoRef = useRef<HTMLButtonElement>(null);
|
||||
const champItemRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
const champItemRefs = useRef<Record<string, HTMLElement | null>>({});
|
||||
|
||||
const updateChampionship = (key: keyof LeagueConfigFormModel['championships'], value: boolean) => {
|
||||
if (!onChange) return;
|
||||
@@ -1073,17 +1081,25 @@ export function ChampionshipsSection({
|
||||
</div>
|
||||
|
||||
{/* Info button */}
|
||||
<button
|
||||
<div
|
||||
ref={(el) => { champItemRefs.current[champ.key] = el; }}
|
||||
type="button"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setActiveChampFlyout(activeChampFlyout === champ.key ? null : champ.key);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setActiveChampFlyout(activeChampFlyout === champ.key ? null : champ.key);
|
||||
}
|
||||
}}
|
||||
className="flex h-5 w-5 items-center justify-center rounded-full text-gray-500 hover:text-primary-blue hover:bg-primary-blue/10 transition-colors shrink-0"
|
||||
>
|
||||
<HelpCircle className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Championship Item Info Flyout */}
|
||||
|
||||
272
apps/website/lib/leagueWizardService.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import type {
|
||||
LeagueConfigFormModel,
|
||||
} from '@gridpilot/racing/application';
|
||||
import type {
|
||||
CreateLeagueWithSeasonAndScoringCommand,
|
||||
CreateLeagueWithSeasonAndScoringResultDTO,
|
||||
} from '@gridpilot/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase';
|
||||
import {
|
||||
getDriverRepository,
|
||||
getCreateLeagueWithSeasonAndScoringUseCase,
|
||||
} from '@/lib/di-container';
|
||||
|
||||
export type WizardStep = 1 | 2 | 3 | 4 | 5;
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step-scoped validation extracted from the React wizard.
|
||||
* Returns a fresh error bag for the given step based on the provided form model.
|
||||
*/
|
||||
export function validateLeagueWizardStep(
|
||||
form: LeagueConfigFormModel,
|
||||
step: WizardStep,
|
||||
): WizardErrors {
|
||||
const errors: WizardErrors = {};
|
||||
|
||||
if (step === 1) {
|
||||
const basicsErrors: NonNullable<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) {
|
||||
errors.basics = basicsErrors;
|
||||
}
|
||||
}
|
||||
|
||||
if (step === 2) {
|
||||
const structureErrors: NonNullable<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) {
|
||||
errors.structure = structureErrors;
|
||||
}
|
||||
}
|
||||
|
||||
if (step === 3) {
|
||||
const timingsErrors: NonNullable<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) {
|
||||
errors.timings = timingsErrors;
|
||||
}
|
||||
}
|
||||
|
||||
if (step === 4) {
|
||||
const scoringErrors: NonNullable<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) {
|
||||
errors.scoring = scoringErrors;
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to validate all steps (1-4) and merge errors into a single bag.
|
||||
*/
|
||||
export function validateAllLeagueWizardSteps(
|
||||
form: LeagueConfigFormModel,
|
||||
): WizardErrors {
|
||||
const aggregate: WizardErrors = {};
|
||||
|
||||
const merge = (next: WizardErrors) => {
|
||||
if (next.basics) {
|
||||
aggregate.basics = { ...aggregate.basics, ...next.basics };
|
||||
}
|
||||
if (next.structure) {
|
||||
aggregate.structure = { ...aggregate.structure, ...next.structure };
|
||||
}
|
||||
if (next.timings) {
|
||||
aggregate.timings = { ...aggregate.timings, ...next.timings };
|
||||
}
|
||||
if (next.scoring) {
|
||||
aggregate.scoring = { ...aggregate.scoring, ...next.scoring };
|
||||
}
|
||||
if (next.submit) {
|
||||
aggregate.submit = next.submit;
|
||||
}
|
||||
};
|
||||
|
||||
merge(validateLeagueWizardStep(form, 1));
|
||||
merge(validateLeagueWizardStep(form, 2));
|
||||
merge(validateLeagueWizardStep(form, 3));
|
||||
merge(validateLeagueWizardStep(form, 4));
|
||||
|
||||
return aggregate;
|
||||
}
|
||||
|
||||
export function hasWizardErrors(errors: WizardErrors): boolean {
|
||||
return Object.keys(errors).some((key) => {
|
||||
const value = (errors as any)[key];
|
||||
if (!value) return false;
|
||||
if (typeof value === 'string') return true;
|
||||
return Object.keys(value).length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure mapping from LeagueConfigFormModel to the creation command.
|
||||
* Driver ownership is handled by the caller.
|
||||
*/
|
||||
export function buildCreateLeagueCommandFromConfig(
|
||||
form: LeagueConfigFormModel,
|
||||
ownerId: string,
|
||||
): CreateLeagueWithSeasonAndScoringCommand {
|
||||
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;
|
||||
}
|
||||
|
||||
return {
|
||||
name: form.basics.name.trim(),
|
||||
description: form.basics.description?.trim() || undefined,
|
||||
visibility: form.basics.visibility,
|
||||
ownerId,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin application-level facade that:
|
||||
* - pulls the current driver via repository
|
||||
* - builds the creation command
|
||||
* - delegates to the create-league use case
|
||||
*/
|
||||
export async function createLeagueFromConfig(
|
||||
form: LeagueConfigFormModel,
|
||||
): Promise<CreateLeagueWithSeasonAndScoringResultDTO> {
|
||||
const driverRepo = getDriverRepository();
|
||||
const drivers = await driverRepo.findAll();
|
||||
const currentDriver = drivers[0];
|
||||
|
||||
if (!currentDriver) {
|
||||
const error = new Error(
|
||||
'No driver profile found. Please create a driver profile first.',
|
||||
);
|
||||
(error as any).code = 'NO_DRIVER';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const useCase = getCreateLeagueWithSeasonAndScoringUseCase();
|
||||
const command = buildCreateLeagueCommandFromConfig(form, currentDriver.id);
|
||||
return useCase.execute(command);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply scoring preset selection and derive timings, returning a new form model.
|
||||
* This mirrors the previous React handler but keeps it in testable, non-UI logic.
|
||||
*/
|
||||
export function applyScoringPresetToConfig(
|
||||
form: LeagueConfigFormModel,
|
||||
patternId: string,
|
||||
): LeagueConfigFormModel {
|
||||
const lowerPresetId = patternId.toLowerCase();
|
||||
const timings = form.timings ?? ({} as LeagueConfigFormModel['timings']);
|
||||
let updatedTimings = { ...timings };
|
||||
|
||||
if (lowerPresetId.includes('sprint') || lowerPresetId.includes('double')) {
|
||||
updatedTimings = {
|
||||
...updatedTimings,
|
||||
practiceMinutes: 15,
|
||||
qualifyingMinutes: 20,
|
||||
sprintRaceMinutes: 20,
|
||||
mainRaceMinutes: 35,
|
||||
sessionCount: 2,
|
||||
};
|
||||
} else if (lowerPresetId.includes('endurance') || lowerPresetId.includes('long')) {
|
||||
updatedTimings = {
|
||||
...updatedTimings,
|
||||
practiceMinutes: 30,
|
||||
qualifyingMinutes: 30,
|
||||
sprintRaceMinutes: undefined,
|
||||
mainRaceMinutes: 90,
|
||||
sessionCount: 1,
|
||||
};
|
||||
} else {
|
||||
updatedTimings = {
|
||||
...updatedTimings,
|
||||
practiceMinutes: 20,
|
||||
qualifyingMinutes: 30,
|
||||
sprintRaceMinutes: undefined,
|
||||
mainRaceMinutes: 40,
|
||||
sessionCount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...form,
|
||||
scoring: {
|
||||
...form.scoring,
|
||||
patternId,
|
||||
customScoringEnabled: false,
|
||||
},
|
||||
timings: updatedTimings,
|
||||
};
|
||||
}
|
||||
6
apps/website/public/images/elements/strokes/1.svg
Normal file
|
After Width: | Height: | Size: 135 KiB |
6
apps/website/public/images/elements/strokes/10.svg
Normal file
|
After Width: | Height: | Size: 140 KiB |
6
apps/website/public/images/elements/strokes/2.svg
Normal file
|
After Width: | Height: | Size: 326 KiB |
6
apps/website/public/images/elements/strokes/3.svg
Normal file
|
After Width: | Height: | Size: 208 KiB |
6
apps/website/public/images/elements/strokes/4.svg
Normal file
|
After Width: | Height: | Size: 633 KiB |
6
apps/website/public/images/elements/strokes/5.svg
Normal file
|
After Width: | Height: | Size: 206 KiB |
6
apps/website/public/images/elements/strokes/6.svg
Normal file
|
After Width: | Height: | Size: 217 KiB |
6
apps/website/public/images/elements/strokes/7.svg
Normal file
|
After Width: | Height: | Size: 180 KiB |
6
apps/website/public/images/elements/strokes/8.svg
Normal file
|
After Width: | Height: | Size: 128 KiB |
6
apps/website/public/images/elements/strokes/9.svg
Normal file
|
After Width: | Height: | Size: 138 KiB |
1
apps/website/public/images/elements/tire-marks/1.svg
Normal file
|
After Width: | Height: | Size: 180 KiB |
1
apps/website/public/images/elements/tire-marks/10.svg
Normal file
|
After Width: | Height: | Size: 317 KiB |
1
apps/website/public/images/elements/tire-marks/2.svg
Normal file
|
After Width: | Height: | Size: 165 KiB |
1
apps/website/public/images/elements/tire-marks/3.svg
Normal file
|
After Width: | Height: | Size: 214 KiB |
1
apps/website/public/images/elements/tire-marks/4.svg
Normal file
|
After Width: | Height: | Size: 202 KiB |
1
apps/website/public/images/elements/tire-marks/5.svg
Normal file
|
After Width: | Height: | Size: 214 KiB |
1
apps/website/public/images/elements/tire-marks/6.svg
Normal file
|
After Width: | Height: | Size: 120 KiB |
1
apps/website/public/images/elements/tire-marks/7.svg
Normal file
|
After Width: | Height: | Size: 231 KiB |
1
apps/website/public/images/elements/tire-marks/8.svg
Normal file
|
After Width: | Height: | Size: 122 KiB |
1
apps/website/public/images/elements/tire-marks/9.svg
Normal file
|
After Width: | Height: | Size: 129 KiB |