This commit is contained in:
2025-12-05 15:18:27 +01:00
parent 01a2c12feb
commit 78c85a429c
50 changed files with 596 additions and 221 deletions

View File

@@ -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>
);

View File

@@ -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 = [

View File

@@ -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 */}

View 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,
};
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 135 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 140 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 326 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 208 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 633 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 206 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 217 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 180 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 128 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 138 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 180 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 317 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 165 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 214 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 202 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 214 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 120 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 231 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 122 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 1025 1025" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><rect id="Square-Icon-Dark" serif:id="Square Icon Dark" x="0.521" y="0.228" width="1024" height="1024"/><g transform="matrix(2.83814,0,0,2.83814,-723.612,-329.472)"><path d="M493,227.168c15.577,-0 28.925,5.503 40.031,16.537c11.132,11.06 16.697,24.433 16.697,40.119c-0,15.686 -5.565,29.059 -16.697,40.118c-11.106,11.035 -24.454,16.538 -40.031,16.538l-6.92,-0l-0,25.488l-95.32,-0c-12.58,-0 -24.193,-3.137 -34.852,-9.36c-10.602,-6.19 -18.998,-14.586 -25.188,-25.188c-6.224,-10.66 -9.36,-22.273 -9.36,-34.852c-0,-9.433 1.862,-18.466 5.582,-27.101c3.683,-8.551 8.625,-15.857 14.743,-21.974c6.117,-6.117 13.423,-11.06 21.974,-14.743c8.635,-3.72 17.668,-5.582 27.101,-5.582l102.24,-0Zm-87.12,56.008l-0,36l-15.12,-0c-6.24,-0 -11.568,-2.208 -15.984,-6.624c-4.416,-4.416 -6.624,-9.744 -6.624,-15.984c-0,-6.24 2.208,-11.568 6.624,-15.984c4.416,-4.416 9.744,-6.624 15.984,-6.624l41.472,-0l-0,-27.792l-41.472,-0c-6.816,-0 -13.344,1.344 -19.584,4.032c-6.24,2.688 -11.592,6.264 -16.056,10.728c-4.464,4.464 -8.04,9.816 -10.728,16.056c-2.688,6.24 -4.032,12.768 -4.032,19.584c-0,9.12 2.256,17.544 6.768,25.272c4.512,7.728 10.632,13.848 18.36,18.36c7.728,4.512 16.152,6.768 25.272,6.768l42.912,-0l-0,-63.792l-27.792,-0Zm87.12,-37.008l-53.424,-0l-0,27.792l53.424,-0c2.784,-0 5.136,0.96 7.056,2.88c1.92,1.92 2.88,4.248 2.88,6.984c-0,2.736 -0.96,5.088 -2.88,7.056c-1.92,1.968 -4.272,2.952 -7.056,2.952l-39.888,-0c-3.84,-0 -7.104,1.344 -9.792,4.032c-2.688,2.688 -4.032,5.952 -4.032,9.792l-0,39.312l27.792,-0l-0,-25.488l25.92,-0c10.368,-0 19.248,-3.672 26.64,-11.016c7.392,-7.344 11.088,-16.224 11.088,-26.64c-0,-10.416 -3.696,-19.296 -11.088,-26.64c-7.392,-7.344 -16.272,-11.016 -26.64,-11.016Z" style="fill:#fff;"/><g transform="matrix(144,0,0,144,530.584,346.968)"></g><text x="340.072px" y="346.968px" style="font-family:'ElectricFormulaRegular', 'Electric Formula';font-size:144px;">GP</text></g></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 1025 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><rect id="Square-Icon" serif:id="Square Icon" x="0.521" y="0" width="1024" height="1024" style="fill:none;"/><g transform="matrix(2.83814,0,0,2.83814,-723.612,-329.7)"><path d="M493,227.168c15.577,-0 28.925,5.503 40.031,16.537c11.132,11.06 16.697,24.433 16.697,40.119c-0,15.686 -5.565,29.059 -16.697,40.118c-11.106,11.035 -24.454,16.538 -40.031,16.538l-6.92,-0l-0,25.488l-95.32,-0c-12.58,-0 -24.193,-3.137 -34.852,-9.36c-10.602,-6.19 -18.998,-14.586 -25.188,-25.188c-6.224,-10.66 -9.36,-22.273 -9.36,-34.852c-0,-9.433 1.862,-18.466 5.582,-27.101c3.683,-8.551 8.625,-15.857 14.743,-21.974c6.117,-6.117 13.423,-11.06 21.974,-14.743c8.635,-3.72 17.668,-5.582 27.101,-5.582l102.24,-0Zm-87.12,56.008l-0,36l-15.12,-0c-6.24,-0 -11.568,-2.208 -15.984,-6.624c-4.416,-4.416 -6.624,-9.744 -6.624,-15.984c-0,-6.24 2.208,-11.568 6.624,-15.984c4.416,-4.416 9.744,-6.624 15.984,-6.624l41.472,-0l-0,-27.792l-41.472,-0c-6.816,-0 -13.344,1.344 -19.584,4.032c-6.24,2.688 -11.592,6.264 -16.056,10.728c-4.464,4.464 -8.04,9.816 -10.728,16.056c-2.688,6.24 -4.032,12.768 -4.032,19.584c-0,9.12 2.256,17.544 6.768,25.272c4.512,7.728 10.632,13.848 18.36,18.36c7.728,4.512 16.152,6.768 25.272,6.768l42.912,-0l-0,-63.792l-27.792,-0Zm87.12,-37.008l-53.424,-0l-0,27.792l53.424,-0c2.784,-0 5.136,0.96 7.056,2.88c1.92,1.92 2.88,4.248 2.88,6.984c-0,2.736 -0.96,5.088 -2.88,7.056c-1.92,1.968 -4.272,2.952 -7.056,2.952l-39.888,-0c-3.84,-0 -7.104,1.344 -9.792,4.032c-2.688,2.688 -4.032,5.952 -4.032,9.792l-0,39.312l27.792,-0l-0,-25.488l25.92,-0c10.368,-0 19.248,-3.672 26.64,-11.016c7.392,-7.344 11.088,-16.224 11.088,-26.64c-0,-10.416 -3.696,-19.296 -11.088,-26.64c-7.392,-7.344 -16.272,-11.016 -26.64,-11.016Z"/><g transform="matrix(144,0,0,144,530.584,346.968)"></g><text x="340.072px" y="346.968px" style="font-family:'ElectricFormulaRegular', 'Electric Formula';font-size:144px;fill:#fff;">GP</text></g></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.8 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.8 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

