334 lines
9.3 KiB
TypeScript
334 lines
9.3 KiB
TypeScript
/**
|
|
* League Wizard Service - Refactored to use API client
|
|
*
|
|
* This service handles league creation wizard logic without direct core dependencies.
|
|
*/
|
|
|
|
import { apiClient } from '@/lib/apiClient';
|
|
|
|
export type WizardStep = 1 | 2 | 3 | 4 | 5 | 6 | 7;
|
|
|
|
export interface LeagueConfigFormModel {
|
|
leagueId?: string;
|
|
basics: {
|
|
name: string;
|
|
description?: string;
|
|
visibility: 'public' | 'private' | 'unlisted';
|
|
gameId: string;
|
|
};
|
|
structure: {
|
|
mode: 'solo' | 'fixedTeams';
|
|
maxDrivers?: number;
|
|
maxTeams?: number;
|
|
driversPerTeam?: number;
|
|
};
|
|
championships: {
|
|
enableDriverChampionship: boolean;
|
|
enableTeamChampionship: boolean;
|
|
enableNationsChampionship: boolean;
|
|
enableTrophyChampionship: boolean;
|
|
};
|
|
scoring: {
|
|
patternId?: string;
|
|
customScoringEnabled?: boolean;
|
|
};
|
|
dropPolicy: {
|
|
strategy: 'none' | 'bestNResults' | 'dropWorstN';
|
|
n?: number;
|
|
};
|
|
timings: {
|
|
practiceMinutes?: number;
|
|
qualifyingMinutes?: number;
|
|
sprintRaceMinutes?: number;
|
|
mainRaceMinutes?: number;
|
|
sessionCount?: number;
|
|
roundsPlanned?: number;
|
|
raceDayOfWeek?: number;
|
|
raceTimeUtc?: string;
|
|
};
|
|
stewarding: {
|
|
decisionMode: 'owner_only' | 'admin_vote' | 'steward_panel';
|
|
requiredVotes?: number;
|
|
requireDefense: boolean;
|
|
defenseTimeLimit: number;
|
|
voteTimeLimit: number;
|
|
protestDeadlineHours: number;
|
|
stewardingClosesHours: number;
|
|
notifyAccusedOnProtest: boolean;
|
|
notifyOnVoteRequired: boolean;
|
|
};
|
|
}
|
|
|
|
export interface WizardErrors {
|
|
basics?: {
|
|
name?: string;
|
|
description?: 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 = {};
|
|
|
|
// Step 1: Basics (name, description, game)
|
|
if (step === 1) {
|
|
const basicsErrors: NonNullable<WizardErrors['basics']> = {};
|
|
|
|
// Basic name validation
|
|
if (!form.basics.name || form.basics.name.trim().length === 0) {
|
|
basicsErrors.name = 'League name is required';
|
|
} else if (form.basics.name.length < 3) {
|
|
basicsErrors.name = 'League name must be at least 3 characters';
|
|
} else if (form.basics.name.length > 100) {
|
|
basicsErrors.name = 'League name must be less than 100 characters';
|
|
}
|
|
|
|
// Description validation
|
|
if (form.basics.description && form.basics.description.length > 500) {
|
|
basicsErrors.description = 'Description must be less than 500 characters';
|
|
}
|
|
|
|
if (Object.keys(basicsErrors).length > 0) {
|
|
errors.basics = basicsErrors;
|
|
}
|
|
}
|
|
|
|
// Step 2: Visibility (ranked/unranked)
|
|
if (step === 2) {
|
|
const basicsErrors: NonNullable<WizardErrors['basics']> = {};
|
|
|
|
if (!form.basics.visibility) {
|
|
basicsErrors.visibility = 'Visibility is required';
|
|
}
|
|
|
|
if (Object.keys(basicsErrors).length > 0) {
|
|
errors.basics = basicsErrors;
|
|
}
|
|
}
|
|
|
|
// Step 3: Structure (solo vs teams)
|
|
if (step === 3) {
|
|
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.maxDrivers > 100) {
|
|
structureErrors.maxDrivers = 'Max drivers cannot exceed 100';
|
|
}
|
|
} 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;
|
|
}
|
|
}
|
|
|
|
// Step 4: Schedule (timings)
|
|
if (step === 4) {
|
|
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;
|
|
}
|
|
}
|
|
|
|
// Step 5: Scoring
|
|
if (step === 5) {
|
|
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;
|
|
}
|
|
}
|
|
|
|
// Step 6: Stewarding - no validation needed currently (all fields have defaults)
|
|
|
|
// Step 7: Review - no validation needed, it's just review
|
|
|
|
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));
|
|
merge(validateLeagueWizardStep(form, 5));
|
|
|
|
return aggregate;
|
|
}
|
|
|
|
export function hasWizardErrors(errors: WizardErrors): boolean {
|
|
return Object.keys(errors).some((key) => {
|
|
const value = errors[key as keyof WizardErrors];
|
|
if (!value) return false;
|
|
if (typeof value === 'string') return true;
|
|
return Object.keys(value).length > 0;
|
|
});
|
|
}
|
|
|
|
export interface CreateLeagueResult {
|
|
leagueId: string;
|
|
seasonId?: string;
|
|
success: boolean;
|
|
}
|
|
|
|
/**
|
|
* Create a league via API.
|
|
*/
|
|
export async function createLeagueFromConfig(
|
|
form: LeagueConfigFormModel,
|
|
ownerId: string,
|
|
): Promise<CreateLeagueResult> {
|
|
const structure = form.structure;
|
|
let maxDrivers: number;
|
|
|
|
if (structure.mode === 'solo') {
|
|
maxDrivers =
|
|
typeof structure.maxDrivers === 'number' && structure.maxDrivers > 0
|
|
? structure.maxDrivers
|
|
: 0;
|
|
} else {
|
|
const teams =
|
|
typeof structure.maxTeams === 'number' && structure.maxTeams > 0
|
|
? structure.maxTeams
|
|
: 0;
|
|
const perTeam =
|
|
typeof structure.driversPerTeam === 'number' && structure.driversPerTeam > 0
|
|
? structure.driversPerTeam
|
|
: 0;
|
|
maxDrivers = teams > 0 && perTeam > 0 ? teams * perTeam : 0;
|
|
}
|
|
|
|
const result = await apiClient.leagues.create({
|
|
name: form.basics.name.trim(),
|
|
description: (form.basics.description ?? '').trim(),
|
|
isPublic: form.basics.visibility === 'public',
|
|
maxMembers: maxDrivers,
|
|
ownerId,
|
|
});
|
|
|
|
return {
|
|
leagueId: result.leagueId,
|
|
success: result.success,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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: NonNullable<LeagueConfigFormModel['timings']> = {
|
|
...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,
|
|
mainRaceMinutes: 90,
|
|
sessionCount: 1,
|
|
};
|
|
delete (updatedTimings as { sprintRaceMinutes?: number }).sprintRaceMinutes;
|
|
} else {
|
|
updatedTimings = {
|
|
...updatedTimings,
|
|
practiceMinutes: 20,
|
|
qualifyingMinutes: 30,
|
|
mainRaceMinutes: 40,
|
|
sessionCount: 1,
|
|
};
|
|
delete (updatedTimings as { sprintRaceMinutes?: number }).sprintRaceMinutes;
|
|
}
|
|
|
|
return {
|
|
...form,
|
|
scoring: {
|
|
...form.scoring,
|
|
patternId,
|
|
customScoringEnabled: false,
|
|
},
|
|
timings: updatedTimings,
|
|
};
|
|
} |