brackt/app/routes/leagues/$leagueId.tsx
Chris Parsons 3f9c1d99e2
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 1m25s
🚀 Deploy / ʦ TypeScript (pull_request) Successful in 1m14s
🚀 Deploy / 🔍 Lint (pull_request) Successful in 48s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Fix invite link showing http and show draft board card during live draft
- Use x-forwarded-proto header (allowlisted to http/https only) when
  building the invite link origin so it shows https behind a TLS proxy
- Show the "View Draft Board" bare card in the right column while a
  draft is running, matching the card already shown for active/completed
  seasons

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 22:31:52 -07:00

361 lines
15 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { Link, useSearchParams } from "react-router";
import { toast } from "sonner";
import type { Route } from "./+types/$leagueId";
import { Link2, TriangleAlert } from "lucide-react";
import { Button } from "~/components/ui/button";
import { GradientIcon } from "~/components/ui/GradientIcon";
import { CommissionersPanel } from "~/components/league/CommissionersPanel";
import { TeamsPanel } from "~/components/league/TeamsPanel";
import { DraftInfoCard } from "~/components/league/DraftInfoCard";
import { SportsSeasonsSummary } from "~/components/league/SportsSeasonsSummary";
import { UpcomingEventsCard } from "~/components/sport-season/UpcomingEventsCard";
import { buildTiedRankChecker, getDisplayRank } from "~/lib/standings-display";
import { StandingsPreview, type StandingsPreviewEntry } from "~/components/league/StandingsPreview";
import { TimezonePromptBanner } from "~/components/league/TimezonePromptBanner";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `${data?.league?.name ?? "League"} - Brackt` }];
}
export { loader } from "./$leagueId.server";
export default function LeagueHome({ loaderData }: Route.ComponentProps) {
const {
league,
season,
teams,
commissioners,
currentUserId,
currentUserTimezone,
isUserCommissioner,
ownerMap,
ownerAvatarDataByUserId,
commissionerMap,
availableTeamCount,
sportsCount,
isDraftOrderSet,
draftSlots,
sportsSeasons,
standings,
origin,
upcomingCalendarEvents,
recentActivity,
showTimezoneBanner,
} = loaderData;
const myTeam = teams.find((t) => t.ownerId === currentUserId);
// 1-based pick position; undefined if myTeam isn't in the slots (order not yet set, or no team)
const userDraftPosition = myTeam
? draftSlots.findIndex((s) => s.team.id === myTeam.id) + 1 || undefined
: undefined;
const [searchParams, setSearchParams] = useSearchParams();
const [copied, setCopied] = useState(false);
// Guard against double-firing toasts (e.g. React Strict Mode double-invoke)
const processedParamsRef = useRef<string | null>(null);
useEffect(() => {
const paramString = searchParams.toString();
if (!paramString || paramString === processedParamsRef.current) return;
if (searchParams.get("updated") === "true") {
processedParamsRef.current = paramString;
toast.success("Team updated successfully");
setSearchParams({});
} else if (searchParams.get("joined") === "true") {
processedParamsRef.current = paramString;
toast.success("Welcome to the league! You've been assigned a team.");
setSearchParams({});
} else if (searchParams.get("left") === "true") {
processedParamsRef.current = paramString;
toast.success("You have left the league. Your team is now available.");
setSearchParams({});
}
}, [searchParams, setSearchParams]);
const handleCopyInviteLink = async () => {
if (!season?.inviteCode) return;
const inviteUrl = `${origin}/i/${season.inviteCode}`;
try {
await navigator.clipboard.writeText(inviteUrl);
setCopied(true);
toast.success("Invite link copied to clipboard!");
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error("Failed to copy invite link");
}
};
// Pre-compute standings map to avoid O(n²) lookups in render
const standingsMap = new Map(standings.map((s) => [s.teamId, s]));
const isTiedRank = buildTiedRankChecker(standings.map((s) => s.currentRank));
// Pre-compute sorted standings list
const sortedTeams = [...teams].toSorted((a, b) => {
const rankA = standingsMap.get(a.id)?.currentRank ?? Infinity;
const rankB = standingsMap.get(b.id)?.currentRank ?? Infinity;
return rankA - rankB;
});
const standingsEntries: StandingsPreviewEntry[] = sortedTeams.map((team) => {
const standing = standingsMap.get(team.id);
const ownerAvatarData = team.ownerId ? (ownerAvatarDataByUserId[team.ownerId] ?? null) : null;
return {
teamId: team.id,
teamName: team.name,
ownerName: team.ownerId ? ownerMap[team.ownerId] : null,
logoUrl: team.logoUrl,
flagConfig: team.flagConfig,
avatarType: team.avatarType,
ownerAvatarData,
displayRank: getDisplayRank(standing, standings.length, standing ? isTiedRank(standing.currentRank) : false),
currentRank: standing?.currentRank,
points: standing ? (standing.actualPoints ?? standing.totalPoints) : 0,
href: season ? `/leagues/${league.id}/standings/${season.id}/teams/${team.id}` : undefined,
rankChange: standing?.rankChange,
pointChange: standing?.sevenDayPointChange,
};
});
// Pre-compute sorted sports seasons: active → upcoming → completed,
// season_standings last within active, then alphabetical by sport name
const sortedSportsSeasons = [...sportsSeasons].toSorted((a, b) => {
const statusOrder = { active: 0, upcoming: 1, completed: 2 } as const;
const statusDiff = statusOrder[a.status] - statusOrder[b.status];
if (statusDiff !== 0) return statusDiff;
if (a.status === "active") {
const aSeasonLong = a.scoringPattern === "season_standings" ? 1 : 0;
const bSeasonLong = b.scoringPattern === "season_standings" ? 1 : 0;
if (aSeasonLong !== bSeasonLong) return aSeasonLong - bSeasonLong;
}
return a.sport.name.localeCompare(b.sport.name);
});
const isDraftOrPreDraft = season?.status === "pre_draft" || season?.status === "draft";
const isActiveOrCompleted = season?.status === "active" || season?.status === "completed";
return (
<div className="container mx-auto py-8 px-4">
{showTimezoneBanner && <TimezonePromptBanner />}
<div className="mb-8">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
<div>
<h1 className="text-4xl font-bold mb-2">{league.name}</h1>
{season && (
<p className="text-muted-foreground">
{season.year} Season {" "}
<span className="capitalize">
{season.status.replace("_", " ")}
</span>
</p>
)}
</div>
<div className="flex gap-2 shrink-0">
{myTeam && (
<Button variant="outline" size="sm" asChild>
<Link to={`/teams/${myTeam.id}/settings`}>
Team Settings
</Link>
</Button>
)}
{isUserCommissioner && (
<Button variant="outline" size="sm" asChild>
<Link to={`/leagues/${league.id}/settings`}>
League Settings
</Link>
</Button>
)}
</div>
</div>
</div>
{isUserCommissioner &&
season?.status === "pre_draft" &&
season.autoStartDraft &&
!isDraftOrderSet &&
season.draftDateTime &&
new Date(season.draftDateTime).getTime() - Date.now() < 60 * 60 * 1000 && (
<div className="mb-6 flex items-center justify-between gap-4 rounded-lg border border-orange-500/50 bg-orange-500/10 px-4 py-3 text-orange-700 dark:text-orange-400">
<div className="flex items-center gap-2">
<TriangleAlert className="h-4 w-4 shrink-0" />
<span className="font-medium">Draft order not set.</span>
<span className="hidden text-sm sm:inline">
The draft is scheduled to auto-start soon but no draft order has been configured.
</span>
</div>
<Button asChild size="sm" variant="outline" className="shrink-0 border-orange-500/60 text-orange-700 hover:bg-orange-500/20 dark:text-orange-400">
<Link to={`/leagues/${league.id}/settings#draft-order`}>
Set Draft Order
</Link>
</Button>
</div>
)}
{season?.status === "draft" && (
<div className="mb-6 flex items-center justify-between gap-4 rounded-lg border border-yellow-500/50 bg-yellow-500/10 px-4 py-3 text-yellow-700 dark:text-yellow-400">
<div className="flex items-center gap-2">
<span className="relative flex h-2.5 w-2.5 shrink-0">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-yellow-500 opacity-75" />
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-yellow-500" />
</span>
<span className="font-medium">Draft is live!</span>
<span className="hidden text-sm sm:inline">
Your league draft is currently in progress.
</span>
</div>
<Button asChild size="sm" variant="outline" className="shrink-0 border-yellow-500/60 text-yellow-700 hover:bg-yellow-500/20 dark:text-yellow-400">
<Link to={`/leagues/${league.id}/draft/${season.id}`}>
Enter Draft Room
</Link>
</Button>
</div>
)}
<div className="grid gap-6 md:grid-cols-3">
{/* Left Column - 2/3 width on desktop */}
<div className="md:col-span-2 space-y-6">
{/* Draft Info card - pre_draft/draft only */}
{season && isDraftOrPreDraft && (
<DraftInfoCard
draftRounds={season.draftRounds}
draftTimerMode={season.draftTimerMode}
draftInitialTime={season.draftInitialTime}
draftIncrementTime={season.draftIncrementTime}
sportsCount={sportsCount}
isDraftOrderSet={isDraftOrderSet}
isDraftOrPreDraft={isDraftOrPreDraft}
draftDateTime={season.draftDateTime}
draftTimezone={currentUserTimezone ?? season.overnightPauseTimezone}
draftBoardHref={`/leagues/${league.id}/draft-board/${season.id}`}
draftRoomHref={`/leagues/${league.id}/draft/${season.id}`}
queueBuilderHref={
myTeam && season.status === "pre_draft"
? `/leagues/${league.id}/draft-queue/${season.id}`
: undefined
}
userDraftPosition={userDraftPosition}
overnightPauseMode={season.overnightPauseMode}
overnightPauseStart={season.overnightPauseStart}
overnightPauseEnd={season.overnightPauseEnd}
overnightPauseTimezone={season.overnightPauseTimezone}
/>
)}
{/* Standings Panel - active/completed seasons */}
{season && isActiveOrCompleted && (
<StandingsPreview
entries={standingsEntries}
description={season.status === "completed" ? "Final standings" : undefined}
fullStandingsHref={`/leagues/${league.id}/standings/${season.id}`}
/>
)}
{/* Sports Seasons Section */}
{season && sortedSportsSeasons.length > 0 && (
<SportsSeasonsSummary
leagueId={league.id}
sportsSeasons={sortedSportsSeasons.map((ss) => ({
id: ss.id,
sportName: ss.sport.name,
sportIconUrl: ss.sport.iconUrl,
seasonName: ss.name,
status: ss.status,
scoringPattern: ss.scoringPattern,
upcomingParticipantEvents: ss.upcomingParticipantEvents,
draftedParticipants: ss.draftedParticipantsWithPoints,
}))}
/>
)}
</div>
{/* Right Column - 1/3 width on desktop */}
<div className="space-y-14">
{/* Teams - shown above invite link when league is full */}
{season && isDraftOrPreDraft && availableTeamCount === 0 && (
<TeamsPanel teams={teams} currentUserId={currentUserId} ownerMap={ownerMap} draftSlots={draftSlots} />
)}
{/* Invite Link - bare, pre_draft commissioner only */}
{isUserCommissioner && season?.status === "pre_draft" && availableTeamCount > 0 && (
<div>
<div className="flex items-center gap-2 mb-4">
<GradientIcon icon={Link2} className="h-5 w-5 shrink-0" />
<span className="text-xl font-bold leading-none tracking-tight">Invite Link</span>
</div>
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
{availableTeamCount} spot{availableTeamCount !== 1 ? "s" : ""} remaining
</p>
<div className="flex items-center gap-2">
<input
type="text"
readOnly
value={`${origin}/i/${season.inviteCode}`}
className="flex-1 px-3 py-2 text-sm border rounded-md bg-muted"
onClick={(e) => e.currentTarget.select()}
/>
<Button
onClick={handleCopyInviteLink}
variant={copied ? "default" : "outline"}
size="sm"
>
{copied ? "Copied!" : "Copy"}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Anyone with this link can join your league
</p>
</div>
</div>
)}
{/* Teams - shown below invite link when league is not full */}
{season && isDraftOrPreDraft && availableTeamCount > 0 && (
<TeamsPanel teams={teams} currentUserId={currentUserId} ownerMap={ownerMap} draftSlots={draftSlots} />
)}
{upcomingCalendarEvents.length > 0 && (
<UpcomingEventsCard
events={upcomingCalendarEvents}
title="Upcoming Events"
limit={6}
viewAllUrl={`/leagues/${league.id}/upcoming-events`}
showLeagueAvatar={false}
/>
)}
{/* Draft historical view - active/completed/draft */}
{season && (isActiveOrCompleted || season.status === "draft") && (
<DraftInfoCard
draftRounds={season.draftRounds}
draftTimerMode={season.draftTimerMode}
draftInitialTime={season.draftInitialTime}
draftIncrementTime={season.draftIncrementTime}
sportsCount={sportsCount}
isDraftOrderSet={isDraftOrderSet}
isDraftOrPreDraft={false}
draftDateTime={season.draftDateTime}
draftTimezone={currentUserTimezone ?? season.overnightPauseTimezone}
draftBoardHref={`/leagues/${league.id}/draft-board/${season.id}`}
draftRoomHref={`/leagues/${league.id}/draft/${season.id}`}
/>
)}
<CommissionersPanel
commissioners={commissioners}
commissionerMap={commissionerMap}
currentUserId={currentUserId}
teams={teams}
activityEntries={recentActivity.entries}
auditLogUrl={`/leagues/${league.id}/audit-log`}
/>
</div>
</div>
</div>
);
}