Files
gridpilot.gg/apps/website/components/leagues/CreateLeagueWizard.tsx
2026-01-12 01:01:49 +01:00

985 lines
34 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 LeagueReviewSummary from '@/components/leagues/LeagueReviewSummary';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import Heading from '@/components/ui/Heading';
import Input from '@/components/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 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';
};
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>
);
}