brackt/app/routes/leagues/new.tsx
Chris Parsons e201ecd28a
Extract reusable league wizard components; wire settings page (#350)
* 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>
2026-04-28 22:24:13 -07:00

1280 lines
47 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.

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&apos;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 &amp; 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&apos;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 &amp; 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>
);
}