wip
This commit is contained in:
985
apps/website/app/leagues/create/CreateLeagueWizard.tsx
Normal file
985
apps/website/app/leagues/create/CreateLeagueWizard.tsx
Normal file
@@ -0,0 +1,985 @@
|
||||
'use client';
|
||||
|
||||
import LeagueReviewSummary from '@/components/leagues/LeagueReviewSummary';
|
||||
import Button from '@/ui/Button';
|
||||
import Card from '@/ui/Card';
|
||||
import Heading from '@/ui/Heading';
|
||||
import Input from '@/ui/Input';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
import {
|
||||
AlertCircle,
|
||||
Award,
|
||||
Calendar,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
Loader2,
|
||||
Scale,
|
||||
Sparkles,
|
||||
Trophy,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { LeagueWizardCommandModel } from '@/lib/command-models/leagues/LeagueWizardCommandModel';
|
||||
|
||||
import { useCreateLeagueWizard } from "@/lib/hooks/useLeagueWizardService";
|
||||
import { useLeagueScoringPresets } from "@/lib/hooks/useLeagueScoringPresets";
|
||||
import { LeagueBasicsSection } from './LeagueBasicsSection';
|
||||
import { LeagueDropSection } from './LeagueDropSection';
|
||||
import {
|
||||
ChampionshipsSection,
|
||||
ScoringPatternSection
|
||||
} from './LeagueScoringSection';
|
||||
import { LeagueStewardingSection } from './LeagueStewardingSection';
|
||||
import { LeagueStructureSection } from './LeagueStructureSection';
|
||||
import { LeagueTimingsSection } from './LeagueTimingsSection';
|
||||
import { LeagueVisibilitySection } from './LeagueVisibilitySection';
|
||||
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
|
||||
import type { LeagueScoringPresetViewModel } from '@/lib/view-models/LeagueScoringPresetViewModel';
|
||||
import type { Weekday } from '@/lib/types/Weekday';
|
||||
import type { WizardErrors } from '@/lib/types/WizardErrors';
|
||||
|
||||
// ============================================================================
|
||||
// LOCAL STORAGE PERSISTENCE
|
||||
// ============================================================================
|
||||
|
||||
const STORAGE_KEY = 'gridpilot_league_wizard_draft';
|
||||
const STORAGE_HIGHEST_STEP_KEY = 'gridpilot_league_wizard_highest_step';
|
||||
|
||||
// TODO there is a better place for this
|
||||
function saveFormToStorage(form: LeagueWizardFormModel): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
|
||||
} catch {
|
||||
// Ignore storage errors (quota exceeded, etc.)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO there is a better place for this
|
||||
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 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;
|
||||
}
|
||||
|
||||
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 {
|
||||
// Default to next Saturday
|
||||
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,
|
||||
// Default to Saturday races, weekly, starting next week
|
||||
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 default 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);
|
||||
|
||||
// Initialize form from localStorage or defaults
|
||||
const [form, setForm] = useState<LeagueWizardFormModel>(() =>
|
||||
createDefaultForm(),
|
||||
);
|
||||
|
||||
// Hydrate from localStorage on mount
|
||||
useEffect(() => {
|
||||
const stored = loadFormFromStorage();
|
||||
if (stored) {
|
||||
setForm(stored);
|
||||
}
|
||||
setHighestCompletedStep(getHighestStep());
|
||||
setIsHydrated(true);
|
||||
}, []);
|
||||
|
||||
// Save form to localStorage whenever it changes (after hydration)
|
||||
useEffect(() => {
|
||||
if (isHydrated) {
|
||||
saveFormToStorage(form);
|
||||
}
|
||||
}, [form, isHydrated]);
|
||||
|
||||
// Track highest step reached
|
||||
useEffect(() => {
|
||||
if (isHydrated) {
|
||||
saveHighestStep(step);
|
||||
setHighestCompletedStep((prev) => Math.max(prev, step));
|
||||
}
|
||||
}, [step, isHydrated]);
|
||||
|
||||
// Use the react-query hook for scoring presets
|
||||
const { data: queryPresets, error: presetsError } = useLeagueScoringPresets();
|
||||
|
||||
// Sync presets from query to local state
|
||||
useEffect(() => {
|
||||
if (queryPresets) {
|
||||
setPresets(queryPresets);
|
||||
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]);
|
||||
|
||||
// Handle presets error
|
||||
useEffect(() => {
|
||||
if (presetsError) {
|
||||
setErrors((prev) => ({
|
||||
...prev,
|
||||
submit: presetsError instanceof Error ? presetsError.message : 'Failed to load scoring presets',
|
||||
}));
|
||||
}
|
||||
}, [presetsError]);
|
||||
|
||||
// Use the create league mutation
|
||||
const createLeagueMutation = useCreateLeagueWizard();
|
||||
|
||||
const validateStep = (currentStep: Step): boolean => {
|
||||
// Convert form to LeagueWizardFormData for validation
|
||||
const formData: LeagueWizardCommandModel.LeagueWizardFormData = {
|
||||
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);
|
||||
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));
|
||||
};
|
||||
|
||||
// Navigate to a specific step (only if it's been reached before)
|
||||
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;
|
||||
}
|
||||
|
||||
// Convert form to LeagueWizardFormData for validation
|
||||
const formData: LeagueWizardCommandModel.LeagueWizardFormData = {
|
||||
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 {
|
||||
// Use the mutation to create the league
|
||||
const result = await createLeagueMutation.mutateAsync({ form, ownerId });
|
||||
|
||||
// Clear the draft on successful creation
|
||||
clearFormStorage();
|
||||
|
||||
// Navigate to the new league
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// Handler for scoring preset selection (timings default from API)
|
||||
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';
|
||||
};
|
||||
|
||||
const currentStepData = steps.find((s) => s.id === step);
|
||||
const CurrentStepIcon = currentStepData?.icon ?? FileText;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="max-w-4xl mx-auto pb-8">
|
||||
{/* Header with icon */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-gradient-to-br from-primary-blue/20 to-primary-blue/5 border border-primary-blue/20">
|
||||
<Sparkles className="w-5 h-5 text-primary-blue" />
|
||||
</div>
|
||||
<div>
|
||||
<Heading level={1} className="text-2xl sm:text-3xl">
|
||||
Create a new league
|
||||
</Heading>
|
||||
<p className="text-sm text-gray-500">
|
||||
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>
|
||||
</div>
|
||||
|
||||
{/* Desktop Progress Bar */}
|
||||
<div className="hidden md:block mb-8">
|
||||
<div className="relative">
|
||||
{/* Background track */}
|
||||
<div className="absolute top-5 left-6 right-6 h-0.5 bg-charcoal-outline rounded-full" />
|
||||
{/* Progress fill */}
|
||||
<div
|
||||
className="absolute top-5 left-6 h-0.5 bg-gradient-to-r from-primary-blue to-neon-aqua rounded-full transition-all duration-500 ease-out"
|
||||
style={{ width: `calc(${((step - 1) / (steps.length - 1)) * 100}% - 48px)` }}
|
||||
/>
|
||||
|
||||
<div className="relative flex justify-between">
|
||||
{steps.map((wizardStep) => {
|
||||
const isCompleted = wizardStep.id < step;
|
||||
const isCurrent = wizardStep.id === step;
|
||||
const isAccessible = wizardStep.id <= highestCompletedStep;
|
||||
const StepIcon = wizardStep.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={wizardStep.id}
|
||||
type="button"
|
||||
onClick={() => goToStep(wizardStep.id)}
|
||||
disabled={!isAccessible}
|
||||
className="flex flex-col items-center bg-transparent border-0 cursor-pointer disabled:cursor-not-allowed"
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
relative z-10 flex h-10 w-10 items-center justify-center rounded-full
|
||||
transition-all duration-300 ease-out
|
||||
${isCurrent
|
||||
? 'bg-primary-blue text-white shadow-[0_0_24px_rgba(25,140,255,0.5)] scale-110'
|
||||
: isCompleted
|
||||
? 'bg-primary-blue text-white hover:scale-105'
|
||||
: isAccessible
|
||||
? 'bg-iron-gray text-gray-400 border-2 border-charcoal-outline hover:border-primary-blue/50'
|
||||
: 'bg-iron-gray text-gray-500 border-2 border-charcoal-outline opacity-60'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<Check className="w-4 h-4" strokeWidth={3} />
|
||||
) : (
|
||||
<StepIcon className="w-4 h-4" />
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-center">
|
||||
<p
|
||||
className={`text-xs font-medium transition-colors duration-200 ${
|
||||
isCurrent
|
||||
? 'text-white'
|
||||
: isCompleted
|
||||
? 'text-primary-blue'
|
||||
: isAccessible
|
||||
? 'text-gray-400'
|
||||
: 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{wizardStep.label}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Progress */}
|
||||
<div className="md:hidden mb-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<CurrentStepIcon className="w-4 h-4 text-primary-blue" />
|
||||
<span className="text-sm font-medium text-white">{currentStepData?.label}</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">
|
||||
{step}/{steps.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-charcoal-outline rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-primary-blue to-neon-aqua rounded-full transition-all duration-500 ease-out"
|
||||
style={{ width: `${(step / steps.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
{/* Step dots */}
|
||||
<div className="flex justify-between mt-2 px-0.5">
|
||||
{steps.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className={`
|
||||
h-1.5 rounded-full transition-all duration-300
|
||||
${s.id === step
|
||||
? 'w-4 bg-primary-blue'
|
||||
: s.id < step
|
||||
? 'w-1.5 bg-primary-blue/60'
|
||||
: 'w-1.5 bg-charcoal-outline'
|
||||
}
|
||||
`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Card */}
|
||||
<Card className="relative overflow-hidden">
|
||||
{/* Top gradient accent */}
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-transparent via-primary-blue to-transparent" />
|
||||
|
||||
{/* Step header */}
|
||||
<div className="flex items-start gap-4 mb-6">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary-blue/10 shrink-0 transition-transform duration-300">
|
||||
<CurrentStepIcon className="w-6 h-6 text-primary-blue" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Heading level={2} className="text-xl sm:text-2xl text-white leading-tight">
|
||||
<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)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="hidden sm:flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-deep-graphite border border-charcoal-outline">
|
||||
<span className="text-xs text-gray-500">Step</span>
|
||||
<span className="text-sm font-semibold text-white">{step}</span>
|
||||
<span className="text-xs text-gray-500">/ {steps.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-px bg-gradient-to-r from-transparent via-charcoal-outline to-transparent mb-6" />
|
||||
|
||||
{/* Step content with min-height for consistency */}
|
||||
<div className="min-h-[320px]">
|
||||
{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">
|
||||
<LeagueVisibilitySection
|
||||
form={form}
|
||||
onChange={setForm}
|
||||
errors={
|
||||
errors.basics?.visibility
|
||||
? { visibility: errors.basics.visibility }
|
||||
: {}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<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}
|
||||
readOnly={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 4 && (
|
||||
<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}
|
||||
errors={errors.timings ?? {}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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 || {}}
|
||||
presets={presets}
|
||||
readOnly={presetsLoading}
|
||||
patternError={errors.scoring?.patternId ?? ''}
|
||||
onChangePatternId={handleScoringPresetChange}
|
||||
onToggleCustomScoring={() =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
scoring: {
|
||||
...prev.scoring,
|
||||
customScoringEnabled: !(prev.scoring?.customScoringEnabled),
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-px bg-gradient-to-r from-transparent via-charcoal-outline to-transparent" />
|
||||
|
||||
{/* Championships & Drop Rules side by side on larger screens */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<ChampionshipsSection form={form} onChange={setForm} readOnly={presetsLoading} />
|
||||
<LeagueDropSection form={form} onChange={setForm} readOnly={false} />
|
||||
</div>
|
||||
|
||||
{errors.submit && (
|
||||
<div className="flex items-start gap-3 rounded-lg bg-warning-amber/10 p-4 border border-warning-amber/20">
|
||||
<AlertCircle className="w-5 h-5 text-warning-amber shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-warning-amber">{errors.submit}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 6 && (
|
||||
<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}
|
||||
readOnly={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 7 && (
|
||||
<div className="animate-fade-in space-y-6">
|
||||
<LeagueReviewSummary form={form} presets={presets} />
|
||||
{errors.submit && (
|
||||
<div className="flex items-start gap-3 rounded-lg bg-warning-amber/10 p-4 border border-warning-amber/20">
|
||||
<AlertCircle className="w-5 h-5 text-warning-amber shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-warning-amber">{errors.submit}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-between items-center mt-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={step === 1 || loading}
|
||||
onClick={goToPreviousStep}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Back</span>
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Mobile step dots */}
|
||||
<div className="flex sm:hidden items-center gap-1">
|
||||
{steps.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className={`
|
||||
h-1.5 rounded-full transition-all duration-300
|
||||
${s.id === step ? 'w-3 bg-primary-blue' : s.id < step ? 'w-1.5 bg-primary-blue/50' : 'w-1.5 bg-charcoal-outline'}
|
||||
`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{step < 7 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
disabled={loading}
|
||||
onClick={goToNextStep}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<span>Continue</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 min-w-[150px] justify-center"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>Creating…</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
<span>Create League</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Helper text */}
|
||||
<p className="text-center text-xs text-gray-500 mt-4">
|
||||
This will create your league and its first season. You can edit both later.
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user