diff --git a/apps/website/app/api/leagues/schedule-preview/route.ts b/apps/website/app/api/leagues/schedule-preview/route.ts new file mode 100644 index 000000000..b174f0c39 --- /dev/null +++ b/apps/website/app/api/leagues/schedule-preview/route.ts @@ -0,0 +1,94 @@ +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; +import { + type LeagueScheduleDTO, + type LeagueSchedulePreviewDTO, +} from '@gridpilot/racing/application'; +import { getPreviewLeagueScheduleQuery } from '@/lib/di-container'; + +interface RequestBody { + seasonStartDate?: string; + raceStartTime?: string; + timezoneId?: string; + recurrenceStrategy?: LeagueScheduleDTO['recurrenceStrategy']; + intervalWeeks?: number; + weekdays?: LeagueScheduleDTO['weekdays']; + monthlyOrdinal?: LeagueScheduleDTO['monthlyOrdinal']; + monthlyWeekday?: LeagueScheduleDTO['monthlyWeekday']; + plannedRounds?: number; +} + +function toLeagueScheduleDTO(body: RequestBody): LeagueScheduleDTO { + const { + seasonStartDate, + raceStartTime, + timezoneId, + recurrenceStrategy, + intervalWeeks, + weekdays, + monthlyOrdinal, + monthlyWeekday, + plannedRounds, + } = body; + + if ( + !seasonStartDate || + !raceStartTime || + !timezoneId || + !recurrenceStrategy || + plannedRounds == null + ) { + throw new Error( + 'seasonStartDate, raceStartTime, timezoneId, recurrenceStrategy, and plannedRounds are required', + ); + } + + const dto: LeagueScheduleDTO = { + seasonStartDate, + raceStartTime, + timezoneId, + recurrenceStrategy, + plannedRounds, + }; + + if (intervalWeeks != null) { + dto.intervalWeeks = intervalWeeks; + } + if (weekdays && weekdays.length > 0) { + dto.weekdays = weekdays; + } + if (monthlyOrdinal != null) { + dto.monthlyOrdinal = monthlyOrdinal; + } + if (monthlyWeekday != null) { + dto.monthlyWeekday = monthlyWeekday; + } + + return dto; +} + +export async function POST(request: NextRequest) { + try { + const json = (await request.json()) as RequestBody; + + const schedule = toLeagueScheduleDTO(json); + + const query = getPreviewLeagueScheduleQuery(); + const preview: LeagueSchedulePreviewDTO = await query.execute({ + schedule, + maxRounds: 10, + }); + + return NextResponse.json(preview, { status: 200 }); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Failed to preview schedule'; + + return NextResponse.json( + { + error: message, + }, + { status: 400 }, + ); + } +} \ No newline at end of file diff --git a/apps/website/app/leagues/[id]/page.tsx b/apps/website/app/leagues/[id]/page.tsx index 207498cac..38ebe64c7 100644 --- a/apps/website/app/leagues/[id]/page.tsx +++ b/apps/website/app/leagues/[id]/page.tsx @@ -9,18 +9,21 @@ import LeagueMembers from '@/components/leagues/LeagueMembers'; import LeagueSchedule from '@/components/leagues/LeagueSchedule'; import LeagueAdmin from '@/components/leagues/LeagueAdmin'; import StandingsTable from '@/components/leagues/StandingsTable'; +import LeagueScoringTab from '@/components/leagues/LeagueScoringTab'; import DriverIdentity from '@/components/drivers/DriverIdentity'; import DriverSummaryPill from '@/components/profile/DriverSummaryPill'; import { League } from '@gridpilot/racing/domain/entities/League'; import { Driver } from '@gridpilot/racing/domain/entities/Driver'; import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO'; import type { LeagueDriverSeasonStatsDTO } from '@gridpilot/racing/application/dto/LeagueDriverSeasonStatsDTO'; +import type { LeagueScoringConfigDTO } from '@gridpilot/racing/application/dto/LeagueScoringConfigDTO'; import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers'; import { getLeagueRepository, getRaceRepository, getDriverRepository, getGetLeagueDriverSeasonStatsQuery, + getGetLeagueScoringConfigQuery, getDriverStats, getAllDriverRankings, } from '@/lib/di-container'; @@ -36,9 +39,12 @@ export default function LeagueDetailPage() { const [owner, setOwner] = useState(null); const [standings, setStandings] = useState([]); const [drivers, setDrivers] = useState([]); + const [scoringConfig, setScoringConfig] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [activeTab, setActiveTab] = useState<'overview' | 'schedule' | 'standings' | 'admin'>('overview'); + const [activeTab, setActiveTab] = useState< + 'overview' | 'schedule' | 'standings' | 'scoring' | 'admin' + >('overview'); const [refreshKey, setRefreshKey] = useState(0); const currentDriverId = useEffectiveDriverId(); @@ -71,6 +77,11 @@ export default function LeagueDetailPage() { const leagueStandings = await getLeagueDriverSeasonStatsQuery.execute({ leagueId }); setStandings(leagueStandings); + // Load scoring configuration for the active season + const getLeagueScoringConfigQuery = getGetLeagueScoringConfigQuery(); + const scoring = await getLeagueScoringConfigQuery.execute({ leagueId }); + setScoringConfig(scoring); + // Load all drivers for standings and map to DTOs for UI components const allDrivers = await driverRepo.findAll(); const driverDtos: DriverDTO[] = allDrivers @@ -100,9 +111,10 @@ export default function LeagueDetailPage() { initialTab === 'overview' || initialTab === 'schedule' || initialTab === 'standings' || + initialTab === 'scoring' || initialTab === 'admin' ) { - setActiveTab(initialTab); + setActiveTab(initialTab as typeof activeTab); } }, [searchParams]); @@ -231,6 +243,16 @@ export default function LeagueDetailPage() { > Standings + {isAdmin && ( - {showCreateForm && ( - -
-

Create New League

-

- Experiment with different point systems -

-
- -
- )} - {leagues.length > 0 && (
diff --git a/apps/website/components/leagues/CreateLeagueWizard.tsx b/apps/website/components/leagues/CreateLeagueWizard.tsx new file mode 100644 index 000000000..810f09619 --- /dev/null +++ b/apps/website/components/leagues/CreateLeagueWizard.tsx @@ -0,0 +1,587 @@ +'use client'; + +import { useEffect, useState, FormEvent } from 'react'; +import { useRouter } from 'next/navigation'; +import Card from '@/components/ui/Card'; +import Button from '@/components/ui/Button'; +import Heading from '@/components/ui/Heading'; +import LeagueReviewSummary from './LeagueReviewSummary'; +import { + getDriverRepository, + getListLeagueScoringPresetsQuery, + getCreateLeagueWithSeasonAndScoringUseCase, +} from '@/lib/di-container'; +import type { LeagueScoringPresetDTO } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider'; +import type { LeagueConfigFormModel } from '@gridpilot/racing/application'; +import { LeagueBasicsSection } from './LeagueBasicsSection'; +import { LeagueStructureSection } from './LeagueStructureSection'; +import { + LeagueScoringSection, + ScoringPatternSection, + ChampionshipsSection, +} from './LeagueScoringSection'; +import { LeagueDropSection } from './LeagueDropSection'; +import { LeagueTimingsSection } from './LeagueTimingsSection'; + +type Step = 1 | 2 | 3 | 4 | 5 | 6; + +interface WizardErrors { + basics?: { + name?: string; + visibility?: string; + }; + structure?: { + maxDrivers?: string; + maxTeams?: string; + driversPerTeam?: string; + }; + timings?: { + qualifyingMinutes?: string; + mainRaceMinutes?: string; + roundsPlanned?: string; + }; + scoring?: { + patternId?: string; + }; + submit?: string; +} + +function createDefaultForm(): LeagueConfigFormModel { + const defaultPatternId = 'sprint-main-driver'; + + return { + basics: { + name: '', + description: '', + visibility: 'public', + gameId: 'iracing', + }, + structure: { + mode: 'solo', + maxDrivers: 24, + maxTeams: undefined, + driversPerTeam: undefined, + multiClassEnabled: false, + }, + championships: { + enableDriverChampionship: true, + enableTeamChampionship: false, + enableNationsChampionship: false, + enableTrophyChampionship: false, + }, + scoring: { + patternId: defaultPatternId, + customScoringEnabled: false, + }, + dropPolicy: { + strategy: 'bestNResults', + n: 6, + }, + timings: { + practiceMinutes: 20, + qualifyingMinutes: 30, + sprintRaceMinutes: defaultPatternId === 'sprint-main-driver' ? 20 : undefined, + mainRaceMinutes: 40, + sessionCount: 2, + roundsPlanned: 8, + }, + }; +} + +export default function CreateLeagueWizard() { + const router = useRouter(); + + const [step, setStep] = useState(1); + const [loading, setLoading] = useState(false); + const [presetsLoading, setPresetsLoading] = useState(true); + const [presets, setPresets] = useState([]); + const [errors, setErrors] = useState({}); + const [form, setForm] = useState(() => + createDefaultForm(), + ); + /** + * Local-only weekend template selection for Step 3. + * This does not touch domain models; it only seeds timing defaults. + */ + const [weekendTemplate, setWeekendTemplate] = useState(''); + + useEffect(() => { + async function loadPresets() { + try { + const query = getListLeagueScoringPresetsQuery(); + const result = await query.execute(); + setPresets(result); + if (result.length > 0) { + setForm((prev) => ({ + ...prev, + scoring: { + ...prev.scoring, + patternId: prev.scoring.patternId || result[0].id, + customScoringEnabled: prev.scoring.customScoringEnabled ?? false, + }, + })); + } + } catch (err) { + setErrors((prev) => ({ + ...prev, + submit: + err instanceof Error + ? err.message + : 'Failed to load scoring presets', + })); + } finally { + setPresetsLoading(false); + } + } + + loadPresets(); + }, []); + + const validateStep = (currentStep: Step): boolean => { + const nextErrors: WizardErrors = {}; + + if (currentStep === 1) { + const basicsErrors: WizardErrors['basics'] = {}; + if (!form.basics.name.trim()) { + basicsErrors.name = 'Name is required'; + } + if (!form.basics.visibility) { + basicsErrors.visibility = 'Visibility is required'; + } + if (Object.keys(basicsErrors).length > 0) { + nextErrors.basics = basicsErrors; + } + } + + if (currentStep === 2) { + const structureErrors: WizardErrors['structure'] = {}; + if (form.structure.mode === 'solo') { + if (!form.structure.maxDrivers || form.structure.maxDrivers <= 0) { + structureErrors.maxDrivers = + 'Max drivers must be greater than 0 for solo leagues'; + } + } else if (form.structure.mode === 'fixedTeams') { + if ( + !form.structure.maxTeams || + form.structure.maxTeams <= 0 + ) { + structureErrors.maxTeams = + 'Max teams must be greater than 0 for team leagues'; + } + if ( + !form.structure.driversPerTeam || + form.structure.driversPerTeam <= 0 + ) { + structureErrors.driversPerTeam = + 'Drivers per team must be greater than 0'; + } + } + if (Object.keys(structureErrors).length > 0) { + nextErrors.structure = structureErrors; + } + } + + if (currentStep === 3) { + const timingsErrors: WizardErrors['timings'] = {}; + if (!form.timings.qualifyingMinutes || form.timings.qualifyingMinutes <= 0) { + timingsErrors.qualifyingMinutes = + 'Qualifying duration must be greater than 0 minutes'; + } + if (!form.timings.mainRaceMinutes || form.timings.mainRaceMinutes <= 0) { + timingsErrors.mainRaceMinutes = + 'Main race duration must be greater than 0 minutes'; + } + if (Object.keys(timingsErrors).length > 0) { + nextErrors.timings = timingsErrors; + } + } + + if (currentStep === 4) { + const scoringErrors: WizardErrors['scoring'] = {}; + if (!form.scoring.patternId && !form.scoring.customScoringEnabled) { + scoringErrors.patternId = + 'Select a scoring preset or enable custom scoring'; + } + if (Object.keys(scoringErrors).length > 0) { + nextErrors.scoring = scoringErrors; + } + } + + setErrors((prev) => ({ + ...prev, + ...nextErrors, + })); + + return Object.keys(nextErrors).length === 0; + }; + + const goToNextStep = () => { + if (!validateStep(step)) { + return; + } + setStep((prev) => (prev < 6 ? ((prev + 1) as Step) : prev)); + }; + + const goToPreviousStep = () => { + setStep((prev) => (prev > 1 ? ((prev - 1) as Step) : prev)); + }; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + if (loading) return; + + if ( + !validateStep(1) || + !validateStep(2) || + !validateStep(3) || + !validateStep(4) || + !validateStep(5) + ) { + setStep(1); + return; + } + + setLoading(true); + setErrors((prev) => ({ ...prev, submit: undefined })); + + try { + const driverRepo = getDriverRepository(); + const drivers = await driverRepo.findAll(); + const currentDriver = drivers[0]; + + if (!currentDriver) { + setErrors((prev) => ({ + ...prev, + submit: + 'No driver profile found. Please create a driver profile first.', + })); + setLoading(false); + return; + } + + const createUseCase = getCreateLeagueWithSeasonAndScoringUseCase(); + + const structure = form.structure; + let maxDrivers: number | undefined; + let maxTeams: number | undefined; + + if (structure.mode === 'solo') { + maxDrivers = + typeof structure.maxDrivers === 'number' + ? structure.maxDrivers + : undefined; + maxTeams = undefined; + } else { + const teams = + typeof structure.maxTeams === 'number' ? structure.maxTeams : 0; + const perTeam = + typeof structure.driversPerTeam === 'number' + ? structure.driversPerTeam + : 0; + maxTeams = teams > 0 ? teams : undefined; + maxDrivers = + teams > 0 && perTeam > 0 ? teams * perTeam : undefined; + } + + const command = { + name: form.basics.name.trim(), + description: form.basics.description?.trim() || undefined, + visibility: form.basics.visibility, + ownerId: currentDriver.id, + gameId: form.basics.gameId, + maxDrivers, + maxTeams, + enableDriverChampionship: form.championships.enableDriverChampionship, + enableTeamChampionship: form.championships.enableTeamChampionship, + enableNationsChampionship: + form.championships.enableNationsChampionship, + enableTrophyChampionship: + form.championships.enableTrophyChampionship, + scoringPresetId: form.scoring.patternId || undefined, + } as const; + + const result = await createUseCase.execute(command); + + router.push(`/leagues/${result.leagueId}`); + } catch (err) { + setErrors((prev) => ({ + ...prev, + submit: + err instanceof Error ? err.message : 'Failed to create league', + })); + setLoading(false); + } + }; + + const currentPreset = + presets.find((p) => p.id === form.scoring.patternId) ?? null; + + const handleWeekendTemplateChange = (template: string) => { + setWeekendTemplate(template); + + setForm((prev) => { + const timings = prev.timings ?? {}; + if (template === 'feature') { + return { + ...prev, + timings: { + ...timings, + practiceMinutes: 20, + qualifyingMinutes: 30, + sprintRaceMinutes: undefined, + mainRaceMinutes: 40, + sessionCount: 1, + }, + }; + } + if (template === 'sprintFeature') { + return { + ...prev, + timings: { + ...timings, + practiceMinutes: 15, + qualifyingMinutes: 20, + sprintRaceMinutes: 20, + mainRaceMinutes: 35, + sessionCount: 2, + }, + }; + } + if (template === 'endurance') { + return { + ...prev, + timings: { + ...timings, + practiceMinutes: 30, + qualifyingMinutes: 30, + sprintRaceMinutes: undefined, + mainRaceMinutes: 90, + sessionCount: 1, + }, + }; + } + return prev; + }); + }; + + const steps = [ + { id: 1 as Step, label: 'Basics' }, + { id: 2 as Step, label: 'Structure' }, + { id: 3 as Step, label: 'Schedule & timings' }, + { id: 4 as Step, label: 'Scoring pattern' }, + { id: 5 as Step, label: 'Championships & drops' }, + { id: 6 as Step, label: 'Review & confirm' }, + ]; + + const getStepTitle = (currentStep: Step): string => { + switch (currentStep) { + case 1: + return 'Step 1 — Basics'; + case 2: + return 'Step 2 — Structure'; + case 3: + return 'Step 3 — Schedule & timings'; + case 4: + return 'Step 4 — Scoring pattern'; + case 5: + return 'Step 5 — Championships & drops'; + case 6: + return 'Step 6 — Review & confirm'; + default: + return ''; + } + }; + + const getStepSubtitle = (currentStep: Step): string => { + switch (currentStep) { + case 1: + return 'Give your league a clear name, description, and visibility.'; + case 2: + return 'Choose whether this is a solo or team-based championship.'; + case 3: + return 'Roughly outline how long your weekends and season should run.'; + case 4: + return 'Pick a scoring pattern that matches your weekends.'; + case 5: + return 'Decide which championships to track and how drops behave.'; + case 6: + return 'Double-check the summary before creating your new league.'; + default: + return ''; + } + }; + + return ( +
+ + Create a new league + +

+ Configure basics, structure, schedule, scoring, and drop rules in a few + simple steps. +

+ +
+
+ {steps.map((wizardStep, index) => { + const isCompleted = wizardStep.id < step; + const isCurrent = wizardStep.id === step; + const baseCircleClasses = + 'flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold'; + const circleClasses = isCurrent + ? 'bg-primary-blue text-white' + : isCompleted + ? 'bg-primary-blue/20 border border-primary-blue text-primary-blue' + : 'bg-iron-gray border border-charcoal-outline text-gray-400'; + + return ( +
+
+ {isCompleted ? '✓' : wizardStep.id} +
+ + {wizardStep.label} + + {index < steps.length - 1 && ( + + )} +
+ ); + })} +
+
+ + +
+ + {getStepTitle(step)} + +

+ {getStepSubtitle(step)} +

+
+
+ + {step === 1 && ( + + )} + + {step === 2 && ( + + )} + + {step === 3 && ( + + )} + + {step === 4 && ( +
+ + setForm((prev) => ({ + ...prev, + scoring: { + ...prev.scoring, + patternId, + customScoringEnabled: false, + }, + })) + } + onToggleCustomScoring={() => + setForm((prev) => ({ + ...prev, + scoring: { + ...prev.scoring, + customScoringEnabled: !prev.scoring.customScoringEnabled, + }, + })) + } + /> +
+ )} + + {step === 5 && ( +
+
+ +
+
+ +
+ + {errors.submit && ( +
+ {errors.submit} +
+ )} +
+ )} + + {step === 6 && ( +
+ + {errors.submit && ( +
+ {errors.submit} +
+ )} +
+ )} +
+ +
+ +
+ {step < 6 && ( + + )} + {step === 6 && ( + + )} +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/website/components/leagues/LeagueAdmin.tsx b/apps/website/components/leagues/LeagueAdmin.tsx index f8af07ce6..c6b98aa70 100644 --- a/apps/website/components/leagues/LeagueAdmin.tsx +++ b/apps/website/components/leagues/LeagueAdmin.tsx @@ -12,7 +12,14 @@ import { getDriverStats, getAllDriverRankings, getDriverRepository, + getGetLeagueFullConfigQuery, } from '@/lib/di-container'; +import type { LeagueConfigFormModel } from '@gridpilot/racing/application'; +import { LeagueBasicsSection } from './LeagueBasicsSection'; +import { LeagueStructureSection } from './LeagueStructureSection'; +import { LeagueScoringSection } from './LeagueScoringSection'; +import { LeagueDropSection } from './LeagueDropSection'; +import { LeagueTimingsSection } from './LeagueTimingsSection'; import { useEffectiveDriverId } from '@/lib/currentDriver'; import type { MembershipRole } from '@/lib/leagueMembership'; import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO'; @@ -46,6 +53,8 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps const [error, setError] = useState(null); const [activeTab, setActiveTab] = useState<'members' | 'requests' | 'races' | 'settings' | 'disputes'>('members'); const [rejectReason, setRejectReason] = useState(''); + const [configForm, setConfigForm] = useState(null); + const [configLoading, setConfigLoading] = useState(false); const loadJoinRequests = useCallback(async () => { setLoading(true); @@ -93,6 +102,23 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps loadOwner(); }, [league.ownerId]); + useEffect(() => { + async function loadConfig() { + setConfigLoading(true); + try { + const query = getGetLeagueFullConfigQuery(); + const form = await query.execute({ leagueId: league.id }); + setConfigForm(form); + } catch (err) { + console.error('Failed to load league config:', err); + } finally { + setConfigLoading(false); + } + } + + loadConfig(); + }, [league.id]); + const handleApproveRequest = async (requestId: string) => { try { const membershipRepo = getLeagueMembershipRepository(); @@ -464,113 +490,74 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps

League Settings

-
-
-
-
- -

{league.name}

-
+ {configLoading && !configForm ? ( +
Loading configuration…
+ ) : configForm ? ( +
+ + + + + -
- -

{league.description}

-
- -
+
+
- +

Alpha Demo Season

-
- -

{league.settings.pointsSystem.toUpperCase()}

-
-
- -

{league.settings.qualifyingFormat}

+
+

+ At a glance +

+

+ Structure:{' '} + {configForm.structure.mode === 'solo' + ? `Solo • ${configForm.structure.maxDrivers} drivers` + : `Teams • ${configForm.structure.maxTeams ?? '—'} × ${ + configForm.structure.driversPerTeam ?? '—' + } drivers (${configForm.structure.maxDrivers ?? '—'} total)`} +

+

+ Schedule:{' '} + {`${configForm.timings.roundsPlanned ?? '?'} rounds • ${ + configForm.timings.qualifyingMinutes + } min Qualifying • ${configForm.timings.mainRaceMinutes} min Race`} +

+

+ Scoring:{' '} + {league.settings.pointsSystem.toUpperCase()} +

- {league.socialLinks && ( -
-

Social Links

-
- {league.socialLinks.discordUrl && ( - - )} - {league.socialLinks.youtubeUrl && ( - - )} - {league.socialLinks.websiteUrl && ( - - )} - {!league.socialLinks.discordUrl && - !league.socialLinks.youtubeUrl && - !league.socialLinks.websiteUrl && ( -

- No social links configured for this league in the alpha demo. -

- )} -
-
- )} +
+

League Owner

+ {ownerSummary ? ( + + ) : ( +

Loading owner details...

+ )} +
-
-

League Owner

- {ownerSummary ? ( - - ) : ( -

Loading owner details...

- )} +
+

+ League settings editing is alpha-only and changes are not persisted yet. +

- -
-

- League settings editing is alpha-only and changes are not persisted yet. -

+ ) : ( +
+ Unable to load league configuration for this demo league.
-
+ )} )} diff --git a/apps/website/components/leagues/LeagueBasicsSection.tsx b/apps/website/components/leagues/LeagueBasicsSection.tsx new file mode 100644 index 000000000..dda7e331c --- /dev/null +++ b/apps/website/components/leagues/LeagueBasicsSection.tsx @@ -0,0 +1,130 @@ +'use client'; + +import Input from '@/components/ui/Input'; +import type { + LeagueConfigFormModel, +} from '@gridpilot/racing/application'; + +interface LeagueBasicsSectionProps { + form: LeagueConfigFormModel; + onChange?: (form: LeagueConfigFormModel) => void; + errors?: { + name?: string; + visibility?: string; + }; + readOnly?: boolean; +} + +export function LeagueBasicsSection({ + form, + onChange, + errors, + readOnly, +}: LeagueBasicsSectionProps) { + const basics = form.basics; + const disabled = readOnly || !onChange; + + const updateBasics = (patch: Partial) => { + if (!onChange) return; + onChange({ + ...form, + basics: { + ...form.basics, + ...patch, + }, + }); + }; + + return ( +
+

Step 1 — Basics

+
+
+ + updateBasics({ name: e.target.value })} + placeholder="GridPilot Sprint Series" + error={!!errors?.name} + errorMessage={errors?.name} + disabled={disabled} + /> +
+ +
+ +