Files
gridpilot.gg/apps/website/components/leagues/CreateLeagueWizard.tsx
2025-12-07 00:18:02 +01:00

712 lines
23 KiB
TypeScript

'use client';
import { useEffect, useState, FormEvent, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import {
FileText,
Users,
Calendar,
Trophy,
Award,
CheckCircle2,
ChevronLeft,
ChevronRight,
Loader2,
AlertCircle,
Sparkles,
Check,
} from 'lucide-react';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Heading from '@/components/ui/Heading';
import LeagueReviewSummary from '@/components/leagues/LeagueReviewSummary';
import {
getListLeagueScoringPresetsQuery,
} from '@/lib/di-container';
import {
validateLeagueWizardStep,
validateAllLeagueWizardSteps,
hasWizardErrors,
createLeagueFromConfig,
applyScoringPresetToConfig,
} from '@/lib/leagueWizardService';
import type { LeagueScoringPresetDTO } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider';
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
import { LeagueBasicsSection } from './LeagueBasicsSection';
import { LeagueVisibilitySection } from './LeagueVisibilitySection';
import { LeagueStructureSection } from './LeagueStructureSection';
import {
LeagueScoringSection,
ScoringPatternSection,
ChampionshipsSection,
} from './LeagueScoringSection';
import { LeagueDropSection } from './LeagueDropSection';
import { LeagueTimingsSection } from './LeagueTimingsSection';
// ============================================================================
// LOCAL STORAGE PERSISTENCE
// ============================================================================
const STORAGE_KEY = 'gridpilot_league_wizard_draft';
const STORAGE_HIGHEST_STEP_KEY = 'gridpilot_league_wizard_highest_step';
function saveFormToStorage(form: LeagueConfigFormModel): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
} catch {
// Ignore storage errors (quota exceeded, etc.)
}
}
function loadFormFromStorage(): LeagueConfigFormModel | null {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
return JSON.parse(stored) as LeagueConfigFormModel;
}
} 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;
type StepName = 'basics' | 'visibility' | 'structure' | 'schedule' | 'scoring' | 'review';
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 'review':
return 6;
}
}
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 'review';
}
}
import type { WizardErrors } from '@/lib/leagueWizardService';
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);
return nextSaturday.toISOString().split('T')[0];
}
function createDefaultForm(): LeagueConfigFormModel {
const defaultPatternId = 'sprint-main-driver';
return {
basics: {
name: '',
description: '',
visibility: 'public',
gameId: 'iracing',
},
structure: {
mode: 'solo',
maxDrivers: 24,
maxTeams: undefined,
driversPerTeam: undefined,
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: defaultPatternId === 'sprint-main-driver' ? 20 : undefined,
mainRaceMinutes: 40,
sessionCount: 2,
roundsPlanned: 8,
// Default to Saturday races, weekly, starting next week
weekdays: ['Sat'] as import('@gridpilot/racing/domain/value-objects/Weekday').Weekday[],
recurrenceStrategy: 'weekly' as const,
raceStartTime: '20:00',
timezoneId: 'UTC',
seasonStartDate: getDefaultSeasonStartDate(),
},
};
}
export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLeagueWizardProps) {
const router = useRouter();
const step = stepNameToStep(stepName);
const [loading, setLoading] = useState(false);
const [presetsLoading, setPresetsLoading] = useState(true);
const [presets, setPresets] = useState<LeagueScoringPresetDTO[]>([]);
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<LeagueConfigFormModel>(() =>
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]);
useEffect(() => {
async function loadPresets() {
try {
const query = getListLeagueScoringPresetsQuery();
const result = await query.execute();
setPresets(result);
if (result.length > 0) {
setForm((prev) => ({
...prev,
scoring: {
...prev.scoring,
patternId: prev.scoring.patternId || result[0].id,
customScoringEnabled: prev.scoring.customScoringEnabled ?? false,
},
}));
}
} catch (err) {
setErrors((prev) => ({
...prev,
submit:
err instanceof Error
? err.message
: 'Failed to load scoring presets',
}));
} finally {
setPresetsLoading(false);
}
}
loadPresets();
}, []);
const validateStep = (currentStep: Step): boolean => {
const stepErrors = validateLeagueWizardStep(form, currentStep);
setErrors((prev) => ({
...prev,
...stepErrors,
}));
return !hasWizardErrors(stepErrors);
};
const goToNextStep = () => {
if (!validateStep(step)) {
return;
}
const nextStep = (step < 6 ? ((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 allErrors = validateAllLeagueWizardSteps(form);
setErrors((prev) => ({
...prev,
...allErrors,
}));
if (hasWizardErrors(allErrors)) {
onStepChange('basics');
return;
}
setLoading(true);
setErrors((prev) => ({ ...prev, submit: undefined }));
try {
const result = await createLeagueFromConfig(form);
// Clear the draft on successful creation
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 currentPreset =
presets.find((p) => p.id === form.scoring.patternId) ?? null;
// Handler for scoring preset selection - delegates to application-level config helper
const handleScoringPresetChange = (patternId: string) => {
setForm((prev) => applyScoringPresetToConfig(prev, patternId));
};
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: '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 '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 'Will drivers compete individually or as part of teams?';
case 4:
return 'Configure session durations and plan your season calendar.';
case 5:
return 'Select a scoring preset, enable championships, and set drop rules.';
case 6:
return 'Everything looks good? Launch your new league!';
default:
return '';
}
};
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">
Set up your racing series in {steps.length} easy steps
</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">
{getStepTitle(step)}
</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">
<LeagueBasicsSection
form={form}
onChange={setForm}
errors={errors.basics}
/>
</div>
)}
{step === 2 && (
<div className="animate-fade-in">
<LeagueVisibilitySection
form={form}
onChange={setForm}
errors={errors.basics}
/>
</div>
)}
{step === 3 && (
<div className="animate-fade-in">
<LeagueStructureSection
form={form}
onChange={setForm}
readOnly={false}
/>
</div>
)}
{step === 4 && (
<div className="animate-fade-in">
<LeagueTimingsSection
form={form}
onChange={setForm}
errors={errors.timings}
/>
</div>
)}
{step === 5 && (
<div className="animate-fade-in space-y-8">
{/* 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-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 < 6 ? (
<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">
You can edit all settings after creating your league
</p>
</form>
);
}