* Show season name instead of year/bracket format; add local timezone hint to draft time Replace the raw year number and scoringType string in the league creation sport picker, review step, and league settings sports section with the human-readable season name (e.g. "2025 NBA Season"), matching how the league homepage sports list already displays seasons. Add a client-side local timezone label below the draft date & time fields on both the league creation wizard and the league settings page, so users know that draft times are in their local timezone. https://claude.ai/code/session_01GtJowGruAc1A4jURkSDVjX * Address code review: shared tz hook, short abbrev, sort fix, dead type cleanup, test update - Extract timezone detection into useLocalTimezone hook to avoid duplication across DraftSetupSection and Step3DraftSettings - Use Intl short timezone abbreviation (e.g. "EST") instead of raw IANA name, matching DraftInfoCard's existing pattern - Sort review step sport list by season name instead of sport.name so the visible order matches the displayed text - Remove unused scoringType and year fields from SportsSection's local type - Update SportsSection tests to use realistic season names and the new display format https://claude.ai/code/session_01GtJowGruAc1A4jURkSDVjX --------- Co-authored-by: Claude <noreply@anthropic.com>
1289 lines
46 KiB
TypeScript
1289 lines
46 KiB
TypeScript
import { useState, useEffect } from "react";
|
||
import { redirect, useFetcher } from "react-router";
|
||
import { auth } from "~/lib/auth.server";
|
||
import { authClient } from "~/lib/auth-client";
|
||
import type { Route } from "./+types/new";
|
||
|
||
import { logger } from "~/lib/logger";
|
||
import { createLeague, setCurrentSeason } from "~/models/league";
|
||
import { createCommissioner } from "~/models/commissioner";
|
||
import { createSeason } from "~/models/season";
|
||
import { linkMultipleSportsToSeason } from "~/models/season-sport";
|
||
import { createManyTeams } from "~/models/team";
|
||
import { findActiveSeasonTemplates, findSeasonTemplateWithSportsSeasons } from "~/models/season-template";
|
||
import { findDraftableSportsSeasons } from "~/models/sports-season";
|
||
import { findUserById, updateUser } from "~/models/user";
|
||
import { generateUniqueTeamNames, 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;
|
||
year: number;
|
||
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 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 !== "customize"
|
||
? 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").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'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,
|
||
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;
|
||
|
||
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 & 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>
|
||
</div>
|
||
|
||
{/* Timer Mode */}
|
||
<div className="space-y-2">
|
||
<Label>Timer Mode</Label>
|
||
<TimerModeSelector value={timerMode} onChange={setTimerMode} />
|
||
</div>
|
||
|
||
{/* Draft Speed */}
|
||
<div className="space-y-2">
|
||
<Label>Draft Speed</Label>
|
||
<DraftSpeedPicker timerMode={timerMode} value={draftSpeed} onChange={setDraftSpeed} />
|
||
</div>
|
||
|
||
{/* Overnight Pause */}
|
||
<OvernightPauseSettings
|
||
show={showOvernightPause}
|
||
mode={overnightMode}
|
||
onModeChange={setOvernightMode}
|
||
start={overnightStart}
|
||
onStartChange={setOvernightStart}
|
||
end={overnightEnd}
|
||
onEndChange={setOvernightEnd}
|
||
timezone={overnightTimezone}
|
||
onTimezoneChange={setOvernightTimezone}
|
||
commishTimezone={commishTimezone}
|
||
/>
|
||
|
||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||
|
||
<div className="grid grid-cols-2 gap-3 pt-2">
|
||
<Button type="button" variant="outline" onClick={onBack} className="w-full">
|
||
← Back
|
||
</Button>
|
||
<Button type="button" onClick={onNext} className="w-full" disabled={!draftDate || !draftTime}>
|
||
NEXT →
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Step 4: Scoring ──────────────────────────────────────────────────────────
|
||
|
||
function Step4Scoring({
|
||
scoringPreset,
|
||
setScoringPreset,
|
||
scoringRules,
|
||
setScoringRules,
|
||
onNext,
|
||
onBack,
|
||
}: {
|
||
scoringPreset: "brackt" | "omnifantasy" | "custom";
|
||
setScoringPreset: (v: "brackt" | "omnifantasy" | "custom") => void;
|
||
scoringRules: ScoringRules;
|
||
setScoringRules: (v: ScoringRules) => void;
|
||
onNext: () => void;
|
||
onBack: () => void;
|
||
}) {
|
||
return (
|
||
<div className="space-y-5">
|
||
<div>
|
||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||
<span className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-sm font-bold shrink-0">4</span>
|
||
Scoring
|
||
</h2>
|
||
<p className="text-sm text-muted-foreground mt-1">
|
||
Points awarded to teams based on how their drafted participants finish in each sport's season.
|
||
</p>
|
||
</div>
|
||
|
||
<ScoringPresetPicker
|
||
preset={scoringPreset}
|
||
onPresetChange={setScoringPreset}
|
||
rules={scoringRules}
|
||
onRulesChange={setScoringRules}
|
||
/>
|
||
|
||
<div className="grid grid-cols-2 gap-3 pt-2">
|
||
<Button type="button" variant="outline" onClick={onBack} className="w-full">
|
||
← Back
|
||
</Button>
|
||
<Button type="button" onClick={onNext} className="w-full">
|
||
NEXT →
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Step 5: Review ───────────────────────────────────────────────────────────
|
||
|
||
function StepReview({
|
||
leagueName,
|
||
teamCount,
|
||
joinAsPlayer,
|
||
selectedSports,
|
||
allSportsSeasons,
|
||
draftRounds,
|
||
draftDate,
|
||
draftTime,
|
||
timerMode,
|
||
draftSpeed,
|
||
overnightMode,
|
||
overnightStart,
|
||
overnightEnd,
|
||
overnightTimezone,
|
||
scoringPreset,
|
||
scoringRules,
|
||
isAuthenticated,
|
||
authTab,
|
||
setAuthTab,
|
||
displayName,
|
||
setDisplayName,
|
||
authEmail,
|
||
setAuthEmail,
|
||
authPassword,
|
||
setAuthPassword,
|
||
userTimezone,
|
||
setUserTimezone,
|
||
onSocialLogin,
|
||
error,
|
||
submitting,
|
||
onSubmit,
|
||
onBack,
|
||
onEditStep,
|
||
}: {
|
||
leagueName: string;
|
||
teamCount: number;
|
||
joinAsPlayer: boolean;
|
||
selectedSports: Set<string>;
|
||
allSportsSeasons: SportSeason[];
|
||
draftRounds: number;
|
||
draftDate: string;
|
||
draftTime: string;
|
||
timerMode: "chess_clock" | "standard";
|
||
draftSpeed: string;
|
||
overnightMode: "none" | "league" | "per_user";
|
||
overnightStart: string;
|
||
overnightEnd: string;
|
||
overnightTimezone: string;
|
||
scoringPreset: "brackt" | "omnifantasy" | "custom";
|
||
scoringRules: ScoringRules;
|
||
isAuthenticated: boolean;
|
||
authTab: "create" | "login";
|
||
setAuthTab: (v: "create" | "login") => void;
|
||
displayName: string;
|
||
setDisplayName: (v: string) => void;
|
||
authEmail: string;
|
||
setAuthEmail: (v: string) => void;
|
||
authPassword: string;
|
||
setAuthPassword: (v: string) => void;
|
||
userTimezone: string;
|
||
setUserTimezone: (v: string) => void;
|
||
onSocialLogin: (provider: "google" | "discord") => void;
|
||
error: string | null;
|
||
submitting: boolean;
|
||
onSubmit: () => void;
|
||
onBack: () => void;
|
||
onEditStep: (n: number) => void;
|
||
}) {
|
||
const selectedSeasons = allSportsSeasons.filter((s) => selectedSports.has(s.id));
|
||
|
||
const draftDateTimeStr =
|
||
draftDate && draftTime
|
||
? `${format(new Date(draftDate + "T00:00:00"), "MMMM do, yyyy")} at ${formatTime12h(draftTime)}`
|
||
: "Not set (required before starting draft)";
|
||
|
||
const tzLabel = TIMEZONE_OPTIONS.flatMap((g) => g.zones).find((z) => z.value === overnightTimezone)?.label ?? overnightTimezone;
|
||
const overnightModeLine = overnightMode === "none" ? "None" : overnightMode === "league" ? "League-wide" : "Per user";
|
||
const overnightTimeLine = overnightMode !== "none" ? `${formatTime12h(overnightStart)} – ${formatTime12h(overnightEnd)} (${tzLabel})` : null;
|
||
|
||
const maxPoints = Math.max(...PLACEMENT_LABELS.map(({ key }) => scoringRules[key]));
|
||
const uniqueSportsCount = new Set(selectedSeasons.map((s) => s.sport.id)).size;
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div>
|
||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||
<span className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-sm font-bold shrink-0">5</span>
|
||
Review
|
||
</h2>
|
||
<p className="text-sm text-muted-foreground mt-1">
|
||
Everything look good? You can edit any section before creating.
|
||
</p>
|
||
</div>
|
||
|
||
<ReviewSection icon={Swords} title="League Basics" onEdit={() => onEditStep(1)}>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<p className="text-lg font-bold text-foreground truncate">{leagueName}</p>
|
||
<div className="flex items-center gap-2 shrink-0">
|
||
<Badge variant="secondary">{teamCount} teams</Badge>
|
||
{!joinAsPlayer && <Badge variant="default">Commish only</Badge>}
|
||
</div>
|
||
</div>
|
||
</ReviewSection>
|
||
|
||
<ReviewSection icon={Trophy} title="Sports" onEdit={() => onEditStep(2)}>
|
||
<p className="text-sm text-muted-foreground">
|
||
<span className="text-primary font-semibold">{uniqueSportsCount} sport{uniqueSportsCount !== 1 ? "s" : ""}</span>
|
||
</p>
|
||
<ul className="mt-1 space-y-1">
|
||
{selectedSeasons.toSorted((a, b) => a.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 & 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>
|
||
);
|
||
}
|