diff --git a/app/components/league/DraftSpeedPicker.stories.tsx b/app/components/league/DraftSpeedPicker.stories.tsx new file mode 100644 index 0000000..033b5a9 --- /dev/null +++ b/app/components/league/DraftSpeedPicker.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { DraftSpeedPicker } from "./DraftSpeedPicker"; + +const meta: Meta = { + title: "League/DraftSpeedPicker", + component: DraftSpeedPicker, + parameters: { layout: "padded" }, + args: { onChange: () => {} }, +}; + +export default meta; +type Story = StoryObj; + +export const StandardPreset: Story = { + args: { timerMode: "standard", value: "90" }, +}; + +export const StandardCustom: Story = { + args: { timerMode: "standard", value: "300" }, +}; + +export const ChessClockStandard: Story = { + args: { timerMode: "chess_clock", value: "standard" }, +}; + +export const ChessClockSlow: Story = { + args: { timerMode: "chess_clock", value: "slow" }, +}; + +export const ChessClockCustom: Story = { + args: { timerMode: "chess_clock", value: "custom:7200:900" }, +}; + +export const Disabled: Story = { + args: { timerMode: "chess_clock", value: "standard", disabled: true }, +}; diff --git a/app/components/league/DraftSpeedPicker.tsx b/app/components/league/DraftSpeedPicker.tsx new file mode 100644 index 0000000..a65a634 --- /dev/null +++ b/app/components/league/DraftSpeedPicker.tsx @@ -0,0 +1,281 @@ +import { Coffee, Snail, Timer, Zap, type LucideIcon } from "lucide-react"; +import { GradientIcon } from "~/components/ui/GradientIcon"; +import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/components/ui/select"; +import { cn } from "~/lib/utils"; + +// ─── Types & constants ──────────────────────────────────────────────────────── + +type TimeUnit = "sec" | "min" | "hr"; + +export const STANDARD_PRESETS: { value: string; label: string }[] = [ + { value: "15", label: "15 sec" }, + { value: "30", label: "30 sec" }, + { value: "60", label: "1 min" }, + { value: "90", label: "90 sec" }, + { value: "120", label: "2 min" }, + { value: "900", label: "15 min" }, + { value: "3600", label: "1 hr" }, + { value: "7200", label: "2 hr" }, + { value: "14400", label: "4 hr" }, + { value: "28800", label: "8 hr" }, + { value: "43200", label: "12 hr" }, +]; + +export const CHESS_PRESETS: { + value: string; + label: string; + icon: LucideIcon; + desc: string; + bankSec: number; + incrSec: number; +}[] = [ + { value: "fast", label: "Fast", icon: Zap, desc: "~1 hour", bankSec: 60, incrSec: 10 }, + { value: "standard", label: "Standard", icon: Timer, desc: "~90 minutes", bankSec: 120, incrSec: 15 }, + { value: "slow", label: "Slow", icon: Coffee, desc: "~1 week", bankSec: 28800, incrSec: 3600 }, + { value: "very-slow", label: "Very Slow", icon: Snail, desc: "~2 weeks", bankSec: 43200, incrSec: 3600 }, +]; + +export function isSlowPreset(s: string): boolean { + return s === "slow" || s === "very-slow"; +} + +// ─── Internal helpers ───────────────────────────────────────────────────────── + +function secToDisplay(sec: number): [number, TimeUnit] { + if (sec >= 3600 && sec % 3600 === 0) return [sec / 3600, "hr"]; + if (sec >= 60 && sec % 60 === 0) return [sec / 60, "min"]; + return [sec, "sec"]; +} + +function displayToSec(amount: string, unit: TimeUnit): number { + const n = parseFloat(amount) || 0; + if (unit === "hr") return Math.round(n * 3600); + if (unit === "min") return Math.round(n * 60); + return Math.round(n); +} + +export function formatSec(sec: number): string { + if (sec >= 3600 && sec % 3600 === 0) return `${sec / 3600} hr`; + if (sec >= 60 && sec % 60 === 0) return `${sec / 60} min`; + return `${sec} sec`; +} + +export function formatDraftSpeed(speed: string, mode: "chess_clock" | "standard"): string { + if (mode === "standard") { + const s = parseInt(speed, 10); + if (s >= 3600) return `${s / 3600} hr`; + if (s >= 60) return `${s / 60} min`; + return `${s} sec`; + } + if (speed.startsWith("custom:")) { + const [, b, i] = speed.split(":"); + return `Custom (${formatSec(parseInt(b, 10))} bank + ${formatSec(parseInt(i, 10))} / pick)`; + } + const map: Record = { + fast: "Fast (1 min + 10 sec)", + standard: "Standard (2 min + 15 sec)", + slow: "Slow (8 hr + 1 hr)", + "very-slow": "Very Slow (12 hr + 1 hr)", + }; + return map[speed] ?? speed; +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export interface DraftSpeedPickerProps { + timerMode: "chess_clock" | "standard"; + value: string; + onChange: (v: string) => void; + disabled?: boolean; +} + +export function DraftSpeedPicker({ timerMode, value, onChange, disabled }: DraftSpeedPickerProps) { + const isStdPreset = STANDARD_PRESETS.some((p) => p.value === value); + const isChessPreset = CHESS_PRESETS.some((p) => p.value === value); + const isCustom = timerMode === "standard" ? !isStdPreset : !isChessPreset; + + // Derive display values from value prop so they always stay in sync. + const stdCustomSec = timerMode === "standard" && isCustom ? (parseInt(value, 10) || 90) : 90; + const [stdCustomAmt, stdCustomUnit] = secToDisplay(stdCustomSec); + + const chessCustomParts = value.startsWith("custom:") ? value.split(":") : []; + const bankSec = chessCustomParts.length === 3 ? (parseInt(chessCustomParts[1], 10) || 120) : 120; + const incrSec = chessCustomParts.length === 3 ? (parseInt(chessCustomParts[2], 10) || 15) : 15; + const [bankAmt, bankUnit] = secToDisplay(bankSec); + const [incrAmt, incrUnit] = secToDisplay(incrSec); + + if (timerMode === "standard") { + return ( +
+
+ {STANDARD_PRESETS.map((p) => ( + + ))} + +
+ {isCustom && ( +
+ onChange(String(displayToSec(e.target.value, stdCustomUnit) || 90))} + className="w-24" + /> + onChange(String(displayToSec(String(stdCustomAmt), u) || 90))} + disabled={disabled} + /> + per pick +
+ )} +
+ ); + } + + return ( +
+
+ {CHESS_PRESETS.map((p) => { + const selected = value === p.value; + return ( + + ); + })} + +
+ {isCustom && ( +
+
+ +
+ onChange(`custom:${displayToSec(e.target.value, bankUnit) || 120}:${incrSec}`)} + className="w-20" + /> + onChange(`custom:${displayToSec(String(bankAmt), u) || 120}:${incrSec}`)} + disabled={disabled} + /> +
+
+
+ +
+ onChange(`custom:${bankSec}:${displayToSec(e.target.value, incrUnit) || 15}`)} + className="w-20" + /> + onChange(`custom:${bankSec}:${displayToSec(String(incrAmt), u) || 15}`)} + disabled={disabled} + /> +
+
+
+ )} +
+ ); +} + +function UnitSelect({ value, onChange, disabled }: { value: TimeUnit; onChange: (v: TimeUnit) => void; disabled?: boolean }) { + return ( + + ); +} diff --git a/app/components/league/OvernightPauseSettings.stories.tsx b/app/components/league/OvernightPauseSettings.stories.tsx new file mode 100644 index 0000000..8f893cf --- /dev/null +++ b/app/components/league/OvernightPauseSettings.stories.tsx @@ -0,0 +1,45 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { OvernightPauseSettings } from "./OvernightPauseSettings"; + +const meta: Meta = { + title: "League/OvernightPauseSettings", + component: OvernightPauseSettings, + parameters: { layout: "padded" }, + args: { + onModeChange: () => {}, + onStartChange: () => {}, + onEndChange: () => {}, + onTimezoneChange: () => {}, + start: "23:00", + end: "07:00", + timezone: "America/Chicago", + commishTimezone: "America/Chicago", + }, +}; + +export default meta; +type Story = StoryObj; + +export const Hidden: Story = { + args: { show: false, mode: "none" }, +}; + +export const ModeNone: Story = { + args: { show: true, mode: "none" }, +}; + +export const ModeLeague: Story = { + args: { show: true, mode: "league" }, +}; + +export const ModePerUser: Story = { + args: { show: true, mode: "per_user" }, +}; + +export const ModePerUserNoCommishTimezone: Story = { + args: { show: true, mode: "per_user", commishTimezone: null }, +}; + +export const Disabled: Story = { + args: { show: true, mode: "league", disabled: true }, +}; diff --git a/app/components/league/OvernightPauseSettings.tsx b/app/components/league/OvernightPauseSettings.tsx new file mode 100644 index 0000000..a1a8f61 --- /dev/null +++ b/app/components/league/OvernightPauseSettings.tsx @@ -0,0 +1,121 @@ +import { Ban, Globe, Moon, Sun, Users } from "lucide-react"; +import { GradientIcon } from "~/components/ui/GradientIcon"; +import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; +import { TimezoneSelect } from "~/components/league/TimezoneSelect"; +import { cn } from "~/lib/utils"; + +export interface OvernightPauseSettingsProps { + show: boolean; + mode: "none" | "league" | "per_user"; + onModeChange: (v: "none" | "league" | "per_user") => void; + start: string; + onStartChange: (v: string) => void; + end: string; + onEndChange: (v: string) => void; + timezone: string; + onTimezoneChange: (v: string) => void; + commishTimezone?: string | null; + disabled?: boolean; +} + +const PAUSE_MODES = [ + { value: "none" as const, icon: Ban, label: "No Pause", sub: "Timer always runs" }, + { value: "league" as const, icon: Globe, label: "League", sub: "Same pause for everyone" }, + { value: "per_user" as const, icon: Users, label: "Per Player", sub: "Pauses in each player's timezone" }, +]; + +export function OvernightPauseSettings({ + show, + mode, + onModeChange, + start, + onStartChange, + end, + onEndChange, + timezone, + onTimezoneChange, + commishTimezone, + disabled, +}: OvernightPauseSettingsProps) { + if (!show) return null; + + return ( +
+ + +
+ {PAUSE_MODES.map(({ value, icon: Icon, label, sub }) => { + const selected = mode === value; + return ( + + ); + })} +
+ + {mode !== "none" && ( +
+
+
+
+ + +
+ onStartChange(e.target.value)} + /> +
+
+
+ + +
+ onEndChange(e.target.value)} + /> +
+
+ +
+ + {mode === "per_user" && ( +

+ Each player's profile timezone is used. Players without one fall back to the timezone below. + {!commishTimezone && ( + <> You haven't set your timezone yet — set it in your profile. + )} +

+ )} + +
+
+ )} +
+ ); +} diff --git a/app/components/league/ReviewSection.stories.tsx b/app/components/league/ReviewSection.stories.tsx new file mode 100644 index 0000000..a6725a3 --- /dev/null +++ b/app/components/league/ReviewSection.stories.tsx @@ -0,0 +1,28 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Trophy } from "lucide-react"; +import { ReviewSection } from "./ReviewSection"; + +const meta: Meta = { + title: "League/ReviewSection", + component: ReviewSection, + parameters: { layout: "centered" }, + args: { + icon: Trophy, + title: "Sports", + onEdit: () => {}, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children: ( +
    +
  • NBA 2025
  • +
  • NFL 2025
  • +
+ ), + }, +}; diff --git a/app/components/league/ReviewSection.tsx b/app/components/league/ReviewSection.tsx new file mode 100644 index 0000000..8fe2d26 --- /dev/null +++ b/app/components/league/ReviewSection.tsx @@ -0,0 +1,28 @@ +import type { LucideIcon } from "lucide-react"; +import type { ReactNode } from "react"; +import { Button } from "~/components/ui/button"; +import { GradientIcon } from "~/components/ui/GradientIcon"; + +export interface ReviewSectionProps { + icon: LucideIcon; + title: string; + onEdit: () => void; + children: ReactNode; +} + +export function ReviewSection({ icon, title, onEdit, children }: ReviewSectionProps) { + return ( +
+
+

+ + {title} +

+ +
+ {children} +
+ ); +} diff --git a/app/components/league/ScoringPresetPicker.stories.tsx b/app/components/league/ScoringPresetPicker.stories.tsx new file mode 100644 index 0000000..bfae84e --- /dev/null +++ b/app/components/league/ScoringPresetPicker.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ScoringPresetPicker, OMNIFANTASY_SCORING } from "./ScoringPresetPicker"; +import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types"; + +const meta: Meta = { + title: "League/ScoringPresetPicker", + component: ScoringPresetPicker, + parameters: { layout: "padded" }, + args: { onPresetChange: () => {}, onRulesChange: () => {} }, +}; + +export default meta; +type Story = StoryObj; + +export const BracktDefault: Story = { + args: { preset: "brackt", rules: { ...DEFAULT_SCORING_RULES } }, +}; + +export const OmnifantasyDefault: Story = { + args: { preset: "omnifantasy", rules: { ...OMNIFANTASY_SCORING } }, +}; + +export const Custom: Story = { + args: { + preset: "custom", + rules: { pointsFor1st: 120, pointsFor2nd: 60, pointsFor3rd: 40, pointsFor4th: 30, pointsFor5th: 20, pointsFor6th: 15, pointsFor7th: 10, pointsFor8th: 5 }, + }, +}; + +export const Disabled: Story = { + args: { preset: "brackt", rules: { ...DEFAULT_SCORING_RULES }, disabled: true }, +}; diff --git a/app/components/league/ScoringPresetPicker.tsx b/app/components/league/ScoringPresetPicker.tsx new file mode 100644 index 0000000..daac0ec --- /dev/null +++ b/app/components/league/ScoringPresetPicker.tsx @@ -0,0 +1,108 @@ +import { cn } from "~/lib/utils"; +import { DEFAULT_SCORING_RULES, type ScoringRules } from "~/lib/scoring-types"; + +export const OMNIFANTASY_SCORING: ScoringRules = { + pointsFor1st: 80, + pointsFor2nd: 50, + pointsFor3rd: 30, + pointsFor4th: 30, + pointsFor5th: 20, + pointsFor6th: 20, + pointsFor7th: 20, + pointsFor8th: 20, +}; + +export const PLACEMENT_LABELS = [ + { key: "pointsFor1st" as const, label: "1st", color: "text-yellow-500" }, + { key: "pointsFor2nd" as const, label: "2nd", color: "text-slate-400" }, + { key: "pointsFor3rd" as const, label: "3rd", color: "text-orange-400" }, + { key: "pointsFor4th" as const, label: "4th", color: "text-sky-400" }, + { key: "pointsFor5th" as const, label: "5th", color: "text-green-500" }, + { key: "pointsFor6th" as const, label: "6th", color: "text-green-500" }, + { key: "pointsFor7th" as const, label: "7th", color: "text-emerald-400" }, + { key: "pointsFor8th" as const, label: "8th", color: "text-emerald-400" }, +]; + +export interface ScoringPresetPickerProps { + preset: "brackt" | "omnifantasy" | "custom"; + onPresetChange: (v: "brackt" | "omnifantasy" | "custom") => void; + rules: ScoringRules; + onRulesChange: (r: ScoringRules) => void; + disabled?: boolean; +} + +export function ScoringPresetPicker({ + preset, + onPresetChange, + rules, + onRulesChange, + disabled, +}: ScoringPresetPickerProps) { + const maxPoints = Math.max(...Object.values(rules)); + + function applyPreset(p: "brackt" | "omnifantasy") { + onPresetChange(p); + onRulesChange(p === "brackt" ? { ...DEFAULT_SCORING_RULES } : { ...OMNIFANTASY_SCORING }); + } + + function handleFieldChange(key: keyof ScoringRules, raw: string) { + const val = parseInt(raw, 10); + if (!isNaN(val) && val >= 0 && val <= 1000) { + onPresetChange("custom"); + onRulesChange({ ...rules, [key]: val }); + } + } + + return ( +
+ {/* Preset tabs */} +
+ {(["brackt", "omnifantasy", "custom"] as const).map((p) => ( + + ))} +
+ + {/* Placement fields */} +
+ {PLACEMENT_LABELS.map(({ key, label, color }) => { + const val = rules[key]; + const barWidth = maxPoints > 0 ? (val / maxPoints) * 100 : 0; + return ( +
+ {label} +
+
+
+ handleFieldChange(key, e.target.value)} + min={0} + max={1000} + className="w-14 h-8 text-center text-sm font-semibold border border-input rounded-md bg-background focus:outline-none focus:ring-2 focus:ring-ring [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none disabled:opacity-50" + /> +
+ ); + })} +
+
+ ); +} diff --git a/app/components/league/SportIcon.stories.tsx b/app/components/league/SportIcon.stories.tsx new file mode 100644 index 0000000..7fef42b --- /dev/null +++ b/app/components/league/SportIcon.stories.tsx @@ -0,0 +1,42 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SportIcon } from "./SportIcon"; + +const meta: Meta = { + title: "League/SportIcon", + component: SportIcon, + parameters: { layout: "centered" }, +}; + +export default meta; +type Story = StoryObj; + +// Icons are uploaded at runtime via the admin panel and are not committed to the +// repo, so we use a data-URI placeholder here instead of a real file path. +const PLACEHOLDER_SVG = + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Ccircle cx='10' cy='10' r='10' fill='%234f46e5'/%3E%3C/svg%3E"; + +export const WithIcon: Story = { + args: { + sport: { name: "NBA", iconUrl: PLACEHOLDER_SVG }, + }, +}; + +export const Fallback: Story = { + args: { + sport: { name: "Unknown Sport", iconUrl: null }, + }, +}; + +export const SmallWithIcon: Story = { + args: { + sport: { name: "NFL", iconUrl: PLACEHOLDER_SVG }, + size: "sm", + }, +}; + +export const SmallFallback: Story = { + args: { + sport: { name: "Unknown Sport", iconUrl: null }, + size: "sm", + }, +}; diff --git a/app/components/league/SportIcon.tsx b/app/components/league/SportIcon.tsx new file mode 100644 index 0000000..f66e600 --- /dev/null +++ b/app/components/league/SportIcon.tsx @@ -0,0 +1,20 @@ +export interface SportIconProps { + sport: { name: string; iconUrl: string | null }; + size?: "sm" | "md"; +} + +export function SportIcon({ sport, size = "md" }: SportIconProps) { + const cls = size === "sm" ? "w-4 h-4" : "w-5 h-5"; + if (sport.iconUrl) { + const isAbsolute = sport.iconUrl.startsWith("data:") || sport.iconUrl.startsWith("http") || sport.iconUrl.startsWith("/"); + const src = isAbsolute ? sport.iconUrl : `/sports-icons/${sport.iconUrl}`; + return ( + {sport.name} + ); + } + return ; +} diff --git a/app/components/league/StepperInput.stories.tsx b/app/components/league/StepperInput.stories.tsx new file mode 100644 index 0000000..6ea481d --- /dev/null +++ b/app/components/league/StepperInput.stories.tsx @@ -0,0 +1,28 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { StepperInput } from "./StepperInput"; + +const meta: Meta = { + title: "League/StepperInput", + component: StepperInput, + parameters: { layout: "centered" }, + args: { + min: 6, + max: 16, + onChange: () => {}, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Middle: Story = { + args: { value: 10 }, +}; + +export const AtMinimum: Story = { + args: { value: 6 }, +}; + +export const AtMaximum: Story = { + args: { value: 16 }, +}; diff --git a/app/components/league/StepperInput.tsx b/app/components/league/StepperInput.tsx new file mode 100644 index 0000000..c98d685 --- /dev/null +++ b/app/components/league/StepperInput.tsx @@ -0,0 +1,41 @@ +export interface StepperInputProps { + value: number; + min: number; + max: number; + onChange: (v: number) => void; + decrementLabel?: string; + incrementLabel?: string; +} + +export function StepperInput({ + value, + min, + max, + onChange, + decrementLabel = "Decrease", + incrementLabel = "Increase", +}: StepperInputProps) { + return ( +
+ + {value} + +
+ ); +} diff --git a/app/components/league/TimerModeSelector.stories.tsx b/app/components/league/TimerModeSelector.stories.tsx new file mode 100644 index 0000000..158883c --- /dev/null +++ b/app/components/league/TimerModeSelector.stories.tsx @@ -0,0 +1,24 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { TimerModeSelector } from "./TimerModeSelector"; + +const meta: Meta = { + title: "League/TimerModeSelector", + component: TimerModeSelector, + parameters: { layout: "padded" }, + args: { onChange: () => {} }, +}; + +export default meta; +type Story = StoryObj; + +export const ChessClockSelected: Story = { + args: { value: "chess_clock" }, +}; + +export const StandardSelected: Story = { + args: { value: "standard" }, +}; + +export const Disabled: Story = { + args: { value: "chess_clock", disabled: true }, +}; diff --git a/app/components/league/TimerModeSelector.tsx b/app/components/league/TimerModeSelector.tsx new file mode 100644 index 0000000..8df9365 --- /dev/null +++ b/app/components/league/TimerModeSelector.tsx @@ -0,0 +1,59 @@ +import { ChessPawn, Hourglass } from "lucide-react"; +import { GradientIcon } from "~/components/ui/GradientIcon"; +import { cn } from "~/lib/utils"; + +export interface TimerModeSelectorProps { + value: "chess_clock" | "standard"; + onChange: (v: "chess_clock" | "standard") => void; + disabled?: boolean; +} + +const MODES = [ + { + value: "chess_clock" as const, + label: "Chess Clock", + icon: ChessPawn, + desc: "Time bank + bonus per pick; bank early to save time for tough decisions", + }, + { + value: "standard" as const, + label: "Standard", + icon: Hourglass, + desc: "Traditional fixed timer; each pick gets the same amount of time, no banking or carry-over", + }, +]; + +export function TimerModeSelector({ value, onChange, disabled }: TimerModeSelectorProps) { + return ( +
+ {MODES.map((mode) => { + const selected = value === mode.value; + return ( + + ); + })} +
+ ); +} diff --git a/app/components/league/WizardAuthForm.stories.tsx b/app/components/league/WizardAuthForm.stories.tsx new file mode 100644 index 0000000..bc8af75 --- /dev/null +++ b/app/components/league/WizardAuthForm.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { WizardAuthForm } from "./WizardAuthForm"; + +const meta: Meta = { + title: "League/WizardAuthForm", + component: WizardAuthForm, + parameters: { layout: "centered" }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: { + setAuthTab: () => {}, + displayName: "", + setDisplayName: () => {}, + authEmail: "", + setAuthEmail: () => {}, + authPassword: "", + setAuthPassword: () => {}, + userTimezone: "America/New_York", + setUserTimezone: () => {}, + onSocialLogin: () => {}, + submitting: false, + }, +}; + +export default meta; +type Story = StoryObj; + +export const CreateTab: Story = { + args: { authTab: "create" }, +}; + +export const LoginTab: Story = { + args: { authTab: "login" }, +}; + +export const Submitting: Story = { + args: { authTab: "create", submitting: true }, +}; diff --git a/app/components/league/WizardAuthForm.tsx b/app/components/league/WizardAuthForm.tsx new file mode 100644 index 0000000..d40fa78 --- /dev/null +++ b/app/components/league/WizardAuthForm.tsx @@ -0,0 +1,157 @@ +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; +import { TimezoneSelect } from "~/components/league/TimezoneSelect"; +import { cn } from "~/lib/utils"; + +export interface WizardAuthFormProps { + 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; + submitting: boolean; +} + +export function WizardAuthForm({ + authTab, + setAuthTab, + displayName, + setDisplayName, + authEmail, + setAuthEmail, + authPassword, + setAuthPassword, + userTimezone, + setUserTimezone, + onSocialLogin, + submitting, +}: WizardAuthFormProps) { + return ( +
+

Your Account

+ +
+ + +
+ +
+ + +
+ +
+
+ or +
+
+ +
+ {authTab === "create" && ( +
+ + setDisplayName(e.target.value)} + autoComplete="name" + /> +
+ )} +
+ + setAuthEmail(e.target.value)} + autoComplete="email" + /> +
+
+ + setAuthPassword(e.target.value)} + autoComplete={authTab === "create" ? "new-password" : "current-password"} + minLength={authTab === "create" ? 8 : undefined} + /> +
+ {authTab === "create" && ( +
+ + +
+ )} +
+ + {authTab === "create" && ( +

+ By creating an account you agree to the{" "} + Terms of Service and{" "} + Privacy Policy. +

+ )} +
+ ); +} diff --git a/app/components/league/WizardStepper.stories.tsx b/app/components/league/WizardStepper.stories.tsx new file mode 100644 index 0000000..1c440af --- /dev/null +++ b/app/components/league/WizardStepper.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { WizardStepper } from "./WizardStepper"; + +const meta: Meta = { + title: "League/WizardStepper", + component: WizardStepper, + parameters: { layout: "padded" }, + args: { + steps: ["Basics", "Sports", "Draft", "Scoring", "Review"], + onStepClick: () => {}, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Step1: Story = { + args: { currentStep: 1 }, +}; + +export const Step3: Story = { + args: { currentStep: 3 }, +}; + +export const LastStep: Story = { + args: { currentStep: 5 }, +}; diff --git a/app/components/league/WizardStepper.tsx b/app/components/league/WizardStepper.tsx new file mode 100644 index 0000000..cc57415 --- /dev/null +++ b/app/components/league/WizardStepper.tsx @@ -0,0 +1,63 @@ +import { Fragment } from "react"; +import { Check } from "lucide-react"; +import { cn } from "~/lib/utils"; + +export interface WizardStepperProps { + currentStep: number; + steps: string[]; + onStepClick: (n: number) => void; +} + +export function WizardStepper({ currentStep, steps, onStepClick }: WizardStepperProps) { + return ( +
+
+ {steps.map((label, i) => { + const n = i + 1; + const done = n < currentStep; + const active = n === currentStep; + return ( + + {i > 0 && ( +
+ )} + + + ); + })} +
+
+ {steps.map((label, i) => { + const n = i + 1; + const done = n < currentStep; + const active = n === currentStep; + return ( + + {i > 0 &&
} +
+ +
+ + ); + })} +
+
+ ); +} diff --git a/app/lib/draft-timer.ts b/app/lib/draft-timer.ts index 832675f..63a0455 100644 --- a/app/lib/draft-timer.ts +++ b/app/lib/draft-timer.ts @@ -10,6 +10,16 @@ * - Between turns: all other teams' banks are left untouched. */ +/** + * Formats a HH:MM time string as 12-hour time with AM/PM. + * e.g. "23:00" → "11:00 PM", "07:30" → "7:30 AM" + */ +export function formatTime12h(t: string): string { + const [h, m] = t.split(":").map(Number); + const ap = (h || 0) >= 12 ? "PM" : "AM"; + return `${(h || 0) % 12 || 12}:${String(m || 0).padStart(2, "0")} ${ap}`; +} + /** * Formats a seconds value for display. * undefined → "--:--" @@ -57,6 +67,12 @@ export function parseDraftSpeed( return { draftInitialTime: time, draftIncrementTime: time }; } + if (draftSpeed?.startsWith("custom:")) { + const [, bankStr, incrStr] = draftSpeed.split(":"); + const bank = parseInt(bankStr ?? "", 10); + const incr = parseInt(incrStr ?? "", 10); + if (!isNaN(bank) && !isNaN(incr)) return { draftInitialTime: bank, draftIncrementTime: incr }; + } switch (draftSpeed) { case "fast": return { draftInitialTime: 60, draftIncrementTime: 10 }; diff --git a/app/lib/wizard-state.ts b/app/lib/wizard-state.ts new file mode 100644 index 0000000..7c26476 --- /dev/null +++ b/app/lib/wizard-state.ts @@ -0,0 +1,71 @@ +import type { ScoringRules } from "~/lib/scoring-types"; + +export const WIZARD_SS_KEY = "brackt_new_league_wizard"; + +export type WizardSnapshot = { + leagueName: string; + teamCount: number; + joinAsPlayer: boolean; + selectedTemplate: string; + selectedSports: string[]; + 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; + userTimezone: string; +}; + +type SS = { getItem(k: string): string | null; setItem(k: string, v: string): void; removeItem(k: string): void }; + +function ss(): SS | null { + const g = globalThis as Record; + return (typeof g["sessionStorage"] !== "undefined" ? g["sessionStorage"] : null) as SS | null; +} + +export function saveWizardState(snapshot: WizardSnapshot) { + try { ss()?.setItem(WIZARD_SS_KEY, JSON.stringify(snapshot)); } catch {} +} + +export function loadWizardState(): WizardSnapshot | null { + try { + const raw = ss()?.getItem(WIZARD_SS_KEY); + return raw ? (JSON.parse(raw) as WizardSnapshot) : null; + } catch { return null; } +} + +export function clearWizardState() { + try { ss()?.removeItem(WIZARD_SS_KEY); } catch {} +} + +export function buildFormDataFromSnapshot(s: WizardSnapshot): FormData { + const fd = new FormData(); + fd.append("name", s.leagueName); + fd.append("teamCount", s.teamCount.toString()); + fd.append("templateId", s.selectedTemplate); + fd.append("draftRounds", s.draftRounds.toString()); + if (s.draftDate && s.draftTime) { + fd.append("draftDateTime", new Date(`${s.draftDate}T${s.draftTime}`).toISOString()); + } + fd.append("draftTimerMode", s.timerMode); + fd.append("draftSpeed", s.draftSpeed); + fd.append("overnightPauseMode", s.overnightMode); + if (s.overnightMode !== "none") { + fd.append("overnightPauseStart", s.overnightStart); + fd.append("overnightPauseEnd", s.overnightEnd); + fd.append("overnightPauseTimezone", s.overnightTimezone); + } + (["pointsFor1st", "pointsFor2nd", "pointsFor3rd", "pointsFor4th", + "pointsFor5th", "pointsFor6th", "pointsFor7th", "pointsFor8th"] as const + ).forEach((k) => fd.append(k, s.scoringRules[k].toString())); + for (const id of s.selectedSports) fd.append("sportsSeasons", id); + if (s.joinAsPlayer) fd.append("joinAsPlayer", "on"); + if (s.userTimezone) fd.append("userTimezone", s.userTimezone); + return fd; +} diff --git a/app/models/__tests__/sports-season.test.ts b/app/models/__tests__/sports-season.test.ts index 34c71ff..5b04078 100644 --- a/app/models/__tests__/sports-season.test.ts +++ b/app/models/__tests__/sports-season.test.ts @@ -36,12 +36,12 @@ const pastStart = "2020-01-01"; const pastEnd = "2020-12-31"; const mockSeasons = [ - { id: "season-1", name: "2025 NBA Playoffs", draftOn: today, draftOff: future, year: 2025, status: "upcoming", sport: { name: "NBA" } }, - { id: "season-2", name: "2025 F1 Season", draftOn: today, draftOff: future, year: 2025, status: "upcoming", sport: { name: "F1" } }, - { id: "season-3", name: "2024 Old Golf", draftOn: pastStart, draftOff: pastEnd, year: 2024, status: "completed", sport: { name: "Golf" } }, + { id: "season-1", name: "2025 NBA Playoffs", draftOn: today, draftOff: future, year: 2025, status: "upcoming", sport: { name: "NBA" }, participants: [] }, + { id: "season-2", name: "2025 F1 Season", draftOn: today, draftOff: future, year: 2025, status: "upcoming", sport: { name: "F1" }, participants: [] }, + { id: "season-3", name: "2024 Old Golf", draftOn: pastStart, draftOff: pastEnd, year: 2024, status: "completed", sport: { name: "Golf" }, participants: [] }, ]; -function makeMockDb(seasons: typeof mockSeasons) { +function makeMockDb(seasons: Record[]) { return { query: { sportsSeasons: { diff --git a/app/models/sports-season.ts b/app/models/sports-season.ts index e6a5e17..ccb6470 100644 --- a/app/models/sports-season.ts +++ b/app/models/sports-season.ts @@ -122,10 +122,10 @@ export async function findAllSportsSeasons(): Promise { }); } -export async function findDraftableSportsSeasons(): Promise { +export async function findDraftableSportsSeasons() { const db = database(); const today = sql`CURRENT_DATE`; - return await db.query.sportsSeasons.findMany({ + const seasons = await db.query.sportsSeasons.findMany({ where: (ss, { and }) => and( lte(ss.draftOn, today), @@ -134,8 +134,13 @@ export async function findDraftableSportsSeasons(): Promise { orderBy: (sportsSeasons, { desc, asc }) => [desc(sportsSeasons.year), asc(sportsSeasons.name)], with: { sport: true, + participants: { columns: { id: true } }, }, }); + return seasons.map(({ participants, ...s }) => ({ + ...s, + participantCount: participants.length, + })); } export async function updateSportsSeason( diff --git a/app/routes.ts b/app/routes.ts index 5d20319..8a28d70 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -4,6 +4,7 @@ export default [ index("routes/home.tsx"), route("i/:inviteCode", "routes/i.$inviteCode.tsx"), route("leagues/new", "routes/leagues/new.tsx"), + route("leagues/creating", "routes/leagues/creating.tsx"), route("leagues/:leagueId", "routes/leagues/$leagueId.tsx"), route("leagues/:leagueId/settings", "routes/leagues/$leagueId.settings.tsx"), route("leagues/:leagueId/audit-log", "routes/leagues/$leagueId.audit-log.tsx"), diff --git a/app/routes/leagues/$leagueId.settings.tsx b/app/routes/leagues/$leagueId.settings.tsx index d473ee3..1b5e74d 100644 --- a/app/routes/leagues/$leagueId.settings.tsx +++ b/app/routes/leagues/$leagueId.settings.tsx @@ -27,6 +27,7 @@ import { clearAllQueuesForSeason } from "~/models/draft-queue"; import { deleteSeasonTimers } from "~/models/draft-timer"; import { logCommissionerAction } from "~/models/audit-log"; import { parseDraftSpeed } from "~/lib/draft-timer"; +import { type ScoringRules, DEFAULT_SCORING_RULES } from "~/lib/scoring-types"; import type { Route } from "./+types/$leagueId.settings"; import { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; @@ -65,7 +66,10 @@ import { AlertDialogTitle, AlertDialogTrigger, } from "~/components/ui/alert-dialog"; -import { ScoringRulesEditor } from "~/components/scoring/ScoringRulesEditor"; +import { TimerModeSelector } from "~/components/league/TimerModeSelector"; +import { DraftSpeedPicker } from "~/components/league/DraftSpeedPicker"; +import { OvernightPauseSettings } from "~/components/league/OvernightPauseSettings"; +import { ScoringPresetPicker, OMNIFANTASY_SCORING } from "~/components/league/ScoringPresetPicker"; import { toast } from "sonner"; function isValidHHMM(s: string): boolean { @@ -315,42 +319,6 @@ export async function action(args: Route.ActionArgs) { return { error: "No active season found" }; } - if (intent === "update-sports") { - if (season.status !== "pre_draft") { - return { error: "Cannot modify sports after draft has started" }; - } - - const selectedSports = formData.getAll("sportsSeasons"); - const currentSportIds = new Set( - season.seasonSports?.map((s) => s.sportsSeason.id) || [] - ); - const newSportIds = new Set(selectedSports as string[]); - - // Remove sports that are no longer selected - for (const sportId of currentSportIds) { - if (!newSportIds.has(sportId)) { - await unlinkSportFromSeason(season.id, sportId); - } - } - - // Add newly selected sports - const sportsToAdd = []; - for (const sportId of newSportIds) { - if (!currentSportIds.has(sportId)) { - sportsToAdd.push({ - seasonId: season.id, - sportsSeasonId: sportId as string, - }); - } - } - - if (sportsToAdd.length > 0) { - await linkMultipleSportsToSeason(sportsToAdd); - } - - return { success: true }; - } - if (intent === "set-draft-order") { if (season.status !== "pre_draft") { return { error: "Cannot modify draft order after draft has started" }; @@ -857,6 +825,43 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [timerMode, setTimerMode] = useState<"chess_clock" | "standard">(season?.draftTimerMode ?? "chess_clock"); const [overnightMode, setOvernightMode] = useState<"none" | "league" | "per_user">(season?.overnightPauseMode ?? "none"); + const [overnightStart, setOvernightStart] = useState(season?.overnightPauseStart ?? "23:00"); + const [overnightEnd, setOvernightEnd] = useState(season?.overnightPauseEnd ?? "07:00"); + const [overnightTimezone, setOvernightTimezone] = useState(season?.overnightPauseTimezone ?? commishTimezone ?? ""); + const [scoringPreset, setScoringPreset] = useState<"brackt" | "omnifantasy" | "custom">(() => { + const r: ScoringRules = { + pointsFor1st: season?.pointsFor1st ?? DEFAULT_SCORING_RULES.pointsFor1st, + pointsFor2nd: season?.pointsFor2nd ?? DEFAULT_SCORING_RULES.pointsFor2nd, + pointsFor3rd: season?.pointsFor3rd ?? DEFAULT_SCORING_RULES.pointsFor3rd, + pointsFor4th: season?.pointsFor4th ?? DEFAULT_SCORING_RULES.pointsFor4th, + pointsFor5th: season?.pointsFor5th ?? DEFAULT_SCORING_RULES.pointsFor5th, + pointsFor6th: season?.pointsFor6th ?? DEFAULT_SCORING_RULES.pointsFor6th, + pointsFor7th: season?.pointsFor7th ?? DEFAULT_SCORING_RULES.pointsFor7th, + pointsFor8th: season?.pointsFor8th ?? DEFAULT_SCORING_RULES.pointsFor8th, + }; + if (Object.entries(DEFAULT_SCORING_RULES).every(([k, v]) => r[k as keyof ScoringRules] === v)) return "brackt"; + if (Object.entries(OMNIFANTASY_SCORING).every(([k, v]) => r[k as keyof ScoringRules] === v)) return "omnifantasy"; + return "custom"; + }); + const [scoringRules, setScoringRules] = useState({ + pointsFor1st: season?.pointsFor1st ?? DEFAULT_SCORING_RULES.pointsFor1st, + pointsFor2nd: season?.pointsFor2nd ?? DEFAULT_SCORING_RULES.pointsFor2nd, + pointsFor3rd: season?.pointsFor3rd ?? DEFAULT_SCORING_RULES.pointsFor3rd, + pointsFor4th: season?.pointsFor4th ?? DEFAULT_SCORING_RULES.pointsFor4th, + pointsFor5th: season?.pointsFor5th ?? DEFAULT_SCORING_RULES.pointsFor5th, + pointsFor6th: season?.pointsFor6th ?? DEFAULT_SCORING_RULES.pointsFor6th, + pointsFor7th: season?.pointsFor7th ?? DEFAULT_SCORING_RULES.pointsFor7th, + pointsFor8th: season?.pointsFor8th ?? DEFAULT_SCORING_RULES.pointsFor8th, + }); + const initTimerMode = season?.draftTimerMode ?? "chess_clock"; + const [draftSpeed, setDraftSpeed] = useState(() => { + if (initTimerMode === "standard") return season?.draftIncrementTime?.toString() ?? "90"; + if (season?.draftInitialTime === 60 && season?.draftIncrementTime === 10) return "fast"; + if (season?.draftInitialTime === 120 && season?.draftIncrementTime === 15) return "standard"; + if (season?.draftInitialTime === 28800 && season?.draftIncrementTime === 3600) return "slow"; + if (season?.draftInitialTime === 43200 && season?.draftIncrementTime === 3600) return "very-slow"; + return "standard"; + }); const [selectedSports, setSelectedSports] = useState>( new Set(season?.seasonSports?.map((s) => s.sportsSeason.id) || []) ); @@ -1194,76 +1199,15 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
- - -

- Chess Clock: unused time carries over between picks. Standard: timer resets each pick. -

+ + +
- - -

- {timerMode === "standard" - ? "Time per pick — resets to this value after each selection" - : "Initial time bank + increment added after each pick"} -

- + + +
@@ -1277,142 +1221,48 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone Autodraft will still fire for teams that have it enabled. - -
- - -

- Per-user mode: each player sets their timezone in their profile. League-wide mode: one timezone applies to all teams. -

- {overnightMode === "per_user" && ( -
-

Each player's profile timezone is used to determine their overnight window. Players without one set fall back to the timezone below.

- {!commishTimezone && ( -

- You haven't set your timezone yet —{" "} - - set it in your profile - - . -

- )} -
- )} -
- -
-
- - -
-
- - -
-
- -
- - -
+ + + + + + {/* Scoring Rules */} - + + + Scoring Rules + + Points awarded for each finishing position in each sports season. + + + + + {(Object.entries(scoringRules) as [string, number][]).map(([k, v]) => ( + + ))} + + {/* Sports Seasons */} diff --git a/app/routes/leagues/$leagueId.standings.$seasonId.tsx b/app/routes/leagues/$leagueId.standings.$seasonId.tsx index 6144bd5..c21bede 100644 --- a/app/routes/leagues/$leagueId.standings.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.standings.$seasonId.tsx @@ -103,11 +103,10 @@ export async function loader(args: Route.LoaderArgs) { recentScoreEvents: enrichedScoreEvents, }; } catch (error) { + if (error instanceof Response) { + throw error; + } logger.error("Error loading standings:", error); - logger.error("Error details:", { - message: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - }); throw error; } } diff --git a/app/routes/leagues/creating.tsx b/app/routes/leagues/creating.tsx new file mode 100644 index 0000000..644b296 --- /dev/null +++ b/app/routes/leagues/creating.tsx @@ -0,0 +1,67 @@ +import { useEffect } from "react"; +import { redirect, useFetcher } from "react-router"; +import { Loader2 } from "lucide-react"; +import { auth } from "~/lib/auth.server"; +import { loadWizardState, clearWizardState, buildFormDataFromSnapshot } from "~/lib/wizard-state"; +import type { Route } from "./+types/creating"; + +export async function loader(args: Route.LoaderArgs) { + const session = await auth.api.getSession({ headers: args.request.headers }); + if (!session?.user) return redirect("/leagues/new"); + return null; +} + +export function meta(): Route.MetaDescriptors { + return [{ title: "Creating League… - Brackt" }]; +} + +export default function CreatingLeague() { + const fetcher = useFetcher(); + + useEffect(() => { + const saved = loadWizardState(); + if (!saved) { + window.location.replace("/leagues/new"); + return; + } + fetcher.submit(buildFormDataFromSnapshot(saved), { method: "post", action: "/leagues/new" }); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // Clear saved state only after a confirmed failure so the user can retry if + // the server returns an error (a successful submit navigates away immediately). + useEffect(() => { + if (fetcher.state === "idle" && fetcher.data !== null && fetcher.data !== undefined) { + clearWizardState(); + } + }, [fetcher.state, fetcher.data]); + + const submitError = + fetcher.data && typeof fetcher.data === "object" && "error" in fetcher.data + ? String((fetcher.data as { error: unknown }).error) + : null; + + if (submitError) { + return ( +
+
+

{submitError}

+ + Go back and try again + +
+
+ ); + } + + return ( +
+
+ +
+

Creating your league…

+

Logged in successfully. Just a moment.

+
+
+
+ ); +} diff --git a/app/routes/leagues/new.tsx b/app/routes/leagues/new.tsx index eb79e39..9744b08 100644 --- a/app/routes/leagues/new.tsx +++ b/app/routes/leagues/new.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from "react"; -import { Form, redirect, Link } from "react-router"; +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"; @@ -11,44 +12,87 @@ 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 } from "~/models/user"; +import { findUserById, updateUser } from "~/models/user"; import { generateUniqueTeamNames } from "~/utils/team-names"; import { format } from "date-fns"; -import { CalendarIcon, Check } from "lucide-react"; -import { Button } from "~/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "~/components/ui/card"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "~/components/ui/select"; +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 { Calendar } from "~/components/ui/calendar"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "~/components/ui/popover"; import { cn } from "~/lib/utils"; -import { parseDraftSpeed } from "~/lib/draft-timer"; -import { ScoringRulesEditor } from "~/components/scoring/ScoringRulesEditor"; -import { TimezoneSelect } from "~/components/league/TimezoneSelect"; +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; @@ -58,12 +102,12 @@ export async function loader(args: Route.LoaderArgs) { const templatesWithSports = await Promise.all( templates.map(async (template) => { - const templateWithSports = await findSeasonTemplateWithSportsSeasons(template.id); + const t = await findSeasonTemplateWithSportsSeasons(template.id); return { ...template, - sportsSeasonIds: templateWithSports?.seasonTemplateSports?.map( + sportsSeasonIds: t?.seasonTemplateSports?.map( (ts: { sportsSeason: { id: string } }) => ts.sportsSeason.id - ) || [] + ) ?? [], }; }) ); @@ -73,12 +117,15 @@ export async function loader(args: Route.LoaderArgs) { : null; return { - templates: templatesWithSports, - allSportsSeasons: allSportsSeasons as Array, + 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; @@ -208,6 +255,11 @@ export async function action(args: Route.ActionArgs) { 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); @@ -215,437 +267,1014 @@ export async function action(args: Route.ActionArgs) { } } -export default function NewLeague({ loaderData, actionData }: Route.ComponentProps) { - const { templates, allSportsSeasons, commishTimezone } = loaderData; - const [selectedTemplate, setSelectedTemplate] = useState(""); - const [selectedSports, setSelectedSports] = useState>(new Set()); - const [draftRounds, setDraftRounds] = useState(20); - const [draftDate, setDraftDate] = useState(); - const [draftTime, setDraftTime] = useState(""); - const [timerMode, setTimerMode] = useState<"chess_clock" | "standard">("chess_clock"); - const [overnightMode, setOvernightMode] = useState<"none" | "league" | "per_user">("none"); - const [overnightTimezone, setOvernightTimezone] = useState(commishTimezone ?? ""); +// ─── Step 1: League Basics ──────────────────────────────────────────────────── - useEffect(() => { - if (!overnightTimezone) { - const detected = Intl.DateTimeFormat().resolvedOptions().timeZone; - if (detected) setOvernightTimezone(detected); - } - }, [overnightTimezone]); - - const handleSportToggle = (sportId: string) => { - const newSelected = new Set(selectedSports); - if (newSelected.has(sportId)) { - newSelected.delete(sportId); - } else { - newSelected.add(sportId); - } - setSelectedSports(newSelected); - }; - - const handleTemplateClick = (templateId: string, sportsSeasonIds: string[]) => { - setSelectedTemplate(templateId); - setSelectedSports(new Set(sportsSeasonIds)); - }; - - const sportsCount = selectedSports.size; - const minRounds = sportsCount; - const recommendedRounds = Math.ceil(sportsCount * 1.25); - const flexSpots = Math.max(0, draftRounds - sportsCount); +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 ( -
- - - Start a New League - - Create your fantasy league and invite your friends to compete. - - - -
-
- - -
+
+
+

+ 1 + League Basics +

+
-
- - -

- Choose between 6 and 16 teams -

-
+
+ + setLeagueName(e.target.value)} + onBlur={() => setNameTouched(true)} + minLength={3} + maxLength={50} + required + aria-invalid={nameInvalid || undefined} + /> +
-
- - -
-

- Minimum: {minRounds} (number of sports selected) - {recommendedRounds > minRounds && ` • Recommended: ${recommendedRounds}`} -

- {sportsCount > 0 && draftRounds < sportsCount && ( -

- ⚠️ You need at least {sportsCount} rounds for the {sportsCount} sports selected -

- )} - {sportsCount > 0 && draftRounds >= sportsCount && ( -

- Flex Spots: {flexSpots} -

- )} -
-
+
+ + +

Choose between 6 and 16 teams

+
-
- -
- - - - - - date < new Date(new Date().setHours(0, 0, 0, 0))} - /> - - - setDraftTime(e.target.value)} - placeholder="Select time" - /> -
- {draftDate && draftTime && ( - - )} -

