539 lines
18 KiB
TypeScript
539 lines
18 KiB
TypeScript
'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 {
|
||
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 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));
|
||
onStepChange(stepToStepName(nextStep));
|
||
};
|
||
|
||
const goToPreviousStep = () => {
|
||
const prevStep = (step > 1 ? ((step - 1) as Step) : step);
|
||
onStepChange(stepToStepName(prevStep));
|
||
};
|
||
|
||
const goToStep = useCallback((targetStep: Step) => {
|
||
if (targetStep <= highestCompletedStep) {
|
||
onStepChange(stepToStepName(targetStep));
|
||
}
|
||
}, [highestCompletedStep, onStepChange]);
|
||
|
||
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)) {
|
||
onStepChange('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 season’s 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}
|
||
/>
|
||
);
|
||
}
|