Files
gridpilot.gg/apps/website/client-wrapper/CreateLeagueWizard.tsx
2026-01-19 18:01:30 +01:00

550 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useAuth } from '@/components/auth/AuthContext';
import { useRouter } from 'next/navigation';
import { FormEvent, useCallback, useEffect, useState } from 'react';
import { LeagueWizardCommandModel } from '@/lib/command-models/leagues/LeagueWizardCommandModel';
import { useLeagueScoringPresets } from "@/hooks/useLeagueScoringPresets";
import { useCreateLeagueWizard } from "@/hooks/useLeagueWizardService";
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import type { Weekday } from '@/lib/types/Weekday';
import type { WizardErrors } from '@/lib/types/WizardErrors';
import type { LeagueScoringPresetViewModel } from '@/lib/view-models/LeagueScoringPresetViewModel';
import { CreateLeagueWizardTemplate, Step } from '@/templates/CreateLeagueWizardTemplate';
import { SearchParamBuilder } from '@/lib/routing/search-params/SearchParamBuilder';
import {
Award,
Calendar,
CheckCircle2,
FileText,
Scale,
Trophy,
Users
} from 'lucide-react';
// ============================================================================
// LOCAL STORAGE PERSISTENCE
// ============================================================================
const STORAGE_KEY = 'gridpilot_league_wizard_draft';
const STORAGE_HIGHEST_STEP_KEY = 'gridpilot_league_wizard_highest_step';
function saveFormToStorage(form: LeagueWizardFormModel): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
} catch {
// Ignore storage errors
}
}
function loadFormFromStorage(): LeagueWizardFormModel | null {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
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
}
return null;
}
function clearFormStorage(): void {
try {
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(STORAGE_HIGHEST_STEP_KEY);
} catch {
// Ignore storage errors
}
}
function saveHighestStep(step: number): void {
try {
const current = getHighestStep();
if (step > current) {
localStorage.setItem(STORAGE_HIGHEST_STEP_KEY, String(step));
}
} catch {
// Ignore storage errors
}
}
function getHighestStep(): number {
try {
const stored = localStorage.getItem(STORAGE_HIGHEST_STEP_KEY);
return stored ? parseInt(stored, 10) : 1;
} catch {
return 1;
}
}
type StepName = 'basics' | 'visibility' | 'structure' | 'schedule' | 'scoring' | 'stewarding' | 'review';
type LeagueWizardFormModel = LeagueConfigFormModel & {
seasonName?: string;
};
interface CreateLeagueWizardProps {
stepName: StepName;
onStepChange?: (stepName: StepName) => void;
}
function stepNameToStep(stepName: StepName): Step {
switch (stepName) {
case 'basics': return 1;
case 'visibility': return 2;
case 'structure': return 3;
case 'schedule': return 4;
case 'scoring': return 5;
case 'stewarding': return 6;
case 'review': return 7;
}
}
function stepToStepName(step: Step): StepName {
switch (step) {
case 1: return 'basics';
case 2: return 'visibility';
case 3: return 'structure';
case 4: return 'schedule';
case 5: return 'scoring';
case 6: return 'stewarding';
case 7: return 'review';
}
}
function getDefaultSeasonStartDate(): string {
const now = new Date();
const daysUntilSaturday = (6 - now.getDay() + 7) % 7 || 7;
const nextSaturday = new Date(now);
nextSaturday.setDate(now.getDate() + daysUntilSaturday);
const [datePart] = nextSaturday.toISOString().split('T');
return datePart ?? '';
}
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: {
name: '',
description: '',
visibility: 'public',
gameId: 'iracing',
},
structure: {
mode: 'solo',
maxDrivers: 24,
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: 20,
mainRaceMinutes: 40,
sessionCount: 2,
roundsPlanned: 8,
weekdays: ['Sat'] as Weekday[],
recurrenceStrategy: 'weekly' as const,
timezoneId: 'UTC',
seasonStartDate: defaultSeasonStartDate,
},
stewarding: {
decisionMode: 'admin_only',
requiredVotes: 2,
requireDefense: false,
defenseTimeLimit: 48,
voteTimeLimit: 72,
protestDeadlineHours: 48,
stewardingClosesHours: 168,
notifyAccusedOnProtest: true,
notifyOnVoteRequired: true,
},
seasonName: getDefaultSeasonName(defaultSeasonStartDate),
};
}
export function CreateLeagueWizard({ stepName, onStepChange }: CreateLeagueWizardProps) {
const router = useRouter();
const { session } = useAuth();
const handleStepChange = useCallback((newStepName: StepName) => {
if (onStepChange) {
onStepChange(newStepName);
} else {
const builder = new SearchParamBuilder();
builder.step(newStepName);
router.push(`/leagues/create${builder.build()}`);
}
}, [onStepChange, router]);
const step = stepNameToStep(stepName);
const [loading, setLoading] = useState(false);
const [presetsLoading, setPresetsLoading] = useState(true);
const [presets, setPresets] = useState<LeagueScoringPresetViewModel[]>([]);
const [errors, setErrors] = useState<WizardErrors>({});
const [highestCompletedStep, setHighestCompletedStep] = useState(1);
const [isHydrated, setIsHydrated] = useState(false);
const [form, setForm] = useState<LeagueWizardFormModel>(() =>
createDefaultForm(),
);
useEffect(() => {
const stored = loadFormFromStorage();
if (stored) {
setForm(stored);
}
setHighestCompletedStep(getHighestStep());
setIsHydrated(true);
}, []);
useEffect(() => {
if (isHydrated) {
saveFormToStorage(form);
}
}, [form, isHydrated]);
useEffect(() => {
if (isHydrated) {
saveHighestStep(step);
setHighestCompletedStep((prev) => Math.max(prev, step));
}
}, [step, isHydrated]);
const { data: queryPresets, error: presetsError } = useLeagueScoringPresets();
useEffect(() => {
if (queryPresets) {
setPresets(queryPresets as any);
const firstPreset = queryPresets[0];
if (firstPreset && !form.scoring?.patternId) {
setForm((prev) => ({
...prev,
scoring: {
...prev.scoring,
patternId: firstPreset.id,
customScoringEnabled: false,
},
}));
}
setPresetsLoading(false);
}
}, [queryPresets, form.scoring?.patternId]);
useEffect(() => {
if (presetsError) {
setErrors((prev) => ({
...prev,
submit: presetsError instanceof Error ? presetsError.message : 'Failed to load scoring presets',
}));
}
}, [presetsError]);
const createLeagueMutation = useCreateLeagueWizard();
const validateStep = (currentStep: Step): boolean => {
const formData: any = {
leagueId: form.leagueId || '',
basics: {
name: form.basics?.name || '',
description: form.basics?.description || '',
visibility: (form.basics?.visibility as 'public' | 'private' | 'unlisted') || 'public',
gameId: form.basics?.gameId || 'iracing',
},
structure: {
mode: (form.structure?.mode as 'solo' | 'fixedTeams') || 'solo',
maxDrivers: form.structure?.maxDrivers || 0,
maxTeams: form.structure?.maxTeams || 0,
driversPerTeam: form.structure?.driversPerTeam || 0,
},
championships: {
enableDriverChampionship: form.championships?.enableDriverChampionship ?? true,
enableTeamChampionship: form.championships?.enableTeamChampionship ?? false,
enableNationsChampionship: form.championships?.enableNationsChampionship ?? false,
enableTrophyChampionship: form.championships?.enableTrophyChampionship ?? false,
},
scoring: {
patternId: form.scoring?.patternId || '',
customScoringEnabled: form.scoring?.customScoringEnabled ?? false,
},
dropPolicy: {
strategy: (form.dropPolicy?.strategy as 'none' | 'bestNResults' | 'dropWorstN') || 'bestNResults',
n: form.dropPolicy?.n || 6,
},
timings: {
practiceMinutes: form.timings?.practiceMinutes || 0,
qualifyingMinutes: form.timings?.qualifyingMinutes || 0,
sprintRaceMinutes: form.timings?.sprintRaceMinutes || 0,
mainRaceMinutes: form.timings?.mainRaceMinutes || 0,
sessionCount: form.timings?.sessionCount || 0,
roundsPlanned: form.timings?.roundsPlanned || 0,
},
stewarding: {
decisionMode: (form.stewarding?.decisionMode as 'owner_only' | 'admin_vote' | 'steward_panel') || 'admin_only',
requiredVotes: form.stewarding?.requiredVotes || 0,
requireDefense: form.stewarding?.requireDefense ?? false,
defenseTimeLimit: form.stewarding?.defenseTimeLimit || 0,
voteTimeLimit: form.stewarding?.voteTimeLimit || 0,
protestDeadlineHours: form.stewarding?.protestDeadlineHours || 0,
stewardingClosesHours: form.stewarding?.stewardingClosesHours || 0,
notifyAccusedOnProtest: form.stewarding?.notifyAccusedOnProtest ?? true,
notifyOnVoteRequired: form.stewarding?.notifyOnVoteRequired ?? true,
},
};
const stepErrors = LeagueWizardCommandModel.validateLeagueWizardStep(formData, currentStep as any);
setErrors((prev) => ({
...prev,
...stepErrors,
}));
return !LeagueWizardCommandModel.hasWizardErrors(stepErrors);
};
const goToNextStep = () => {
if (!validateStep(step)) {
return;
}
const nextStep = (step < 7 ? ((step + 1) as Step) : step);
saveHighestStep(nextStep);
setHighestCompletedStep((prev) => Math.max(prev, nextStep));
handleStepChange(stepToStepName(nextStep));
};
const goToPreviousStep = () => {
const prevStep = (step > 1 ? ((step - 1) as Step) : step);
handleStepChange(stepToStepName(prevStep));
};
const goToStep = useCallback((targetStep: Step) => {
if (targetStep <= highestCompletedStep) {
handleStepChange(stepToStepName(targetStep));
}
}, [highestCompletedStep, handleStepChange]);
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
if (loading) return;
const ownerId = session?.user.userId;
if (!ownerId) {
setErrors((prev) => ({
...prev,
submit: 'You must be logged in to create a league',
}));
return;
}
const formData: any = {
leagueId: form.leagueId || '',
basics: {
name: form.basics?.name || '',
description: form.basics?.description || '',
visibility: (form.basics?.visibility as 'public' | 'private' | 'unlisted') || 'public',
gameId: form.basics?.gameId || 'iracing',
},
structure: {
mode: (form.structure?.mode as 'solo' | 'fixedTeams') || 'solo',
maxDrivers: form.structure?.maxDrivers || 0,
maxTeams: form.structure?.maxTeams || 0,
driversPerTeam: form.structure?.driversPerTeam || 0,
},
championships: {
enableDriverChampionship: form.championships?.enableDriverChampionship ?? true,
enableTeamChampionship: form.championships?.enableTeamChampionship ?? false,
enableNationsChampionship: form.championships?.enableNationsChampionship ?? false,
enableTrophyChampionship: form.championships?.enableTrophyChampionship ?? false,
},
scoring: {
patternId: form.scoring?.patternId || '',
customScoringEnabled: form.scoring?.customScoringEnabled ?? false,
},
dropPolicy: {
strategy: (form.dropPolicy?.strategy as 'none' | 'bestNResults' | 'dropWorstN') || 'bestNResults',
n: form.dropPolicy?.n || 6,
},
timings: {
practiceMinutes: form.timings?.practiceMinutes || 0,
qualifyingMinutes: form.timings?.qualifyingMinutes || 0,
sprintRaceMinutes: form.timings?.sprintRaceMinutes || 0,
mainRaceMinutes: form.timings?.mainRaceMinutes || 0,
sessionCount: form.timings?.sessionCount || 0,
roundsPlanned: form.timings?.roundsPlanned || 0,
},
stewarding: {
decisionMode: (form.stewarding?.decisionMode as 'owner_only' | 'admin_vote' | 'steward_panel') || 'admin_only',
requiredVotes: form.stewarding?.requiredVotes || 0,
requireDefense: form.stewarding?.requireDefense ?? false,
defenseTimeLimit: form.stewarding?.defenseTimeLimit || 0,
voteTimeLimit: form.stewarding?.voteTimeLimit || 0,
protestDeadlineHours: form.stewarding?.protestDeadlineHours || 0,
stewardingClosesHours: form.stewarding?.stewardingClosesHours || 0,
notifyAccusedOnProtest: form.stewarding?.notifyAccusedOnProtest ?? true,
notifyOnVoteRequired: form.stewarding?.notifyOnVoteRequired ?? true,
},
};
const allErrors = LeagueWizardCommandModel.validateAllLeagueWizardSteps(formData);
setErrors((prev) => ({
...prev,
...allErrors,
}));
if (LeagueWizardCommandModel.hasWizardErrors(allErrors)) {
handleStepChange('basics');
return;
}
setLoading(true);
setErrors((prev) => {
const { submit: _, ...rest } = prev;
return rest;
});
try {
const result = await createLeagueMutation.mutateAsync({ form, ownerId });
clearFormStorage();
router.push(`/leagues/${result.leagueId}`);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create league';
setErrors((prev) => ({
...prev,
submit: message,
}));
setLoading(false);
}
};
const handleScoringPresetChange = (patternId: string) => {
setForm((prev) => {
const selectedPreset = presets.find((p) => p.id === patternId);
return {
...prev,
scoring: {
...prev.scoring,
patternId,
customScoringEnabled: false,
},
timings: selectedPreset
? {
...prev.timings,
practiceMinutes: prev.timings?.practiceMinutes ?? selectedPreset.defaultTimings.practiceMinutes,
qualifyingMinutes: prev.timings?.qualifyingMinutes ?? selectedPreset.defaultTimings.qualifyingMinutes,
sprintRaceMinutes: prev.timings?.sprintRaceMinutes ?? selectedPreset.defaultTimings.sprintRaceMinutes,
mainRaceMinutes: prev.timings?.mainRaceMinutes ?? selectedPreset.defaultTimings.mainRaceMinutes,
sessionCount: selectedPreset.defaultTimings.sessionCount,
}
: prev.timings,
};
});
};
const steps = [
{ id: 1 as Step, label: 'Basics', icon: FileText, shortLabel: 'Name' },
{ id: 2 as Step, label: 'Visibility', icon: Award, shortLabel: 'Type' },
{ id: 3 as Step, label: 'Structure', icon: Users, shortLabel: 'Mode' },
{ id: 4 as Step, label: 'Schedule', icon: Calendar, shortLabel: 'Time' },
{ id: 5 as Step, label: 'Scoring', icon: Trophy, shortLabel: 'Points' },
{ id: 6 as Step, label: 'Stewarding', icon: Scale, shortLabel: 'Rules' },
{ id: 7 as Step, label: 'Review', icon: CheckCircle2, shortLabel: 'Done' },
];
const getStepTitle = (currentStep: Step): string => {
switch (currentStep) {
case 1: return 'Name your league';
case 2: return 'Choose your destiny';
case 3: return 'Choose the structure';
case 4: return 'Set the schedule';
case 5: return 'Scoring & championships';
case 6: return 'Stewarding & protests';
case 7: return 'Review & create';
default: return '';
}
};
const getStepSubtitle = (currentStep: Step): string => {
switch (currentStep) {
case 1: return 'Give your league a memorable name and tell your story.';
case 2: return 'Will you compete for global rankings or race with friends?';
case 3: return 'Define how races in this season will run.';
case 4: return 'Plan when this seasons races happen.';
case 5: return 'Choose how points and drop scores work for this season.';
case 6: return 'Set how protests and stewarding work for this season.';
case 7: 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';
};
return (
<CreateLeagueWizardTemplate
viewData={{}}
step={step}
steps={steps}
form={form}
errors={errors}
loading={loading}
presetsLoading={presetsLoading}
presets={presets}
highestCompletedStep={highestCompletedStep}
onGoToStep={goToStep}
onFormChange={setForm}
onSubmit={handleSubmit}
onNextStep={goToNextStep}
onPreviousStep={goToPreviousStep}
onScoringPresetChange={handleScoringPresetChange}
onToggleCustomScoring={() =>
setForm((prev) => ({
...prev,
scoring: {
...prev.scoring,
customScoringEnabled: !(prev.scoring?.customScoringEnabled),
},
}))
}
getStepTitle={getStepTitle}
getStepSubtitle={getStepSubtitle}
getStepContextLabel={getStepContextLabel}
/>
);
}