brackt/app/routes/leagues/new.tsx
chrisp f657293430
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 2m32s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m18s
🚀 Deploy / 🐳 Build (push) Successful in 1m10s
🚀 Deploy / 🚀 Deploy (push) Successful in 12s
claude/sports-templates-conversion-ILWAM (#71)
Co-authored-by: Claude <noreply@anthropic.com>
Reviewed-on: #71
2026-06-04 21:31:49 +00:00

1314 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, findSeasonTemplateWithSports } from "~/models/season-template";
import { findDraftableSportsSeasons } from "~/models/sports-season";
import { findUserById, updateUser } from "~/models/user";
import { generateUniqueTeamNames, prependOwnerToTeamName } from "~/utils/team-names";
import { format } from "date-fns";
import { CalendarClock, Check, Info, 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 { useLocalTimezone } from "~/hooks/useLocalTimezone";
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";
import {
applyBracktSportsForSeason,
seedOrRepairBracktTemplate,
} from "~/services/brackt.server";
// ─── Types ────────────────────────────────────────────────────────────────────
type SportSeason = {
id: string;
name: string;
year: number;
scoringType: string;
fantasySeasonId: string | null;
sport: { id: string; name: string; type: string; slug: string; iconUrl: string | null };
participantCount: number;
};
type Template = {
id: string;
name: string;
description: string | null;
sportsSeasonIds: string[];
};
const BRACKT_SPORT_TOOLTIP =
"Draft managers from your own league. They score based on how their teams perform across the other sports.";
function isBracktSport(sport: { slug: string }) {
return sport.slug === "brackt";
}
function BracktInfoIcon() {
return (
<span
title={BRACKT_SPORT_TOOLTIP}
aria-label={BRACKT_SPORT_TOOLTIP}
className="inline-flex text-muted-foreground"
>
<Info className="h-3.5 w-3.5" />
</span>
);
}
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));
}
// ─── 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;
await seedOrRepairBracktTemplate();
const templates = await findActiveSeasonTemplates();
const allSportsSeasons = await findDraftableSportsSeasons();
const draftableSeasonBySportId = new Map(
allSportsSeasons.map((s) => [s.sportId, s])
);
const templatesWithSports = await Promise.all(
templates.map(async (template) => {
const t = await findSeasonTemplateWithSports(template.id);
return {
...template,
sportsSeasonIds: (t?.seasonTemplateSports ?? [])
.map((ts: { sportId: string }) => draftableSeasonBySportId.get(ts.sportId)?.id)
.filter((id): id is string => id !== null && id !== undefined),
};
})
);
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 autoStartDraft = formData.get("autoStartDraft") === "on" && !!(typeof draftDateTime === "string" && 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 !== "customize"
? templateId
: null,
draftRounds: draftRoundsNum,
draftDateTime: typeof draftDateTime === "string" && draftDateTime ? new Date(draftDateTime) : null,
autoStartDraft,
draftInitialTime: draftInitialTimeNum,
draftIncrementTime: draftIncrementTimeNum,
draftTimerMode,
overnightPauseMode,
overnightPauseStart,
overnightPauseEnd,
overnightPauseTimezone,
...scoringRules,
});
await setCurrentSeason(league.id, season.id);
const selectedSports = formData.getAll("sportsSeasons").map(String);
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.`
};
}
const joinAsPlayer = formData.get("joinAsPlayer") === "on";
const teamNames = generateUniqueTeamNames(teamCountNum);
const creator = await findUserById(userId);
const creatorUsername = creator?.username ?? creator?.displayName;
const teams = teamNames.map((teamName, i) => ({
seasonId: season.id,
name: joinAsPlayer && i === 0 && creatorUsername
? prependOwnerToTeamName(teamName, creatorUsername)
: teamName,
ownerId: joinAsPlayer && i === 0 ? userId : null,
}));
await createManyTeams(teams);
const sportsToLink = await applyBracktSportsForSeason(season.id, selectedSports);
if (sportsToLink.length > 0) {
await linkMultipleSportsToSeason(
sportsToLink.map((id) => ({
seasonId: season.id,
sportsSeasonId: id,
}))
);
}
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 || (s.sport.slug === "brackt" && s.fantasySeasonId === null)
);
const displaySeasons = selectedTemplate === "customize" || !activeTemplate
? eligibleSeasons
: eligibleSeasons.filter((s) => activeTemplate.sportsSeasonIds.includes(s.id));
const sortedDisplaySeasons = displaySeasons.toSorted((a, b) => {
const sportNameDiff = a.sport.name.localeCompare(b.sport.name);
if (sportNameDiff !== 0) return sportNameDiff;
return b.year - a.year;
});
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"
title={isBracktSport(sport) ? BRACKT_SPORT_TOOLTIP : undefined}
>
<SportIcon sport={sport} />
<span>{sport.name}</span>
{isBracktSport(sport) && <BracktInfoIcon />}
</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.name}
<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="max-h-72 overflow-y-auto pr-1">
<div className="flex flex-col gap-1">
{sortedDisplaySeasons.map((s) => {
const selected = selectedSports.has(s.id);
return (
<button
key={s.id}
type="button"
onClick={() => toggleSport(s.id)}
title={isBracktSport(s.sport) ? BRACKT_SPORT_TOOLTIP : undefined}
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.name}
{isBracktSport(s.sport) && <BracktInfoIcon />}
</span>
<span className="text-xs font-semibold">{selected ? "Remove" : "Add"}</span>
</button>
);
})}
{sortedDisplaySeasons.length === 0 && (
<p className="text-sm text-muted-foreground">No sports seasons available.</p>
)}
</div>
</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,
autoStartDraft,
setAutoStartDraft,
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;
autoStartDraft: boolean;
setAutoStartDraft: (v: boolean) => 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;
const localTz = useLocalTimezone();
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.
{localTz && <> Times are in your local timezone ({localTz}).</>}
</p>
{draftDate && draftTime && (
<div className="flex items-center gap-3 pt-1">
<Checkbox
id="autoStartDraft"
checked={autoStartDraft}
onCheckedChange={(v) => setAutoStartDraft(v === true)}
/>
<Label htmlFor="autoStartDraft" className="font-normal cursor-pointer">
Auto-start at scheduled time
</Label>
</div>
)}
<input type="hidden" name="autoStartDraft" value={draftDate && draftTime && autoStartDraft ? "on" : ""} />
</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.name.localeCompare(b.name)).map((s) => (
<li key={s.id} className="flex items-center gap-2 text-sm text-foreground">
<SportIcon sport={s.sport} />
{s.name}
</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 [autoStartDraft, setAutoStartDraft] = useState(false);
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, autoStartDraft, 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, autoStartDraft, 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}
autoStartDraft={autoStartDraft}
setAutoStartDraft={setAutoStartDraft}
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>
);
}