diff --git a/apps/website/app/leagues/create/page.tsx b/apps/website/app/leagues/create/page.tsx index f4d86c8ad..0c01e3d8a 100644 --- a/apps/website/app/leagues/create/page.tsx +++ b/apps/website/app/leagues/create/page.tsx @@ -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 (
- +
); diff --git a/apps/website/components/leagues/CreateLeagueWizard.tsx b/apps/website/components/leagues/CreateLeagueWizard.tsx index 4f0c79100..0ba02cf44 100644 --- a/apps/website/components/leagues/CreateLeagueWizard.tsx +++ b/apps/website/components/leagues/CreateLeagueWizard.tsx @@ -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(1); + + const step = stepNameToStep(stepName); const [loading, setLoading] = useState(false); const [presetsLoading, setPresetsLoading] = useState(true); const [presets, setPresets] = useState([]); @@ -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 = [ diff --git a/apps/website/components/leagues/LeagueScoringSection.tsx b/apps/website/components/leagues/LeagueScoringSection.tsx index 1fa6dd712..d1d396736 100644 --- a/apps/website/components/leagues/LeagueScoringSection.tsx +++ b/apps/website/components/leagues/LeagueScoringSection.tsx @@ -522,7 +522,7 @@ export function ScoringPatternSection({ const [activePresetFlyout, setActivePresetFlyout] = useState(null); const pointsInfoRef = useRef(null); const bonusInfoRef = useRef(null); - const presetInfoRefs = useRef>({}); + const presetInfoRefs = useRef>({}); return (
@@ -623,17 +623,25 @@ export function ScoringPatternSection({ )} {/* Info button */} - +
{/* Preset Info Flyout */} @@ -923,7 +931,7 @@ export function ChampionshipsSection({ const [showChampFlyout, setShowChampFlyout] = useState(false); const [activeChampFlyout, setActiveChampFlyout] = useState(null); const champInfoRef = useRef(null); - const champItemRefs = useRef>({}); + const champItemRefs = useRef>({}); const updateChampionship = (key: keyof LeagueConfigFormModel['championships'], value: boolean) => { if (!onChange) return; @@ -1073,17 +1081,25 @@ export function ChampionshipsSection({ {/* Info button */} - + {/* Championship Item Info Flyout */} diff --git a/apps/website/lib/leagueWizardService.ts b/apps/website/lib/leagueWizardService.ts new file mode 100644 index 000000000..4ad826f08 --- /dev/null +++ b/apps/website/lib/leagueWizardService.ts @@ -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 = {}; + 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 = {}; + 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 = {}; + 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 = {}; + 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 { + 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, + }; +} \ No newline at end of file diff --git a/apps/website/public/images/elements/strokes/1.svg b/apps/website/public/images/elements/strokes/1.svg new file mode 100644 index 000000000..d730f3888 --- /dev/null +++ b/apps/website/public/images/elements/strokes/1.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/apps/website/public/images/elements/strokes/10.svg b/apps/website/public/images/elements/strokes/10.svg new file mode 100644 index 000000000..a4baf71a0 --- /dev/null +++ b/apps/website/public/images/elements/strokes/10.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/apps/website/public/images/elements/strokes/2.svg b/apps/website/public/images/elements/strokes/2.svg new file mode 100644 index 000000000..6cf25ac99 --- /dev/null +++ b/apps/website/public/images/elements/strokes/2.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/apps/website/public/images/elements/strokes/3.svg b/apps/website/public/images/elements/strokes/3.svg new file mode 100644 index 000000000..3d3ff96e4 --- /dev/null +++ b/apps/website/public/images/elements/strokes/3.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/apps/website/public/images/elements/strokes/4.svg b/apps/website/public/images/elements/strokes/4.svg new file mode 100644 index 000000000..8e6695e59 --- /dev/null +++ b/apps/website/public/images/elements/strokes/4.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/apps/website/public/images/elements/strokes/5.svg b/apps/website/public/images/elements/strokes/5.svg new file mode 100644 index 000000000..ac87cf7e9 --- /dev/null +++ b/apps/website/public/images/elements/strokes/5.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/apps/website/public/images/elements/strokes/6.svg b/apps/website/public/images/elements/strokes/6.svg new file mode 100644 index 000000000..256c4a0ca --- /dev/null +++ b/apps/website/public/images/elements/strokes/6.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/apps/website/public/images/elements/strokes/7.svg b/apps/website/public/images/elements/strokes/7.svg new file mode 100644 index 000000000..dab2be1a5 --- /dev/null +++ b/apps/website/public/images/elements/strokes/7.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/apps/website/public/images/elements/strokes/8.svg b/apps/website/public/images/elements/strokes/8.svg new file mode 100644 index 000000000..afcd0dfa4 --- /dev/null +++ b/apps/website/public/images/elements/strokes/8.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/apps/website/public/images/elements/strokes/9.svg b/apps/website/public/images/elements/strokes/9.svg new file mode 100644 index 000000000..33ea486a3 --- /dev/null +++ b/apps/website/public/images/elements/strokes/9.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/apps/website/public/images/elements/tire-marks/1.svg b/apps/website/public/images/elements/tire-marks/1.svg new file mode 100644 index 000000000..ee669aae6 --- /dev/null +++ b/apps/website/public/images/elements/tire-marks/1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/website/public/images/elements/tire-marks/10.svg b/apps/website/public/images/elements/tire-marks/10.svg new file mode 100644 index 000000000..a9f35fd9d --- /dev/null +++ b/apps/website/public/images/elements/tire-marks/10.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/website/public/images/elements/tire-marks/2.svg b/apps/website/public/images/elements/tire-marks/2.svg new file mode 100644 index 000000000..0c6d72f4e --- /dev/null +++ b/apps/website/public/images/elements/tire-marks/2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/website/public/images/elements/tire-marks/3.svg b/apps/website/public/images/elements/tire-marks/3.svg new file mode 100644 index 000000000..0b97c3867 --- /dev/null +++ b/apps/website/public/images/elements/tire-marks/3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/website/public/images/elements/tire-marks/4.svg b/apps/website/public/images/elements/tire-marks/4.svg new file mode 100644 index 000000000..028c9e7a2 --- /dev/null +++ b/apps/website/public/images/elements/tire-marks/4.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/website/public/images/elements/tire-marks/5.svg b/apps/website/public/images/elements/tire-marks/5.svg new file mode 100644 index 000000000..e5b97f208 --- /dev/null +++ b/apps/website/public/images/elements/tire-marks/5.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/website/public/images/elements/tire-marks/6.svg b/apps/website/public/images/elements/tire-marks/6.svg new file mode 100644 index 000000000..19bca3301 --- /dev/null +++ b/apps/website/public/images/elements/tire-marks/6.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/website/public/images/elements/tire-marks/7.svg b/apps/website/public/images/elements/tire-marks/7.svg new file mode 100644 index 000000000..15747bce0 --- /dev/null +++ b/apps/website/public/images/elements/tire-marks/7.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/website/public/images/elements/tire-marks/8.svg b/apps/website/public/images/elements/tire-marks/8.svg new file mode 100644 index 000000000..f3a1828ae --- /dev/null +++ b/apps/website/public/images/elements/tire-marks/8.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/website/public/images/elements/tire-marks/9.svg b/apps/website/public/images/elements/tire-marks/9.svg new file mode 100644 index 000000000..20b55efcd --- /dev/null +++ b/apps/website/public/images/elements/tire-marks/9.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/logo/logo.afdesign b/resources/logo/logo.afdesign new file mode 100644 index 000000000..85182c367 Binary files /dev/null and b/resources/logo/logo.afdesign differ diff --git a/resources/logo/png/Rectangle Wordmark Dark.png b/resources/logo/png/Rectangle Wordmark Dark.png new file mode 100644 index 000000000..066567fd3 Binary files /dev/null and b/resources/logo/png/Rectangle Wordmark Dark.png differ diff --git a/resources/logo/png/Rectangle Wordmark Dark@2x.png b/resources/logo/png/Rectangle Wordmark Dark@2x.png new file mode 100644 index 000000000..66c80fa0b Binary files /dev/null and b/resources/logo/png/Rectangle Wordmark Dark@2x.png differ diff --git a/resources/logo/png/Rectangle Wordmark.png b/resources/logo/png/Rectangle Wordmark.png new file mode 100644 index 000000000..788e23a46 Binary files /dev/null and b/resources/logo/png/Rectangle Wordmark.png differ diff --git a/resources/logo/png/Rectangle Wordmark@2x.png b/resources/logo/png/Rectangle Wordmark@2x.png new file mode 100644 index 000000000..38c4a6a86 Binary files /dev/null and b/resources/logo/png/Rectangle Wordmark@2x.png differ diff --git a/resources/logo/png/Square Icon Dark.png b/resources/logo/png/Square Icon Dark.png new file mode 100644 index 000000000..b92245992 Binary files /dev/null and b/resources/logo/png/Square Icon Dark.png differ diff --git a/resources/logo/png/Square Icon Dark@2x.png b/resources/logo/png/Square Icon Dark@2x.png new file mode 100644 index 000000000..d64829cbe Binary files /dev/null and b/resources/logo/png/Square Icon Dark@2x.png differ diff --git a/resources/logo/png/Square Icon.png b/resources/logo/png/Square Icon.png new file mode 100644 index 000000000..cce4c71c0 Binary files /dev/null and b/resources/logo/png/Square Icon.png differ diff --git a/resources/logo/png/Square Icon@2x.png b/resources/logo/png/Square Icon@2x.png new file mode 100644 index 000000000..6add659ea Binary files /dev/null and b/resources/logo/png/Square Icon@2x.png differ diff --git a/resources/logo/png/Square Logo Dark.png b/resources/logo/png/Square Logo Dark.png new file mode 100644 index 000000000..554a01ff2 Binary files /dev/null and b/resources/logo/png/Square Logo Dark.png differ diff --git a/resources/logo/png/Square Logo Dark@2x.png b/resources/logo/png/Square Logo Dark@2x.png new file mode 100644 index 000000000..793ad1ca5 Binary files /dev/null and b/resources/logo/png/Square Logo Dark@2x.png differ diff --git a/resources/logo/png/Square Logo.png b/resources/logo/png/Square Logo.png new file mode 100644 index 000000000..517cd2263 Binary files /dev/null and b/resources/logo/png/Square Logo.png differ diff --git a/resources/logo/png/Square Logo@2x.png b/resources/logo/png/Square Logo@2x.png new file mode 100644 index 000000000..e716d0bcd Binary files /dev/null and b/resources/logo/png/Square Logo@2x.png differ diff --git a/resources/logo/png/Square Wordmark Dark.png b/resources/logo/png/Square Wordmark Dark.png new file mode 100644 index 000000000..7d4d65a68 Binary files /dev/null and b/resources/logo/png/Square Wordmark Dark.png differ diff --git a/resources/logo/png/Square Wordmark Dark@2x.png b/resources/logo/png/Square Wordmark Dark@2x.png new file mode 100644 index 000000000..2cc9ee8a5 Binary files /dev/null and b/resources/logo/png/Square Wordmark Dark@2x.png differ diff --git a/resources/logo/png/Square Wordmark.png b/resources/logo/png/Square Wordmark.png new file mode 100644 index 000000000..4365e1108 Binary files /dev/null and b/resources/logo/png/Square Wordmark.png differ diff --git a/resources/logo/png/Square Wordmark@2x.png b/resources/logo/png/Square Wordmark@2x.png new file mode 100644 index 000000000..00bf9a827 Binary files /dev/null and b/resources/logo/png/Square Wordmark@2x.png differ diff --git a/resources/logo/svg/Rectangle Wordmark Dark.svg b/resources/logo/svg/Rectangle Wordmark Dark.svg new file mode 100644 index 000000000..4f7c24551 --- /dev/null +++ b/resources/logo/svg/Rectangle Wordmark Dark.svg @@ -0,0 +1 @@ +GRIDPILOT.GG \ No newline at end of file diff --git a/resources/logo/svg/Rectangle Wordmark.svg b/resources/logo/svg/Rectangle Wordmark.svg new file mode 100644 index 000000000..9cf4df611 --- /dev/null +++ b/resources/logo/svg/Rectangle Wordmark.svg @@ -0,0 +1 @@ +GRIDPILOT.GG \ No newline at end of file diff --git a/resources/logo/svg/Square Icon Dark.svg b/resources/logo/svg/Square Icon Dark.svg new file mode 100644 index 000000000..3a9c68747 --- /dev/null +++ b/resources/logo/svg/Square Icon Dark.svg @@ -0,0 +1 @@ +GP \ No newline at end of file diff --git a/resources/logo/svg/Square Icon.svg b/resources/logo/svg/Square Icon.svg new file mode 100644 index 000000000..6b9e60396 --- /dev/null +++ b/resources/logo/svg/Square Icon.svg @@ -0,0 +1 @@ +GP \ No newline at end of file diff --git a/resources/logo/svg/Square Logo Dark.svg b/resources/logo/svg/Square Logo Dark.svg new file mode 100644 index 000000000..8ad4e99ab --- /dev/null +++ b/resources/logo/svg/Square Logo Dark.svg @@ -0,0 +1 @@ +GRIDPILOT.GGGP \ No newline at end of file diff --git a/resources/logo/svg/Square Logo.svg b/resources/logo/svg/Square Logo.svg new file mode 100644 index 000000000..3271f6544 --- /dev/null +++ b/resources/logo/svg/Square Logo.svg @@ -0,0 +1 @@ +GRIDPILOT.GGGP \ No newline at end of file diff --git a/resources/logo/svg/Square Wordmark Dark.svg b/resources/logo/svg/Square Wordmark Dark.svg new file mode 100644 index 000000000..9d774aa65 --- /dev/null +++ b/resources/logo/svg/Square Wordmark Dark.svg @@ -0,0 +1 @@ +GRIDPILOT.GG \ No newline at end of file diff --git a/resources/logo/svg/Square Wordmark.svg b/resources/logo/svg/Square Wordmark.svg new file mode 100644 index 000000000..ee6b831a7 --- /dev/null +++ b/resources/logo/svg/Square Wordmark.svg @@ -0,0 +1 @@ +GRIDPILOT.GG \ No newline at end of file diff --git a/tests/unit/website/leagues/CreateLeaguePage.wizardStep.test.tsx b/tests/unit/website/leagues/CreateLeaguePage.wizardStep.test.tsx new file mode 100644 index 000000000..e481d5c47 --- /dev/null +++ b/tests/unit/website/leagues/CreateLeaguePage.wizardStep.test.tsx @@ -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) => ( + + {children} + + ); + 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(); + + // 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(); + + expect(screen.getByText('Name your league')).toBeInTheDocument(); + }); + + it('mounts directly on scoring step when step=scoring', () => { + useSearchParamsMock.mockReturnValue(createSearchParams('scoring')); + + render(); + + // 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(); + + 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(); + + 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(); + expect(screen.getByText('Scoring & championships')).toBeInTheDocument(); + + // Simulate a logical reload by re-rendering with the same URL state + render(); + expect(screen.getByText('Scoring & championships')).toBeInTheDocument(); + }); +}); \ No newline at end of file