Files
lotteryFront/src/features/hall/hall-bet-preview-dialog.tsx
kang 0b14e77f64 feat: enhance player login validation and update betting preview
- Added username and password validation to the player login screen, improving user feedback for input errors.
- Updated the betting preview dialog to conditionally display rebate information based on the applied instant rebate status.
- Enhanced documentation in AGENTS.md to clarify credit flow and settlement processes.
- Modified .env.example to provide clearer instructions for multi-site deployment configurations.
2026-06-14 21:13:24 +08:00

350 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import {
AlertTriangleIcon,
CheckCircle2,
LoaderCircle,
Lock,
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;
jackpotEnabled?: 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}
style={{ width: "auto", height: "auto" }}
className="mx-auto max-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")}
</DialogTitle>
<DialogDescription className="max-w-[220px] text-center text-sm leading-relaxed text-slate-500">
{t("hall.preview.processingDescription")}
</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")}
</div>
</div>
</DialogContent>
);
}
/**
* 预览弹窗 + 提交确认(产品文档 §10.1.2:预览不下单,确认后 place
*/
export function HallBetPreviewDialog({
open,
onOpenChange,
currencyCode,
data,
placing,
jackpotEnabled = false,
allowSubmit = true,
onConfirmPlace,
}: HallBetPreviewDialogProps) {
const { t } = useTranslation("player");
const summary = data?.summary;
const lines = data?.lines ?? [];
const periodRebate = summary?.instant_rebate_applied === false;
const rebateLabel = periodRebate
? t("hall.preview.periodRebate", { defaultValue: "账期回水" })
: t("hall.preview.rebate");
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-3 pb-2.5 pt-4 sm:px-4">
<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")}
>
<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-3 sm:px-4"
>
<div className="space-y-3 py-3">
{!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>
{jackpotEnabled ? (
<Alert className="border-[#d9ecff] bg-[#f4f9ff] text-[#0b3f96]">
<CheckCircle2 className="size-4" />
<AlertTitle>{t("hall.jackpotParticipation.title")}</AlertTitle>
<AlertDescription className="text-xs leading-relaxed">
{t("hall.jackpotParticipation.previewDescription")}
</AlertDescription>
</Alert>
) : null}
<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")}
</th>
<th className="border-r border-[#dfe8f6] px-2 py-3 text-center font-black">
{t("orders.play")}
</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">
{rebateLabel}
</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">
{periodRebate ? "" : "-"}
{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]">{rebateLabel}</p>
<p className="mt-1 font-black tabular-nums text-emerald-600">
{periodRebate ? "" : "-"}
{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}
{periodRebate ? (
<p className="text-xs leading-relaxed text-slate-500">
{t("hall.preview.periodRebateHint", {
defaultValue:
"信用盘回水在账期结算入账,下注时按全额占用可用信用,此处为账期回水预估。",
})}
</p>
) : 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")}
</div>
)}
</>
)}
</div>
</div>
<div className="grid shrink-0 grid-cols-2 gap-2.5 border-t border-[#e8eef7] bg-white p-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] sm:p-4">
<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 gap-2 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-500 disabled:opacity-100 disabled:shadow-none"
>
{!allowSubmit && !placing ? <Lock className="size-5 shrink-0" aria-hidden /> : null}
{placing
? t("hall.preview.submitting")
: allowSubmit
? t("hall.preview.confirmSubmit")
: t("hall.preview.sealedTitle")}
</Button>
</div>
</DialogContent>
</Dialog>
);
}