- You must set a draft date and time before starting the draft -

-
+
+ setJoinAsPlayer(v !== true)} + /> + +
-
- - -

- Chess Clock: unused time carries over between picks. Standard: timer resets each pick. -

-
+ {error && ( +

{error}

+ )} -
- - -

- {timerMode === "standard" - ? "Time per pick — resets to this value after each selection" - : "Initial time bank + increment added after each pick"} -

-
+
+ + +
-
-
- - -

- Protect players from having their pick timer expire while they're asleep. Autodraft still fires normally. -

-
- - {overnightMode === "per_user" && ( -
-

Each player's profile timezone is used to determine their overnight window. Players without one set fall back to the timezone below.

- {!commishTimezone && ( -

- You haven't set your timezone yet —{" "} - - set it in your profile - - . -

- )} -
- )} - - {overnightMode !== "none" && ( - <> -
-
- - -
-
- - -
-
- -
- - - {overnightMode === "per_user" && ( -

- Each player sets their timezone in their profile. This is used as a fallback if they haven't set one. -

- )} -
- - )} -
- - - -
- -

- Choose a template to auto-select sports, or manually select your own -

- - - - {templates.length > 0 && ( -
- {templates.map((template) => ( - handleTemplateClick(template.id, template.sportsSeasonIds)} - > - -
-
- {template.name} - {template.year} -
- {selectedTemplate === template.id && ( -
- -
- )} -
-
- {template.description && ( - -

{template.description}

-
- )} -
- ))} -
- )} - -
-
-

