Files
lotteryFront/src/features/orders/ticket-order-detail-screen.tsx
kang 0cd85ae287 feat: enhance UI consistency and improve spacing across components
- 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.
2026-05-21 17:28:06 +08:00

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>
);
}