diff --git a/apps/website/components/leagues/CreateLeagueWizard.tsx b/apps/website/components/leagues/CreateLeagueWizard.tsx index ec7733208..e11607e26 100644 --- a/apps/website/components/leagues/CreateLeagueWizard.tsx +++ b/apps/website/components/leagues/CreateLeagueWizard.tsx @@ -21,6 +21,7 @@ import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import Heading from '@/components/ui/Heading'; import LeagueReviewSummary from '@/components/leagues/LeagueReviewSummary'; +import Input from '@/components/ui/Input'; import { getListLeagueScoringPresetsQuery, } from '@/lib/di-container'; @@ -52,7 +53,7 @@ import { LeagueStewardingSection } from './LeagueStewardingSection'; const STORAGE_KEY = 'gridpilot_league_wizard_draft'; const STORAGE_HIGHEST_STEP_KEY = 'gridpilot_league_wizard_highest_step'; -function saveFormToStorage(form: LeagueConfigFormModel): void { +function saveFormToStorage(form: LeagueWizardFormModel): void { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(form)); } catch { @@ -60,11 +61,16 @@ function saveFormToStorage(form: LeagueConfigFormModel): void { } } -function loadFormFromStorage(): LeagueConfigFormModel | null { +function loadFormFromStorage(): LeagueWizardFormModel | null { try { const stored = localStorage.getItem(STORAGE_KEY); if (stored) { - return JSON.parse(stored) as LeagueConfigFormModel; + const parsed = JSON.parse(stored) as LeagueWizardFormModel; + if (!parsed.seasonName) { + const seasonStartDate = parsed.timings?.seasonStartDate; + parsed.seasonName = getDefaultSeasonName(seasonStartDate); + } + return parsed; } } catch { // Ignore parse errors @@ -105,6 +111,10 @@ type Step = 1 | 2 | 3 | 4 | 5 | 6 | 7; type StepName = 'basics' | 'visibility' | 'structure' | 'schedule' | 'scoring' | 'stewarding' | 'review'; +type LeagueWizardFormModel = LeagueConfigFormModel & { + seasonName?: string; +}; + interface CreateLeagueWizardProps { stepName: StepName; onStepChange: (stepName: StepName) => void; @@ -160,8 +170,21 @@ function getDefaultSeasonStartDate(): string { return datePart ?? ''; } -function createDefaultForm(): LeagueConfigFormModel { +function getDefaultSeasonName(seasonStartDate?: string): string { + if (seasonStartDate) { + const parsed = new Date(seasonStartDate); + if (!Number.isNaN(parsed.getTime())) { + const year = parsed.getFullYear(); + return `Season 1 (${year})`; + } + } + const fallbackYear = new Date().getFullYear(); + return `Season 1 (${fallbackYear})`; +} + +function createDefaultForm(): LeagueWizardFormModel { const defaultPatternId = 'sprint-main-driver'; + const defaultSeasonStartDate = getDefaultSeasonStartDate(); return { basics: { @@ -201,7 +224,7 @@ function createDefaultForm(): LeagueConfigFormModel { recurrenceStrategy: 'weekly' as const, raceStartTime: '20:00', timezoneId: 'UTC', - seasonStartDate: getDefaultSeasonStartDate(), + seasonStartDate: defaultSeasonStartDate, }, stewarding: { decisionMode: 'admin_only', @@ -214,6 +237,7 @@ function createDefaultForm(): LeagueConfigFormModel { notifyAccusedOnProtest: true, notifyOnVoteRequired: true, }, + seasonName: getDefaultSeasonName(defaultSeasonStartDate), }; } @@ -229,7 +253,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea const [isHydrated, setIsHydrated] = useState(false); // Initialize form from localStorage or defaults - const [form, setForm] = useState(() => + const [form, setForm] = useState(() => createDefaultForm(), ); @@ -405,20 +429,30 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea case 2: return 'Will you compete for global rankings or race with friends?'; case 3: - return 'Will drivers compete individually or as part of teams?'; + return 'Define how races in this season will run.'; case 4: - return 'Configure session durations and plan your season calendar.'; + return 'Plan when this season’s races happen.'; case 5: - return 'Select a scoring preset, enable championships, and set drop rules.'; + return 'Choose how points and drop scores work for this season.'; case 6: - return 'Configure how protests are handled and penalties decided.'; + return 'Set how protests and stewarding work for this season.'; case 7: - return 'Everything looks good? Launch your new league!'; + return 'Review your league and first season before launching.'; default: return ''; } }; + const getStepContextLabel = (currentStep: Step): string => { + if (currentStep === 1 || currentStep === 2) { + return 'League setup'; + } + if (currentStep >= 3 && currentStep <= 6) { + return 'Season setup'; + } + return 'League & Season summary'; + }; + const currentStepData = steps.find((s) => s.id === step); const CurrentStepIcon = currentStepData?.icon ?? FileText; @@ -435,7 +469,10 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea Create a new league

- Set up your racing series in {steps.length} easy steps + We'll also set up your first season in {steps.length} easy steps. +

+

+ A league is your long-term brand. Each season is a block of races you can run again and again.

@@ -557,7 +594,12 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
- {getStepTitle(step)} +
+ {getStepTitle(step)} + + {getStepContextLabel(step)} + +

{getStepSubtitle(step)} @@ -575,15 +617,45 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea {/* Step content with min-height for consistency */}

- {step === 1 && ( -
- -
- )} + {step === 1 && ( +
+ +
+
+
+

+ First season +

+

+ Name the first season that will run in this league. +

+
+
+
+ + + setForm((prev) => ({ + ...prev, + seasonName: e.target.value, + })) + } + placeholder="e.g., Season 1 (2025)" + /> +

+ Seasons are the individual competitive runs inside your league. You can run Season 2, Season 3, or parallel seasons later. +

+
+
+
+ )} {step === 2 && (
@@ -600,7 +672,15 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea )} {step === 3 && ( -
+
+
+

+ Applies to: First season of this league. +

+

+ These settings only affect this season. Future seasons can use different formats. +

+
+
+
+

+ Applies to: First season of this league. +

+

+ These settings only affect this season. Future seasons can use different formats. +

+
+
+

+ Applies to: First season of this league. +

+

+ These settings only affect this season. Future seasons can use different formats. +

+
{/* Scoring Pattern Selection */} +
+
+

+ Applies to: First season of this league. +

+

+ These settings only affect this season. Future seasons can use different formats. +

+
- You can edit all settings after creating your league + This will create your league and its first season. You can edit both later.

); diff --git a/apps/website/components/leagues/LeagueAdmin.tsx b/apps/website/components/leagues/LeagueAdmin.tsx index 86d47f9b9..034f7d15d 100644 --- a/apps/website/components/leagues/LeagueAdmin.tsx +++ b/apps/website/components/leagues/LeagueAdmin.tsx @@ -15,9 +15,11 @@ import { loadLeagueProtests, removeLeagueMember as removeLeagueMemberCommand, updateLeagueMemberRole as updateLeagueMemberRoleCommand, + loadLeagueSeasons, type LeagueJoinRequestViewModel, type LeagueOwnerSummaryViewModel, type LeagueAdminProtestsViewModel, + type LeagueSeasonSummaryViewModel, } from '@/lib/presenters/LeagueAdminPresenter'; import type { LeagueConfigFormModel } from '@gridpilot/racing/application'; import type { LeagueSummaryViewModel } from '@/lib/presenters/LeagueAdminPresenter'; @@ -51,13 +53,15 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps const [ownerSummary, setOwnerSummary] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [activeTab, setActiveTab] = useState<'members' | 'requests' | 'races' | 'settings' | 'protests' | 'sponsorships' | 'fees' | 'wallet' | 'prizes' | 'liveries'>('members'); + const [activeTab, setActiveTab] = useState<'members' | 'requests' | 'races' | 'seasons' | 'settings' | 'protests' | 'sponsorships' | 'fees' | 'wallet' | 'prizes' | 'liveries'>('members'); const [downloadingLiveryPack, setDownloadingLiveryPack] = useState(false); const [rejectReason, setRejectReason] = useState(''); const [configForm, setConfigForm] = useState(null); const [configLoading, setConfigLoading] = useState(false); const [protestsViewModel, setProtestsViewModel] = useState(null); const [protestsLoading, setProtestsLoading] = useState(false); + const [seasons, setSeasons] = useState([]); + const [seasonsLoading, setSeasonsLoading] = useState(false); const loadJoinRequests = useCallback(async () => { setLoading(true); @@ -104,6 +108,22 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps loadConfig(); }, [league.id]); + useEffect(() => { + async function loadSeasonsVm() { + setSeasonsLoading(true); + try { + const items = await loadLeagueSeasons(league.id); + setSeasons(items); + } catch (err) { + console.error('Failed to load seasons:', err); + } finally { + setSeasonsLoading(false); + } + } + + loadSeasonsVm(); + }, [league.id]); + // Load protests for this league's races useEffect(() => { async function loadProtests() { @@ -257,6 +277,16 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps > Races +