2025-10-11 00:07:39 -07:00
|
|
|
import { Form, Link, redirect } from "react-router";
|
|
|
|
|
import { getAuth } from "@clerk/react-router/server";
|
2025-10-11 21:10:01 -07:00
|
|
|
import { createLeague, setCurrentSeason } from "~/models/league";
|
2025-10-11 00:07:39 -07:00
|
|
|
import { createSeason } from "~/models/season";
|
|
|
|
|
import { createManyTeams } from "~/models/team";
|
2025-10-11 00:29:04 -07:00
|
|
|
import { createCommissioner } from "~/models/commissioner";
|
2025-10-13 19:59:34 -07:00
|
|
|
import { linkMultipleSportsToSeason } from "~/models/season-sport";
|
|
|
|
|
import { findActiveSeasonTemplates, findSeasonTemplateWithSportsSeasons } from "~/models/season-template";
|
|
|
|
|
import { findAllSportsSeasons } from "~/models/sports-season";
|
2025-10-11 00:07:39 -07:00
|
|
|
import type { Route } from "./+types/new";
|
|
|
|
|
import { Button } from "~/components/ui/button";
|
|
|
|
|
import { Input } from "~/components/ui/input";
|
|
|
|
|
import { Label } from "~/components/ui/label";
|
2025-10-13 19:59:34 -07:00
|
|
|
import { Checkbox } from "~/components/ui/checkbox";
|
|
|
|
|
import { Check } from "lucide-react";
|
|
|
|
|
import { useState } from "react";
|
2025-10-11 00:07:39 -07:00
|
|
|
import {
|
|
|
|
|
Card,
|
|
|
|
|
CardContent,
|
|
|
|
|
CardDescription,
|
|
|
|
|
CardHeader,
|
|
|
|
|
CardTitle,
|
|
|
|
|
} from "~/components/ui/card";
|
|
|
|
|
import {
|
|
|
|
|
Select,
|
|
|
|
|
SelectContent,
|
|
|
|
|
SelectItem,
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
SelectValue,
|
|
|
|
|
} from "~/components/ui/select";
|
2025-10-13 19:59:34 -07:00
|
|
|
import { Badge } from "~/components/ui/badge";
|
|
|
|
|
|
|
|
|
|
export async function loader() {
|
|
|
|
|
const templates = await findActiveSeasonTemplates();
|
|
|
|
|
const allSportsSeasons = await findAllSportsSeasons();
|
|
|
|
|
|
|
|
|
|
// Fetch templates with their sports seasons for mapping
|
|
|
|
|
const templatesWithSports = await Promise.all(
|
|
|
|
|
templates.map(async (template) => {
|
|
|
|
|
const templateWithSports = await findSeasonTemplateWithSportsSeasons(template.id);
|
|
|
|
|
return {
|
|
|
|
|
...template,
|
|
|
|
|
sportsSeasonIds: templateWithSports?.seasonTemplateSports?.map(
|
|
|
|
|
(ts: any) => ts.sportsSeason.id
|
|
|
|
|
) || []
|
|
|
|
|
};
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
templates: templatesWithSports,
|
|
|
|
|
allSportsSeasons: allSportsSeasons as Array<typeof allSportsSeasons[0] & { sport: { id: string; name: string; type: string; slug: string } }>
|
|
|
|
|
};
|
|
|
|
|
}
|
2025-10-11 00:07:39 -07:00
|
|
|
|
|
|
|
|
export async function action(args: Route.ActionArgs) {
|
|
|
|
|
const { userId } = await getAuth(args);
|
|
|
|
|
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");
|
2025-10-13 19:59:34 -07:00
|
|
|
const templateId = formData.get("templateId");
|
2025-10-11 00:07:39 -07:00
|
|
|
|
|
|
|
|
// Validation
|
|
|
|
|
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" };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Create league
|
|
|
|
|
const league = await createLeague({
|
|
|
|
|
name: name.trim(),
|
|
|
|
|
createdBy: userId,
|
|
|
|
|
});
|
|
|
|
|
|
2025-10-11 00:29:04 -07:00
|
|
|
// Make creator a commissioner
|
|
|
|
|
await createCommissioner({
|
|
|
|
|
leagueId: league.id,
|
|
|
|
|
userId: userId,
|
|
|
|
|
});
|
|
|
|
|
|
2025-10-11 00:07:39 -07:00
|
|
|
// Create first season
|
|
|
|
|
const currentYear = new Date().getFullYear();
|
|
|
|
|
const season = await createSeason({
|
|
|
|
|
leagueId: league.id,
|
|
|
|
|
year: currentYear,
|
|
|
|
|
status: "pre_draft",
|
2025-10-13 19:59:34 -07:00
|
|
|
templateId: typeof templateId === "string" && templateId !== "" ? templateId : null,
|
2025-10-11 00:07:39 -07:00
|
|
|
});
|
|
|
|
|
|
2025-10-11 21:10:01 -07:00
|
|
|
// Set this as the current season for the league
|
|
|
|
|
await setCurrentSeason(league.id, season.id);
|
|
|
|
|
|
2025-10-13 19:59:34 -07:00
|
|
|
// Add selected sports to the season
|
|
|
|
|
const selectedSports = formData.getAll("sportsSeasons");
|
|
|
|
|
if (selectedSports.length > 0) {
|
|
|
|
|
await linkMultipleSportsToSeason(
|
|
|
|
|
selectedSports.map((id) => ({
|
|
|
|
|
seasonId: season.id,
|
|
|
|
|
sportsSeasonId: id as string,
|
|
|
|
|
}))
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-11 00:07:39 -07:00
|
|
|
// Create teams with placeholder names
|
|
|
|
|
const teams = Array.from({ length: teamCountNum }, (_, i) => ({
|
|
|
|
|
seasonId: season.id,
|
|
|
|
|
name: `Team ${i + 1}`,
|
|
|
|
|
ownerId: i === 0 ? userId : null, // Assign first team to creator
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
await createManyTeams(teams);
|
|
|
|
|
|
|
|
|
|
// Redirect to league homepage
|
|
|
|
|
return redirect(`/leagues/${league.id}`);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("Error creating league:", error);
|
|
|
|
|
return { error: "Failed to create league. Please try again." };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-13 19:59:34 -07:00
|
|
|
export default function NewLeague({ loaderData, actionData }: Route.ComponentProps) {
|
|
|
|
|
const { templates, allSportsSeasons } = loaderData;
|
|
|
|
|
const [selectedTemplate, setSelectedTemplate] = useState<string>("");
|
|
|
|
|
const [selectedSports, setSelectedSports] = useState<Set<string>>(new Set());
|
|
|
|
|
|
|
|
|
|
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));
|
|
|
|
|
};
|
2025-10-11 00:07:39 -07:00
|
|
|
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>
|
|
|
|
|
|
2025-10-13 19:59:34 -07:00
|
|
|
<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} />
|
|
|
|
|
|
|
|
|
|
{/* Template Cards */}
|
|
|
|
|
{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>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Sports Checkboxes */}
|
|
|
|
|
<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>
|
|
|
|
|
|
2025-10-11 00:07:39 -07:00
|
|
|
{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>
|
|
|
|
|
);
|
|
|
|
|
}
|