- Recommendation: Select 16-20 sports seasons for optimal league balance and variety. -

-
-
- -
- {allSportsSeasons.length > 0 ? ( - allSportsSeasons.map((season) => ( -
- handleSportToggle(season.id)} - /> - -
- )) - ) : ( -

- No sports seasons available -

- )} -
-
-
-
- -
- - -
- - {actionData?.error && ( -
- {actionData.error} -
- )} - -
- - -
- - - +
+ ); +} + +// ─── 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; + setSelectedSports: (v: Set) => 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 ( +
+
+

+ 2 + Sports +

+
+ + {templates.length > 0 && ( +
+ {templates.map((t) => ( + + ))} + +
+ )} + + {/* Named template: read-only alphabetical sport list */} + {selectedTemplate !== "" && selectedTemplate !== "customize" && ( +
+
    + {Object.values( + displaySeasons.reduce>((acc, s) => { + acc[s.sport.name] = s.sport; + return acc; + }, {}) + ).toSorted((a, b) => a.name.localeCompare(b.name)).map((sport) => ( +
  • + + {sport.name} +
  • + ))} +
+

+ These are the sport seasons included in this template. Switch to Customize to adjust your selection. +

+
+ )} + + {/* Customize: full interactive selector */} + {selectedTemplate === "customize" && ( +
+ {/* Selected chips */} + {selectedSports.size > 0 && ( +
+
+

{selectedSports.size} selected

+ +
+
+ {allSportsSeasons + .filter((s) => selectedSports.has(s.id)) + .toSorted((a, b) => a.sport.name.localeCompare(b.sport.name)) + .map((s) => ( + + + {s.sport.name} {s.year} + + + ))} +
+
+ )} + + {/* Full sport picker */} +
+
+ {grouped.map(({ category, seasons }) => ( +
+

+ {category} +

+
+ {seasons.map((s) => { + const selected = selectedSports.has(s.id); + return ( + + ); + })} +
+
+ ))} + {grouped.length === 0 && ( +

No sports seasons available.

+ )} +
+
+
+
+ )} + + {error &&

{error}

} + +
+ + +
+ + {selectedSports.size === 0 && ( +

+ Select at least one sport season to continue. +

+ )} +
+ ); +} + +// ─── 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 ( +
+
+

