321 lines
14 KiB
TypeScript
321 lines
14 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
AlertTriangleIcon,
|
||
CheckCircle2,
|
||
LoaderCircle,
|
||
WalletCards,
|
||
XIcon,
|
||
} from "lucide-react";
|
||
import Image from "next/image";
|
||
import { useEffect } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
|
||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog";
|
||
import { formatMinorAsCurrency } from "@/lib/money";
|
||
import { playLabel } from "@/lib/play-labels";
|
||
import type { TicketPreviewData, TicketPreviewWarning } from "@/types/api/ticket";
|
||
|
||
type HallBetPreviewDialogProps = {
|
||
open: boolean;
|
||
onOpenChange: (open: boolean) => void;
|
||
currencyCode: string;
|
||
data: TicketPreviewData | null;
|
||
placing: boolean;
|
||
/** 界面 §4.2:封盘后禁止提交,主按钮文案为「已封盘」 */
|
||
allowSubmit?: boolean;
|
||
onConfirmPlace: () => void;
|
||
};
|
||
|
||
function WarningsBlock({ warnings }: { warnings: TicketPreviewWarning[] }) {
|
||
const { t } = useTranslation("player");
|
||
|
||
if (warnings.length === 0) return null;
|
||
return (
|
||
<Alert className="rounded-xl border-[#ffd7b8] bg-[#fff7ed] p-3 text-[#b45309]">
|
||
<AlertTriangleIcon className="size-5" />
|
||
<AlertTitle className="text-sm font-black">{t("hall.preview.warningsTitle")}</AlertTitle>
|
||
<AlertDescription className="space-y-2">
|
||
<p className="text-xs leading-relaxed text-[#92400e]">
|
||
{t("hall.preview.warningsDescription")}
|
||
</p>
|
||
<ul className="space-y-1 text-xs text-slate-700">
|
||
{warnings.map((w, i) => (
|
||
<li key={`${w.number_4d}-${i}`}>
|
||
<span className="font-mono font-black text-[#e5002c]">{w.number_4d}</span> · {w.message}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</AlertDescription>
|
||
</Alert>
|
||
);
|
||
}
|
||
|
||
function SubmittingPanel() {
|
||
const { t } = useTranslation("player");
|
||
|
||
return (
|
||
<DialogContent
|
||
showCloseButton={false}
|
||
className="max-w-[340px] overflow-hidden rounded-2xl border border-white/70 bg-white p-0 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
|
||
>
|
||
<div className="px-8 py-8 text-center">
|
||
<Image
|
||
src="/entry/image6.png"
|
||
alt=""
|
||
width={150}
|
||
height={119}
|
||
className="mx-auto h-[118px] w-[150px] object-contain"
|
||
priority
|
||
aria-hidden
|
||
/>
|
||
<div className="mx-auto mt-2 h-2 w-44 overflow-hidden rounded-full bg-[#dbe3f1]">
|
||
<div className="h-full w-2/3 rounded-full bg-[#0755c7] shadow-[0_0_14px_rgba(7,85,199,0.45)]" />
|
||
</div>
|
||
<DialogHeader className="mt-8 items-center gap-2">
|
||
<DialogTitle className="text-xl font-black text-slate-950">
|
||
{t("hall.preview.processingTitle", { defaultValue: "正在提交下注" })}
|
||
</DialogTitle>
|
||
<DialogDescription className="max-w-[220px] text-center text-sm leading-relaxed text-slate-500">
|
||
{t("hall.preview.processingDescription", { defaultValue: "请勿关闭页面或返回上一页。" })}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="mt-7 inline-flex items-center gap-2 text-sm font-semibold text-slate-500">
|
||
<LoaderCircle className="size-4 animate-spin text-[#0755c7]" />
|
||
{t("hall.preview.processingProgress", { defaultValue: "正在处理注单..." })}
|
||
</div>
|
||
</div>
|
||
</DialogContent>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 预览弹窗 + 提交确认(产品文档 §10.1.2:预览不下单,确认后 place)。
|
||
*/
|
||
export function HallBetPreviewDialog({
|
||
open,
|
||
onOpenChange,
|
||
currencyCode,
|
||
data,
|
||
placing,
|
||
allowSubmit = true,
|
||
onConfirmPlace,
|
||
}: HallBetPreviewDialogProps) {
|
||
const { t } = useTranslation("player");
|
||
const summary = data?.summary;
|
||
const lines = data?.lines ?? [];
|
||
|
||
useEffect(() => {
|
||
if (open && !placing && !data) {
|
||
onOpenChange(false);
|
||
}
|
||
}, [data, onOpenChange, open, placing]);
|
||
|
||
if (placing) {
|
||
return (
|
||
<Dialog open={open} onOpenChange={() => {}}>
|
||
<SubmittingPanel />
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
if (!data) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent
|
||
showCloseButton={false}
|
||
className="flex max-h-[calc(100dvh-24px)] flex-col gap-0 overflow-hidden rounded-2xl border-[#e4ebf6] bg-white p-0 shadow-[0_18px_60px_rgba(15,23,42,0.18)] ring-[#e8eef7] sm:max-h-[min(92vh,760px)] sm:max-w-lg"
|
||
>
|
||
<div className="relative shrink-0 px-4 pb-3 pt-5 sm:px-5">
|
||
<button
|
||
type="button"
|
||
onClick={() => onOpenChange(false)}
|
||
disabled={placing}
|
||
className="absolute right-3 top-3 inline-flex size-9 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700 disabled:opacity-50"
|
||
aria-label={t("actions.close", { defaultValue: "关闭" })}
|
||
>
|
||
<XIcon className="size-5" />
|
||
</button>
|
||
<DialogHeader className="pr-10">
|
||
<div className="mb-2 flex items-center gap-3">
|
||
<span className="flex size-11 items-center justify-center rounded-full bg-[#fff0f3] text-[#e5002c]">
|
||
<WalletCards className="size-6" />
|
||
</span>
|
||
<DialogTitle className="text-xl font-black text-slate-950">
|
||
{t("hall.preview.title")}
|
||
</DialogTitle>
|
||
</div>
|
||
<DialogDescription className="text-sm leading-relaxed text-slate-500">
|
||
{t("hall.preview.description")}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
{!allowSubmit ? (
|
||
<Alert className="mt-3 border-[#ff4d4f]/35 bg-[#ff4d4f]/8 text-[#ff4d4f]">
|
||
<AlertTriangleIcon />
|
||
<AlertTitle>{t("hall.preview.sealedTitle")}</AlertTitle>
|
||
<AlertDescription className="text-xs leading-relaxed">
|
||
{t("hall.preview.sealedDescription")}
|
||
</AlertDescription>
|
||
</Alert>
|
||
) : null}
|
||
</div>
|
||
|
||
<div
|
||
className="min-h-0 flex-1 overflow-y-auto border-y border-[#e8eef7] px-4 sm:px-5"
|
||
>
|
||
<div className="space-y-4 py-4">
|
||
{!data ? (
|
||
<p className="text-sm text-slate-500">{t("hall.preview.empty")}</p>
|
||
) : (
|
||
<>
|
||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||
<span className="text-slate-500">{t("hall.preview.draw")}:</span>
|
||
<span className="font-mono font-black text-[#e5002c]">{data.draw.draw_id}</span>
|
||
<span className="rounded-full bg-[#edf4ff] px-2 py-0.5 text-xs font-bold text-[#0b3f96]">
|
||
{data.draw.status}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="overflow-x-auto rounded-xl border border-[#dfe8f6]">
|
||
<table className="min-w-[640px] w-full border-collapse text-xs">
|
||
<thead className="bg-[#f4f7fd] text-[#304f86]">
|
||
<tr>
|
||
<th className="w-10 border-r border-[#dfe8f6] px-2 py-3 text-center font-black">No.</th>
|
||
<th className="border-r border-[#dfe8f6] px-2 py-3 text-center font-black">
|
||
{t("hall.result.number", { defaultValue: "号码" })}
|
||
</th>
|
||
<th className="border-r border-[#dfe8f6] px-2 py-3 text-center font-black">
|
||
{t("orders.play", { defaultValue: "玩法" })}
|
||
</th>
|
||
<th className="border-r border-[#dfe8f6] px-2 py-3 text-center font-black">
|
||
{t("hall.preview.amount")}
|
||
</th>
|
||
<th className="border-r border-[#dfe8f6] px-2 py-3 text-center font-black">
|
||
{t("hall.preview.rebate")}
|
||
</th>
|
||
<th className="border-r border-[#dfe8f6] px-2 py-3 text-center font-black">
|
||
{t("hall.preview.estimatedMax")}
|
||
</th>
|
||
<th className="px-2 py-3 text-center font-black">
|
||
{t("hall.preview.actual")}
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{lines.map((ln) => (
|
||
<tr key={ln.client_line_no} className="border-t border-[#e8eef7] bg-white">
|
||
<td className="border-r border-[#e8eef7] px-2 py-3 text-center">
|
||
<span className="inline-flex size-6 items-center justify-center rounded-full border border-[#dfe8f6] font-black text-[#304f86]">
|
||
{ln.client_line_no}
|
||
</span>
|
||
</td>
|
||
<td className="border-r border-[#e8eef7] px-2 py-3 text-center">
|
||
<span className="block font-mono text-base font-black text-slate-950">{ln.number}</span>
|
||
<span className="text-[11px] font-bold text-[#0755c7]">{playLabel(ln.play_code, t)}</span>
|
||
</td>
|
||
<td className="border-r border-[#e8eef7] px-2 py-3 text-center font-semibold text-slate-700">
|
||
{ln.play_code}
|
||
</td>
|
||
<td className="border-r border-[#e8eef7] px-2 py-3 text-center font-semibold tabular-nums">
|
||
{formatMinorAsCurrency(ln.total_bet_amount, currencyCode)}
|
||
</td>
|
||
<td className="border-r border-[#e8eef7] px-2 py-3 text-center font-semibold tabular-nums text-emerald-600">
|
||
-{formatMinorAsCurrency(ln.rebate_amount, currencyCode).replace(`${currencyCode} `, "")}
|
||
</td>
|
||
<td className="border-r border-[#e8eef7] px-2 py-3 text-center font-black tabular-nums text-[#e5002c]">
|
||
{formatMinorAsCurrency(ln.estimated_max_payout, currencyCode)}
|
||
</td>
|
||
<td className="px-2 py-3 text-center font-black tabular-nums text-[#0b3f96]">
|
||
{formatMinorAsCurrency(ln.actual_deduct_amount, currencyCode)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{summary ? (
|
||
<div className="grid grid-cols-2 overflow-hidden rounded-xl border border-[#dfe8f6] bg-[#f8fbff] text-center text-xs sm:grid-cols-4">
|
||
<div className="border-r border-[#dfe8f6] px-2 py-3">
|
||
<p className="font-bold text-[#304f86]">{t("hall.preview.totalBet")}</p>
|
||
<p className="mt-1 font-black tabular-nums text-[#0b3f96]">
|
||
{formatMinorAsCurrency(summary.total_bet_amount, currencyCode)}
|
||
</p>
|
||
</div>
|
||
<div className="border-r border-[#dfe8f6] px-2 py-3">
|
||
<p className="font-bold text-[#304f86]">{t("hall.preview.rebate")}</p>
|
||
<p className="mt-1 font-black tabular-nums text-emerald-600">
|
||
-
|
||
{formatMinorAsCurrency(summary.total_rebate_amount, currencyCode).replace(
|
||
`${currencyCode} `,
|
||
"",
|
||
)}
|
||
</p>
|
||
</div>
|
||
<div className="border-r border-[#dfe8f6] px-2 py-3">
|
||
<p className="font-bold text-[#304f86]">{t("hall.preview.actualDeduct")}</p>
|
||
<p className="mt-1 font-black tabular-nums text-[#e5002c]">
|
||
{formatMinorAsCurrency(summary.total_actual_deduct, currencyCode)}
|
||
</p>
|
||
</div>
|
||
<div className="px-2 py-3">
|
||
<p className="font-bold text-[#304f86]">{t("hall.preview.estimatedPayout")}</p>
|
||
<p className="mt-1 font-black tabular-nums text-[#e5002c]">
|
||
{formatMinorAsCurrency(summary.total_estimated_payout, currencyCode)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{data.warnings.length > 0 ? (
|
||
<WarningsBlock warnings={data.warnings} />
|
||
) : (
|
||
<div className="flex items-center gap-2 rounded-xl border border-emerald-100 bg-emerald-50 px-3 py-3 text-sm font-semibold text-emerald-700">
|
||
<CheckCircle2 className="size-5" />
|
||
{t("hall.preview.noWarnings", { defaultValue: "当前预览未发现明显风险。" })}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid shrink-0 grid-cols-2 gap-3 border-t border-[#e8eef7] bg-white p-4 pb-[calc(1rem+env(safe-area-inset-bottom))] sm:p-5">
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() => onOpenChange(false)}
|
||
disabled={placing}
|
||
className="h-12 rounded-lg border-[#ff3650] bg-white text-base font-black text-[#e5002c] hover:bg-[#fff5f6]"
|
||
>
|
||
{t("hall.preview.backEdit")}
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
onClick={onConfirmPlace}
|
||
disabled={!data || placing || !allowSubmit}
|
||
className="h-12 rounded-lg border-0 bg-[#e5002c] text-base font-black text-white shadow-[0_10px_24px_rgba(229,0,44,0.28)] hover:bg-[#d10028] disabled:bg-slate-300 disabled:shadow-none"
|
||
>
|
||
{placing
|
||
? t("hall.preview.submitting")
|
||
: allowSubmit
|
||
? t("hall.preview.confirmSubmit")
|
||
: t("hall.preview.sealedTitle")}
|
||
</Button>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|