- Added styles for player-side toast notifications to improve user feedback. - Adjusted padding and spacing in various components for a more cohesive layout. - Updated card and dialog components to streamline visual hierarchy and enhance readability. - Refactored player panel and navigation elements for better alignment and user experience.
384 lines
15 KiB
TypeScript
384 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
import { getTicketItemDetail } from "@/api/ticket-items";
|
|
import { Button } from "@/components/ui/button";
|
|
import { PlayerPanel } from "@/components/layout/player-panel";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/ui/card";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { TwentyThreeResultsGrid } from "@/features/results/twenty-three-results-grid";
|
|
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
|
import { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-status";
|
|
import { formatLotteryInstant } from "@/lib/player-datetime";
|
|
import { formatMinorAsCurrency } from "@/lib/money";
|
|
import { norm4d } from "@/lib/norm-4d";
|
|
import { resolvePlayerCurrency } from "@/lib/player-currency";
|
|
import { playLabel } from "@/lib/play-labels";
|
|
import { cn } from "@/lib/utils";
|
|
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
|
import type { TicketItemDetailPayload } from "@/types/api/ticket-items";
|
|
|
|
type OddsSnapRow = { prize_scope?: string; odds_value?: number };
|
|
|
|
function formatOddsSnapshot(
|
|
json: unknown,
|
|
t: (key: string, options?: { defaultValue?: string }) => string,
|
|
): string {
|
|
if (!Array.isArray(json)) return "—";
|
|
const parts = (json as OddsSnapRow[])
|
|
.filter((r) => r.prize_scope && r.odds_value != null)
|
|
.map((r) => {
|
|
const scope = String(r.prize_scope);
|
|
const label = t(`prizeTier.${scope}`, { defaultValue: scope });
|
|
const mult = Number(r.odds_value) / 10_000;
|
|
return `${label} ${mult}x`;
|
|
});
|
|
return parts.length ? parts.join(" · ") : "—";
|
|
}
|
|
|
|
type TimelineRow = {
|
|
code: string;
|
|
label: string;
|
|
time: string;
|
|
};
|
|
|
|
type TicketItemDetailWithExtras = TicketItemDetailPayload & {
|
|
timeline?: TimelineRow[];
|
|
match_result?: {
|
|
matched?: boolean;
|
|
matched_prize_tier?: string | null;
|
|
win_amount_minor?: number;
|
|
jackpot_allocation_minor?: number;
|
|
lines?: Array<{
|
|
number_4d?: string | null;
|
|
matched_tier?: string | null;
|
|
payout?: number | null;
|
|
}>;
|
|
};
|
|
};
|
|
|
|
/** 界面文档 §4.8 注单详情 */
|
|
export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
|
const { t } = useTranslation("player");
|
|
const profile = usePlayerSessionStore((state) => state.profile);
|
|
useCurrencyCatalog();
|
|
const [data, setData] = useState<TicketItemDetailPayload | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const row = await getTicketItemDetail(ticketNo);
|
|
setData(row);
|
|
} catch {
|
|
setData(null);
|
|
setError(t("orders.notFound"));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [ticketNo, t]);
|
|
|
|
useEffect(() => {
|
|
queueMicrotask(() => {
|
|
void load();
|
|
});
|
|
}, [load]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<PlayerPanel
|
|
title={t("orders.betDetail")}
|
|
backHref="/orders"
|
|
backLabel={t("orders.title")}
|
|
>
|
|
<div className="space-y-3">
|
|
<Skeleton className="h-12 rounded-xl" />
|
|
<Skeleton className="h-56 rounded-xl" />
|
|
</div>
|
|
</PlayerPanel>
|
|
);
|
|
}
|
|
|
|
if (error || !data) {
|
|
return (
|
|
<PlayerPanel
|
|
title={t("orders.betDetail")}
|
|
backHref="/orders"
|
|
backLabel={t("orders.title")}
|
|
>
|
|
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700">
|
|
<p>{error ?? t("orders.noData")}</p>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
className="mt-3 bg-[#e5002c] text-white hover:bg-[#d10028]"
|
|
onClick={() => void load()}
|
|
>
|
|
{t("actions.retry")}
|
|
</Button>
|
|
</div>
|
|
</PlayerPanel>
|
|
);
|
|
}
|
|
|
|
const cur = data.currency_code ?? resolvePlayerCurrency(profile);
|
|
const st = ticketStatusDisplay(data.status, data.win_amount, data.jackpot_win_amount, t);
|
|
const totalWin = data.win_amount + data.jackpot_win_amount;
|
|
const pub = data.published_draw_results;
|
|
const first = pub?.results?.["1st"] ?? "";
|
|
const comboHits =
|
|
first && data.combinations.length
|
|
? data.combinations.filter((c) => norm4d(c.number_4d) === norm4d(first))
|
|
: [];
|
|
|
|
const highlight =
|
|
pub?.results && data.combinations.length
|
|
? new Set(
|
|
data.combinations
|
|
.map((c) => norm4d(c.number_4d))
|
|
.filter((n) => {
|
|
const nums = [
|
|
pub.results["1st"],
|
|
pub.results["2nd"],
|
|
pub.results["3rd"],
|
|
...(pub.results.starter ?? []),
|
|
...(pub.results.consolation ?? []),
|
|
]
|
|
.filter(Boolean)
|
|
.map((x) => norm4d(String(x)));
|
|
return nums.includes(n);
|
|
}),
|
|
)
|
|
: null;
|
|
|
|
const tierLabel = data.settlement?.matched_prize_tier
|
|
? t(`prizeTier.${data.settlement.matched_prize_tier}`, {
|
|
defaultValue: data.settlement.matched_prize_tier,
|
|
})
|
|
: null;
|
|
const extras = data as TicketItemDetailWithExtras;
|
|
const timeline = extras.timeline ?? [];
|
|
const matchResult = extras.match_result;
|
|
const hasSettlement = data.settlement !== null || data.status === "settled_win" || data.status === "settled_lose";
|
|
|
|
return (
|
|
<PlayerPanel
|
|
title={t("orders.betDetail")}
|
|
backHref="/orders"
|
|
backLabel={t("orders.title")}
|
|
>
|
|
<div className="flex flex-col gap-3">
|
|
<Card className="ring-0 border border-[#e8eef7] bg-white shadow-[0_8px_28px_rgba(15,23,42,0.05)]">
|
|
<CardHeader className="space-y-2 border-b border-[#edf2f9] pb-3">
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<CardTitle className="text-base font-black text-[#0b3f96]">
|
|
{t("orders.detailTitle")}
|
|
</CardTitle>
|
|
<StatusDot label={st.label} dotClass={st.dotClass} ring={st.ring} />
|
|
</div>
|
|
<CardDescription className="font-mono text-[11px] leading-relaxed text-slate-500">
|
|
{t("orders.ticketNo", { ticketNo: data.ticket_no })} ·{" "}
|
|
{t("orders.orderNo", { orderNo: data.order_no ?? "—" })}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3 text-sm">
|
|
<div className="space-y-2.5 text-xs">
|
|
<div className="flex items-baseline justify-between gap-3">
|
|
<span className="shrink-0 text-slate-500">{t("orders.drawNo")}</span>
|
|
<span className="text-right font-mono font-black text-[#0b3f96]">
|
|
{data.draw_no ?? "—"}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-baseline justify-between gap-3">
|
|
<span className="shrink-0 text-slate-500">{t("orders.placedAt")}</span>
|
|
<span className="text-right font-medium text-slate-800">
|
|
{formatLotteryInstant(data.placed_at ?? null)}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-baseline justify-between gap-3">
|
|
<span className="shrink-0 text-slate-500">{t("orders.number")}</span>
|
|
<span className="text-right font-mono text-base font-black text-[#0b3f96]">
|
|
{data.original_number ?? "—"}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-baseline justify-between gap-3">
|
|
<span className="shrink-0 text-slate-500">{t("orders.play")}</span>
|
|
<span className="text-right font-semibold text-[#32518d]">
|
|
{playLabel(data.play_code, t)} ({data.dimension ?? "—"}D)
|
|
</span>
|
|
</div>
|
|
<div className="flex items-baseline justify-between gap-3">
|
|
<span className="shrink-0 text-slate-500">{t("orders.amount")}</span>
|
|
<span className="text-right font-black tabular-nums text-[#d81435]">
|
|
{formatMinorAsCurrency(data.total_bet_amount, cur)}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-baseline justify-between gap-3">
|
|
<span className="shrink-0 text-slate-500">{t("orders.rebateRate")}</span>
|
|
<span className="text-right font-semibold tabular-nums text-emerald-600">
|
|
{(Number(data.rebate_rate_snapshot) * 100).toFixed(1)}%
|
|
</span>
|
|
</div>
|
|
<div className="flex items-baseline justify-between gap-3">
|
|
<span className="shrink-0 text-slate-500">{t("orders.actualDeduct")}</span>
|
|
<span className="text-right font-black tabular-nums text-[#0b3f96]">
|
|
{formatMinorAsCurrency(data.actual_deduct_amount, cur)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-lg border border-[#c8daf6] bg-[#f0f6ff] px-3 py-2.5 text-xs">
|
|
<p className="font-bold text-[#0b3f96]">{t("orders.oddsSnapshot")}</p>
|
|
<p className="mt-1 leading-relaxed text-[#32518d]">
|
|
{formatOddsSnapshot(data.odds_snapshot_json, t)}
|
|
</p>
|
|
</div>
|
|
|
|
{pub?.results ? (
|
|
<div className="space-y-2">
|
|
<p className="text-sm font-bold text-[#0b3f96]">{t("orders.drawNumbers")}</p>
|
|
<TwentyThreeResultsGrid numbers={pub.results} highlighted4d={highlight} />
|
|
{first ? (
|
|
<p className="text-xs text-slate-500">
|
|
{t("orders.firstPrize")}{" "}
|
|
<span className="font-mono font-semibold text-slate-900">{first}</span>
|
|
{comboHits.length > 0 ? (
|
|
<span className="font-semibold text-emerald-600">
|
|
{" "}
|
|
← {t("orders.hit")}
|
|
</span>
|
|
) : null}
|
|
</p>
|
|
) : null}
|
|
{!hasSettlement ? (
|
|
<p className="rounded-lg border border-[#dce7f7] bg-[#f8fbff] px-3 py-2 text-xs text-[#32518d]">
|
|
{t("orders.matchPendingSettlement")}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
) : (
|
|
<div className="rounded-lg border border-[#dce7f7] bg-[#f8fbff] px-3 py-3 text-xs">
|
|
<p className="font-bold text-[#0b3f96]">{t("orders.drawNumbers")}</p>
|
|
<p className="mt-1 text-[#32518d]">
|
|
{t("orders.drawPendingMatch")}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{data.settlement && tierLabel ? (
|
|
<div className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs">
|
|
<p className="font-bold text-emerald-900">
|
|
{t("orders.matchWin", { tier: tierLabel })}
|
|
</p>
|
|
<p className="mt-1 font-mono text-emerald-800/90">
|
|
{t("orders.winAmount", {
|
|
amount: formatMinorAsCurrency(data.settlement.win_amount_minor, cur),
|
|
})}
|
|
{data.settlement.jackpot_allocation_minor > 0 ? (
|
|
<>
|
|
{" "}
|
|
·{" "}
|
|
{t("orders.jackpotAmount", {
|
|
amount: formatMinorAsCurrency(
|
|
data.settlement.jackpot_allocation_minor,
|
|
cur,
|
|
),
|
|
})}
|
|
</>
|
|
) : null}
|
|
</p>
|
|
<p className="mt-1 font-mono font-semibold text-emerald-900">
|
|
{t("orders.payoutTotal", { amount: formatMinorAsCurrency(totalWin, cur) })}
|
|
</p>
|
|
</div>
|
|
) : hasSettlement ? (
|
|
<p className="text-xs text-slate-500">{t("orders.matchLose")}</p>
|
|
) : null}
|
|
|
|
{matchResult && hasSettlement ? (
|
|
<div className="rounded-lg border border-[#dce7f7] bg-[#f8fbff] px-3 py-3 text-xs">
|
|
<p className="font-bold text-[#0b3f96]">
|
|
{t("orders.matchResult")}
|
|
</p>
|
|
<p className="mt-1 text-slate-600">
|
|
{matchResult.matched
|
|
? t("orders.matchWin", { tier: tierLabel ?? (matchResult.matched_prize_tier ?? "—") })
|
|
: t("orders.matchLose")}
|
|
</p>
|
|
{Array.isArray(matchResult.lines) && matchResult.lines.length > 0 ? (
|
|
<div className="mt-2 space-y-1">
|
|
{matchResult.lines.map((line, idx) => (
|
|
<p key={`${line.number_4d ?? "line"}-${idx}`} className="font-mono text-[11px] text-slate-500">
|
|
{line.number_4d ?? "—"} · {line.matched_tier ?? "—"} · {formatMinorAsCurrency(line.payout ?? 0, cur)}
|
|
</p>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
{timeline.length > 0 ? (
|
|
<div className="rounded-xl border border-[#e8eef7] bg-[#f8fbff] px-3 py-3">
|
|
<p className="text-sm font-bold text-[#0b3f96]">
|
|
{t("orders.timeline")}
|
|
</p>
|
|
<div className="mt-2 space-y-2">
|
|
{timeline.map((row) => (
|
|
<div key={row.code} className="flex items-start justify-between gap-3 rounded-lg bg-white px-3 py-2">
|
|
<div className="min-w-0">
|
|
<p className="text-xs font-bold text-[#32518d]">{row.label}</p>
|
|
<p className="mt-0.5 font-mono text-[11px] text-slate-500">{row.code}</p>
|
|
</div>
|
|
<p className="shrink-0 font-mono text-[11px] text-slate-500">
|
|
{formatLotteryInstant(row.time)}
|
|
</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{data.settled_at ? (
|
|
<p className="text-[11px] text-slate-500">
|
|
{t("orders.settledAt", { time: formatLotteryInstant(data.settled_at) })}
|
|
</p>
|
|
) : null}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<div className="flex flex-wrap gap-3">
|
|
{data.draw_no ? (
|
|
<Link
|
|
href={`/results/${encodeURIComponent(data.draw_no)}`}
|
|
className={cn(
|
|
"inline-flex h-11 min-w-[140px] flex-1 items-center justify-center rounded-xl bg-[#07459f] px-4 text-sm font-bold text-white shadow-sm transition-colors hover:bg-[#063b88]",
|
|
)}
|
|
>
|
|
{t("orders.viewDraw")}
|
|
</Link>
|
|
) : null}
|
|
<Link
|
|
href="/orders"
|
|
className={cn(
|
|
"inline-flex h-11 min-w-[140px] flex-1 items-center justify-center rounded-xl border border-[#dce7f7] bg-white px-4 text-sm font-semibold text-[#07459f] transition-colors hover:bg-[#f1f6ff]",
|
|
)}
|
|
>
|
|
{t("orders.backToOrders")}
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</PlayerPanel>
|
|
);
|
|
}
|