brackt/app/routes/sports.tsx
2026-05-19 23:02:58 -07:00

188 lines
6 KiB
TypeScript

import { Link } from "react-router";
import { ArrowRight, Trophy } from "lucide-react";
import type { Route } from "./+types/sports";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { SportIcon } from "~/components/SportIcon";
import {
findPublicSportsWithCurrentSeasons,
type PublicSportWithCurrentSeasons,
} from "~/models/sport";
export function meta(): Route.MetaDescriptors {
return [
{ title: "Sports - Brackt" },
{
name: "description",
content: "Explore the sports currently supported on Brackt.",
},
];
}
export async function loader() {
const sports = await findPublicSportsWithCurrentSeasons();
return { sports };
}
function pluralize(count: number, singular: string, plural = `${singular}s`) {
return `${count} ${count === 1 ? singular : plural}`;
}
type SportGroupKey = "playoffs" | "standings" | "majors";
const SPORT_GROUPS: Array<{
key: SportGroupKey;
title: string;
description: string;
}> = [
{
key: "playoffs",
title: "Playoffs",
description: "Bracket and postseason formats where advancement drives scoring.",
},
{
key: "standings",
title: "Season-Long Standings",
description: "Full-season tables, races, and standings-based competitions.",
},
{
key: "majors",
title: "Majors",
description: "Major tournaments and qualifying-points formats.",
},
];
function seasonGroupKey(
season: PublicSportWithCurrentSeasons["currentSeasons"][number]
): SportGroupKey {
if (season.scoringPattern === "season_standings" || season.scoringType === "regular_season") {
return "standings";
}
if (season.scoringPattern === "qualifying_points" || season.scoringType === "majors") {
return "majors";
}
return "playoffs";
}
function sportGroupKey(sport: PublicSportWithCurrentSeasons): SportGroupKey {
const keys = sport.currentSeasons.map(seasonGroupKey);
return SPORT_GROUPS.find((group) => keys.includes(group.key))?.key ?? "playoffs";
}
function SportCard({ sport }: { sport: PublicSportWithCurrentSeasons }) {
const fallbackDescription = "Available for Brackt leagues.";
return (
<Card className="h-full gap-3 transition-colors hover:bg-card/80">
<CardHeader className="pb-0">
<div className="flex items-start gap-4">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-md border border-border/70 bg-background/60">
<SportIcon
sportName={sport.name}
iconUrl={sport.iconUrl}
size="lg"
className="h-10 w-10"
/>
</div>
<div className="min-w-0 flex-1">
<CardTitle className="truncate text-xl">{sport.name}</CardTitle>
<p className="mt-1 text-sm capitalize text-muted-foreground">
{sport.type} sport · {pluralize(sport.currentSeasons.length, "current season")}
</p>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm leading-6 text-muted-foreground">
{sport.description || fallbackDescription}
</p>
</CardContent>
</Card>
);
}
function SportSection({
title,
description,
sports,
}: {
title: string;
description: string;
sports: PublicSportWithCurrentSeasons[];
}) {
if (sports.length === 0) return null;
return (
<section className="space-y-4">
<div>
<h2 className="text-2xl font-bold">{title}</h2>
<p className="mt-1 text-sm text-muted-foreground">
{description} {pluralize(sports.length, "sport")} available.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{sports.map((sport) => (
<SportCard key={sport.id} sport={sport} />
))}
</div>
</section>
);
}
export default function Sports({ loaderData }: Route.ComponentProps) {
const { sports } = loaderData;
const groupedSports = new Map<SportGroupKey, PublicSportWithCurrentSeasons[]>();
for (const group of SPORT_GROUPS) groupedSports.set(group.key, []);
for (const sport of sports) {
groupedSports.get(sportGroupKey(sport))?.push(sport);
}
return (
<div className="container mx-auto max-w-6xl px-4 py-10">
<div className="mb-10 max-w-3xl">
<h1 className="text-4xl font-extrabold tracking-tight sm:text-5xl">Sports</h1>
<p className="mt-4 text-lg leading-8 text-muted-foreground">
Brackt currently supports {pluralize(sports.length, "sport")} with active
or upcoming seasons, and we&apos;re adding more.
</p>
</div>
{sports.length === 0 ? (
<div className="rounded-md border border-border/70 p-10 text-center">
<Trophy className="mx-auto h-12 w-12 text-muted-foreground" />
<h2 className="mt-4 text-xl font-semibold">No sports are listed yet</h2>
<p className="mx-auto mt-2 max-w-md text-muted-foreground">
Active and upcoming sports will appear here once they are available.
</p>
</div>
) : (
<div className="space-y-12">
{SPORT_GROUPS.map((group) => (
<SportSection
key={group.key}
title={group.title}
description={group.description}
sports={groupedSports.get(group.key) ?? []}
/>
))}
</div>
)}
<section className="mt-12 rounded-md border border-border/70 bg-card/40 p-5 sm:flex sm:items-center sm:justify-between sm:gap-6">
<div>
<h2 className="font-semibold">Want to see another sport on Brackt?</h2>
<p className="mt-1 text-sm text-muted-foreground">
Tell us what should be next and we&apos;ll take a look.
</p>
</div>
<Button asChild variant="outline" className="mt-4 sm:mt-0">
<Link to="/support">
Contact support
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</section>
</div>
);
}