* Extract reusable league wizard components; wire settings page (#103) Extracts 9 domain components from new.tsx and $leagueId.settings.tsx into app/components/league/ (each with a Storybook story), consolidates wizard form-building into wizard-state.ts, and updates the settings page to use the shared components instead of hand-rolled duplicates. new.tsx shrinks from ~2000 → ~1280 lines. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix sports-season test: add participants to mock data findDraftableSportsSeasons now returns participantCount, which requires participants in the mock db response. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix sports-season test: loosen makeMockDb type to Record<string, unknown>[] typeof mockSeasons became too strict after adding participants to the mock data, breaking inline arrays in other tests that don't include participants. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1280 lines
47 KiB
TypeScript
1280 lines
47 KiB
TypeScript
import { useState, useEffect } from "react";
|
||
import { redirect, useFetcher } from "react-router";
|
||
import { auth } from "~/lib/auth.server";
|
||
import { authClient } from "~/lib/auth-client";
|
||
import type { Route } from "./+types/new";
|
||
|
||
import { logger } from "~/lib/logger";
|
||
import { createLeague, setCurrentSeason } from "~/models/league";
|
||
import { createCommissioner } from "~/models/commissioner";
|
||
import { createSeason } from "~/models/season";
|
||
import { linkMultipleSportsToSeason } from "~/models/season-sport";
|
||
import { createManyTeams } from "~/models/team";
|
||
import { findActiveSeasonTemplates, findSeasonTemplateWithSportsSeasons } from "~/models/season-template";
|
||
import { findDraftableSportsSeasons } from "~/models/sports-season";
|
||
import { findUserById, updateUser } from "~/models/user";
|
||
import { generateUniqueTeamNames } from "~/utils/team-names";
|
||
import { format } from "date-fns";
|
||
import { CalendarClock, Check, Star, Swords, Trophy } from "lucide-react";
|
||
import { Badge } from "~/components/ui/badge";
|
||
import { Button } from "~/components/ui/button";
|
||
import { Card, CardContent } from "~/components/ui/card";
|
||
import { Input } from "~/components/ui/input";
|
||
import { Label } from "~/components/ui/label";
|
||
import { Checkbox } from "~/components/ui/checkbox";
|
||
import { cn } from "~/lib/utils";
|
||
import { parseDraftSpeed, formatTime12h } from "~/lib/draft-timer";
|
||
import { TIMEZONE_OPTIONS } from "~/components/league/TimezoneSelect";
|
||
import { saveWizardState, buildFormDataFromSnapshot } from "~/lib/wizard-state";
|
||
import { type ScoringRules, DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
|
||
import { SportIcon } from "~/components/league/SportIcon";
|
||
import { TimerModeSelector } from "~/components/league/TimerModeSelector";
|
||
import { DraftSpeedPicker, formatDraftSpeed, isSlowPreset } from "~/components/league/DraftSpeedPicker";
|
||
import { OvernightPauseSettings } from "~/components/league/OvernightPauseSettings";
|
||
import { ScoringPresetPicker, PLACEMENT_LABELS } from "~/components/league/ScoringPresetPicker";
|
||
import { WizardStepper } from "~/components/league/WizardStepper";
|
||
import { ReviewSection } from "~/components/league/ReviewSection";
|
||
import { StepperInput } from "~/components/league/StepperInput";
|
||
import { WizardAuthForm } from "~/components/league/WizardAuthForm";
|
||
|
||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||
|
||
type SportSeason = {
|
||
id: string;
|
||
name: string;
|
||
year: number;
|
||
scoringType: string;
|
||
sport: { id: string; name: string; type: string; slug: string; iconUrl: string | null };
|
||
participantCount: number;
|
||
};
|
||
|
||
type Template = {
|
||
id: string;
|
||
name: string;
|
||
year: number;
|
||
description: string | null;
|
||
sportsSeasonIds: string[];
|
||
};
|
||
|
||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||
|
||
const CATEGORY_ORDER = [
|
||
"Basketball", "Football", "Baseball", "Hockey",
|
||
"Soccer", "Motorsport", "Golf", "Tennis", "Other",
|
||
];
|
||
|
||
function defaultRounds(count: number): number {
|
||
if (count <= 0) return 3;
|
||
const pct = count >= 20 ? 1.25 : 1.33;
|
||
return Math.max(count + 3, Math.ceil(count * pct));
|
||
}
|
||
|
||
|
||
|
||
|
||
function getSportCategory(sportName: string): string {
|
||
const n = sportName.toLowerCase();
|
||
if (n.includes("basketball") || n === "nba" || n === "wnba" || n === "euroleague" || n.includes("ncaa")) return "Basketball";
|
||
if (n === "nfl" || n === "cfl" || n === "xfl" || (n.includes("football") && !n.includes("soccer"))) return "Football";
|
||
if (n === "mlb" || n.includes("baseball") || n === "kbo" || n === "npb" || n.includes("little league")) return "Baseball";
|
||
if (n === "nhl" || n === "ahl" || n === "pwhl" || n.includes("hockey") || n.includes("iihf")) return "Hockey";
|
||
if (n.includes("soccer") || n.includes("mls") || n.includes("premier") || n.includes("champions")) return "Soccer";
|
||
if (n === "f1" || n.includes("formula") || n.includes("indy") || n.includes("nascar") || n.includes("racing")) return "Motorsport";
|
||
if (n.includes("golf") || n.includes("pga")) return "Golf";
|
||
if (n.includes("tennis") || n.includes("atp") || n.includes("wta")) return "Tennis";
|
||
return "Other";
|
||
}
|
||
|
||
// ─── Meta ─────────────────────────────────────────────────────────────────────
|
||
|
||
export function meta(): Route.MetaDescriptors {
|
||
return [{ title: "New League - Brackt" }];
|
||
}
|
||
|
||
// ─── Loader ───────────────────────────────────────────────────────────────────
|
||
|
||
export async function loader(args: Route.LoaderArgs) {
|
||
const session = await auth.api.getSession({ headers: args.request.headers });
|
||
const userId = session?.user.id ?? null;
|
||
|
||
const templates = await findActiveSeasonTemplates();
|
||
const allSportsSeasons = await findDraftableSportsSeasons();
|
||
|
||
const templatesWithSports = await Promise.all(
|
||
templates.map(async (template) => {
|
||
const t = await findSeasonTemplateWithSportsSeasons(template.id);
|
||
return {
|
||
...template,
|
||
sportsSeasonIds: t?.seasonTemplateSports?.map(
|
||
(ts: { sportsSeason: { id: string } }) => ts.sportsSeason.id
|
||
) ?? [],
|
||
};
|
||
})
|
||
);
|
||
|
||
const commishTimezone = userId
|
||
? ((await findUserById(userId))?.timezone ?? null)
|
||
: null;
|
||
|
||
return {
|
||
templates: templatesWithSports as Template[],
|
||
allSportsSeasons: allSportsSeasons as SportSeason[],
|
||
commishTimezone,
|
||
isAuthenticated: userId !== null,
|
||
};
|
||
}
|
||
|
||
// ─── Action ───────────────────────────────────────────────────────────────────
|
||
|
||
export async function action(args: Route.ActionArgs) {
|
||
const session = await auth.api.getSession({ headers: args.request.headers });
|
||
const userId = session?.user.id ?? null;
|
||
const { request } = args;
|
||
|
||
if (!userId) {
|
||
throw new Response("Unauthorized", { status: 401 });
|
||
}
|
||
|
||
const formData = await request.formData();
|
||
const name = formData.get("name");
|
||
const teamCount = formData.get("teamCount");
|
||
const templateId = formData.get("templateId");
|
||
const draftRounds = formData.get("draftRounds");
|
||
const draftDateTime = formData.get("draftDateTime");
|
||
const draftSpeed = formData.get("draftSpeed");
|
||
const draftTimerMode = (formData.get("draftTimerMode") as "chess_clock" | "standard") || "chess_clock";
|
||
|
||
const { draftInitialTime: draftInitialTimeNum, draftIncrementTime: draftIncrementTimeNum } =
|
||
parseDraftSpeed(draftSpeed as string | null, draftTimerMode);
|
||
|
||
if (typeof name !== "string" || !name.trim()) {
|
||
return { error: "League name is required" };
|
||
}
|
||
|
||
if (typeof teamCount !== "string") {
|
||
return { error: "Number of teams is required" };
|
||
}
|
||
|
||
const teamCountNum = parseInt(teamCount, 10);
|
||
if (isNaN(teamCountNum) || teamCountNum < 6 || teamCountNum > 16) {
|
||
return { error: "Number of teams must be between 6 and 16" };
|
||
}
|
||
|
||
const draftRoundsNum = typeof draftRounds === "string" ? parseInt(draftRounds, 10) : 20;
|
||
if (isNaN(draftRoundsNum) || draftRoundsNum < 1 || draftRoundsNum > 50) {
|
||
return { error: "Draft rounds must be between 1 and 50" };
|
||
}
|
||
|
||
const scoringFields = [
|
||
"pointsFor1st", "pointsFor2nd", "pointsFor3rd", "pointsFor4th",
|
||
"pointsFor5th", "pointsFor6th", "pointsFor7th", "pointsFor8th"
|
||
];
|
||
|
||
const scoringRules: Record<string, number> = {};
|
||
for (const field of scoringFields) {
|
||
const value = formData.get(field);
|
||
if (typeof value === "string") {
|
||
const points = parseInt(value, 10);
|
||
if (!isNaN(points)) {
|
||
if (points < 0 || points > 1000) {
|
||
return { error: `${field} must be between 0 and 1000 points` };
|
||
}
|
||
scoringRules[field] = points;
|
||
}
|
||
}
|
||
}
|
||
|
||
const rawPauseMode = formData.get("overnightPauseMode") as string | null;
|
||
const overnightPauseMode: "none" | "league" | "per_user" =
|
||
rawPauseMode === "league" || rawPauseMode === "per_user" ? rawPauseMode : "none";
|
||
const overnightPauseStart = overnightPauseMode !== "none"
|
||
? (formData.get("overnightPauseStart") as string | null) ?? "23:00"
|
||
: null;
|
||
const overnightPauseEnd = overnightPauseMode !== "none"
|
||
? (formData.get("overnightPauseEnd") as string | null) ?? "07:00"
|
||
: null;
|
||
const overnightPauseTimezone = overnightPauseMode !== "none"
|
||
? (formData.get("overnightPauseTimezone") as string | null) ?? null
|
||
: null;
|
||
|
||
try {
|
||
const league = await createLeague({
|
||
name: name.trim(),
|
||
createdBy: userId,
|
||
});
|
||
|
||
await createCommissioner({
|
||
leagueId: league.id,
|
||
userId: userId,
|
||
});
|
||
|
||
const currentYear = new Date().getFullYear();
|
||
const season = await createSeason({
|
||
leagueId: league.id,
|
||
year: currentYear,
|
||
status: "pre_draft",
|
||
templateId: typeof templateId === "string" && templateId !== "" ? templateId : null,
|
||
draftRounds: draftRoundsNum,
|
||
draftDateTime: typeof draftDateTime === "string" && draftDateTime ? new Date(draftDateTime) : null,
|
||
draftInitialTime: draftInitialTimeNum,
|
||
draftIncrementTime: draftIncrementTimeNum,
|
||
draftTimerMode,
|
||
overnightPauseMode,
|
||
overnightPauseStart,
|
||
overnightPauseEnd,
|
||
overnightPauseTimezone,
|
||
...scoringRules,
|
||
});
|
||
|
||
await setCurrentSeason(league.id, season.id);
|
||
|
||
const selectedSports = formData.getAll("sportsSeasons");
|
||
|
||
if (selectedSports.length > draftRoundsNum) {
|
||
return {
|
||
error: `You need at least ${selectedSports.length} draft rounds for the ${selectedSports.length} sports selected. Currently set to ${draftRoundsNum} rounds.`
|
||
};
|
||
}
|
||
|
||
if (selectedSports.length > 0) {
|
||
await linkMultipleSportsToSeason(
|
||
selectedSports.map((id) => ({
|
||
seasonId: season.id,
|
||
sportsSeasonId: id as string,
|
||
}))
|
||
);
|
||
}
|
||
|
||
const joinAsPlayer = formData.get("joinAsPlayer") === "on";
|
||
const teamNames = generateUniqueTeamNames(teamCountNum);
|
||
const teams = teamNames.map((teamName, i) => ({
|
||
seasonId: season.id,
|
||
name: teamName,
|
||
ownerId: joinAsPlayer && i === 0 ? userId : null,
|
||
}));
|
||
|
||
await createManyTeams(teams);
|
||
|
||
const userTimezone = formData.get("userTimezone");
|
||
if (typeof userTimezone === "string" && userTimezone) {
|
||
await updateUser(userId, { timezone: userTimezone });
|
||
}
|
||
|
||
return redirect(`/leagues/${league.id}`);
|
||
} catch (error) {
|
||
logger.error("Error creating league:", error);
|
||
return { error: "Failed to create league. Please try again." };
|
||
}
|
||
}
|
||
|
||
// ─── Step 1: League Basics ────────────────────────────────────────────────────
|
||
|
||
function Step1LeagueBasics({
|
||
leagueName,
|
||
setLeagueName,
|
||
teamCount,
|
||
setTeamCount,
|
||
joinAsPlayer,
|
||
setJoinAsPlayer,
|
||
error,
|
||
onNext,
|
||
}: {
|
||
leagueName: string;
|
||
setLeagueName: (v: string) => void;
|
||
teamCount: number;
|
||
setTeamCount: (v: number) => void;
|
||
joinAsPlayer: boolean;
|
||
setJoinAsPlayer: (v: boolean) => void;
|
||
error: string | null;
|
||
onNext: () => void;
|
||
}) {
|
||
const [nameTouched, setNameTouched] = useState(false);
|
||
const nameInvalid = nameTouched && !leagueName.trim();
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div>
|
||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||
<span className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-sm font-bold shrink-0">1</span>
|
||
League Basics
|
||
</h2>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="name">League Name <span className="text-destructive">*</span></Label>
|
||
<Input
|
||
id="name"
|
||
type="text"
|
||
placeholder="e.g. Friday Night Drafters"
|
||
value={leagueName}
|
||
onChange={(e) => setLeagueName(e.target.value)}
|
||
onBlur={() => setNameTouched(true)}
|
||
minLength={3}
|
||
maxLength={50}
|
||
required
|
||
aria-invalid={nameInvalid || undefined}
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label>Number of Teams</Label>
|
||
<StepperInput
|
||
value={teamCount}
|
||
min={6}
|
||
max={16}
|
||
onChange={setTeamCount}
|
||
decrementLabel="Decrease team count"
|
||
incrementLabel="Increase team count"
|
||
/>
|
||
<p className="text-sm text-muted-foreground">Choose between 6 and 16 teams</p>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
<Checkbox
|
||
id="commissionerOnly"
|
||
checked={!joinAsPlayer}
|
||
onCheckedChange={(v) => setJoinAsPlayer(v !== true)}
|
||
/>
|
||
<Label htmlFor="commissionerOnly" className="font-normal cursor-pointer text-muted-foreground">
|
||
Commissioner only — I won't be managing a team
|
||
</Label>
|
||
</div>
|
||
|
||
{error && (
|
||
<p className="text-sm text-destructive">{error}</p>
|
||
)}
|
||
|
||
<div className="grid grid-cols-2 gap-3 pt-2">
|
||
<Button type="button" variant="outline" disabled className="w-full">
|
||
← Back
|
||
</Button>
|
||
<Button type="button" onClick={onNext} className="w-full" disabled={!leagueName.trim()}>
|
||
NEXT →
|
||
</Button>
|
||
</div>
|
||
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Step 2: Sports ───────────────────────────────────────────────────────────
|
||
|
||
function Step2Sports({
|
||
templates,
|
||
allSportsSeasons,
|
||
teamCount,
|
||
selectedTemplate,
|
||
setSelectedTemplate,
|
||
selectedSports,
|
||
setSelectedSports,
|
||
error,
|
||
onNext,
|
||
onBack,
|
||
}: {
|
||
templates: Template[];
|
||
allSportsSeasons: SportSeason[];
|
||
teamCount: number;
|
||
selectedTemplate: string;
|
||
setSelectedTemplate: (v: string) => void;
|
||
selectedSports: Set<string>;
|
||
setSelectedSports: (v: Set<string>) => void;
|
||
error: string | null;
|
||
onNext: () => void;
|
||
onBack: () => void;
|
||
}) {
|
||
const toggleSport = (id: string) => {
|
||
const next = new Set(selectedSports);
|
||
if (next.has(id)) next.delete(id);
|
||
else next.add(id);
|
||
setSelectedSports(next);
|
||
};
|
||
|
||
const handleTemplateClick = (id: string, sportsSeasonIds: string[]) => {
|
||
setSelectedTemplate(id);
|
||
setSelectedSports(new Set(sportsSeasonIds));
|
||
};
|
||
|
||
// When a named template is selected, only show that template's seasons
|
||
// Always exclude seasons with fewer participants than the league's team count
|
||
const activeTemplate = templates.find((t) => t.id === selectedTemplate);
|
||
const eligibleSeasons = allSportsSeasons.filter((s) => s.participantCount >= teamCount);
|
||
const displaySeasons = selectedTemplate === "customize" || !activeTemplate
|
||
? eligibleSeasons
|
||
: eligibleSeasons.filter((s) => activeTemplate.sportsSeasonIds.includes(s.id));
|
||
|
||
// Group sports by category
|
||
const grouped: { category: string; seasons: SportSeason[] }[] = CATEGORY_ORDER
|
||
.map((cat) => ({
|
||
category: cat,
|
||
seasons: displaySeasons.filter((s) => getSportCategory(s.sport.name) === cat),
|
||
}))
|
||
.filter(({ seasons }) =>
|
||
seasons.length > 0
|
||
);
|
||
|
||
return (
|
||
<div className="space-y-5">
|
||
<div>
|
||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||
<span className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-sm font-bold shrink-0">2</span>
|
||
Sports
|
||
</h2>
|
||
</div>
|
||
|
||
{templates.length > 0 && (
|
||
<div className="space-y-2">
|
||
{templates.map((t) => (
|
||
<button
|
||
key={t.id}
|
||
type="button"
|
||
onClick={() => handleTemplateClick(t.id, t.sportsSeasonIds)}
|
||
className={cn(
|
||
"w-full text-left rounded-lg border-2 px-4 py-3 flex items-center justify-between transition-all hover:border-primary/60",
|
||
selectedTemplate === t.id ? "border-primary" : "border-border"
|
||
)}
|
||
>
|
||
<div>
|
||
<p className="font-semibold text-sm">{t.name}</p>
|
||
{t.description && (
|
||
<p className="text-xs text-muted-foreground mt-0.5">{t.description}</p>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-3 shrink-0 ml-4">
|
||
<span className="text-xs text-muted-foreground">
|
||
{new Set(allSportsSeasons.filter(s => t.sportsSeasonIds.includes(s.id)).map(s => s.sport.name)).size} sports
|
||
</span>
|
||
{selectedTemplate === t.id && (
|
||
<div className="w-5 h-5 rounded-full bg-primary flex items-center justify-center">
|
||
<Check className="h-3 w-3 text-primary-foreground" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
</button>
|
||
))}
|
||
<button
|
||
type="button"
|
||
onClick={() => setSelectedTemplate("customize")}
|
||
className={cn(
|
||
"w-full text-left rounded-lg border-2 px-4 py-3 flex items-center justify-between transition-all hover:border-primary/60",
|
||
selectedTemplate === "customize" ? "border-primary" : "border-border"
|
||
)}
|
||
>
|
||
<div>
|
||
<p className="font-semibold text-sm">Customize</p>
|
||
<p className="text-xs text-muted-foreground mt-0.5">Pick your own sports seasons</p>
|
||
</div>
|
||
{selectedTemplate === "customize" && (
|
||
<div className="w-5 h-5 rounded-full bg-primary flex items-center justify-center shrink-0 ml-4">
|
||
<Check className="h-3 w-3 text-primary-foreground" />
|
||
</div>
|
||
)}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Named template: read-only alphabetical sport list */}
|
||
{selectedTemplate !== "" && selectedTemplate !== "customize" && (
|
||
<div className="space-y-3">
|
||
<ul className="space-y-1">
|
||
{Object.values(
|
||
displaySeasons.reduce<Record<string, SportSeason["sport"]>>((acc, s) => {
|
||
acc[s.sport.name] = s.sport;
|
||
return acc;
|
||
}, {})
|
||
).toSorted((a, b) => a.name.localeCompare(b.name)).map((sport) => (
|
||
<li key={sport.name} className="flex items-center gap-2 text-sm">
|
||
<SportIcon sport={sport} />
|
||
<span>{sport.name}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
<p className="text-xs text-muted-foreground">
|
||
These are the sport seasons included in this template. Switch to Customize to adjust your selection.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Customize: full interactive selector */}
|
||
{selectedTemplate === "customize" && (
|
||
<div className="space-y-3">
|
||
{/* Selected chips */}
|
||
{selectedSports.size > 0 && (
|
||
<div className="space-y-1.5">
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-xs text-muted-foreground">{selectedSports.size} selected</p>
|
||
<button
|
||
type="button"
|
||
onClick={() => setSelectedSports(new Set())}
|
||
className="text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground"
|
||
>
|
||
Clear all
|
||
</button>
|
||
</div>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{allSportsSeasons
|
||
.filter((s) => selectedSports.has(s.id))
|
||
.toSorted((a, b) => a.sport.name.localeCompare(b.sport.name))
|
||
.map((s) => (
|
||
<span
|
||
key={s.id}
|
||
className="inline-flex items-center gap-1 pl-2 pr-1 py-0.5 rounded-md bg-primary/10 border border-primary text-primary text-sm font-medium"
|
||
>
|
||
<SportIcon sport={s.sport} />
|
||
{s.sport.name} <span className="text-xs opacity-70">{s.year}</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => toggleSport(s.id)}
|
||
className="ml-0.5 hover:text-primary/60 transition-colors"
|
||
aria-label={`Remove ${s.sport.name}`}
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Full sport picker */}
|
||
<div className="relative">
|
||
<div className="space-y-4 max-h-72 overflow-y-auto pr-1">
|
||
{grouped.map(({ category, seasons }) => (
|
||
<div key={category}>
|
||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||
{category}
|
||
</p>
|
||
<div className="flex flex-col gap-1">
|
||
{seasons.map((s) => {
|
||
const selected = selectedSports.has(s.id);
|
||
return (
|
||
<button
|
||
key={s.id}
|
||
type="button"
|
||
onClick={() => toggleSport(s.id)}
|
||
className={cn(
|
||
"w-full flex items-center justify-between px-3 py-2 rounded-md text-sm border transition-colors",
|
||
selected
|
||
? "bg-primary/10 border-primary text-primary font-medium"
|
||
: "bg-muted border-border text-foreground hover:border-primary/50"
|
||
)}
|
||
>
|
||
<span className="flex items-center gap-2">
|
||
<SportIcon sport={s.sport} />
|
||
{s.sport.name} <span className="text-xs opacity-70">{s.year}</span>
|
||
</span>
|
||
<span className="text-xs font-semibold">{selected ? "Remove" : "Add"}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
))}
|
||
{grouped.length === 0 && (
|
||
<p className="text-sm text-muted-foreground">No sports seasons available.</p>
|
||
)}
|
||
</div>
|
||
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-10 bg-gradient-to-t from-card to-transparent" />
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||
|
||
<div className="grid grid-cols-2 gap-3 pt-2">
|
||
<Button type="button" variant="outline" onClick={onBack} className="w-full">
|
||
← Back
|
||
</Button>
|
||
<Button type="button" onClick={onNext} className="w-full">
|
||
NEXT →
|
||
</Button>
|
||
</div>
|
||
|
||
{selectedSports.size === 0 && (
|
||
<p className="text-center text-sm text-muted-foreground">
|
||
Select at least one sport season to continue.
|
||
</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Step 3: Draft Settings ───────────────────────────────────────────────────
|
||
|
||
function Step3DraftSettings({
|
||
draftRounds,
|
||
setDraftRounds,
|
||
draftDate,
|
||
setDraftDate,
|
||
draftTime,
|
||
setDraftTime,
|
||
timerMode,
|
||
setTimerMode,
|
||
draftSpeed,
|
||
setDraftSpeed,
|
||
overnightMode,
|
||
setOvernightMode,
|
||
overnightStart,
|
||
setOvernightStart,
|
||
overnightEnd,
|
||
setOvernightEnd,
|
||
overnightTimezone,
|
||
setOvernightTimezone,
|
||
commishTimezone,
|
||
sportsCount,
|
||
error,
|
||
onNext,
|
||
onBack,
|
||
}: {
|
||
draftRounds: number;
|
||
setDraftRounds: (v: number) => void;
|
||
draftDate: string;
|
||
setDraftDate: (v: string) => void;
|
||
draftTime: string;
|
||
setDraftTime: (v: string) => void;
|
||
timerMode: "chess_clock" | "standard";
|
||
setTimerMode: (v: "chess_clock" | "standard") => void;
|
||
draftSpeed: string;
|
||
setDraftSpeed: (v: string) => void;
|
||
overnightMode: "none" | "league" | "per_user";
|
||
setOvernightMode: (v: "none" | "league" | "per_user") => void;
|
||
overnightStart: string;
|
||
setOvernightStart: (v: string) => void;
|
||
overnightEnd: string;
|
||
setOvernightEnd: (v: string) => void;
|
||
overnightTimezone: string;
|
||
setOvernightTimezone: (v: string) => void;
|
||
commishTimezone: string | null;
|
||
sportsCount: number;
|
||
error: string | null;
|
||
onNext: () => void;
|
||
onBack: () => void;
|
||
}) {
|
||
const minRounds = sportsCount;
|
||
const flexSpots = Math.max(0, draftRounds - sportsCount);
|
||
const { draftInitialTime, draftIncrementTime } = parseDraftSpeed(draftSpeed, timerMode);
|
||
const showOvernightPause = draftInitialTime >= 3600 || draftIncrementTime >= 600;
|
||
|
||
useEffect(() => {
|
||
if (!showOvernightPause) setOvernightMode("none");
|
||
}, [showOvernightPause]); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
|
||
return (
|
||
<div className="space-y-8">
|
||
<div>
|
||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||
<span className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-sm font-bold shrink-0">3</span>
|
||
Draft Settings
|
||
</h2>
|
||
</div>
|
||
|
||
{/* Draft Rounds */}
|
||
<div className="space-y-2">
|
||
<Label>Draft Rounds</Label>
|
||
<StepperInput
|
||
value={draftRounds}
|
||
min={Math.max(1, minRounds)}
|
||
max={50}
|
||
onChange={setDraftRounds}
|
||
decrementLabel="Decrease rounds"
|
||
incrementLabel="Increase rounds"
|
||
/>
|
||
<p className="text-sm text-muted-foreground">
|
||
Minimum: {minRounds} (based on sports selected)
|
||
{sportsCount > 0 && ` · Flex spots: ${flexSpots}`}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Draft Date & Time */}
|
||
<div className="space-y-2">
|
||
<Label>Draft Date & Time</Label>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<Input
|
||
type="date"
|
||
value={draftDate}
|
||
onChange={(e) => setDraftDate(e.target.value)}
|
||
min={new Date().toISOString().slice(0, 10)}
|
||
/>
|
||
<Input
|
||
type="time"
|
||
value={draftTime}
|
||
onChange={(e) => setDraftTime(e.target.value)}
|
||
/>
|
||
</div>
|
||
<p className="text-sm text-muted-foreground">
|
||
This can be changed before the draft starts.
|
||
</p>
|
||
</div>
|
||
|
||
{/* Timer Mode */}
|
||
<div className="space-y-2">
|
||
<Label>Timer Mode</Label>
|
||
<TimerModeSelector value={timerMode} onChange={setTimerMode} />
|
||
</div>
|
||
|
||
{/* Draft Speed */}
|
||
<div className="space-y-2">
|
||
<Label>Draft Speed</Label>
|
||
<DraftSpeedPicker timerMode={timerMode} value={draftSpeed} onChange={setDraftSpeed} />
|
||
</div>
|
||
|
||
{/* Overnight Pause */}
|
||
<OvernightPauseSettings
|
||
show={showOvernightPause}
|
||
mode={overnightMode}
|
||
onModeChange={setOvernightMode}
|
||
start={overnightStart}
|
||
onStartChange={setOvernightStart}
|
||
end={overnightEnd}
|
||
onEndChange={setOvernightEnd}
|
||
timezone={overnightTimezone}
|
||
onTimezoneChange={setOvernightTimezone}
|
||
commishTimezone={commishTimezone}
|
||
/>
|
||
|
||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||
|
||
<div className="grid grid-cols-2 gap-3 pt-2">
|
||
<Button type="button" variant="outline" onClick={onBack} className="w-full">
|
||
← Back
|
||
</Button>
|
||
<Button type="button" onClick={onNext} className="w-full" disabled={!draftDate || !draftTime}>
|
||
NEXT →
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Step 4: Scoring ──────────────────────────────────────────────────────────
|
||
|
||
function Step4Scoring({
|
||
scoringPreset,
|
||
setScoringPreset,
|
||
scoringRules,
|
||
setScoringRules,
|
||
onNext,
|
||
onBack,
|
||
}: {
|
||
scoringPreset: "brackt" | "omnifantasy" | "custom";
|
||
setScoringPreset: (v: "brackt" | "omnifantasy" | "custom") => void;
|
||
scoringRules: ScoringRules;
|
||
setScoringRules: (v: ScoringRules) => void;
|
||
onNext: () => void;
|
||
onBack: () => void;
|
||
}) {
|
||
return (
|
||
<div className="space-y-5">
|
||
<div>
|
||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||
<span className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-sm font-bold shrink-0">4</span>
|
||
Scoring
|
||
</h2>
|
||
<p className="text-sm text-muted-foreground mt-1">
|
||
Points awarded to teams based on how their drafted participants finish in each sport's season.
|
||
</p>
|
||
</div>
|
||
|
||
<ScoringPresetPicker
|
||
preset={scoringPreset}
|
||
onPresetChange={setScoringPreset}
|
||
rules={scoringRules}
|
||
onRulesChange={setScoringRules}
|
||
/>
|
||
|
||
<div className="grid grid-cols-2 gap-3 pt-2">
|
||
<Button type="button" variant="outline" onClick={onBack} className="w-full">
|
||
← Back
|
||
</Button>
|
||
<Button type="button" onClick={onNext} className="w-full">
|
||
NEXT →
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Step 5: Review ───────────────────────────────────────────────────────────
|
||
|
||
function StepReview({
|
||
leagueName,
|
||
teamCount,
|
||
joinAsPlayer,
|
||
selectedSports,
|
||
allSportsSeasons,
|
||
draftRounds,
|
||
draftDate,
|
||
draftTime,
|
||
timerMode,
|
||
draftSpeed,
|
||
overnightMode,
|
||
overnightStart,
|
||
overnightEnd,
|
||
overnightTimezone,
|
||
scoringPreset,
|
||
scoringRules,
|
||
isAuthenticated,
|
||
authTab,
|
||
setAuthTab,
|
||
displayName,
|
||
setDisplayName,
|
||
authEmail,
|
||
setAuthEmail,
|
||
authPassword,
|
||
setAuthPassword,
|
||
userTimezone,
|
||
setUserTimezone,
|
||
onSocialLogin,
|
||
error,
|
||
submitting,
|
||
onSubmit,
|
||
onBack,
|
||
onEditStep,
|
||
}: {
|
||
leagueName: string;
|
||
teamCount: number;
|
||
joinAsPlayer: boolean;
|
||
selectedSports: Set<string>;
|
||
allSportsSeasons: SportSeason[];
|
||
draftRounds: number;
|
||
draftDate: string;
|
||
draftTime: string;
|
||
timerMode: "chess_clock" | "standard";
|
||
draftSpeed: string;
|
||
overnightMode: "none" | "league" | "per_user";
|
||
overnightStart: string;
|
||
overnightEnd: string;
|
||
overnightTimezone: string;
|
||
scoringPreset: "brackt" | "omnifantasy" | "custom";
|
||
scoringRules: ScoringRules;
|
||
isAuthenticated: boolean;
|
||
authTab: "create" | "login";
|
||
setAuthTab: (v: "create" | "login") => void;
|
||
displayName: string;
|
||
setDisplayName: (v: string) => void;
|
||
authEmail: string;
|
||
setAuthEmail: (v: string) => void;
|
||
authPassword: string;
|
||
setAuthPassword: (v: string) => void;
|
||
userTimezone: string;
|
||
setUserTimezone: (v: string) => void;
|
||
onSocialLogin: (provider: "google" | "discord") => void;
|
||
error: string | null;
|
||
submitting: boolean;
|
||
onSubmit: () => void;
|
||
onBack: () => void;
|
||
onEditStep: (n: number) => void;
|
||
}) {
|
||
const selectedSeasons = allSportsSeasons.filter((s) => selectedSports.has(s.id));
|
||
|
||
const draftDateTimeStr =
|
||
draftDate && draftTime
|
||
? `${format(new Date(draftDate + "T00:00:00"), "MMMM do, yyyy")} at ${formatTime12h(draftTime)}`
|
||
: "Not set (required before starting draft)";
|
||
|
||
const tzLabel = TIMEZONE_OPTIONS.flatMap((g) => g.zones).find((z) => z.value === overnightTimezone)?.label ?? overnightTimezone;
|
||
const overnightModeLine = overnightMode === "none" ? "None" : overnightMode === "league" ? "League-wide" : "Per user";
|
||
const overnightTimeLine = overnightMode !== "none" ? `${formatTime12h(overnightStart)} – ${formatTime12h(overnightEnd)} (${tzLabel})` : null;
|
||
|
||
const maxPoints = Math.max(...PLACEMENT_LABELS.map(({ key }) => scoringRules[key]));
|
||
const uniqueSportsCount = new Set(selectedSeasons.map((s) => s.sport.id)).size;
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div>
|
||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||
<span className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-sm font-bold shrink-0">5</span>
|
||
Review
|
||
</h2>
|
||
<p className="text-sm text-muted-foreground mt-1">
|
||
Everything look good? You can edit any section before creating.
|
||
</p>
|
||
</div>
|
||
|
||
<ReviewSection icon={Swords} title="League Basics" onEdit={() => onEditStep(1)}>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<p className="text-lg font-bold text-foreground truncate">{leagueName}</p>
|
||
<div className="flex items-center gap-2 shrink-0">
|
||
<Badge variant="secondary">{teamCount} teams</Badge>
|
||
{!joinAsPlayer && <Badge variant="default">Commish only</Badge>}
|
||
</div>
|
||
</div>
|
||
</ReviewSection>
|
||
|
||
<ReviewSection icon={Trophy} title="Sports" onEdit={() => onEditStep(2)}>
|
||
<p className="text-sm text-muted-foreground">
|
||
<span className="text-primary font-semibold">{uniqueSportsCount} sport{uniqueSportsCount !== 1 ? "s" : ""}</span>
|
||
</p>
|
||
<ul className="mt-1 space-y-1">
|
||
{selectedSeasons.toSorted((a, b) => a.sport.name.localeCompare(b.sport.name)).map((s) => (
|
||
<li key={s.id} className="flex items-center gap-2 text-sm text-foreground">
|
||
<SportIcon sport={s.sport} />
|
||
{s.sport.name} {s.year}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</ReviewSection>
|
||
|
||
<ReviewSection icon={CalendarClock} title="Draft Settings" onEdit={() => onEditStep(3)}>
|
||
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm">
|
||
<dt className="text-muted-foreground">Rounds</dt>
|
||
<dd className="text-foreground">{draftRounds}</dd>
|
||
<dt className="text-muted-foreground">Date & Time</dt>
|
||
<dd className="text-primary font-medium">{draftDateTimeStr}</dd>
|
||
<dt className="text-muted-foreground">Timer</dt>
|
||
<dd className="text-foreground">{timerMode === "chess_clock" ? "Chess Clock" : "Standard"}</dd>
|
||
<dt className="text-muted-foreground">Speed</dt>
|
||
<dd className="text-foreground">{formatDraftSpeed(draftSpeed, timerMode)}</dd>
|
||
<dt className="text-muted-foreground">Pause</dt>
|
||
<dd className="text-foreground">
|
||
{overnightModeLine}
|
||
{overnightTimeLine && <div className="text-muted-foreground text-xs mt-0.5">{overnightTimeLine}</div>}
|
||
</dd>
|
||
</dl>
|
||
</ReviewSection>
|
||
|
||
<ReviewSection icon={Star} title="Scoring" onEdit={() => onEditStep(4)}>
|
||
<p className="text-xs text-muted-foreground mb-2">
|
||
{scoringPreset === "brackt" ? "Brackt Default" : scoringPreset === "omnifantasy" ? "Omnifantasy Default" : "Custom"}
|
||
</p>
|
||
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
|
||
{PLACEMENT_LABELS.map(({ key, label, color }) => {
|
||
const val = scoringRules[key];
|
||
const pct = maxPoints > 0 ? (val / maxPoints) * 100 : 0;
|
||
return (
|
||
<div key={key} className="flex items-center gap-2">
|
||
<span className={cn("text-xs font-semibold w-6 shrink-0", color)}>{label}</span>
|
||
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||
<div className="h-full bg-primary rounded-full" style={{ width: `${pct}%` }} />
|
||
</div>
|
||
<span className="text-xs font-semibold w-6 text-right shrink-0">{val}</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</ReviewSection>
|
||
|
||
{!isAuthenticated && (
|
||
<WizardAuthForm
|
||
authTab={authTab}
|
||
setAuthTab={setAuthTab}
|
||
displayName={displayName}
|
||
setDisplayName={setDisplayName}
|
||
authEmail={authEmail}
|
||
setAuthEmail={setAuthEmail}
|
||
authPassword={authPassword}
|
||
setAuthPassword={setAuthPassword}
|
||
userTimezone={userTimezone}
|
||
setUserTimezone={setUserTimezone}
|
||
onSocialLogin={onSocialLogin}
|
||
submitting={submitting}
|
||
/>
|
||
)}
|
||
|
||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||
|
||
<div className="grid grid-cols-2 gap-3 pt-2">
|
||
<Button type="button" variant="outline" onClick={onBack} disabled={submitting} className="w-full">
|
||
← Back
|
||
</Button>
|
||
<Button type="button" onClick={onSubmit} disabled={submitting} className="w-full">
|
||
{submitting
|
||
? "Creating…"
|
||
: isAuthenticated
|
||
? "CREATE LEAGUE"
|
||
: authTab === "create"
|
||
? "CREATE ACCOUNT & LEAGUE"
|
||
: "LOG IN & CREATE LEAGUE"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||
|
||
export default function NewLeague({ loaderData }: Route.ComponentProps) {
|
||
const { templates, allSportsSeasons, commishTimezone, isAuthenticated } = loaderData;
|
||
const fetcher = useFetcher();
|
||
|
||
// Wizard
|
||
const [step, setStep] = useState(1);
|
||
|
||
// Step-level validation error
|
||
const [stepError, setStepError] = useState<string | null>(null);
|
||
|
||
// Step 1
|
||
const [leagueName, setLeagueName] = useState("");
|
||
const [teamCount, setTeamCount] = useState(12);
|
||
const [joinAsPlayer, setJoinAsPlayer] = useState(true);
|
||
|
||
// Step 2
|
||
const [selectedTemplate, setSelectedTemplate] = useState("");
|
||
const [selectedSports, setSelectedSports] = useState<Set<string>>(new Set());
|
||
|
||
// Step 3
|
||
const [draftRounds, setDraftRounds] = useState(20);
|
||
const [draftDate, setDraftDate] = useState("");
|
||
const [draftTime, setDraftTime] = useState("");
|
||
const [timerMode, setTimerMode] = useState<"chess_clock" | "standard">("chess_clock");
|
||
const [draftSpeed, setDraftSpeed] = useState("standard");
|
||
const [overnightMode, setOvernightMode] = useState<"none" | "league" | "per_user">("none");
|
||
const [overnightStart, setOvernightStart] = useState("23:00");
|
||
const [overnightEnd, setOvernightEnd] = useState("07:00");
|
||
const [overnightTimezone, setOvernightTimezone] = useState(commishTimezone ?? "");
|
||
|
||
// Step 4
|
||
const [scoringPreset, setScoringPreset] = useState<"brackt" | "omnifantasy" | "custom">("brackt");
|
||
const [scoringRules, setScoringRules] = useState<ScoringRules>({ ...DEFAULT_SCORING_RULES });
|
||
|
||
// Step 5
|
||
const [authTab, setAuthTab] = useState<"create" | "login">("create");
|
||
const [displayName, setDisplayName] = useState("");
|
||
const [authEmail, setAuthEmail] = useState("");
|
||
const [authPassword, setAuthPassword] = useState("");
|
||
const [authError, setAuthError] = useState<string | null>(null);
|
||
const [authLoading, setAuthLoading] = useState(false);
|
||
const [userTimezone, setUserTimezone] = useState("");
|
||
|
||
// Detect browser timezone on mount
|
||
useEffect(() => {
|
||
const detected = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||
if (!overnightTimezone) setOvernightTimezone(detected);
|
||
setUserTimezone(detected);
|
||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
// Recalculate default rounds whenever sports count changes
|
||
const sportsCount = selectedSports.size;
|
||
useEffect(() => {
|
||
setDraftRounds(defaultRounds(sportsCount));
|
||
}, [sportsCount]); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
const isSubmitting = fetcher.state !== "idle" || authLoading;
|
||
const submitError =
|
||
fetcher.data && typeof fetcher.data === "object" && "error" in fetcher.data
|
||
? String((fetcher.data as { error: unknown }).error)
|
||
: null;
|
||
|
||
function buildFormData(): FormData {
|
||
return buildFormDataFromSnapshot({
|
||
leagueName, teamCount, joinAsPlayer, selectedTemplate,
|
||
selectedSports: [...selectedSports],
|
||
draftRounds, draftDate, draftTime, timerMode, draftSpeed,
|
||
overnightMode, overnightStart, overnightEnd, overnightTimezone,
|
||
scoringPreset, scoringRules, userTimezone,
|
||
});
|
||
}
|
||
|
||
async function handleFinalSubmit() {
|
||
if (isAuthenticated) {
|
||
fetcher.submit(buildFormData(), { method: "post" });
|
||
return;
|
||
}
|
||
|
||
setAuthError(null);
|
||
setAuthLoading(true);
|
||
|
||
if (authTab === "create") {
|
||
const { error } = await authClient.signUp.email({
|
||
name: displayName,
|
||
email: authEmail,
|
||
password: authPassword,
|
||
});
|
||
if (error) {
|
||
setAuthError(error.message ?? "Registration failed. Please try again.");
|
||
setAuthLoading(false);
|
||
return;
|
||
}
|
||
} else {
|
||
const { error } = await authClient.signIn.email({
|
||
email: authEmail,
|
||
password: authPassword,
|
||
});
|
||
if (error) {
|
||
setAuthError(error.message ?? "Sign in failed. Please check your credentials.");
|
||
setAuthLoading(false);
|
||
return;
|
||
}
|
||
}
|
||
|
||
fetcher.submit(buildFormData(), { method: "post" });
|
||
setAuthLoading(false);
|
||
}
|
||
|
||
async function handleSocialLogin(provider: "google" | "discord") {
|
||
saveWizardState({
|
||
leagueName, teamCount, joinAsPlayer, selectedTemplate,
|
||
selectedSports: [...selectedSports],
|
||
draftRounds, draftDate, draftTime, timerMode, draftSpeed,
|
||
overnightMode, overnightStart, overnightEnd, overnightTimezone,
|
||
scoringPreset, scoringRules, userTimezone,
|
||
});
|
||
await authClient.signIn.social({ provider, callbackURL: "/leagues/creating" });
|
||
}
|
||
|
||
function goNext() {
|
||
setStepError(null);
|
||
if (step === 1 && leagueName.trim().length < 3) {
|
||
setStepError("League name must be at least 3 characters.");
|
||
return;
|
||
}
|
||
if (step === 2 && selectedSports.size === 0) {
|
||
setStepError("Please select at least one sport season.");
|
||
return;
|
||
}
|
||
if (step === 3 && (!draftDate || !draftTime)) {
|
||
setStepError("Please set a draft date and time.");
|
||
return;
|
||
}
|
||
if (step === 3 && draftRounds < sportsCount) {
|
||
setStepError(`Draft rounds must be at least ${sportsCount} (number of sports selected).`);
|
||
return;
|
||
}
|
||
setStep((s) => s + 1);
|
||
}
|
||
|
||
function goBack() {
|
||
setStepError(null);
|
||
setStep((s) => s - 1);
|
||
}
|
||
|
||
function goToStep(n: number) {
|
||
if (n < step) {
|
||
setStepError(null);
|
||
setStep(n);
|
||
}
|
||
}
|
||
|
||
const STEPS = ["Basics", "Sports", "Draft Settings", "Scoring", "Review"];
|
||
|
||
return (
|
||
<div className="min-h-screen bg-background">
|
||
<div className="max-w-lg mx-auto px-4 py-4">
|
||
<div className="mb-6">
|
||
<h1 className="text-3xl font-bold">Start a New League</h1>
|
||
<p className="text-muted-foreground mt-1">
|
||
Create your fantasy league and invite your friends to compete.
|
||
</p>
|
||
</div>
|
||
|
||
<WizardStepper currentStep={step} steps={STEPS} onStepClick={goToStep} />
|
||
|
||
<Card className="py-0">
|
||
<CardContent className="p-5">
|
||
{step === 1 && (
|
||
<Step1LeagueBasics
|
||
leagueName={leagueName}
|
||
setLeagueName={setLeagueName}
|
||
teamCount={teamCount}
|
||
setTeamCount={setTeamCount}
|
||
joinAsPlayer={joinAsPlayer}
|
||
setJoinAsPlayer={setJoinAsPlayer}
|
||
error={stepError}
|
||
onNext={goNext}
|
||
/>
|
||
)}
|
||
{step === 2 && (
|
||
<Step2Sports
|
||
templates={templates}
|
||
allSportsSeasons={allSportsSeasons}
|
||
teamCount={teamCount}
|
||
selectedTemplate={selectedTemplate}
|
||
setSelectedTemplate={setSelectedTemplate}
|
||
selectedSports={selectedSports}
|
||
setSelectedSports={setSelectedSports}
|
||
error={stepError}
|
||
onNext={goNext}
|
||
onBack={goBack}
|
||
/>
|
||
)}
|
||
{step === 3 && (
|
||
<Step3DraftSettings
|
||
draftRounds={draftRounds}
|
||
setDraftRounds={setDraftRounds}
|
||
draftDate={draftDate}
|
||
setDraftDate={setDraftDate}
|
||
draftTime={draftTime}
|
||
setDraftTime={setDraftTime}
|
||
timerMode={timerMode}
|
||
setTimerMode={(mode) => {
|
||
setTimerMode(mode);
|
||
setDraftSpeed(mode === "chess_clock" ? "standard" : "90");
|
||
}}
|
||
draftSpeed={draftSpeed}
|
||
setDraftSpeed={(newSpeed) => {
|
||
if (isSlowPreset(newSpeed) && !isSlowPreset(draftSpeed)) {
|
||
setOvernightMode("league");
|
||
setOvernightStart("23:00");
|
||
setOvernightEnd("07:00");
|
||
}
|
||
setDraftSpeed(newSpeed);
|
||
}}
|
||
overnightMode={overnightMode}
|
||
setOvernightMode={setOvernightMode}
|
||
overnightStart={overnightStart}
|
||
setOvernightStart={setOvernightStart}
|
||
overnightEnd={overnightEnd}
|
||
setOvernightEnd={setOvernightEnd}
|
||
overnightTimezone={overnightTimezone}
|
||
setOvernightTimezone={setOvernightTimezone}
|
||
commishTimezone={commishTimezone}
|
||
sportsCount={sportsCount}
|
||
error={stepError}
|
||
onNext={goNext}
|
||
onBack={goBack}
|
||
/>
|
||
)}
|
||
{step === 4 && (
|
||
<Step4Scoring
|
||
scoringPreset={scoringPreset}
|
||
setScoringPreset={setScoringPreset}
|
||
scoringRules={scoringRules}
|
||
setScoringRules={setScoringRules}
|
||
onNext={goNext}
|
||
onBack={goBack}
|
||
/>
|
||
)}
|
||
{step === 5 && (
|
||
<StepReview
|
||
leagueName={leagueName}
|
||
teamCount={teamCount}
|
||
joinAsPlayer={joinAsPlayer}
|
||
selectedSports={selectedSports}
|
||
allSportsSeasons={allSportsSeasons}
|
||
draftRounds={draftRounds}
|
||
draftDate={draftDate}
|
||
draftTime={draftTime}
|
||
timerMode={timerMode}
|
||
draftSpeed={draftSpeed}
|
||
overnightMode={overnightMode}
|
||
overnightStart={overnightStart}
|
||
overnightEnd={overnightEnd}
|
||
overnightTimezone={overnightTimezone}
|
||
scoringPreset={scoringPreset}
|
||
scoringRules={scoringRules}
|
||
isAuthenticated={isAuthenticated}
|
||
authTab={authTab}
|
||
setAuthTab={setAuthTab}
|
||
displayName={displayName}
|
||
setDisplayName={setDisplayName}
|
||
authEmail={authEmail}
|
||
setAuthEmail={setAuthEmail}
|
||
authPassword={authPassword}
|
||
setAuthPassword={setAuthPassword}
|
||
userTimezone={userTimezone}
|
||
setUserTimezone={setUserTimezone}
|
||
onSocialLogin={handleSocialLogin}
|
||
error={submitError ?? authError}
|
||
submitting={isSubmitting}
|
||
onSubmit={handleFinalSubmit}
|
||
onBack={goBack}
|
||
onEditStep={goToStep}
|
||
/>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|