@@ -0,0 +1,124 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
// --- Mocks for Next.js navigation ---
const useSearchParamsMock = vi.fn();
const useRouterMock = vi.fn();
const routerInstance = {
push: vi.fn(),
replace: vi.fn(),
prefetch: vi.fn(),
};
vi.mock('next/navigation', () => {
return {
useSearchParams: () => useSearchParamsMock(),
useRouter: () => {
return useRouterMock() ?? routerInstance;
},
};
});
// Minimal next/link mock to keep existing patterns consistent
vi.mock('next/link', () => {
const ActualLink = ({ href, children, ...rest }: any) => (
<a href={href} {...rest}>
{children}
</a>
);
return { default: ActualLink };
});
import CreateLeaguePage from '../../../../apps/website/app/leagues/create/page';
// Helper to build a searchParams-like object
function createSearchParams(stepValue: string | null) {
return {
get: (key: string) => {
if (key === 'step') {
return stepValue;
}
return null;
},
} as any;
}
describe('CreateLeaguePage - URL-bound wizard steps', () => {
beforeEach(() => {
useSearchParamsMock.mockReset();
useRouterMock.mockReset();
routerInstance.push.mockReset();
routerInstance.replace.mockReset();
});
it('defaults to basics step when step param is missing', () => {
useSearchParamsMock.mockReturnValue(createSearchParams(null));
render(<CreateLeaguePage />);
// Basics step title from the wizard
expect(screen.getByText('Name your league')).toBeInTheDocument();
});
it('treats invalid step value as basics', () => {
useSearchParamsMock.mockReturnValue(createSearchParams('invalid-step'));
render(<CreateLeaguePage />);
expect(screen.getByText('Name your league')).toBeInTheDocument();
});
it('mounts directly on scoring step when step=scoring', () => {
useSearchParamsMock.mockReturnValue(createSearchParams('scoring'));
render(<CreateLeaguePage />);
// Step 4 title in the wizard
expect(screen.getByText('Scoring & championships')).toBeInTheDocument();
});
it('clicking Continue from basics navigates to step=structure via router', () => {
useSearchParamsMock.mockReturnValue(createSearchParams(null));
useRouterMock.mockReturnValue(routerInstance);
render(<CreateLeaguePage />);
const continueButton = screen.getByRole('button', { name: /continue/i });
fireEvent.click(continueButton);
expect(routerInstance.push).toHaveBeenCalledTimes(1);
const callArg = routerInstance.push.mock.calls[0][0] as string;
expect(callArg).toContain('/leagues/create');
expect(callArg).toContain('step=structure');
});
it('clicking Back from schedule navigates to step=structure via router', () => {
useSearchParamsMock.mockReturnValue(createSearchParams('schedule'));
useRouterMock.mockReturnValue(routerInstance);
render(<CreateLeaguePage />);
const backButton = screen.getByRole('button', { name: /back/i });
fireEvent.click(backButton);
expect(routerInstance.push).toHaveBeenCalledTimes(1);
const callArg = routerInstance.push.mock.calls[0][0] as string;
expect(callArg).toContain('/leagues/create');
expect(callArg).toContain('step=structure');
});
it('derives current step solely from URL so a "reload" keeps the same step', () => {
useSearchParamsMock.mockReturnValueOnce(createSearchParams('scoring'));
useSearchParamsMock.mockReturnValueOnce(createSearchParams('scoring'));
render(<CreateLeaguePage />);
expect(screen.getByText('Scoring & championships')).toBeInTheDocument();
// Simulate a logical reload by re-rendering with the same URL state
render(<CreateLeaguePage />);
expect(screen.getByText('Scoring & championships')).toBeInTheDocument();
});
});