* Add overnight pause feature for draft timers Protects players from having their pick timer expire while asleep. Admins configure a nightly window (league-wide or per-user timezone); the timer freezes during that window while autodraft can still fire. Fixes #66 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix TS error: guard getUserDisplayName against undefined user Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
651 lines
27 KiB
TypeScript
651 lines
27 KiB
TypeScript
import { useState, useEffect } from "react";
|
||
import { Form, redirect, Link } from "react-router";
|
||
import { auth } from "~/lib/auth.server";
|
||
import type { Route } from "./+types/new";
|
||
|
||
import { logger } from "~/lib/logger";
|
||
import { createLeague, setCurrentSeason } from "~/models/league";
|
||
import { createCommissioner } from "~/models/commissioner";
|
||
import { createSeason } from "~/models/season";
|
||
import { linkMultipleSportsToSeason } from "~/models/season-sport";
|
||
import { createManyTeams } from "~/models/team";
|
||
import { findActiveSeasonTemplates, findSeasonTemplateWithSportsSeasons } from "~/models/season-template";
|
||
import { findDraftableSportsSeasons } from "~/models/sports-season";
|
||
import { findUserById } 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 { Badge } from "~/components/ui/badge";
|
||
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";
|
||
|
||
export function meta(): Route.MetaDescriptors {
|
||
return [{ title: "New League - Brackt" }];
|
||
}
|
||
|
||
export async function loader(args: Route.LoaderArgs) {
|
||
const session = await auth.api.getSession({ headers: args.request.headers });
|
||
const userId = session?.user.id ?? null;
|
||
|
||
const templates = await findActiveSeasonTemplates();
|
||
const allSportsSeasons = await findDraftableSportsSeasons();
|
||
|
||
const templatesWithSports = await Promise.all(
|
||
templates.map(async (template) => {
|
||
const templateWithSports = await findSeasonTemplateWithSportsSeasons(template.id);
|
||
return {
|
||
...template,
|
||
sportsSeasonIds: templateWithSports?.seasonTemplateSports?.map(
|
||
(ts: { sportsSeason: { id: string } }) => ts.sportsSeason.id
|
||
) || []
|
||
};
|
||
})
|
||
);
|
||
|
||
const commishTimezone = userId
|
||
? ((await findUserById(userId))?.timezone ?? null)
|
||
: null;
|
||
|
||
return {
|
||
templates: templatesWithSports,
|
||
allSportsSeasons: allSportsSeasons as Array<typeof allSportsSeasons[0] & { sport: { id: string; name: string; type: string; slug: string } }>,
|
||
commishTimezone,
|
||
};
|
||
}
|
||
|
||
export async function action(args: Route.ActionArgs) {
|
||
const session = await auth.api.getSession({ headers: args.request.headers });
|
||
const userId = session?.user.id ?? null;
|
||
const { request } = args;
|
||
|
||
if (!userId) {
|
||
throw new Response("Unauthorized", { status: 401 });
|
||
}
|
||
|
||
const formData = await request.formData();
|
||
const name = formData.get("name");
|
||
const teamCount = formData.get("teamCount");
|
||
const templateId = formData.get("templateId");
|
||
const draftRounds = formData.get("draftRounds");
|
||
const draftDateTime = formData.get("draftDateTime");
|
||
const draftSpeed = formData.get("draftSpeed");
|
||
const draftTimerMode = (formData.get("draftTimerMode") as "chess_clock" | "standard") || "chess_clock";
|
||
|
||
const { draftInitialTime: draftInitialTimeNum, draftIncrementTime: draftIncrementTimeNum } =
|
||
parseDraftSpeed(draftSpeed as string | null, draftTimerMode);
|
||
|
||
if (typeof name !== "string" || !name.trim()) {
|
||
return { error: "League name is required" };
|
||
}
|
||
|
||
if (typeof teamCount !== "string") {
|
||
return { error: "Number of teams is required" };
|
||
}
|
||
|
||
const teamCountNum = parseInt(teamCount, 10);
|
||
if (isNaN(teamCountNum) || teamCountNum < 6 || teamCountNum > 16) {
|
||
return { error: "Number of teams must be between 6 and 16" };
|
||
}
|
||
|
||
const draftRoundsNum = typeof draftRounds === "string" ? parseInt(draftRounds, 10) : 20;
|
||
if (isNaN(draftRoundsNum) || draftRoundsNum < 1 || draftRoundsNum > 50) {
|
||
return { error: "Draft rounds must be between 1 and 50" };
|
||
}
|
||
|
||
const scoringFields = [
|
||
"pointsFor1st", "pointsFor2nd", "pointsFor3rd", "pointsFor4th",
|
||
"pointsFor5th", "pointsFor6th", "pointsFor7th", "pointsFor8th"
|
||
];
|
||
|
||
const scoringRules: Record<string, number> = {};
|
||
for (const field of scoringFields) {
|
||
const value = formData.get(field);
|
||
if (typeof value === "string") {
|
||
const points = parseInt(value, 10);
|
||
if (!isNaN(points)) {
|
||
if (points < 0 || points > 1000) {
|
||
return { error: `${field} must be between 0 and 1000 points` };
|
||
}
|
||
scoringRules[field] = points;
|
||
}
|
||
}
|
||
}
|
||
|
||
const rawPauseMode = formData.get("overnightPauseMode") as string | null;
|
||
const overnightPauseMode: "none" | "league" | "per_user" =
|
||
rawPauseMode === "league" || rawPauseMode === "per_user" ? rawPauseMode : "none";
|
||
const overnightPauseStart = overnightPauseMode !== "none"
|
||
? (formData.get("overnightPauseStart") as string | null) ?? "23:00"
|
||
: null;
|
||
const overnightPauseEnd = overnightPauseMode !== "none"
|
||
? (formData.get("overnightPauseEnd") as string | null) ?? "07:00"
|
||
: null;
|
||
const overnightPauseTimezone = overnightPauseMode !== "none"
|
||
? (formData.get("overnightPauseTimezone") as string | null) ?? null
|
||
: null;
|
||
|
||
try {
|
||
const league = await createLeague({
|
||
name: name.trim(),
|
||
createdBy: userId,
|
||
});
|
||
|
||
await createCommissioner({
|
||
leagueId: league.id,
|
||
userId: userId,
|
||
});
|
||
|
||
const currentYear = new Date().getFullYear();
|
||
const season = await createSeason({
|
||
leagueId: league.id,
|
||
year: currentYear,
|
||
status: "pre_draft",
|
||
templateId: typeof templateId === "string" && templateId !== "" ? templateId : null,
|
||
draftRounds: draftRoundsNum,
|
||
draftDateTime: typeof draftDateTime === "string" && draftDateTime ? new Date(draftDateTime) : null,
|
||
draftInitialTime: draftInitialTimeNum,
|
||
draftIncrementTime: draftIncrementTimeNum,
|
||
draftTimerMode,
|
||
overnightPauseMode,
|
||
overnightPauseStart,
|
||
overnightPauseEnd,
|
||
overnightPauseTimezone,
|
||
...scoringRules,
|
||
});
|
||
|
||
await setCurrentSeason(league.id, season.id);
|
||
|
||
const selectedSports = formData.getAll("sportsSeasons");
|
||
|
||
if (selectedSports.length > draftRoundsNum) {
|
||
return {
|
||
error: `You need at least ${selectedSports.length} draft rounds for the ${selectedSports.length} sports selected. Currently set to ${draftRoundsNum} rounds.`
|
||
};
|
||
}
|
||
|
||
if (selectedSports.length > 0) {
|
||
await linkMultipleSportsToSeason(
|
||
selectedSports.map((id) => ({
|
||
seasonId: season.id,
|
||
sportsSeasonId: id as string,
|
||
}))
|
||
);
|
||
}
|
||
|
||
const joinAsPlayer = formData.get("joinAsPlayer") === "on";
|
||
const teamNames = generateUniqueTeamNames(teamCountNum);
|
||
const teams = teamNames.map((teamName, i) => ({
|
||
seasonId: season.id,
|
||
name: teamName,
|
||
ownerId: joinAsPlayer && i === 0 ? userId : null,
|
||
}));
|
||
|
||
await createManyTeams(teams);
|
||
|
||
return redirect(`/leagues/${league.id}`);
|
||
} catch (error) {
|
||
logger.error("Error creating league:", error);
|
||
return { error: "Failed to create league. Please try again." };
|
||
}
|
||
}
|
||
|
||
export default function NewLeague({ loaderData, actionData }: Route.ComponentProps) {
|
||
const { templates, allSportsSeasons, commishTimezone } = loaderData;
|
||
const [selectedTemplate, setSelectedTemplate] = useState<string>("");
|
||
const [selectedSports, setSelectedSports] = useState<Set<string>>(new Set());
|
||
const [draftRounds, setDraftRounds] = useState<number>(20);
|
||
const [draftDate, setDraftDate] = useState<Date>();
|
||
const [draftTime, setDraftTime] = useState<string>("");
|
||
const [timerMode, setTimerMode] = useState<"chess_clock" | "standard">("chess_clock");
|
||
const [overnightMode, setOvernightMode] = useState<"none" | "league" | "per_user">("none");
|
||
const [overnightTimezone, setOvernightTimezone] = useState<string>(commishTimezone ?? "");
|
||
|
||
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);
|
||
|
||
return (
|
||
<div className="container max-w-2xl mx-auto py-8 px-4">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-3xl">Start a New League</CardTitle>
|
||
<CardDescription>
|
||
Create your fantasy league and invite your friends to compete.
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<Form method="post" className="space-y-6">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="name">League Name</Label>
|
||
<Input
|
||
id="name"
|
||
name="name"
|
||
type="text"
|
||
placeholder="Enter league name"
|
||
required
|
||
minLength={3}
|
||
maxLength={50}
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="teamCount">Number of Teams</Label>
|
||
<Select name="teamCount" defaultValue="12" required>
|
||
<SelectTrigger id="teamCount">
|
||
<SelectValue placeholder="Select number of teams" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="6">6 Teams</SelectItem>
|
||
<SelectItem value="7">7 Teams</SelectItem>
|
||
<SelectItem value="8">8 Teams</SelectItem>
|
||
<SelectItem value="9">9 Teams</SelectItem>
|
||
<SelectItem value="10">10 Teams</SelectItem>
|
||
<SelectItem value="11">11 Teams</SelectItem>
|
||
<SelectItem value="12">12 Teams</SelectItem>
|
||
<SelectItem value="13">13 Teams</SelectItem>
|
||
<SelectItem value="14">14 Teams</SelectItem>
|
||
<SelectItem value="15">15 Teams</SelectItem>
|
||
<SelectItem value="16">16 Teams</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<p className="text-sm text-muted-foreground">
|
||
Choose between 6 and 16 teams
|
||
</p>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="draftRounds">Draft Rounds</Label>
|
||
<Select
|
||
name="draftRounds"
|
||
value={draftRounds.toString()}
|
||
onValueChange={(value) => setDraftRounds(parseInt(value))}
|
||
required
|
||
>
|
||
<SelectTrigger id="draftRounds">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{Array.from({ length: 50 }, (_, i) => i + 1)
|
||
.filter(num => num >= minRounds)
|
||
.map((num) => (
|
||
<SelectItem
|
||
key={num}
|
||
value={num.toString()}
|
||
>
|
||
{num} Round{num !== 1 ? 's' : ''}
|
||
{num === recommendedRounds && ' (recommended)'}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<div className="space-y-1">
|
||
<p className="text-sm text-muted-foreground">
|
||
Minimum: {minRounds} (number of sports selected)
|
||
{recommendedRounds > minRounds && ` • Recommended: ${recommendedRounds}`}
|
||
</p>
|
||
{sportsCount > 0 && draftRounds < sportsCount && (
|
||
<p className="text-sm font-medium text-destructive">
|
||
⚠️ You need at least {sportsCount} rounds for the {sportsCount} sports selected
|
||
</p>
|
||
)}
|
||
{sportsCount > 0 && draftRounds >= sportsCount && (
|
||
<p className="text-sm font-medium text-primary">
|
||
Flex Spots: {flexSpots}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="draftDate">Draft Date & Time</Label>
|
||
<div className="grid gap-2 sm:grid-cols-2">
|
||
<Popover>
|
||
<PopoverTrigger asChild>
|
||
<Button
|
||
variant={"outline"}
|
||
className={cn(
|
||
"justify-start text-left font-normal",
|
||
!draftDate && "text-muted-foreground"
|
||
)}
|
||
>
|
||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||
{draftDate ? format(draftDate, "PPP") : <span>Pick a date</span>}
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent className="w-auto p-0">
|
||
<Calendar
|
||
mode="single"
|
||
selected={draftDate}
|
||
onSelect={setDraftDate}
|
||
initialFocus
|
||
disabled={(date) => date < new Date(new Date().setHours(0, 0, 0, 0))}
|
||
/>
|
||
</PopoverContent>
|
||
</Popover>
|
||
<Input
|
||
type="time"
|
||
value={draftTime}
|
||
onChange={(e) => setDraftTime(e.target.value)}
|
||
placeholder="Select time"
|
||
/>
|
||
</div>
|
||
{draftDate && draftTime && (
|
||
<input
|
||
type="hidden"
|
||
name="draftDateTime"
|
||
value={new Date(`${format(draftDate, "yyyy-MM-dd")}T${draftTime}`).toISOString()}
|
||
/>
|
||
)}
|
||
<p className="text-sm text-muted-foreground">
|
||
You must set a draft date and time before starting the draft
|
||
</p>
|
||
</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")}
|
||
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>
|
||
</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" ? "90" : "standard"}
|
||
key={timerMode}
|
||
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>
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="overnightPauseMode">Overnight Pause</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"
|
||
value={overnightMode}
|
||
onChange={(e) => setOvernightMode(e.target.value as "none" | "league" | "per_user")}
|
||
>
|
||
<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">
|
||
Protect players from having their pick timer expire while they're asleep. Autodraft still fires normally.
|
||
</p>
|
||
</div>
|
||
|
||
{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>
|
||
)}
|
||
|
||
{overnightMode !== "none" && (
|
||
<>
|
||
<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="23:00"
|
||
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"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="overnightPauseEnd">Pause ends at</Label>
|
||
<input
|
||
id="overnightPauseEnd"
|
||
name="overnightPauseEnd"
|
||
type="time"
|
||
defaultValue="07:00"
|
||
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"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="overnightPauseTimezone">
|
||
Timezone
|
||
{overnightMode === "per_user" && (
|
||
<span className="text-muted-foreground font-normal ml-1">
|
||
(fallback for users without one set)
|
||
</span>
|
||
)}
|
||
</Label>
|
||
<TimezoneSelect
|
||
value={overnightTimezone}
|
||
onChange={setOvernightTimezone}
|
||
name="overnightPauseTimezone"
|
||
/>
|
||
{overnightMode === "per_user" && (
|
||
<p className="text-sm text-muted-foreground">
|
||
Each player sets their timezone in their profile. This is used as a fallback if they haven't set one.
|
||
</p>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
<ScoringRulesEditor disabled={false} />
|
||
|
||
<div className="space-y-4">
|
||
<Label>Sports Selection</Label>
|
||
<p className="text-sm text-muted-foreground">
|
||
Choose a template to auto-select sports, or manually select your own
|
||
</p>
|
||
|
||
<input type="hidden" name="templateId" value={selectedTemplate} />
|
||
|
||
{templates.length > 0 && (
|
||
<div className="grid gap-3">
|
||
{templates.map((template) => (
|
||
<Card
|
||
key={template.id}
|
||
className={`cursor-pointer transition-all ${
|
||
selectedTemplate === template.id
|
||
? "ring-2 ring-primary"
|
||
: "hover:border-primary/50"
|
||
}`}
|
||
onClick={() => handleTemplateClick(template.id, template.sportsSeasonIds)}
|
||
>
|
||
<CardHeader className="pb-3">
|
||
<div className="flex items-start justify-between">
|
||
<div className="flex-1">
|
||
<CardTitle className="text-lg">{template.name}</CardTitle>
|
||
<CardDescription>{template.year}</CardDescription>
|
||
</div>
|
||
{selectedTemplate === template.id && (
|
||
<div className="rounded-full bg-primary text-primary-foreground p-1">
|
||
<Check className="h-4 w-4" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
</CardHeader>
|
||
{template.description && (
|
||
<CardContent className="pt-0">
|
||
<p className="text-sm text-muted-foreground">{template.description}</p>
|
||
</CardContent>
|
||
)}
|
||
</Card>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="space-y-3">
|
||
<div className="bg-muted/50 border border-muted-foreground/20 rounded-md p-3">
|
||
<p className="text-sm text-muted-foreground">
|
||
<strong>Recommendation:</strong> Select 16-20 sports seasons for optimal league balance and variety.
|
||
</p>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-sm font-medium">
|
||
Sports Seasons ({selectedSports.size} selected)
|
||
</Label>
|
||
<div className="max-h-64 overflow-y-auto border rounded-md p-3 space-y-2">
|
||
{allSportsSeasons.length > 0 ? (
|
||
allSportsSeasons.map((season) => (
|
||
<div key={season.id} className="flex items-center space-x-2">
|
||
<Checkbox
|
||
id={season.id}
|
||
name="sportsSeasons"
|
||
value={season.id}
|
||
checked={selectedSports.has(season.id)}
|
||
onCheckedChange={() => handleSportToggle(season.id)}
|
||
/>
|
||
<Label
|
||
htmlFor={season.id}
|
||
className="font-normal cursor-pointer flex-1"
|
||
>
|
||
<span className="font-medium">{season.sport.name}</span> - {season.name} ({season.year})
|
||
<Badge variant="outline" className="ml-2 text-xs">
|
||
{season.scoringType.replace("_", " ")}
|
||
</Badge>
|
||
</Label>
|
||
</div>
|
||
))
|
||
) : (
|
||
<p className="text-sm text-muted-foreground">
|
||
No sports seasons available
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center space-x-2">
|
||
<Checkbox
|
||
id="joinAsPlayer"
|
||
name="joinAsPlayer"
|
||
defaultChecked={true}
|
||
/>
|
||
<Label htmlFor="joinAsPlayer" className="font-normal cursor-pointer">
|
||
I want to play in this league (claim a team)
|
||
</Label>
|
||
</div>
|
||
|
||
{actionData?.error && (
|
||
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
||
{actionData.error}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex gap-4">
|
||
<Button type="submit" className="flex-1">
|
||
Create League
|
||
</Button>
|
||
<Button type="button" variant="outline" asChild>
|
||
<Link to="/">Cancel</Link>
|
||
</Button>
|
||
</div>
|
||
</Form>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|