+ 3 + Draft Settings +

+
+ + {/* Draft Rounds */} +
+ + +

+ Minimum: {minRounds} (based on sports selected) + {sportsCount > 0 && ` · Flex spots: ${flexSpots}`} +

+
+ + {/* Draft Date & Time */} +
+ +
+ setDraftDate(e.target.value)} + min={new Date().toISOString().slice(0, 10)} + /> + setDraftTime(e.target.value)} + /> +
+

+ This can be changed before the draft starts. +

+
+ + {/* Timer Mode */} +
+ + +
+ + {/* Draft Speed */} +
+ + +
+ + {/* Overnight Pause */} + + + {error &&

{error}

} + +
+ + +
+
+ ); +} + +// ─── 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 ( +
+
+

+ 4 + Scoring +

+

+ Points awarded to teams based on how their drafted participants finish in each sport's season. +

+
+ + + +
+ + +
+
+ ); +} + +// ─── 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; + 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 ( +
+
+

+ 5 + Review +

+

+ Everything look good? You can edit any section before creating. +

+
+ + onEditStep(1)}> +
+

{leagueName}

+
+ {teamCount} teams + {!joinAsPlayer && Commish only} +
+
+
+ + onEditStep(2)}> +

+ {uniqueSportsCount} sport{uniqueSportsCount !== 1 ? "s" : ""} +

