Extract reusable league wizard components; wire settings page (#350)
* Extract reusable league wizard components; wire settings page (#103) Extracts 9 domain components from new.tsx and $leagueId.settings.tsx into app/components/league/ (each with a Storybook story), consolidates wizard form-building into wizard-state.ts, and updates the settings page to use the shared components instead of hand-rolled duplicates. new.tsx shrinks from ~2000 → ~1280 lines. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix sports-season test: add participants to mock data findDraftableSportsSeasons now returns participantCount, which requires participants in the mock db response. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix sports-season test: loosen makeMockDb type to Record<string, unknown>[] typeof mockSeasons became too strict after adding participants to the mock data, breaking inline arrays in other tests that don't include participants. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
dbc23f14ce
commit
e201ecd28a
27 changed files with 2521 additions and 700 deletions
36
app/components/league/DraftSpeedPicker.stories.tsx
Normal file
36
app/components/league/DraftSpeedPicker.stories.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { DraftSpeedPicker } from "./DraftSpeedPicker";
|
||||
|
||||
const meta: Meta<typeof DraftSpeedPicker> = {
|
||||
title: "League/DraftSpeedPicker",
|
||||
component: DraftSpeedPicker,
|
||||
parameters: { layout: "padded" },
|
||||
args: { onChange: () => {} },
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof DraftSpeedPicker>;
|
||||
|
||||
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 },
|
||||
};
|
||||
281
app/components/league/DraftSpeedPicker.tsx
Normal file
281
app/components/league/DraftSpeedPicker.tsx
Normal file
|
|
@ -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<string, string> = {
|
||||
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 (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{STANDARD_PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.value}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(p.value)}
|
||||
className={cn(
|
||||
"py-2.5 rounded-lg border-2 text-sm font-semibold transition-all",
|
||||
value === p.value ? "border-primary" : "border-border hover:border-primary/60",
|
||||
disabled && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(isCustom ? value : "90")}
|
||||
className={cn(
|
||||
"py-2.5 rounded-lg border-2 text-sm font-semibold transition-all",
|
||||
isCustom ? "border-primary" : "border-border hover:border-primary/60",
|
||||
disabled && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
Custom
|
||||
</button>
|
||||
</div>
|
||||
{isCustom && (
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={stdCustomAmt}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(String(displayToSec(e.target.value, stdCustomUnit) || 90))}
|
||||
className="w-24"
|
||||
/>
|
||||
<UnitSelect
|
||||
value={stdCustomUnit}
|
||||
onChange={(u) => onChange(String(displayToSec(String(stdCustomAmt), u) || 90))}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">per pick</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
{CHESS_PRESETS.map((p) => {
|
||||
const selected = value === p.value;
|
||||
return (
|
||||
<button
|
||||
key={p.value}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(p.value)}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between px-4 py-3 rounded-lg border-2 transition-all",
|
||||
selected ? "border-primary" : "border-border hover:border-primary/60",
|
||||
disabled && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<GradientIcon
|
||||
icon={p.icon}
|
||||
className="h-5 w-5 shrink-0"
|
||||
style={selected ? undefined : { stroke: "var(--muted-foreground)" }}
|
||||
/>
|
||||
<div className="text-left">
|
||||
<p className="font-semibold text-sm">{p.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{p.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<p className="text-xs uppercase tracking-wider text-muted-foreground">Bank</p>
|
||||
<p className="text-base font-bold">{formatSec(p.bankSec)}</p>
|
||||
</div>
|
||||
<div className="w-px h-8 bg-border" />
|
||||
<div className="text-right">
|
||||
<p className="text-xs uppercase tracking-wider text-muted-foreground">+/Pick</p>
|
||||
<p className="text-base font-bold text-primary">{formatSec(p.incrSec)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(isCustom ? value : `custom:${bankSec}:${incrSec}`)}
|
||||
className={cn(
|
||||
"w-full py-2.5 rounded-lg border-2 text-sm font-semibold transition-all",
|
||||
isCustom ? "border-primary" : "border-border hover:border-primary/60",
|
||||
disabled && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
Custom
|
||||
</button>
|
||||
</div>
|
||||
{isCustom && (
|
||||
<div className="grid grid-cols-2 gap-3 pt-1">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Time Bank</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={bankAmt}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(`custom:${displayToSec(e.target.value, bankUnit) || 120}:${incrSec}`)}
|
||||
className="w-20"
|
||||
/>
|
||||
<UnitSelect
|
||||
value={bankUnit}
|
||||
onChange={(u) => onChange(`custom:${displayToSec(String(bankAmt), u) || 120}:${incrSec}`)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Per-pick Increment</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={incrAmt}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(`custom:${bankSec}:${displayToSec(e.target.value, incrUnit) || 15}`)}
|
||||
className="w-20"
|
||||
/>
|
||||
<UnitSelect
|
||||
value={incrUnit}
|
||||
onChange={(u) => onChange(`custom:${bankSec}:${displayToSec(String(incrAmt), u) || 15}`)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UnitSelect({ value, onChange, disabled }: { value: TimeUnit; onChange: (v: TimeUnit) => void; disabled?: boolean }) {
|
||||
return (
|
||||
<Select value={value} onValueChange={(v) => onChange(v as TimeUnit)} disabled={disabled}>
|
||||
<SelectTrigger className="w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="sec">sec</SelectItem>
|
||||
<SelectItem value="min">min</SelectItem>
|
||||
<SelectItem value="hr">hr</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
45
app/components/league/OvernightPauseSettings.stories.tsx
Normal file
45
app/components/league/OvernightPauseSettings.stories.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { OvernightPauseSettings } from "./OvernightPauseSettings";
|
||||
|
||||
const meta: Meta<typeof OvernightPauseSettings> = {
|
||||
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<typeof OvernightPauseSettings>;
|
||||
|
||||
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 },
|
||||
};
|
||||
121
app/components/league/OvernightPauseSettings.tsx
Normal file
121
app/components/league/OvernightPauseSettings.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="space-y-4">
|
||||
<Label>Overnight Pause</Label>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{PAUSE_MODES.map(({ value, icon: Icon, label, sub }) => {
|
||||
const selected = mode === value;
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onModeChange(value)}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1 py-3 px-2 rounded-lg border-2 transition-all",
|
||||
selected ? "border-primary" : "border-border hover:border-primary/60",
|
||||
disabled && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<GradientIcon
|
||||
icon={Icon}
|
||||
className="h-5 w-5"
|
||||
style={selected ? undefined : { stroke: "var(--muted-foreground)" }}
|
||||
/>
|
||||
<span className="text-xs font-semibold">{label}</span>
|
||||
<span className="text-xs text-muted-foreground text-center leading-tight">{sub}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{mode !== "none" && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Moon className="h-3.5 w-3.5 text-indigo-400 shrink-0" />
|
||||
<Label className="text-xs">Draft pauses</Label>
|
||||
</div>
|
||||
<Input
|
||||
type="time"
|
||||
value={start}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onStartChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Sun className="h-3.5 w-3.5 text-yellow-400 shrink-0" />
|
||||
<Label className="text-xs">Draft resumes</Label>
|
||||
</div>
|
||||
<Input
|
||||
type="time"
|
||||
value={end}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onEndChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-sm">
|
||||
{mode === "per_user" ? "Default Timezone" : "Timezone"}
|
||||
</Label>
|
||||
{mode === "per_user" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Each player's profile timezone is used. Players without one fall back to the timezone below.
|
||||
{!commishTimezone && (
|
||||
<> You haven't set your timezone yet — <a href="/profile" className="font-medium underline underline-offset-2">set it in your profile</a>.</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<TimezoneSelect value={timezone} onChange={onTimezoneChange} disabled={disabled} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
app/components/league/ReviewSection.stories.tsx
Normal file
28
app/components/league/ReviewSection.stories.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Trophy } from "lucide-react";
|
||||
import { ReviewSection } from "./ReviewSection";
|
||||
|
||||
const meta: Meta<typeof ReviewSection> = {
|
||||
title: "League/ReviewSection",
|
||||
component: ReviewSection,
|
||||
parameters: { layout: "centered" },
|
||||
args: {
|
||||
icon: Trophy,
|
||||
title: "Sports",
|
||||
onEdit: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ReviewSection>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: (
|
||||
<ul className="space-y-1 text-sm">
|
||||
<li>NBA 2025</li>
|
||||
<li>NFL 2025</li>
|
||||
</ul>
|
||||
),
|
||||
},
|
||||
};
|
||||
28
app/components/league/ReviewSection.tsx
Normal file
28
app/components/league/ReviewSection.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="rounded-lg border border-border p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
<GradientIcon icon={icon} className="h-4 w-4" />
|
||||
{title}
|
||||
</h3>
|
||||
<Button type="button" variant="outline" size="sm" onClick={onEdit} className="h-7 text-xs px-3">
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
app/components/league/ScoringPresetPicker.stories.tsx
Normal file
32
app/components/league/ScoringPresetPicker.stories.tsx
Normal file
|
|
@ -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<typeof ScoringPresetPicker> = {
|
||||
title: "League/ScoringPresetPicker",
|
||||
component: ScoringPresetPicker,
|
||||
parameters: { layout: "padded" },
|
||||
args: { onPresetChange: () => {}, onRulesChange: () => {} },
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ScoringPresetPicker>;
|
||||
|
||||
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 },
|
||||
};
|
||||
108
app/components/league/ScoringPresetPicker.tsx
Normal file
108
app/components/league/ScoringPresetPicker.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="space-y-4">
|
||||
{/* Preset tabs */}
|
||||
<div className="flex rounded-lg border border-border overflow-hidden">
|
||||
{(["brackt", "omnifantasy", "custom"] as const).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => p !== "custom" ? applyPreset(p) : onPresetChange("custom")}
|
||||
className={cn(
|
||||
"flex-1 py-2 text-sm font-medium transition-colors",
|
||||
preset === p
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-accent",
|
||||
disabled && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
{p === "brackt" ? "Brackt Default" : p === "omnifantasy" ? "Omnifantasy Default" : "Custom"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Placement fields */}
|
||||
<div className="space-y-3">
|
||||
{PLACEMENT_LABELS.map(({ key, label, color }) => {
|
||||
const val = rules[key];
|
||||
const barWidth = maxPoints > 0 ? (val / maxPoints) * 100 : 0;
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-3">
|
||||
<span className={cn("text-sm font-medium w-8 shrink-0", color)}>{label}</span>
|
||||
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary rounded-full transition-all duration-200"
|
||||
style={{ width: `${barWidth}%` }}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
value={val}
|
||||
disabled={disabled}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
app/components/league/SportIcon.stories.tsx
Normal file
42
app/components/league/SportIcon.stories.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { SportIcon } from "./SportIcon";
|
||||
|
||||
const meta: Meta<typeof SportIcon> = {
|
||||
title: "League/SportIcon",
|
||||
component: SportIcon,
|
||||
parameters: { layout: "centered" },
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SportIcon>;
|
||||
|
||||
// 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",
|
||||
},
|
||||
};
|
||||
20
app/components/league/SportIcon.tsx
Normal file
20
app/components/league/SportIcon.tsx
Normal file
|
|
@ -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 (
|
||||
<img
|
||||
src={src}
|
||||
alt={sport.name}
|
||||
className={`${cls} object-contain shrink-0`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <span className={`${cls} rounded-sm bg-muted shrink-0 inline-block`} aria-hidden />;
|
||||
}
|
||||
28
app/components/league/StepperInput.stories.tsx
Normal file
28
app/components/league/StepperInput.stories.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { StepperInput } from "./StepperInput";
|
||||
|
||||
const meta: Meta<typeof StepperInput> = {
|
||||
title: "League/StepperInput",
|
||||
component: StepperInput,
|
||||
parameters: { layout: "centered" },
|
||||
args: {
|
||||
min: 6,
|
||||
max: 16,
|
||||
onChange: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof StepperInput>;
|
||||
|
||||
export const Middle: Story = {
|
||||
args: { value: 10 },
|
||||
};
|
||||
|
||||
export const AtMinimum: Story = {
|
||||
args: { value: 6 },
|
||||
};
|
||||
|
||||
export const AtMaximum: Story = {
|
||||
args: { value: 16 },
|
||||
};
|
||||
41
app/components/league/StepperInput.tsx
Normal file
41
app/components/league/StepperInput.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(Math.max(min, value - 1))}
|
||||
className="w-9 h-9 rounded-md border border-input flex items-center justify-center text-lg font-medium hover:bg-accent transition-colors"
|
||||
aria-label={decrementLabel}
|
||||
disabled={value <= min}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="w-10 text-center text-lg font-semibold tabular-nums">{value}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(Math.min(max, value + 1))}
|
||||
className="w-9 h-9 rounded-md border border-input flex items-center justify-center text-lg font-medium hover:bg-accent transition-colors"
|
||||
aria-label={incrementLabel}
|
||||
disabled={value >= max}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
app/components/league/TimerModeSelector.stories.tsx
Normal file
24
app/components/league/TimerModeSelector.stories.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { TimerModeSelector } from "./TimerModeSelector";
|
||||
|
||||
const meta: Meta<typeof TimerModeSelector> = {
|
||||
title: "League/TimerModeSelector",
|
||||
component: TimerModeSelector,
|
||||
parameters: { layout: "padded" },
|
||||
args: { onChange: () => {} },
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof TimerModeSelector>;
|
||||
|
||||
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 },
|
||||
};
|
||||
59
app/components/league/TimerModeSelector.tsx
Normal file
59
app/components/league/TimerModeSelector.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{MODES.map((mode) => {
|
||||
const selected = value === mode.value;
|
||||
return (
|
||||
<button
|
||||
key={mode.value}
|
||||
type="button"
|
||||
onClick={() => onChange(mode.value)}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-all",
|
||||
selected ? "border-primary" : "border-border hover:border-primary/60",
|
||||
disabled && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<GradientIcon
|
||||
icon={mode.icon}
|
||||
className="h-7 w-7"
|
||||
style={selected ? undefined : { stroke: "var(--muted-foreground)" }}
|
||||
/>
|
||||
<span className={cn("text-sm font-semibold", !selected && "text-muted-foreground")}>
|
||||
{mode.label}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground text-center leading-tight">
|
||||
{mode.desc}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
app/components/league/WizardAuthForm.stories.tsx
Normal file
43
app/components/league/WizardAuthForm.stories.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { WizardAuthForm } from "./WizardAuthForm";
|
||||
|
||||
const meta: Meta<typeof WizardAuthForm> = {
|
||||
title: "League/WizardAuthForm",
|
||||
component: WizardAuthForm,
|
||||
parameters: { layout: "centered" },
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="w-[420px]">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
args: {
|
||||
setAuthTab: () => {},
|
||||
displayName: "",
|
||||
setDisplayName: () => {},
|
||||
authEmail: "",
|
||||
setAuthEmail: () => {},
|
||||
authPassword: "",
|
||||
setAuthPassword: () => {},
|
||||
userTimezone: "America/New_York",
|
||||
setUserTimezone: () => {},
|
||||
onSocialLogin: () => {},
|
||||
submitting: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof WizardAuthForm>;
|
||||
|
||||
export const CreateTab: Story = {
|
||||
args: { authTab: "create" },
|
||||
};
|
||||
|
||||
export const LoginTab: Story = {
|
||||
args: { authTab: "login" },
|
||||
};
|
||||
|
||||
export const Submitting: Story = {
|
||||
args: { authTab: "create", submitting: true },
|
||||
};
|
||||
157
app/components/league/WizardAuthForm.tsx
Normal file
157
app/components/league/WizardAuthForm.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="text-base font-semibold mb-3">Your Account</h3>
|
||||
|
||||
<div className="flex rounded-lg border border-border overflow-hidden mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAuthTab("create")}
|
||||
className={cn(
|
||||
"flex-1 py-2 text-sm font-medium transition-colors",
|
||||
authTab === "create" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
Create Account
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAuthTab("login")}
|
||||
className={cn(
|
||||
"flex-1 py-2 text-sm font-medium transition-colors",
|
||||
authTab === "login" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
Log In
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full flex items-center gap-2"
|
||||
onClick={() => onSocialLogin("discord")}
|
||||
disabled={submitting}
|
||||
>
|
||||
<svg className="w-4 h-4 shrink-0" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057c.002.022.015.043.032.055a19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z"/>
|
||||
</svg>
|
||||
{authTab === "create" ? "Create Account & League with Discord" : "Log in with Discord & Create League"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full flex items-center gap-2"
|
||||
onClick={() => onSocialLogin("google")}
|
||||
disabled={submitting}
|
||||
>
|
||||
<svg className="w-4 h-4 shrink-0" viewBox="0 0 24 24">
|
||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
||||
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"/>
|
||||
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||
</svg>
|
||||
{authTab === "create" ? "Create Account & League with Google" : "Log in with Google & Create League"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="flex-1 border-t border-border" />
|
||||
<span className="text-xs text-muted-foreground">or</span>
|
||||
<div className="flex-1 border-t border-border" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{authTab === "create" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="displayName">Display Name</Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
type="text"
|
||||
placeholder="Your name in the league"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
autoComplete="name"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="authEmail">Email</Label>
|
||||
<Input
|
||||
id="authEmail"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={authEmail}
|
||||
onChange={(e) => setAuthEmail(e.target.value)}
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="authPassword">Password</Label>
|
||||
<Input
|
||||
id="authPassword"
|
||||
type="password"
|
||||
placeholder={authTab === "create" ? "Choose a strong password" : "Your password"}
|
||||
value={authPassword}
|
||||
onChange={(e) => setAuthPassword(e.target.value)}
|
||||
autoComplete={authTab === "create" ? "new-password" : "current-password"}
|
||||
minLength={authTab === "create" ? 8 : undefined}
|
||||
/>
|
||||
</div>
|
||||
{authTab === "create" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="userTimezone">Timezone</Label>
|
||||
<TimezoneSelect
|
||||
value={userTimezone}
|
||||
onChange={setUserTimezone}
|
||||
name="userTimezone"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{authTab === "create" && (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
By creating an account you agree to the{" "}
|
||||
<a href="/terms" className="underline">Terms of Service</a> and{" "}
|
||||
<a href="/privacy" className="underline">Privacy Policy</a>.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
app/components/league/WizardStepper.stories.tsx
Normal file
27
app/components/league/WizardStepper.stories.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { WizardStepper } from "./WizardStepper";
|
||||
|
||||
const meta: Meta<typeof WizardStepper> = {
|
||||
title: "League/WizardStepper",
|
||||
component: WizardStepper,
|
||||
parameters: { layout: "padded" },
|
||||
args: {
|
||||
steps: ["Basics", "Sports", "Draft", "Scoring", "Review"],
|
||||
onStepClick: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof WizardStepper>;
|
||||
|
||||
export const Step1: Story = {
|
||||
args: { currentStep: 1 },
|
||||
};
|
||||
|
||||
export const Step3: Story = {
|
||||
args: { currentStep: 3 },
|
||||
};
|
||||
|
||||
export const LastStep: Story = {
|
||||
args: { currentStep: 5 },
|
||||
};
|
||||
63
app/components/league/WizardStepper.tsx
Normal file
63
app/components/league/WizardStepper.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center">
|
||||
{steps.map((label, i) => {
|
||||
const n = i + 1;
|
||||
const done = n < currentStep;
|
||||
const active = n === currentStep;
|
||||
return (
|
||||
<Fragment key={label}>
|
||||
{i > 0 && (
|
||||
<div className={cn("flex-1 h-0.5 transition-colors", done ? "bg-primary" : "bg-border")} />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => done && onStepClick(n)}
|
||||
className={cn(
|
||||
"shrink-0 w-9 h-9 rounded-full border-2 flex items-center justify-center text-sm font-semibold bg-background transition-colors",
|
||||
done && "bg-primary border-primary text-primary-foreground",
|
||||
active && !done && "border-primary text-primary",
|
||||
!done && !active && "border-border text-muted-foreground",
|
||||
done ? "cursor-pointer" : "cursor-default"
|
||||
)}
|
||||
>
|
||||
{done ? <Check className="h-4 w-4" /> : n}
|
||||
</button>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex mt-1.5">
|
||||
{steps.map((label, i) => {
|
||||
const n = i + 1;
|
||||
const done = n < currentStep;
|
||||
const active = n === currentStep;
|
||||
return (
|
||||
<Fragment key={label}>
|
||||
{i > 0 && <div className="flex-1" />}
|
||||
<div className="shrink-0 w-9 flex justify-center">
|
||||
<span className={cn(
|
||||
"text-xs hidden sm:block whitespace-nowrap",
|
||||
active ? "text-primary font-medium" : done ? "text-foreground" : "text-muted-foreground"
|
||||
)}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
71
app/lib/wizard-state.ts
Normal file
71
app/lib/wizard-state.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
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;
|
||||
}
|
||||
|
|
@ -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<string, unknown>[]) {
|
||||
return {
|
||||
query: {
|
||||
sportsSeasons: {
|
||||
|
|
|
|||
|
|
@ -122,10 +122,10 @@ export async function findAllSportsSeasons(): Promise<SportsSeasonListItem[]> {
|
|||
});
|
||||
}
|
||||
|
||||
export async function findDraftableSportsSeasons(): Promise<SportsSeason[]> {
|
||||
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<SportsSeason[]> {
|
|||
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(
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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<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,
|
||||
});
|
||||
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<Set<string>>(
|
||||
new Set(season?.seasonSports?.map((s) => s.sportsSeason.id) || [])
|
||||
);
|
||||
|
|
@ -1194,76 +1199,15 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="draftTimerMode">Timer Mode</Label>
|
||||
<select
|
||||
id="draftTimerMode"
|
||||
name="draftTimerMode"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={timerMode}
|
||||
onChange={(e) => setTimerMode(e.target.value as "chess_clock" | "standard")}
|
||||
disabled={!canEditDraftRounds}
|
||||
required
|
||||
>
|
||||
<option value="chess_clock">Chess Clock (accumulating bank)</option>
|
||||
<option value="standard">Standard (per-pick countdown)</option>
|
||||
</select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Chess Clock: unused time carries over between picks. Standard: timer resets each pick.
|
||||
</p>
|
||||
<Label>Timer Mode</Label>
|
||||
<TimerModeSelector value={timerMode} onChange={setTimerMode} disabled={!canEditDraftRounds} />
|
||||
<input type="hidden" name="draftTimerMode" value={timerMode} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="draftSpeed">Draft Speed</Label>
|
||||
<select
|
||||
id="draftSpeed"
|
||||
name="draftSpeed"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
defaultValue={
|
||||
timerMode === "standard"
|
||||
? (season?.draftIncrementTime?.toString() ?? "90")
|
||||
: season?.draftInitialTime === 60 && season?.draftIncrementTime === 10
|
||||
? "fast"
|
||||
: season?.draftInitialTime === 120 && season?.draftIncrementTime === 15
|
||||
? "standard"
|
||||
: season?.draftInitialTime === 28800 && season?.draftIncrementTime === 3600
|
||||
? "slow"
|
||||
: season?.draftInitialTime === 43200 && season?.draftIncrementTime === 3600
|
||||
? "very-slow"
|
||||
: "standard"
|
||||
}
|
||||
key={timerMode}
|
||||
disabled={!canEditDraftRounds}
|
||||
required
|
||||
>
|
||||
{timerMode === "standard" ? (
|
||||
<>
|
||||
<option value="15">15 seconds</option>
|
||||
<option value="30">30 seconds</option>
|
||||
<option value="60">1 minute</option>
|
||||
<option value="90">90 seconds (default)</option>
|
||||
<option value="120">2 minutes</option>
|
||||
<option value="900">15 minutes</option>
|
||||
<option value="3600">1 hour</option>
|
||||
<option value="7200">2 hours</option>
|
||||
<option value="14400">4 hours</option>
|
||||
<option value="28800">8 hours</option>
|
||||
<option value="43200">12 hours</option>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<option value="fast">Fast (1 min + 10 sec)</option>
|
||||
<option value="standard">Standard (2 min + 15 sec)</option>
|
||||
<option value="slow">Slow (8 hours + 1 hour)</option>
|
||||
<option value="very-slow">Very Slow (12 hours + 1 hour)</option>
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{timerMode === "standard"
|
||||
? "Time per pick — resets to this value after each selection"
|
||||
: "Initial time bank + increment added after each pick"}
|
||||
</p>
|
||||
|
||||
<Label>Draft Speed</Label>
|
||||
<DraftSpeedPicker timerMode={timerMode} value={draftSpeed} onChange={setDraftSpeed} disabled={!canEditDraftRounds} />
|
||||
<input type="hidden" name="draftSpeed" value={draftSpeed} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -1277,142 +1221,48 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
|
|||
Autodraft will still fire for teams that have it enabled.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="overnightPauseMode">Pause Mode</Label>
|
||||
<select
|
||||
id="overnightPauseMode"
|
||||
name="overnightPauseMode"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={overnightMode}
|
||||
onChange={(e) => setOvernightMode(e.target.value as "none" | "league" | "per_user")}
|
||||
disabled={!canEditDraftRounds}
|
||||
>
|
||||
<option value="none">No pause — timer always runs</option>
|
||||
<option value="league">League-wide — one timezone for everyone</option>
|
||||
<option value="per_user">Per-user — each player's own timezone</option>
|
||||
</select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Per-user mode: each player sets their timezone in their profile. League-wide mode: one timezone applies to all teams.
|
||||
</p>
|
||||
{overnightMode === "per_user" && (
|
||||
<div className={`rounded-md border px-3 py-2.5 text-sm space-y-1 ${commishTimezone ? "border-border bg-muted/40 text-muted-foreground" : "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-400"}`}>
|
||||
<p>Each player's profile timezone is used to determine their overnight window. Players without one set fall back to the timezone below.</p>
|
||||
{!commishTimezone && (
|
||||
<p>
|
||||
You haven't set your timezone yet —{" "}
|
||||
<a href="/profile" className="font-medium underline underline-offset-2">
|
||||
set it in your profile
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="overnightPauseStart">Pause starts at</Label>
|
||||
<input
|
||||
id="overnightPauseStart"
|
||||
name="overnightPauseStart"
|
||||
type="time"
|
||||
defaultValue={season?.overnightPauseStart ?? "23:00"}
|
||||
disabled={!canEditDraftRounds}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="overnightPauseEnd">Pause ends at</Label>
|
||||
<input
|
||||
id="overnightPauseEnd"
|
||||
name="overnightPauseEnd"
|
||||
type="time"
|
||||
defaultValue={season?.overnightPauseEnd ?? "07:00"}
|
||||
disabled={!canEditDraftRounds}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="overnightPauseTimezone">
|
||||
Timezone
|
||||
<span className="text-muted-foreground font-normal ml-1">
|
||||
(league mode) or fallback for users without one set (per-user mode)
|
||||
</span>
|
||||
</Label>
|
||||
<select
|
||||
id="overnightPauseTimezone"
|
||||
name="overnightPauseTimezone"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
defaultValue={season?.overnightPauseTimezone ?? commishTimezone ?? ""}
|
||||
disabled={!canEditDraftRounds}
|
||||
>
|
||||
<option value="">Select a timezone…</option>
|
||||
<optgroup label="North America">
|
||||
<option value="America/New_York">Eastern Time (ET)</option>
|
||||
<option value="America/Chicago">Central Time (CT)</option>
|
||||
<option value="America/Denver">Mountain Time (MT)</option>
|
||||
<option value="America/Phoenix">Mountain Time – Arizona (no DST)</option>
|
||||
<option value="America/Los_Angeles">Pacific Time (PT)</option>
|
||||
<option value="America/Anchorage">Alaska Time (AKT)</option>
|
||||
<option value="Pacific/Honolulu">Hawaii Time (HT)</option>
|
||||
<option value="America/Toronto">Eastern Time – Toronto</option>
|
||||
<option value="America/Vancouver">Pacific Time – Vancouver</option>
|
||||
<option value="America/Winnipeg">Central Time – Winnipeg</option>
|
||||
<option value="America/Halifax">Atlantic Time – Halifax</option>
|
||||
<option value="America/Mexico_City">Central Time – Mexico City</option>
|
||||
</optgroup>
|
||||
<optgroup label="South America">
|
||||
<option value="America/Sao_Paulo">Brasília Time (BRT)</option>
|
||||
<option value="America/Argentina/Buenos_Aires">Argentina Time (ART)</option>
|
||||
<option value="America/Bogota">Colombia Time (COT)</option>
|
||||
<option value="America/Santiago">Chile Time (CLT)</option>
|
||||
</optgroup>
|
||||
<optgroup label="Europe">
|
||||
<option value="Europe/London">GMT / British Time (GMT/BST)</option>
|
||||
<option value="Europe/Dublin">Irish Time (GMT/IST)</option>
|
||||
<option value="Europe/Paris">Central European Time (CET/CEST)</option>
|
||||
<option value="Europe/Berlin">Central European Time – Berlin</option>
|
||||
<option value="Europe/Moscow">Moscow Time (MSK)</option>
|
||||
</optgroup>
|
||||
<optgroup label="Asia / Middle East">
|
||||
<option value="Asia/Dubai">Gulf Standard Time (GST)</option>
|
||||
<option value="Asia/Kolkata">India Standard Time (IST)</option>
|
||||
<option value="Asia/Singapore">Singapore Time (SGT)</option>
|
||||
<option value="Asia/Shanghai">China Standard Time (CST)</option>
|
||||
<option value="Asia/Tokyo">Japan Standard Time (JST)</option>
|
||||
<option value="Asia/Seoul">Korea Standard Time (KST)</option>
|
||||
</optgroup>
|
||||
<optgroup label="Oceania">
|
||||
<option value="Australia/Sydney">Australian Eastern Time (AEST/AEDT)</option>
|
||||
<option value="Australia/Perth">Australian Western Time (AWST)</option>
|
||||
<option value="Pacific/Auckland">New Zealand Time (NZST/NZDT)</option>
|
||||
</optgroup>
|
||||
<optgroup label="UTC">
|
||||
<option value="UTC">UTC (Coordinated Universal Time)</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<CardContent>
|
||||
<OvernightPauseSettings
|
||||
show={true}
|
||||
mode={overnightMode}
|
||||
onModeChange={setOvernightMode}
|
||||
start={overnightStart}
|
||||
onStartChange={setOvernightStart}
|
||||
end={overnightEnd}
|
||||
onEndChange={setOvernightEnd}
|
||||
timezone={overnightTimezone}
|
||||
onTimezoneChange={setOvernightTimezone}
|
||||
commishTimezone={commishTimezone}
|
||||
disabled={!canEditDraftRounds}
|
||||
/>
|
||||
<input type="hidden" name="overnightPauseMode" value={overnightMode} />
|
||||
<input type="hidden" name="overnightPauseStart" value={overnightStart} />
|
||||
<input type="hidden" name="overnightPauseEnd" value={overnightEnd} />
|
||||
<input type="hidden" name="overnightPauseTimezone" value={overnightTimezone} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Scoring Rules */}
|
||||
<ScoringRulesEditor
|
||||
scoringRules={season ? {
|
||||
pointsFor1st: season.pointsFor1st,
|
||||
pointsFor2nd: season.pointsFor2nd,
|
||||
pointsFor3rd: season.pointsFor3rd,
|
||||
pointsFor4th: season.pointsFor4th,
|
||||
pointsFor5th: season.pointsFor5th,
|
||||
pointsFor6th: season.pointsFor6th,
|
||||
pointsFor7th: season.pointsFor7th,
|
||||
pointsFor8th: season.pointsFor8th,
|
||||
} : undefined}
|
||||
disabled={!season || season.status !== "pre_draft"}
|
||||
/>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Scoring Rules</CardTitle>
|
||||
<CardDescription>
|
||||
Points awarded for each finishing position in each sports season.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScoringPresetPicker
|
||||
preset={scoringPreset}
|
||||
onPresetChange={setScoringPreset}
|
||||
rules={scoringRules}
|
||||
onRulesChange={setScoringRules}
|
||||
disabled={!season || season.status !== "pre_draft"}
|
||||
/>
|
||||
{(Object.entries(scoringRules) as [string, number][]).map(([k, v]) => (
|
||||
<input key={k} type="hidden" name={k} value={v} />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Sports Seasons */}
|
||||
<Card>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
67
app/routes/leagues/creating.tsx
Normal file
67
app/routes/leagues/creating.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="text-center space-y-4 max-w-sm px-4">
|
||||
<p className="text-destructive font-medium">{submitError}</p>
|
||||
<a href="/leagues/new" className="text-sm underline text-muted-foreground">
|
||||
Go back and try again
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="text-center space-y-4">
|
||||
<Loader2 className="h-10 w-10 animate-spin mx-auto text-primary" />
|
||||
<div>
|
||||
<p className="text-lg font-semibold">Creating your league…</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">Logged in successfully. Just a moment.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue