wip
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, FormEvent } from 'react';
|
||||
import { useEffect, useState, FormEvent, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
FileText,
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
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,
|
||||
@@ -42,9 +43,65 @@ import {
|
||||
import { LeagueDropSection } from './LeagueDropSection';
|
||||
import { LeagueTimingsSection } from './LeagueTimingsSection';
|
||||
|
||||
type Step = 1 | 2 | 3 | 4 | 5;
|
||||
// ============================================================================
|
||||
// LOCAL STORAGE PERSISTENCE
|
||||
// ============================================================================
|
||||
|
||||
type StepName = 'basics' | 'structure' | 'schedule' | 'scoring' | 'review';
|
||||
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;
|
||||
@@ -55,14 +112,16 @@ function stepNameToStep(stepName: StepName): Step {
|
||||
switch (stepName) {
|
||||
case 'basics':
|
||||
return 1;
|
||||
case 'structure':
|
||||
case 'visibility':
|
||||
return 2;
|
||||
case 'schedule':
|
||||
case 'structure':
|
||||
return 3;
|
||||
case 'scoring':
|
||||
case 'schedule':
|
||||
return 4;
|
||||
case 'review':
|
||||
case 'scoring':
|
||||
return 5;
|
||||
case 'review':
|
||||
return 6;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,18 +130,29 @@ function stepToStepName(step: Step): StepName {
|
||||
case 1:
|
||||
return 'basics';
|
||||
case 2:
|
||||
return 'structure';
|
||||
return 'visibility';
|
||||
case 3:
|
||||
return 'schedule';
|
||||
return 'structure';
|
||||
case 4:
|
||||
return 'scoring';
|
||||
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';
|
||||
|
||||
@@ -121,6 +191,12 @@ function createDefaultForm(): LeagueConfigFormModel {
|
||||
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(),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -133,10 +209,39 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
||||
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 {
|
||||
@@ -182,7 +287,9 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
||||
if (!validateStep(step)) {
|
||||
return;
|
||||
}
|
||||
const nextStep = (step < 5 ? ((step + 1) as Step) : step);
|
||||
const nextStep = (step < 6 ? ((step + 1) as Step) : step);
|
||||
saveHighestStep(nextStep);
|
||||
setHighestCompletedStep((prev) => Math.max(prev, nextStep));
|
||||
onStepChange(stepToStepName(nextStep));
|
||||
};
|
||||
|
||||
@@ -191,6 +298,13 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
||||
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;
|
||||
@@ -211,6 +325,8 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
||||
|
||||
try {
|
||||
const result = await createLeagueFromConfig(form);
|
||||
// Clear the draft on successful creation
|
||||
clearFormStorage();
|
||||
router.push(`/leagues/${result.leagueId}`);
|
||||
} catch (err) {
|
||||
const message =
|
||||
@@ -233,10 +349,11 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
||||
|
||||
const steps = [
|
||||
{ id: 1 as Step, label: 'Basics', icon: FileText, shortLabel: 'Name' },
|
||||
{ id: 2 as Step, label: 'Structure', icon: Users, shortLabel: 'Mode' },
|
||||
{ id: 3 as Step, label: 'Schedule', icon: Calendar, shortLabel: 'Time' },
|
||||
{ id: 4 as Step, label: 'Scoring', icon: Trophy, shortLabel: 'Points' },
|
||||
{ id: 5 as Step, label: 'Review', icon: CheckCircle2, shortLabel: 'Done' },
|
||||
{ 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 => {
|
||||
@@ -244,12 +361,14 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
||||
case 1:
|
||||
return 'Name your league';
|
||||
case 2:
|
||||
return 'Choose the structure';
|
||||
return 'Choose your destiny';
|
||||
case 3:
|
||||
return 'Set the schedule';
|
||||
return 'Choose the structure';
|
||||
case 4:
|
||||
return 'Scoring & championships';
|
||||
return 'Set the schedule';
|
||||
case 5:
|
||||
return 'Scoring & championships';
|
||||
case 6:
|
||||
return 'Review & create';
|
||||
default:
|
||||
return '';
|
||||
@@ -259,14 +378,16 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
||||
const getStepSubtitle = (currentStep: Step): string => {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return 'Give your league a memorable name and choose who can join.';
|
||||
return 'Give your league a memorable name and tell your story.';
|
||||
case 2:
|
||||
return 'Will drivers compete individually or as part of teams?';
|
||||
return 'Will you compete for global rankings or race with friends?';
|
||||
case 3:
|
||||
return 'Configure session durations and plan your season calendar.';
|
||||
return 'Will drivers compete individually or as part of teams?';
|
||||
case 4:
|
||||
return 'Select a scoring preset, enable championships, and set drop rules.';
|
||||
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 '';
|
||||
@@ -310,10 +431,17 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
||||
{steps.map((wizardStep) => {
|
||||
const isCompleted = wizardStep.id < step;
|
||||
const isCurrent = wizardStep.id === step;
|
||||
const isAccessible = wizardStep.id <= highestCompletedStep;
|
||||
const StepIcon = wizardStep.icon;
|
||||
|
||||
return (
|
||||
<div key={wizardStep.id} className="flex flex-col items-center">
|
||||
<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
|
||||
@@ -321,8 +449,10 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
||||
${isCurrent
|
||||
? 'bg-primary-blue text-white shadow-[0_0_24px_rgba(25,140,255,0.5)] scale-110'
|
||||
: isCompleted
|
||||
? 'bg-primary-blue text-white'
|
||||
: 'bg-iron-gray text-gray-500 border-2 border-charcoal-outline'
|
||||
? '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'
|
||||
}
|
||||
`}
|
||||
>
|
||||
@@ -339,13 +469,15 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
||||
? 'text-white'
|
||||
: isCompleted
|
||||
? 'text-primary-blue'
|
||||
: isAccessible
|
||||
? 'text-gray-400'
|
||||
: 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{wizardStep.label}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -429,6 +561,16 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
||||
)}
|
||||
|
||||
{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}
|
||||
@@ -438,7 +580,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
{step === 4 && (
|
||||
<div className="animate-fade-in">
|
||||
<LeagueTimingsSection
|
||||
form={form}
|
||||
@@ -448,7 +590,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 4 && (
|
||||
{step === 5 && (
|
||||
<div className="animate-fade-in space-y-8">
|
||||
{/* Scoring Pattern Selection */}
|
||||
<ScoringPatternSection
|
||||
@@ -486,7 +628,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 5 && (
|
||||
{step === 6 && (
|
||||
<div className="animate-fade-in space-y-6">
|
||||
<LeagueReviewSummary form={form} presets={presets} />
|
||||
{errors.submit && (
|
||||
@@ -527,7 +669,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
||||
))}
|
||||
</div>
|
||||
|
||||
{step < 5 ? (
|
||||
{step < 6 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
|
||||
Reference in New Issue
Block a user