+
    + {selectedSeasons.toSorted((a, b) => a.sport.name.localeCompare(b.sport.name)).map((s) => ( +
  • + + {s.sport.name} {s.year} +
  • + ))} +
+
+ + onEditStep(3)}> +
+
Rounds
+
{draftRounds}
+
Date & Time
+
{draftDateTimeStr}
+
Timer
+
{timerMode === "chess_clock" ? "Chess Clock" : "Standard"}
+
Speed
+
{formatDraftSpeed(draftSpeed, timerMode)}
+
Pause
+
+ {overnightModeLine} + {overnightTimeLine &&
{overnightTimeLine}
} +
+
+
+ + onEditStep(4)}> +

+ {scoringPreset === "brackt" ? "Brackt Default" : scoringPreset === "omnifantasy" ? "Omnifantasy Default" : "Custom"} +

+
+ {PLACEMENT_LABELS.map(({ key, label, color }) => { + const val = scoringRules[key]; + const pct = maxPoints > 0 ? (val / maxPoints) * 100 : 0; + return ( +
+ {label} +
+
+
+ {val} +
+ ); + })} +
+ + + {!isAuthenticated && ( + + )} + + {error &&

{error}

} + +
+ + +
+
+ ); +} + +// ─── 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(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>(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({ ...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(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 ( +
+
+
+

Start a New League

+

+ Create your fantasy league and invite your friends to compete. +

+
+ + + + + + {step === 1 && ( + + )} + {step === 2 && ( + + )} + {step === 3 && ( + { + 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 && ( + + )} + {step === 5 && ( + + )} + + + +
); }