This commit is contained in:
2025-12-12 14:23:40 +01:00
parent 6a88fe93ab
commit 2cd3bfbb47
58 changed files with 2866 additions and 260 deletions

View File

@@ -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<LeagueConfigFormModel>(() =>
const [form, setForm] = useState<LeagueWizardFormModel>(() =>
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 seasons 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
</Heading>
<p className="text-sm text-gray-500">
Set up your racing series in {steps.length} easy steps
We'll also set up your first season in {steps.length} easy steps.
</p>
<p className="text-xs text-gray-500 mt-1">
A league is your long-term brand. Each season is a block of races you can run again and again.
</p>
</div>
</div>
@@ -557,7 +594,12 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
</div>
<div className="flex-1 min-w-0">
<Heading level={2} className="text-xl sm:text-2xl text-white leading-tight">
{getStepTitle(step)}
<div className="flex items-center gap-2 flex-wrap">
<span>{getStepTitle(step)}</span>
<span className="inline-flex items-center px-2 py-0.5 rounded-full border border-charcoal-outline bg-iron-gray/60 text-[11px] font-medium text-gray-300">
{getStepContextLabel(step)}
</span>
</div>
</Heading>
<p className="text-sm text-gray-400 mt-1">
{getStepSubtitle(step)}
@@ -575,15 +617,45 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
{/* Step content with min-height for consistency */}
<div className="min-h-[320px]">
{step === 1 && (
<div className="animate-fade-in">
<LeagueBasicsSection
form={form}
onChange={setForm}
errors={errors.basics ?? {}}
/>
</div>
)}
{step === 1 && (
<div className="animate-fade-in space-y-8">
<LeagueBasicsSection
form={form}
onChange={setForm}
errors={errors.basics ?? {}}
/>
<div className="rounded-xl border border-charcoal-outline bg-iron-gray/40 p-4">
<div className="flex items-center justify-between gap-2 mb-2">
<div>
<p className="text-xs font-semibold text-gray-300 uppercase tracking-wide">
First season
</p>
<p className="text-xs text-gray-500">
Name the first season that will run in this league.
</p>
</div>
</div>
<div className="space-y-2 mt-2">
<label className="text-sm font-medium text-gray-300">
Season name
</label>
<Input
value={form.seasonName ?? ''}
onChange={(e) =>
setForm((prev) => ({
...prev,
seasonName: e.target.value,
}))
}
placeholder="e.g., Season 1 (2025)"
/>
<p className="text-xs text-gray-500">
Seasons are the individual competitive runs inside your league. You can run Season 2, Season 3, or parallel seasons later.
</p>
</div>
</div>
</div>
)}
{step === 2 && (
<div className="animate-fade-in">
@@ -600,7 +672,15 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
)}
{step === 3 && (
<div className="animate-fade-in">
<div className="animate-fade-in space-y-4">
<div className="mb-2">
<p className="text-xs text-gray-500">
Applies to: First season of this league.
</p>
<p className="text-xs text-gray-500">
These settings only affect this season. Future seasons can use different formats.
</p>
</div>
<LeagueStructureSection
form={form}
onChange={setForm}
@@ -610,7 +690,15 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
)}
{step === 4 && (
<div className="animate-fade-in">
<div className="animate-fade-in space-y-4">
<div className="mb-2">
<p className="text-xs text-gray-500">
Applies to: First season of this league.
</p>
<p className="text-xs text-gray-500">
These settings only affect this season. Future seasons can use different formats.
</p>
</div>
<LeagueTimingsSection
form={form}
onChange={setForm}
@@ -621,6 +709,14 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
{step === 5 && (
<div className="animate-fade-in space-y-8">
<div className="mb-2">
<p className="text-xs text-gray-500">
Applies to: First season of this league.
</p>
<p className="text-xs text-gray-500">
These settings only affect this season. Future seasons can use different formats.
</p>
</div>
{/* Scoring Pattern Selection */}
<ScoringPatternSection
scoring={form.scoring}
@@ -658,7 +754,15 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
)}
{step === 6 && (
<div className="animate-fade-in">
<div className="animate-fade-in space-y-4">
<div className="mb-2">
<p className="text-xs text-gray-500">
Applies to: First season of this league.
</p>
<p className="text-xs text-gray-500">
These settings only affect this season. Future seasons can use different formats.
</p>
</div>
<LeagueStewardingSection
form={form}
onChange={setForm}
@@ -744,7 +848,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
{/* Helper text */}
<p className="text-center text-xs text-gray-500 mt-4">
You can edit all settings after creating your league
This will create your league and its first season. You can edit both later.
</p>
</form>
);