* Redesign home page with new layout and component system - Two-column layout (My Leagues 2/3, Upcoming Events 1/3) with mobile stack - LeagueRow: square avatar, gradient draft highlight, rank/points display, progress bar - MyLeaguesCard, CreateLeagueCard with shared SectionCardHeader - UpcomingEventsCard: vertical timeline with grouped multi-league events - Shared gradient system: BracktGradients SVG defs, GradientIcon wrapper, brand.ts constants - Button default variant updated to green→cyan gradient - Navbar: plain nav links with gradient hover, support/admin icon buttons - Accessibility fixes: semantic h2 headings, aria-label on LeagueAvatar and nav elements - Storybook stories for all new components Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Responsive league row layout and mobile polish - League rows stack avatar+name on top, stats full-width below on mobile - Stats spread to right side on sm+ screens with border separator on mobile - Tighter padding on mobile (px-3/py-3), full padding on sm+ - Card headers and content use px-3 sm:px-6 to reduce mobile gutters - Two-column home layout deferred to lg breakpoint (tablet gets stacked) - Active leagues sorted by completion percentage descending - Default rank 1 / 0 points for active leagues with no scoring events yet - Fix ordinal bug for 11th/12th/13th; add aria-labels to rank change indicators - Remove dead StatDivider className prop Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Improve claude file. * Add StandingsPreview card component with podium row styling - New StandingsPreview component with gold/silver/bronze row tints for top 3, team avatar, and LeagueRow-style stat columns (Ranking + Points) with rank and 7-day point change indicators - Fix GradientIcon in Storybook by adding BracktGradients decorator to preview.tsx (renamed from .ts to support JSX) - Fix degenerate SVG gradient on horizontal strokes by switching BracktGradients to gradientUnits="userSpaceOnUse" with Lucide-space coordinates (0→24) - Revert erroneous fill: url(#gradient) from GradientIcon; stroke-only fix was sufficient once gradientUnits was corrected Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Update components on league homepage. * Finish up league page styling. * Work on standings page. * Add story for RecentScoresCard * Update Point Progression Chart. * Sort point progression legend by ranking and add team links to standings rows * Fix standings discrepancy on change. * Create draft cell component. * Update draft board page * Draft room improvements. * Update some draft room styling. * Fix context menu missing. * Move tab navigation and autodraft to header row, narrow sidebar * Virtualize available participants list, memoize draft room props Adds @tanstack/react-virtual to replace separate mobile/desktop lists with a single unified virtual scroll loop. Also memoizes miniDraftGrid and availableParticipantsSectionProps, and switches pick lookup from Array.find to a Map for O(1) access. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Update draft room UI. * More draft room fixes. * Draft room tweaks. * Fix Rosters page. * Queue Section fixes. * Mobile Draft fixes. * Fix draft board page. * Create bracket look. * Bracket work. * Finish bracket page. * Homepage initial styling * homepage copy * Add privacy policy. Fixes #88. * how to play copy * rules copy * Fix brackets on homepage. * Add footer to website. * Glow on dots. * Landing page copy. * Fix sidebar. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
555 lines
23 KiB
TypeScript
555 lines
23 KiB
TypeScript
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from "~/components/ui/table";
|
||
import { Trophy, Star } from "lucide-react";
|
||
import { TeamOwnerBadge } from "~/components/ui/team-owner-badge";
|
||
import { GradientIcon } from "~/components/ui/GradientIcon";
|
||
import { TeamAvatar } from "~/components/TeamAvatar";
|
||
import {
|
||
BracketTreeView,
|
||
BracketTreePaginated,
|
||
type BracketMatch,
|
||
type BracketOwnership,
|
||
} from "./BracketTreeView";
|
||
import { getBracketTemplate } from "~/lib/bracket-templates";
|
||
import { NbaBracketLayout } from "./NbaBracketLayout";
|
||
import { TabbedBracketLayout } from "./TabbedBracketLayout";
|
||
|
||
interface Participant {
|
||
id: string;
|
||
name: string;
|
||
}
|
||
|
||
export interface Match {
|
||
id: string;
|
||
round: string;
|
||
matchNumber: number;
|
||
participant1Id: string | null;
|
||
participant2Id: string | null;
|
||
winnerId: string | null;
|
||
loserId: string | null;
|
||
isComplete: boolean;
|
||
participant1Score: string | null;
|
||
participant2Score: string | null;
|
||
isScoring?: boolean;
|
||
participant1?: Participant | null;
|
||
participant2?: Participant | null;
|
||
winner?: Participant | null;
|
||
loser?: Participant | null;
|
||
}
|
||
|
||
export interface TeamOwnership {
|
||
participantId: string;
|
||
teamName: string;
|
||
teamId: string;
|
||
ownerName?: string;
|
||
}
|
||
|
||
interface PlayoffBracketProps {
|
||
matches: Match[];
|
||
rounds: string[]; // Ordered list of round names (earliest first)
|
||
bracketTemplateId?: string | null;
|
||
preEliminatedParticipants?: { id: string; name: string }[];
|
||
participantPoints?: { participantId: string; points: number }[];
|
||
partialScoreParticipantIds?: string[];
|
||
teamOwnerships?: TeamOwnership[];
|
||
userParticipantIds?: string[];
|
||
showOwnership?: boolean;
|
||
title?: string;
|
||
description?: string;
|
||
/** "bracket" = full bracket + tables (default); "rankings" = final rankings table only */
|
||
mode?: "bracket" | "rankings";
|
||
}
|
||
|
||
/** Group matches by round, sorted by matchNumber, in one pass. */
|
||
export function groupMatchesByRound(matches: Match[]): Map<string, Match[]> {
|
||
const byRound = new Map<string, Match[]>();
|
||
for (const match of matches) {
|
||
if (!byRound.has(match.round)) byRound.set(match.round, []);
|
||
byRound.get(match.round)?.push(match);
|
||
}
|
||
for (const group of byRound.values()) {
|
||
group.sort((a, b) => a.matchNumber - b.matchNumber);
|
||
}
|
||
return byRound;
|
||
}
|
||
|
||
/**
|
||
* For a standard single-elimination bracket, slot p1 of match N in round R
|
||
* comes from match (2N-1) in the previous round, and slot p2 from match 2N.
|
||
*/
|
||
export function buildFeederMap(
|
||
matchesByRound: Map<string, Match[]>,
|
||
orderedRounds: string[]
|
||
): Map<string, { round: string; matchNumber: number }> {
|
||
const feederMap = new Map<string, { round: string; matchNumber: number }>();
|
||
|
||
for (let ri = 1; ri < orderedRounds.length; ri++) {
|
||
const currentRound = orderedRounds[ri];
|
||
const prevRound = orderedRounds[ri - 1];
|
||
const prevMatchNums = new Set(
|
||
(matchesByRound.get(prevRound) || []).map((m) => m.matchNumber)
|
||
);
|
||
for (const match of matchesByRound.get(currentRound) || []) {
|
||
const p1Src = 2 * (match.matchNumber - 1) + 1;
|
||
const p2Src = 2 * (match.matchNumber - 1) + 2;
|
||
if (prevMatchNums.has(p1Src)) {
|
||
feederMap.set(`${currentRound}:${match.matchNumber}:p1`, {
|
||
round: prevRound,
|
||
matchNumber: p1Src,
|
||
});
|
||
}
|
||
if (prevMatchNums.has(p2Src)) {
|
||
feederMap.set(`${currentRound}:${match.matchNumber}:p2`, {
|
||
round: prevRound,
|
||
matchNumber: p2Src,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
return feederMap;
|
||
}
|
||
|
||
interface EliminatedEntry {
|
||
participant: Participant;
|
||
score: string | null;
|
||
rankLabel: string;
|
||
ownership: TeamOwnership | null;
|
||
}
|
||
|
||
/**
|
||
* For each complete match, determine which losers should be shown as eliminated
|
||
* in that round. In double-chance brackets (e.g. AFL), a participant who lost
|
||
* round X but then won a later round Y is NOT eliminated at round X — only their
|
||
* final loss counts.
|
||
*
|
||
* Returns a map of round name → array of eliminated participant IDs.
|
||
* Exported for unit testing.
|
||
*/
|
||
export function computeEliminatedByRound(
|
||
matches: Array<{
|
||
round: string;
|
||
isComplete: boolean;
|
||
winnerId: string | null;
|
||
loser?: { id: string; name: string } | null;
|
||
participant1Id?: string | null;
|
||
participant2Id?: string | null;
|
||
}>,
|
||
rounds: string[]
|
||
): Map<string, string[]> {
|
||
const roundIndex = new Map(rounds.map((r, i) => [r, i]));
|
||
|
||
// Track the latest round (by index) each participant has APPEARED IN.
|
||
// We track winnerId/loserId for completed matches AND participant1Id/participant2Id
|
||
// for ALL matches (even incomplete ones). This correctly handles:
|
||
// - AFL double-chance: a QF loser who appears in a later Semi-Finals match
|
||
// - NBA play-in: a 7v8 loser placed into a PIR2 slot (match not yet complete)
|
||
// If a participant appears in a later round slot, they are not yet eliminated.
|
||
const participantLatestRound = new Map<string, number>();
|
||
const updateLatest = (id: string, ri: number) => {
|
||
const prev = participantLatestRound.get(id) ?? -1;
|
||
if (ri > prev) participantLatestRound.set(id, ri);
|
||
};
|
||
for (const match of matches) {
|
||
const ri = roundIndex.get(match.round) ?? -1;
|
||
if (match.winnerId) updateLatest(match.winnerId, ri);
|
||
if (match.loser?.id) updateLatest(match.loser.id, ri);
|
||
if (match.participant1Id) updateLatest(match.participant1Id, ri);
|
||
if (match.participant2Id) updateLatest(match.participant2Id, ri);
|
||
}
|
||
|
||
const result = new Map<string, string[]>();
|
||
for (const match of matches) {
|
||
if (!match.isComplete || !match.loser) continue;
|
||
const lossRoundIndex = roundIndex.get(match.round) ?? -1;
|
||
const loserLatestAppear = participantLatestRound.get(match.loser.id) ?? -1;
|
||
// Double-chance survivor: participant appeared in a LATER round — not yet eliminated here.
|
||
if (loserLatestAppear > lossRoundIndex) continue;
|
||
if (!result.has(match.round)) result.set(match.round, []);
|
||
result.get(match.round)?.push(match.loser.id);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/** Find the index of the first round that has scoring matches. */
|
||
function firstScoringRoundIdx(matchesByRound: Map<string, Match[]>, rounds: string[]): number {
|
||
for (let i = 0; i < rounds.length; i++) {
|
||
if (matchesByRound.get(rounds[i])?.some((m) => m.isScoring)) return i;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
const RANKING_ROW_CLASSES: Record<number, string> = {
|
||
1: "bg-yellow-500/10",
|
||
2: "bg-white/[0.14]",
|
||
3: "bg-orange-600/[0.08]",
|
||
};
|
||
|
||
function rankingRowCls(currentRank: number | undefined): string {
|
||
const podium = currentRank !== undefined ? RANKING_ROW_CLASSES[currentRank] : undefined;
|
||
return `flex flex-col sm:flex-row sm:items-center gap-0 rounded-lg px-3 py-3 sm:px-5 sm:py-4 transition-colors ${podium ?? "bg-white/[0.04]"}`;
|
||
}
|
||
|
||
export function PlayoffBracket({
|
||
matches,
|
||
rounds,
|
||
bracketTemplateId = null,
|
||
preEliminatedParticipants = [],
|
||
participantPoints = [],
|
||
partialScoreParticipantIds: _partialScoreParticipantIds = [],
|
||
teamOwnerships = [],
|
||
userParticipantIds = [],
|
||
showOwnership = true,
|
||
title = "Playoff Bracket",
|
||
description,
|
||
mode = "bracket",
|
||
}: PlayoffBracketProps) {
|
||
const userParticipantSet = new Set(userParticipantIds);
|
||
const ownershipMap = new Map<string, TeamOwnership>();
|
||
teamOwnerships.forEach((o) => ownershipMap.set(o.participantId, o));
|
||
const pointsMap = new Map(participantPoints.map((p) => [p.participantId, p.points]));
|
||
|
||
const matchesByRound = groupMatchesByRound(matches);
|
||
const scoringRoundIdx = firstScoringRoundIdx(matchesByRound, rounds);
|
||
const template = bracketTemplateId ? getBracketTemplate(bracketTemplateId) : undefined;
|
||
|
||
const thirdPlaceRound = template?.rounds
|
||
.find((r) => template.rounds.some((other) => other.loserFeedsInto === r.name))
|
||
?.name;
|
||
|
||
// Build elimination rankings
|
||
const losersByRound = new Map<string, Array<{ participant: Participant; score: string | null; ownership: TeamOwnership | null }>>();
|
||
let bracketWinner: Participant | null = null;
|
||
|
||
const lastRound = rounds[rounds.length - 1];
|
||
const finalMatch = lastRound
|
||
? (matchesByRound.get(lastRound) || []).find((m) => m.matchNumber === 1)
|
||
: null;
|
||
if (finalMatch?.winner) bracketWinner = finalMatch.winner;
|
||
|
||
const eliminatedByRound = computeEliminatedByRound(matches, rounds);
|
||
|
||
for (const match of matches) {
|
||
if (!match.isComplete || !match.loser) continue;
|
||
const eliminatedInRound = eliminatedByRound.get(match.round);
|
||
if (!eliminatedInRound?.includes(match.loser.id)) continue;
|
||
const loserScore =
|
||
match.loserId === match.participant1Id
|
||
? match.participant1Score
|
||
: match.participant2Score;
|
||
if (!losersByRound.has(match.round)) losersByRound.set(match.round, []);
|
||
losersByRound.get(match.round)?.push({
|
||
participant: match.loser,
|
||
score: loserScore,
|
||
ownership: ownershipMap.get(match.loser.id) || null,
|
||
});
|
||
}
|
||
|
||
const allBracketParticipantIds = new Set<string>();
|
||
for (const match of matches) {
|
||
if (match.participant1Id) allBracketParticipantIds.add(match.participant1Id);
|
||
if (match.participant2Id) allBracketParticipantIds.add(match.participant2Id);
|
||
}
|
||
const rankedEntries: EliminatedEntry[] = [];
|
||
let nextRank = 2;
|
||
for (let ri = rounds.length - 1; ri >= 0; ri--) {
|
||
const roundName = rounds[ri];
|
||
const roundLosers = losersByRound.get(roundName) || [];
|
||
const totalMatchesInRound = matchesByRound.get(roundName)?.length ?? 0;
|
||
if (roundLosers.length > 0) {
|
||
const rankLabel = `T${nextRank}`;
|
||
for (const loser of roundLosers) {
|
||
rankedEntries.push({ ...loser, rankLabel });
|
||
}
|
||
}
|
||
nextRank += totalMatchesInRound;
|
||
}
|
||
|
||
const rankedParticipantIds = new Set(rankedEntries.map((e) => e.participant.id));
|
||
if (bracketWinner) rankedParticipantIds.add(bracketWinner.id);
|
||
const filteredPreEliminated = preEliminatedParticipants.filter(
|
||
(p) => !rankedParticipantIds.has(p.id)
|
||
);
|
||
|
||
const showRankings = rankedEntries.length > 0 || bracketWinner !== null || filteredPreEliminated.length > 0;
|
||
|
||
const participantMap = new Map<string, Participant>();
|
||
for (const match of matches) {
|
||
if (match.participant1) participantMap.set(match.participant1.id, match.participant1);
|
||
if (match.participant2) participantMap.set(match.participant2.id, match.participant2);
|
||
}
|
||
const activeParticipants = [...allBracketParticipantIds]
|
||
.filter((id) => !rankedParticipantIds.has(id))
|
||
.map((id) => participantMap.get(id))
|
||
.filter((p): p is Participant => p !== undefined);
|
||
|
||
const isDraftedOrScoring = (participantId: string) =>
|
||
ownershipMap.has(participantId) || (pointsMap.get(participantId) ?? 0) > 0;
|
||
|
||
const winnerIsOwned = bracketWinner ? userParticipantSet.has(bracketWinner.id) : false;
|
||
const winnerOwnership = bracketWinner ? ownershipMap.get(bracketWinner.id) : undefined;
|
||
const winnerPts = bracketWinner ? pointsMap.get(bracketWinner.id) : undefined;
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* Header */}
|
||
<div>
|
||
<h2 className="text-xl font-semibold">{title}</h2>
|
||
{description && (
|
||
<p className="text-sm text-muted-foreground mt-1">{description}</p>
|
||
)}
|
||
</div>
|
||
|
||
{matches.length === 0 ? (
|
||
<div className="text-center py-8 text-muted-foreground">
|
||
<p className="text-sm">No bracket matches available yet.</p>
|
||
<p className="text-xs mt-1">
|
||
Matches will appear here once the bracket is set up.
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-8">
|
||
{mode === "bracket" && (template?.phases ? (
|
||
<TabbedBracketLayout
|
||
rounds={rounds}
|
||
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
|
||
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
|
||
userParticipantIds={userParticipantSet}
|
||
phases={template.phases}
|
||
scoringRoundIdx={scoringRoundIdx}
|
||
/>
|
||
) : template?.conferenceGroups ? (
|
||
<NbaBracketLayout
|
||
matches={matches}
|
||
rounds={rounds}
|
||
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
|
||
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
|
||
userParticipantIds={userParticipantSet}
|
||
conferenceGroups={template.conferenceGroups}
|
||
scoringRoundIdx={scoringRoundIdx}
|
||
/>
|
||
) : (
|
||
<>
|
||
{/* ── Desktop ── */}
|
||
<div className="hidden md:block">
|
||
<BracketTreeView
|
||
rounds={rounds}
|
||
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
|
||
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
|
||
userParticipantIds={userParticipantSet}
|
||
thirdPlaceRound={thirdPlaceRound}
|
||
/>
|
||
</div>
|
||
|
||
{/* ── Mobile ── */}
|
||
<div className="md:hidden">
|
||
<BracketTreePaginated
|
||
rounds={rounds}
|
||
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
|
||
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
|
||
userParticipantIds={userParticipantSet}
|
||
firstScoringRoundIdx={scoringRoundIdx}
|
||
thirdPlaceRound={thirdPlaceRound}
|
||
/>
|
||
</div>
|
||
</>
|
||
))}
|
||
|
||
{/* ── In Contention ── */}
|
||
{mode === "bracket" && activeParticipants.length > 0 && (
|
||
<Card className="border-green-500/30">
|
||
<CardHeader>
|
||
<CardTitle className="text-green-600 dark:text-green-400 flex items-center gap-2">
|
||
<Star className="h-5 w-5" />
|
||
In Contention
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>Participant</TableHead>
|
||
{showOwnership && (
|
||
<TableHead className="w-40 pl-6">Drafted By</TableHead>
|
||
)}
|
||
{pointsMap.size > 0 && (
|
||
<TableHead className="text-right w-16">Pts</TableHead>
|
||
)}
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{activeParticipants.map((p) => {
|
||
const isOwned = userParticipantSet.has(p.id);
|
||
const ownership = showOwnership ? ownershipMap.get(p.id) || null : null;
|
||
const pts = pointsMap.get(p.id);
|
||
return (
|
||
<TableRow
|
||
key={p.id}
|
||
className={isOwned ? "bg-electric/5 border-l-2 border-l-electric" : ""}
|
||
>
|
||
<TableCell className={isOwned ? "text-electric font-medium" : ""}>
|
||
{p.name}
|
||
{isOwned && (
|
||
<Star className="inline ml-1.5 h-3 w-3 fill-current text-electric" />
|
||
)}
|
||
</TableCell>
|
||
{showOwnership && (
|
||
<TableCell className="pl-6">
|
||
{ownership ? (
|
||
<TeamOwnerBadge teamName={ownership.teamName} ownerName={ownership.ownerName} />
|
||
) : (
|
||
<span className="text-xs text-muted-foreground">-</span>
|
||
)}
|
||
</TableCell>
|
||
)}
|
||
{pointsMap.size > 0 && (
|
||
<TableCell className="text-right font-mono text-sm">
|
||
{pts !== undefined ? pts : "–"}
|
||
</TableCell>
|
||
)}
|
||
</TableRow>
|
||
);
|
||
})}
|
||
</TableBody>
|
||
</Table>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* ── Final Rankings ── */}
|
||
{mode === "rankings" && showRankings && (
|
||
<Card className="gap-2">
|
||
<CardHeader className="px-3 sm:px-6 pb-2">
|
||
<div className="flex items-center gap-2">
|
||
<GradientIcon icon={Trophy} className="h-5 w-5 shrink-0" />
|
||
<h2 className="text-xl font-bold leading-none tracking-tight">
|
||
{bracketWinner ? "Final Rankings" : "Eliminated Teams"}
|
||
</h2>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="px-3 sm:px-6">
|
||
<div className="space-y-3">
|
||
{bracketWinner && (
|
||
<div className={rankingRowCls(1)}>
|
||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||
<TeamAvatar
|
||
teamId={winnerOwnership?.teamId ?? bracketWinner.id}
|
||
teamName={winnerOwnership?.teamName ?? bracketWinner.name}
|
||
/>
|
||
<div className="min-w-0">
|
||
<p className={`font-medium text-sm leading-tight truncate${winnerIsOwned ? " text-electric" : ""}`}>
|
||
{bracketWinner.name}
|
||
{winnerIsOwned && <Star className="inline ml-1.5 h-3 w-3 fill-current text-electric" />}
|
||
</p>
|
||
{winnerOwnership?.ownerName && (
|
||
<p className="text-xs text-muted-foreground truncate">{winnerOwnership.ownerName}</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-4 w-full border-t border-border/30 pt-2 mt-1 sm:w-auto sm:border-0 sm:pt-0 sm:mt-0 sm:shrink-0">
|
||
<div className="text-right shrink-0 flex-1 sm:flex-none">
|
||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Rank</p>
|
||
<span className="text-2xl font-bold leading-none">1</span>
|
||
</div>
|
||
{pointsMap.size > 0 && (
|
||
<>
|
||
<div className="h-8 w-px bg-border shrink-0 self-center" />
|
||
<div className="text-right shrink-0 flex-1 sm:flex-none">
|
||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Points</p>
|
||
<span className="text-2xl font-bold leading-none text-electric">{winnerPts ?? 0}</span>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{rankedEntries.filter((entry) => isDraftedOrScoring(entry.participant.id)).map((entry) => {
|
||
const isOwned = userParticipantSet.has(entry.participant.id);
|
||
const pts = pointsMap.get(entry.participant.id);
|
||
const numRank = parseInt(entry.rankLabel.replace("T", ""), 10);
|
||
return (
|
||
<div key={entry.participant.id} className={rankingRowCls(numRank)}>
|
||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||
<TeamAvatar
|
||
teamId={entry.ownership?.teamId ?? entry.participant.id}
|
||
teamName={entry.ownership?.teamName ?? entry.participant.name}
|
||
/>
|
||
<div className="min-w-0">
|
||
<p className={`font-medium text-sm leading-tight truncate${isOwned ? " text-electric" : ""}`}>
|
||
{entry.participant.name}
|
||
{isOwned && <Star className="inline ml-1.5 h-3 w-3 fill-current text-electric" />}
|
||
</p>
|
||
{entry.ownership?.ownerName && (
|
||
<p className="text-xs text-muted-foreground truncate">{entry.ownership.ownerName}</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-4 w-full border-t border-border/30 pt-2 mt-1 sm:w-auto sm:border-0 sm:pt-0 sm:mt-0 sm:shrink-0">
|
||
<div className="text-right shrink-0 flex-1 sm:flex-none">
|
||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Rank</p>
|
||
<span className="text-2xl font-bold leading-none">{entry.rankLabel}</span>
|
||
</div>
|
||
{pointsMap.size > 0 && (
|
||
<>
|
||
<div className="h-8 w-px bg-border shrink-0 self-center" />
|
||
<div className="text-right shrink-0 flex-1 sm:flex-none">
|
||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Points</p>
|
||
<span className="text-2xl font-bold leading-none text-electric">{pts ?? 0}</span>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{filteredPreEliminated.filter((p) => isDraftedOrScoring(p.id)).map((p) => {
|
||
const isOwned = userParticipantSet.has(p.id);
|
||
const ownership = ownershipMap.get(p.id);
|
||
const pts = pointsMap.get(p.id);
|
||
return (
|
||
<div key={p.id} className={rankingRowCls(undefined)}>
|
||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||
<TeamAvatar
|
||
teamId={ownership?.teamId ?? p.id}
|
||
teamName={ownership?.teamName ?? p.name}
|
||
/>
|
||
<div className="min-w-0">
|
||
<p className={`font-medium text-sm leading-tight truncate${isOwned ? " text-electric" : ""}`}>
|
||
{p.name}
|
||
{isOwned && <Star className="inline ml-1.5 h-3 w-3 fill-current text-electric" />}
|
||
</p>
|
||
{ownership?.ownerName && (
|
||
<p className="text-xs text-muted-foreground truncate">{ownership.ownerName}</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{pointsMap.size > 0 && (
|
||
<div className="flex items-center gap-4 w-full border-t border-border/30 pt-2 mt-1 sm:w-auto sm:border-0 sm:pt-0 sm:mt-0 sm:shrink-0">
|
||
<div className="text-right shrink-0 flex-1 sm:flex-none">
|
||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Points</p>
|
||
<span className="text-2xl font-bold leading-none text-electric">{pts ?? 0}</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|