274 lines
9.5 KiB
TypeScript
274 lines
9.5 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import type { ReactNode } from "react";
|
|
import { ClickableUserId } from "@/components/clickable-user-id";
|
|
import { formatRcLabelFromCoinsBigInt } from "@/lib/coins-rc";
|
|
|
|
/** Ledger rows are per player; both players pay → show `1.0 RC x 2 = 2.0 RC`. */
|
|
function rcPerPlayerTimesTwoLine(coinStr: string | null): string {
|
|
if (coinStr == null || String(coinStr).trim() === "") return "—";
|
|
try {
|
|
const perPlayer = BigInt(String(coinStr).trim());
|
|
const bothPlayers = perPlayer * BigInt(2);
|
|
return `${formatRcLabelFromCoinsBigInt(perPlayer)} x 2 = ${formatRcLabelFromCoinsBigInt(bothPlayers)}`;
|
|
} catch {
|
|
return "—";
|
|
}
|
|
}
|
|
|
|
function entryCombinedPerPlayerTimesTwoLine(
|
|
feeCoins: string | null,
|
|
holdCoins: string | null,
|
|
): string {
|
|
if (
|
|
feeCoins == null ||
|
|
holdCoins == null ||
|
|
String(feeCoins).trim() === "" ||
|
|
String(holdCoins).trim() === ""
|
|
) {
|
|
return "—";
|
|
}
|
|
try {
|
|
const perPlayer =
|
|
BigInt(String(feeCoins).trim()) + BigInt(String(holdCoins).trim());
|
|
const bothPlayers = perPlayer * BigInt(2);
|
|
return `${formatRcLabelFromCoinsBigInt(perPlayer)} x 2 = ${formatRcLabelFromCoinsBigInt(bothPlayers)}`;
|
|
} catch {
|
|
return "—";
|
|
}
|
|
}
|
|
|
|
type PlayerSide = {
|
|
id: number | null;
|
|
username: string | null;
|
|
cc: number | null;
|
|
rc: number | null;
|
|
color: "red" | "blue";
|
|
};
|
|
|
|
type Props = {
|
|
matchId: number;
|
|
statusLabel: string;
|
|
createdAtLabel: ReactNode;
|
|
/** Ledger `entry_fee` / player as coin string; null if unknown. */
|
|
entryFeeCoins: string | null;
|
|
/** Ledger `entry_hold` / player as coin string; null if unknown. */
|
|
entryHoldPerPlayerCoins: string | null;
|
|
/** Preformatted `prize_cc` for display (e.g. with grouping). */
|
|
prizeCcLabel: string;
|
|
left: PlayerSide;
|
|
right: PlayerSide;
|
|
/** May be string when `bigint` is JSON-serialized. */
|
|
winnerId: number | string | null;
|
|
};
|
|
|
|
function idsMatch(
|
|
a: number | string | null | undefined,
|
|
b: number | string | null | undefined,
|
|
): boolean {
|
|
if (a == null || b == null) return false;
|
|
const na = typeof a === "number" ? a : Number(a);
|
|
const nb = typeof b === "number" ? b : Number(b);
|
|
return Number.isFinite(na) && Number.isFinite(nb) && na === nb;
|
|
}
|
|
|
|
function sideStyles(color: PlayerSide["color"]) {
|
|
if (color === "red") {
|
|
return {
|
|
panel:
|
|
"border-rose-500/60 bg-gradient-to-br from-rose-700/80 via-fuchsia-700/70 to-violet-700/80",
|
|
tag: "bg-rose-500/90 text-rose-50",
|
|
iconBg: "bg-rose-500/30 text-rose-100",
|
|
score: "text-rose-100",
|
|
};
|
|
}
|
|
return {
|
|
panel:
|
|
"border-sky-500/60 bg-gradient-to-br from-sky-700/80 via-indigo-700/70 to-violet-700/80",
|
|
tag: "bg-sky-500/90 text-sky-50",
|
|
iconBg: "bg-sky-500/30 text-sky-100",
|
|
score: "text-sky-100",
|
|
};
|
|
}
|
|
|
|
function initials(name: string | null): string {
|
|
if (!name || name.trim() === "") return "?";
|
|
const compact = name.trim().slice(0, 2);
|
|
return compact.toUpperCase();
|
|
}
|
|
|
|
function playerLabel(side: PlayerSide): string {
|
|
if (side.id == null) return "Open slot";
|
|
if (side.username && side.username.trim() !== "") return side.username;
|
|
return `Player ${side.id}`;
|
|
}
|
|
|
|
function winnerLabel(
|
|
winnerId: number | string | null,
|
|
left: PlayerSide,
|
|
right: PlayerSide,
|
|
): string {
|
|
if (winnerId == null || winnerId === "") return "No winner yet";
|
|
if (idsMatch(winnerId, left.id)) return `${playerLabel(left)} won`;
|
|
if (idsMatch(winnerId, right.id)) return `${playerLabel(right)} won`;
|
|
return `Winner #${winnerId}`;
|
|
}
|
|
|
|
function PlayerPanel({
|
|
side,
|
|
isWinner,
|
|
isDimmed,
|
|
}: {
|
|
side: PlayerSide;
|
|
isWinner: boolean;
|
|
isDimmed: boolean;
|
|
}) {
|
|
const s = sideStyles(side.color);
|
|
return (
|
|
<div
|
|
className={`relative flex min-h-24 flex-col justify-between rounded-xl border px-2.5 py-2 shadow-lg shadow-black/20 transition ${s.panel} ${
|
|
isWinner ? "ring-2 ring-amber-300 shadow-amber-200/30" : ""
|
|
} ${isDimmed ? "opacity-50" : "opacity-100"}`}
|
|
>
|
|
{isWinner ? (
|
|
<span className="absolute -top-2 right-2 rounded bg-amber-300 px-1.5 py-0.5 text-[10px] font-black uppercase tracking-wider text-amber-950">
|
|
Winner
|
|
</span>
|
|
) : null}
|
|
<span
|
|
className={`inline-flex w-fit rounded-md px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider ${s.tag}`}
|
|
>
|
|
{side.color === "red" ? "Home" : "Away"}
|
|
</span>
|
|
|
|
<div className="mt-1.5 flex items-center gap-2">
|
|
<div
|
|
className={`grid h-8 w-8 place-items-center rounded-full text-xs font-bold ${s.iconBg}`}
|
|
>
|
|
{initials(side.username)}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="truncate text-xs font-extrabold uppercase tracking-wide text-white">
|
|
{playerLabel(side)}
|
|
</p>
|
|
<p className={`text-[11px] font-semibold tabular-nums ${s.score}`}>
|
|
ID:{" "}
|
|
{side.id != null ? (
|
|
<ClickableUserId
|
|
id={side.id}
|
|
className="cursor-pointer border-0 bg-transparent p-0 text-[11px] font-semibold text-inherit underline decoration-white/50 underline-offset-2 hover:decoration-white"
|
|
>
|
|
{side.id}
|
|
</ClickableUserId>
|
|
) : (
|
|
"—"
|
|
)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-1.5 flex items-center gap-1.5 text-[10px] font-semibold text-white/85">
|
|
<span className="rounded bg-black/25 px-1.5 py-0.5">
|
|
CC {side.cc ?? "—"}
|
|
</span>
|
|
<span className="rounded bg-black/25 px-1.5 py-0.5">
|
|
RC {side.rc ?? "—"}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function MatchHistoryBattleCard({
|
|
matchId,
|
|
statusLabel,
|
|
createdAtLabel,
|
|
entryFeeCoins,
|
|
entryHoldPerPlayerCoins,
|
|
prizeCcLabel,
|
|
left,
|
|
right,
|
|
winnerId,
|
|
}: Props) {
|
|
const entryLine = entryCombinedPerPlayerTimesTwoLine(
|
|
entryFeeCoins,
|
|
entryHoldPerPlayerCoins,
|
|
);
|
|
const entryHoldLine = rcPerPlayerTimesTwoLine(entryHoldPerPlayerCoins);
|
|
const entryFeeLine = rcPerPlayerTimesTwoLine(entryFeeCoins);
|
|
const prizeCcDisplay = prizeCcLabel.trim() === "" ? "—" : prizeCcLabel;
|
|
const hasWinner =
|
|
winnerId != null &&
|
|
winnerId !== "" &&
|
|
(idsMatch(winnerId, left.id) || idsMatch(winnerId, right.id));
|
|
const winnerIsRight = hasWinner && idsMatch(winnerId, right.id);
|
|
const displayLeft = winnerIsRight ? right : left;
|
|
const displayRight = winnerIsRight ? left : right;
|
|
const leftIsWinner = hasWinner && idsMatch(displayLeft.id, winnerId);
|
|
const rightIsWinner = hasWinner && idsMatch(displayRight.id, winnerId);
|
|
const noWinner = !hasWinner;
|
|
|
|
return (
|
|
<article className="rounded-2xl border border-zinc-200 bg-gradient-to-b from-zinc-100 to-zinc-200 p-2.5 shadow-sm dark:border-zinc-700 dark:from-zinc-900 dark:to-zinc-950">
|
|
<div className="mb-1.5 flex items-center justify-between gap-2">
|
|
<span className="rounded-md bg-zinc-900 px-2 py-1 text-[11px] font-bold uppercase tracking-wider text-white dark:bg-zinc-50 dark:text-zinc-900">
|
|
Match #{matchId}
|
|
</span>
|
|
<span className="rounded-md bg-amber-400/90 px-2 py-1 text-[11px] font-extrabold uppercase tracking-wider text-amber-950">
|
|
{statusLabel}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2">
|
|
<PlayerPanel
|
|
side={displayLeft}
|
|
isWinner={leftIsWinner}
|
|
isDimmed={noWinner ? true : !leftIsWinner}
|
|
/>
|
|
<div className="flex flex-col items-center gap-2">
|
|
<div className="rounded-lg border-2 border-amber-300 bg-amber-200/95 px-2 py-0.5 text-[11px] font-black uppercase tracking-wider text-amber-950">
|
|
Final
|
|
</div>
|
|
<div className="rounded-lg border border-zinc-400 bg-zinc-100 px-2 py-1 text-[11px] font-semibold text-zinc-700 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200">
|
|
{winnerLabel(winnerId, displayLeft, displayRight)}
|
|
</div>
|
|
</div>
|
|
<PlayerPanel
|
|
side={displayRight}
|
|
isWinner={rightIsWinner}
|
|
isDimmed={noWinner ? true : !rightIsWinner}
|
|
/>
|
|
</div>
|
|
|
|
<div className="mt-2 flex flex-wrap items-center justify-between gap-2 border-t border-zinc-300/80 pt-1.5 text-xs font-medium text-zinc-700 dark:border-zinc-700 dark:text-zinc-300">
|
|
<div className="flex min-w-0 flex-1 flex-wrap gap-2">
|
|
<span className="rounded bg-zinc-900/10 px-2 py-1 dark:bg-zinc-100/10">
|
|
Time {createdAtLabel}
|
|
</span>
|
|
<span className="rounded bg-zinc-900/10 px-2 py-1 dark:bg-zinc-100/10">
|
|
Entry {entryLine}
|
|
</span>
|
|
<span className="rounded bg-zinc-900/10 px-2 py-1 dark:bg-zinc-100/10">
|
|
Entry hold {entryHoldLine}
|
|
</span>
|
|
<span className="rounded bg-zinc-900/10 px-2 py-1 dark:bg-zinc-100/10">
|
|
Prize {prizeCcDisplay} CC
|
|
</span>
|
|
<span className="rounded-md border border-emerald-200/90 bg-emerald-50 px-2 py-1 text-[11px] font-medium text-emerald-600 tabular-nums dark:border-emerald-300/35 dark:bg-emerald-400/15 dark:text-emerald-200">
|
|
Entry fee {entryFeeLine}
|
|
</span>
|
|
</div>
|
|
<Link
|
|
href={`/match-logs/${matchId}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="rounded-md border border-zinc-400 bg-white px-3 py-1.5 text-xs font-semibold text-zinc-800 shadow-sm transition hover:bg-zinc-100 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800"
|
|
>
|
|
Show logs
|
|
</Link>
|
|
</div>
|
|
</article>
|
|
);
|
|
}
|