brackt/app/routes/leagues/new.tsx
Chris Parsons 5268e07365
Migrate auth from Clerk to BetterAuth (#354)
* Fix BetterAuth field mapping and add owner-prefixed team names

- Fix auth.server.ts: use camelCase Drizzle field names for BetterAuth
  adapter (was snake_case, causing user inserts to fail); add
  generateId: "uuid" so PostgreSQL UUID columns accept generated IDs
- Add prependOwnerToTeamName / stripOwnerFromTeamName utilities
- Invite join, league creation, and settings assign/remove all now
  manage the owner-prefix on team names atomically

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Complete BetterAuth migration: auth flows, rename, and cleanup

- Add /forgot-password and /reset-password pages (full password reset flow)
- Add 'Forgot password?' link on login page
- Fix register.tsx: add explicit window.location fallback after signUp
- Rename commissionerAuditLog.actorClerkId → actorUserId (migration 0084)
  and update all references in model, routes, components, and tests
- Rewrite docs/agents/auth.md to document BetterAuth (removes all Clerk refs)
- Update test fixtures and schema comments to remove Clerk ID references;
  use UUID-format IDs throughout test data
- Update docs/agents/domain-models.md ownerId description

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix BetterAuth ID generation, clean up review issues

- Set generateId: false so BetterAuth omits id from inserts; add
  $defaultFn(crypto.randomUUID) to accounts/sessions/verifications
  so Drizzle fills it in (fixes null id constraint violation on signup)
- Move renameTeam to static import; rename before removeTeamOwner so
  a failed rename leaves owner intact rather than leaving a stale prefix
- Clarify stripOwnerFromTeamName toTitleCase assumption in comment
- Annotate reset-password token fallback to explain loader guarantee

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 10:03:50 -07:00

1284 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, prependOwnerToTeamName } 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 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 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>
);
}