188 lines
6 KiB
TypeScript
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'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'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>
|
|
);
|
|
}
|