refactor: 重构整体页面布局与样式,统一UI设计风格

- 重构PlayerAppShell,移除冗余头部导航与国际化依赖,统一页面背景与内边距
- 新增通用页面容器组件PlayerPanel,统一页面头部布局与样式
- 重构底部导航栏,调整图标、文案与样式,新增激活状态指示器
- 重构所有页面组件:大厅页、注单页、结果页、开奖面板等,统一使用新的UI组件与设计风格
- 优化状态标签、卡片、按钮等组件的视觉样式,统一配色与圆角规范
- 移除冗余依赖与注释代码,整理代码结构
This commit is contained in:
2026-05-14 11:18:08 +08:00
parent f777888940
commit ac612cb32c
11 changed files with 1054 additions and 735 deletions

View File

@@ -1,33 +1,24 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { CirclePlus, Cuboid, PackageOpen, Ticket, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { getPlayEffective } from "@/api/play";
import { postTicketPlace, postTicketPreview } from "@/api/ticket";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { mapTicketBetError } from "@/features/hall/hall-bet-errors";
import { HallBetAmountInput } from "@/features/hall/hall-bet-amount-input";
import { HallBetPreviewDialog } from "@/features/hall/hall-bet-preview-dialog";
import { HallBetNumberInput } from "@/features/hall/hall-bet-number-input";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { isHallSealedCountdownUi } from "@/features/draw/draw-status-meta";
import { triggerWalletPollingAfterBet } from "@/hooks/use-wallet-polling";
import { HallBetPreviewDialog } from "@/features/hall/hall-bet-preview-dialog";
import { mapTicketBetError } from "@/features/hall/hall-bet-errors";
import {
playNeedsDigitSlot,
playNeedsDimension,
ticketAmountHint,
ticketNumberSpec,
} from "@/features/hall/hall-bet-rules";
import { HallPlaySwitcher, type PlayChip } from "@/features/hall/hall-play-switcher";
import { useHallDrawLive } from "@/features/hall/use-hall-draw-live";
import { triggerWalletPollingAfterBet } from "@/hooks/use-wallet-polling";
import { getLotteryRequestLocale } from "@/lib/lottery-locale";
import { formatMinorAsCurrency, parseDecimalInputToMinor } from "@/lib/money";
import { cn } from "@/lib/utils";
@@ -36,6 +27,58 @@ import type { PlayEffectivePayload, PlayEffectivePlayRow } from "@/types/api/pla
import type { TicketLineInput, TicketPreviewData } from "@/types/api/ticket";
const DEFAULT_POLL_MS = 120_000;
const MAX_ROWS = 20;
type HallCategory = "D2" | "D3" | "D4" | "JACKPOT";
type BoxMode = "ibox" | "box";
type DraftRow = {
id: string;
number: string;
amounts: Record<string, string>;
};
type DraftEntry = {
rowId: string;
rowNo: number;
play: PlayEffectivePlayRow;
number: string;
amountMinor: number;
line: TicketLineInput;
};
const categoryTabs: { value: HallCategory; label: string }[] = [
{ value: "D2", label: "2D" },
{ value: "D3", label: "3D" },
{ value: "D4", label: "4D" },
{ value: "JACKPOT", label: "Jackpot" },
];
const preferredD4Columns = [
"big",
"small",
"pos_3c",
"pos_3a",
"pos_4a",
"pos_4b",
"pos_4c",
"pos_4d",
"pos_4e",
"straight",
] as const;
const simpleCategoryPreferred: Record<"D2" | "D3", string[]> = {
D2: ["pos_2a", "pos_2b", "pos_2c", "pos_2abc"],
D3: ["pos_3a", "pos_3b", "pos_3c", "pos_3abc"],
};
function newDraftRow(): DraftRow {
const id =
typeof crypto !== "undefined" && crypto.randomUUID
? crypto.randomUUID()
: `row-${Date.now()}-${Math.random().toString(36).slice(2)}`;
return { id, number: "", amounts: {} };
}
function isPlayOpenForPlayer(row: PlayEffectivePlayRow): boolean {
if (!row.master_enabled || row.config === null) {
@@ -55,59 +98,121 @@ function pickDisplayName(row: PlayEffectivePlayRow): string {
return row.display_name_en ?? row.display_name_zh ?? row.play_code;
}
function digitSlotOptions(dimension: "D2" | "D3" | "D4"): { value: number; label: string }[] {
if (dimension === "D2") {
return [
{ value: 2, label: "十位" },
{ value: 3, label: "个位" },
];
}
if (dimension === "D3") {
return [
{ value: 1, label: "百位" },
{ value: 2, label: "十位" },
{ value: 3, label: "个位" },
];
}
return [
{ value: 0, label: "千位" },
{ value: 1, label: "百位" },
{ value: 2, label: "十位" },
{ value: 3, label: "个位" },
];
function inferCategory(row: PlayEffectivePlayRow): HallCategory {
if (row.play_code.startsWith("pos_2")) return "D2";
if (row.play_code.startsWith("pos_3")) return "D3";
if (row.category.toLowerCase().includes("jackpot")) return "JACKPOT";
if (row.dimension === 2) return "D2";
if (row.dimension === 3) return "D3";
return "D4";
}
function numberHelper(playCode: string, spec: ReturnType<typeof ticketNumberSpec>): string | null {
if (spec.mode === "roll") {
return "Roll共 4 位,须包含字母 R 表示滚动位其余为数字0-9。";
}
if (playCode.startsWith("pos_")) {
return "位置玩法请输入对应位数2D / 3D系统按后 2/3 位展开为全部 4D 组合。";
}
if (playCode === "head") {
return "Head请输入 1 个数字0-9用于生成千位为 5-9 的全部组合。";
}
if (playCode === "tail") {
return "Tail请输入 1 个数字0-9用于生成千位为 0-4 的全部组合。";
}
if (playCode === "odd" || playCode === "even") {
return "单双请选择维度2D/3D/4D后输入 1 个数字0-9。";
}
if (playCode === "digit_big" || playCode === "digit_small") {
return "大小:请选择维度与具体位数后输入 1 个数字0-9。";
}
return null;
function categoryDigits(category: HallCategory): number {
if (category === "D2") return 2;
if (category === "D3") return 3;
if (category === "D4") return 4;
return 0;
}
function rollInputValid(v: string): boolean {
return v.length === 4 && v.includes("R") && /^[0-9R]+$/i.test(v);
function sanitizeNumber(raw: string, category: HallCategory): string {
const max = categoryDigits(category);
return raw.replace(/\D/g, "").slice(0, max);
}
function sanitizeAmount(raw: string): string {
return raw.replace(/[^\d.]/g, "").replace(/(\..*)\./g, "$1").slice(0, 12);
}
function parseRebateRate(rate: string | undefined): number {
const n = Number(rate ?? 0);
if (!Number.isFinite(n) || n <= 0) return 0;
return n > 1 ? n / 100 : n;
}
function formatRatePercent(rate: string | undefined): string {
const n = parseRebateRate(rate);
return `${(n * 100).toFixed(2)}%`;
}
function amountToDisplay(minor: number): string {
return (minor / 100).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
function normalizeNumberForPlay(number: string, playCode: string): string {
if (playCode.startsWith("pos_2")) return number.slice(-2);
if (playCode.startsWith("pos_3")) return number.slice(-3);
if (
playCode === "head" ||
playCode === "tail" ||
playCode === "odd" ||
playCode === "even" ||
playCode === "digit_big" ||
playCode === "digit_small"
) {
return number.slice(-1);
}
return number;
}
function lineForPlay(
category: "D2" | "D3" | "D4",
play: PlayEffectivePlayRow,
displayNumber: string,
amountMinor: number,
): TicketLineInput | null {
const number = normalizeNumberForPlay(displayNumber, play.play_code);
const spec = ticketNumberSpec(play.play_code);
if (number.length !== spec.maxChars) {
return null;
}
const line: TicketLineInput = {
number,
play_code: play.play_code,
amount: amountMinor,
};
if (playNeedsDimension(play.play_code)) {
line.dimension = category;
}
if (playNeedsDigitSlot(play.play_code)) {
line.digit_slot = 3;
}
return line;
}
function findPlayByCode(
plays: PlayEffectivePlayRow[],
playCode: string,
): PlayEffectivePlayRow | undefined {
return plays.find((p) => p.play_code === playCode);
}
function pickSimplePlay(
plays: PlayEffectivePlayRow[],
category: "D2" | "D3",
): PlayEffectivePlayRow | undefined {
const preferred = simpleCategoryPreferred[category];
return (
preferred.map((code) => findPlayByCode(plays, code)).find(Boolean) ??
plays.find((p) => inferCategory(p) === category)
);
}
/**
* 下注大厅表格:号码 / 金额 / 玩法切换、预览与确认、结果提示(实施计划 §13.3,产品文档 §4.2 / §6.3)。
*/
export function HallBettingGrid() {
const { display, isBettable, reload: reloadDraw } = useHallDrawLive();
const [activeCategory, setActiveCategory] = useState<HallCategory>("D2");
const [boxMode, setBoxMode] = useState<BoxMode>("ibox");
const [rows, setRows] = useState<DraftRow[]>(() => [
{ ...newDraftRow(), number: "23", amounts: {} },
{ ...newDraftRow(), number: "75", amounts: {} },
{ ...newDraftRow(), number: "08", amounts: {} },
{ ...newDraftRow(), number: "46", amounts: {} },
]);
const currencyParam = useMemo(() => {
const fromEnv = process.env.NEXT_PUBLIC_LOTTERY_PLAY_CURRENCY?.trim();
@@ -120,6 +225,11 @@ export function HallBettingGrid() {
| { kind: "error"; message: string }
>({ kind: "loading" });
const [previewOpen, setPreviewOpen] = useState(false);
const [previewData, setPreviewData] = useState<TicketPreviewData | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [placeLoading, setPlaceLoading] = useState(false);
const loadCatalog = useCallback(async () => {
setCatalogState((s) => (s.kind === "ok" ? s : { kind: "loading" }));
try {
@@ -151,115 +261,143 @@ export function HallBettingGrid() {
if (catalogState.kind !== "ok") return [];
return [...catalogState.data.plays]
.filter(isPlayOpenForPlayer)
.filter((p) => p.play_code !== "half_box")
.filter((p) => p.play_code !== "half_box" && p.play_code !== "roll")
.sort((a, b) => a.sort_order - b.sort_order || a.play_code.localeCompare(b.play_code));
}, [catalogState]);
const [playCode, setPlayCode] = useState("");
const [number, setNumber] = useState("");
const [amountStr, setAmountStr] = useState("");
const [dimension, setDimension] = useState<"D2" | "D3" | "D4">("D4");
const [digitSlot, setDigitSlot] = useState(3);
/** 目录刷新后若原玩法关闭,自动回落到列表首个开放玩法(不依赖 effect 写 state */
const activePlayCode = useMemo(() => {
if (openPlays.length === 0) return "";
if (playCode && openPlays.some((p) => p.play_code === playCode)) {
return playCode;
}
return openPlays[0].play_code;
}, [openPlays, playCode]);
const slotOpts = useMemo(() => digitSlotOptions(dimension), [dimension]);
const activeDigitSlot = useMemo(() => {
if (slotOpts.some((o) => o.value === digitSlot)) {
return digitSlot;
}
return slotOpts[0].value;
}, [digitSlot, slotOpts]);
const spec = useMemo(() => ticketNumberSpec(activePlayCode), [activePlayCode]);
const selectedRow = useMemo(
() => openPlays.find((p) => p.play_code === activePlayCode),
[openPlays, activePlayCode],
);
const chips: PlayChip[] = useMemo(
() => openPlays.map((p) => ({ play_code: p.play_code, label: pickDisplayName(p) })),
[openPlays],
);
const currencyCode =
catalogState.kind === "ok" ? catalogState.data.currency_code : "NPR";
const minBet = selectedRow?.config?.min_bet_amount ?? 1;
const maxBet = selectedRow?.config?.max_bet_amount ?? 999_999_999;
const simplePlay = useMemo(() => {
if (activeCategory !== "D2" && activeCategory !== "D3") return undefined;
return pickSimplePlay(openPlays, activeCategory);
}, [activeCategory, openPlays]);
const tableDisabled = !isBettable || catalogState.kind !== "ok";
const d4Columns = useMemo(() => {
if (activeCategory !== "D4") return [];
const first = findPlayByCode(openPlays, boxMode);
const preferred = preferredD4Columns
.map((code) => findPlayByCode(openPlays, code))
.filter((p): p is PlayEffectivePlayRow => Boolean(p));
const merged = first ? [first, ...preferred] : preferred;
return merged.filter((p, i, arr) => arr.findIndex((x) => x.play_code === p.play_code) === i);
}, [activeCategory, boxMode, openPlays]);
const tableDisabled =
activeCategory === "JACKPOT" || !isBettable || catalogState.kind !== "ok";
const sealedBetUi = Boolean(display && isHallSealedCountdownUi(display.status));
const [previewOpen, setPreviewOpen] = useState(false);
const [previewData, setPreviewData] = useState<TicketPreviewData | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [placeLoading, setPlaceLoading] = useState(false);
const updateRowNumber = (id: string, value: string) => {
setRows((current) =>
current.map((row) =>
row.id === id ? { ...row, number: sanitizeNumber(value, activeCategory) } : row,
),
);
};
const buildLine = useCallback((): TicketLineInput | null => {
if (!activePlayCode) return null;
const minor = parseDecimalInputToMinor(amountStr);
if (minor === null || minor < minBet || minor > maxBet) {
return null;
}
if (spec.mode === "roll") {
if (!rollInputValid(number)) return null;
} else if (number.length !== spec.maxChars) {
return null;
}
const line: TicketLineInput = {
number,
play_code: activePlayCode,
amount: minor,
};
if (playNeedsDimension(activePlayCode)) {
line.dimension = dimension;
}
if (playNeedsDigitSlot(activePlayCode)) {
line.digit_slot = activeDigitSlot;
}
return line;
}, [
activeDigitSlot,
activePlayCode,
amountStr,
dimension,
maxBet,
minBet,
number,
spec.maxChars,
spec.mode,
]);
const updateAmount = (rowId: string, playCode: string, value: string) => {
setRows((current) =>
current.map((row) =>
row.id === rowId
? {
...row,
amounts: { ...row.amounts, [playCode]: sanitizeAmount(value) },
}
: row,
),
);
};
const addRow = () => {
setRows((current) => (current.length >= MAX_ROWS ? current : [...current, newDraftRow()]));
};
const removeRow = (id: string) => {
setRows((current) =>
current.length <= 1 ? current : current.filter((row) => row.id !== id),
);
};
const collectEntries = useCallback((): DraftEntry[] => {
if (activeCategory === "JACKPOT") return [];
const entries: DraftEntry[] = [];
const plays =
activeCategory === "D4" ? d4Columns : simplePlay ? [simplePlay] : [];
rows.forEach((row, rowIndex) => {
plays.forEach((play) => {
const amount = parseDecimalInputToMinor(row.amounts[play.play_code] ?? "");
if (amount === null || amount <= 0) return;
const line = lineForPlay(
activeCategory as "D2" | "D3" | "D4",
play,
row.number,
amount,
);
if (!line) return;
entries.push({
rowId: row.id,
rowNo: rowIndex + 1,
play,
number: row.number,
amountMinor: amount,
line,
});
});
});
return entries;
}, [activeCategory, d4Columns, rows, simplePlay]);
const draftEntries = collectEntries();
const draftSummary = useMemo(() => {
return draftEntries.reduce(
(acc, entry) => {
const rebateRate = parseRebateRate(entry.play.odds?.rebate_rate);
const rebate = Math.round(entry.amountMinor * rebateRate);
acc.bet += entry.amountMinor;
acc.rebate += rebate;
acc.actual += Math.max(0, entry.amountMinor - rebate);
return acc;
},
{ bet: 0, rebate: 0, actual: 0 },
);
}, [draftEntries]);
const buildLines = (): TicketLineInput[] => {
return collectEntries().map((entry) => entry.line);
};
const handlePreview = async () => {
if (!display) {
toast.error("暂无当期期号,无法预览。");
toast.error("暂无当期期号,无法提交。");
return;
}
if (!isBettable) {
toast.error("当前已封盘或不可下注,无法预览。");
toast.error("当前已封盘或不可下注。");
return;
}
const line = buildLine();
if (!line) {
toast.error("请检查号码长度与金额是否在玩法限额内。");
if (catalogState.kind !== "ok") {
toast.error("玩法配置尚未加载完成。");
return;
}
const lines = buildLines();
if (lines.length === 0) {
toast.error("请至少填写一组有效号码和下注金额。");
return;
}
setPreviewLoading(true);
try {
const data = await postTicketPreview({
draw_id: display.draw_no,
currency_code: currencyCode,
client_trace_id: `pv-${typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : String(Date.now())}`,
lines: [line],
lines,
});
setPreviewData(data);
setPreviewOpen(true);
@@ -278,11 +416,13 @@ export function HallBettingGrid() {
toast.error("已封盘,无法提交。");
return;
}
const line = buildLine();
if (!line) {
const lines = buildLines();
if (lines.length === 0) {
toast.error("提交前数据已变化,请关闭预览后重试。");
return;
}
setPlaceLoading(true);
try {
const data = await postTicketPlace({
@@ -292,7 +432,7 @@ export function HallBettingGrid() {
typeof crypto !== "undefined" && crypto.randomUUID
? crypto.randomUUID()
: `pl-${Date.now()}`,
lines: [line],
lines,
expected_config_versions: previewData.config_versions,
});
toast.success(
@@ -300,9 +440,7 @@ export function HallBettingGrid() {
);
setPreviewOpen(false);
setPreviewData(null);
setAmountStr("");
setNumber("");
// 触发钱包轮询降级模式下启动2分钟限时轮询
setRows([newDraftRow()]);
triggerWalletPollingAfterBet();
void reloadDraw();
} catch (e) {
@@ -314,167 +452,301 @@ export function HallBettingGrid() {
}
};
const body = (() => {
if (catalogState.kind === "loading") {
return <p className="text-sm text-muted-foreground"></p>;
}
if (catalogState.kind === "error") {
return (
<div className="space-y-2">
<p className="text-sm text-destructive">{catalogState.message}</p>
<Button type="button" size="sm" variant="secondary" onClick={() => void loadCatalog()}>
</Button>
</div>
);
}
if (openPlays.length === 0) {
return <p className="text-sm text-muted-foreground"></p>;
}
if (catalogState.kind === "loading") {
return (
<div
className={cn(
"space-y-5 transition-opacity",
tableDisabled && "pointer-events-none",
tableDisabled && (sealedBetUi ? "opacity-[0.52]" : "opacity-50"),
)}
>
{!isBettable && display ? (
sealedBetUi ? (
<div className="space-y-1 rounded-lg border border-[#ff4d4f]/35 bg-[#ff4d4f]/8 px-3 py-2 text-sm text-[#ff4d4f] dark:bg-[#ff4d4f]/10">
<p className="font-medium">
§4.2
</p>
<p className="text-xs leading-relaxed"></p>
</div>
) : (
<p className="rounded-lg border border-muted-foreground/30 bg-muted/50 px-3 py-2 text-sm text-muted-foreground">
{display.status}
</p>
)
) : null}
<HallPlaySwitcher
plays={chips}
value={activePlayCode}
onChange={(code) => {
setPlayCode(code);
setNumber("");
}}
disabled={tableDisabled}
/>
{playNeedsDimension(activePlayCode) ? (
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="bet-dimension"></Label>
<select
id="bet-dimension"
disabled={tableDisabled}
value={dimension}
onChange={(e) => {
const d = e.target.value as "D2" | "D3" | "D4";
setDimension(d);
setDigitSlot(digitSlotOptions(d)[0].value);
}}
className="h-8 w-full rounded-lg border border-input bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<option value="D4">4D</option>
<option value="D3">3D</option>
<option value="D2">2D</option>
</select>
</div>
{playNeedsDigitSlot(activePlayCode) ? (
<div className="space-y-2">
<Label htmlFor="bet-digit-slot"></Label>
<select
id="bet-digit-slot"
disabled={tableDisabled}
value={String(activeDigitSlot)}
onChange={(e) => setDigitSlot(Number(e.target.value))}
className="h-8 w-full rounded-lg border border-input bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{slotOpts.map((o) => (
<option key={o.value} value={String(o.value)}>
{o.label}slot {o.value}
</option>
))}
</select>
</div>
) : (
<div aria-hidden className="hidden sm:block" />
)}
</div>
) : null}
<HallBetNumberInput
id="bet-number"
label="号码"
value={number}
onChange={setNumber}
spec={spec}
disabled={tableDisabled}
helper={numberHelper(activePlayCode, spec)}
/>
<HallBetAmountInput
id="bet-amount"
label="金额(主货币,如 10.00"
value={amountStr}
onChange={setAmountStr}
currencyCode={currencyCode}
minBetMinor={minBet}
maxBetMinor={maxBet}
disabled={tableDisabled}
hint={ticketAmountHint(activePlayCode)}
/>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-muted-foreground">
= × (1 ) §16.1§6.4
</p>
<Button
type="button"
className="sm:min-w-36"
disabled={tableDisabled || previewLoading || openPlays.length === 0}
onClick={() => void handlePreview()}
>
{previewLoading ? "预览中…" : "预览下注"}
</Button>
</div>
{!isBettable && display ? (
<Button type="button" variant="secondary" disabled className="w-full">
</Button>
) : null}
</div>
<section className="space-y-3" aria-label="Betting table">
<Skeleton className="h-12 rounded-xl" />
<Skeleton className="h-72 rounded-xl" />
<Skeleton className="h-14 rounded-xl" />
</section>
);
})();
}
if (catalogState.kind === "error") {
return (
<section className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
<p>{catalogState.message}</p>
<Button
type="button"
size="sm"
variant="outline"
className="mt-3 border-red-200 bg-white text-red-700 hover:bg-red-50"
onClick={() => void loadCatalog()}
>
</Button>
</section>
);
}
const simplePlayCode = simplePlay?.play_code ?? "";
const numberPlaceholder =
activeCategory === "D2" ? "00" : activeCategory === "D3" ? "000" : "0000";
return (
<>
<Card
className={cn(
sealedBetUi && "border-[#ff4d4f]/40 bg-muted/30",
!isBettable && display && !sealedBetUi && "border-muted-foreground/20 bg-muted/20",
<section className="space-y-4" aria-label="Betting table">
<div className="grid grid-cols-4 rounded-xl border border-[#e8eef7] bg-white p-1 shadow-[0_6px_18px_rgba(30,64,175,0.06)]">
{categoryTabs.map((tab) => {
const active = activeCategory === tab.value;
return (
<button
key={tab.value}
type="button"
onClick={() => {
setActiveCategory(tab.value);
setRows((current) =>
current.map((row) => ({
...row,
number: sanitizeNumber(row.number, tab.value),
})),
);
}}
className={cn(
"relative flex h-10 min-w-0 items-center justify-center rounded-lg text-sm font-semibold transition-colors",
active
? "bg-[#07459f] text-white shadow-[inset_0_-2px_0_rgba(255,255,255,0.28)]"
: "text-[#4b5563] hover:bg-[#f4f7fb]",
tab.value === "JACKPOT" && active && "bg-[#7b8492]",
)}
>
<span className="truncate">{tab.label}</span>
</button>
);
})}
</div>
{activeCategory === "D4" ? (
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setBoxMode("ibox")}
disabled={tableDisabled}
className={cn(
"flex min-w-0 items-center gap-2 rounded-lg border bg-white px-3 py-2 text-left shadow-sm transition-colors",
boxMode === "ibox"
? "border-[#b9ccf6] text-[#07459f]"
: "border-[#f2b4bc] text-[#e11d48]",
tableDisabled && "opacity-50",
)}
>
<span
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-full text-white",
boxMode === "ibox" ? "bg-[#0956c8]" : "bg-[#ff4158]",
)}
>
<Cuboid className="size-4" aria-hidden />
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-bold">iBox</span>
<span className="block truncate text-[10px] text-slate-500">
Divide all by amount
</span>
</span>
</button>
<button
type="button"
onClick={() => setBoxMode("box")}
disabled={tableDisabled}
className={cn(
"flex min-w-0 items-center gap-2 rounded-lg border bg-white px-3 py-2 text-left shadow-sm transition-colors",
boxMode === "box"
? "border-[#f2b4bc] text-[#e11d48]"
: "border-[#e8eef7] text-[#ef4056]",
tableDisabled && "opacity-50",
)}
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-[#ff4158] text-white">
<PackageOpen className="size-4" aria-hidden />
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-bold">Box</span>
<span className="block truncate text-[10px] text-slate-500">
Multiply all by amount
</span>
</span>
</button>
</div>
) : null}
{activeCategory === "JACKPOT" ? (
<div className="rounded-xl border border-[#edf1f7] bg-[#f7f9fc] p-7 text-center text-slate-500">
<div className="mx-auto flex size-14 items-center justify-center rounded-full bg-slate-200 text-slate-600">
<Ticket className="size-7" aria-hidden />
</div>
<p className="mt-4 text-lg font-bold text-slate-900">Closed</p>
<p className="mt-1 text-xs">This issue is now closed.</p>
<div className="mt-5 rounded-lg border border-[#cbdcf7] bg-white px-3 py-3 text-left text-xs text-[#315a9f]">
The betting window has closed. Please wait for the next issue to place your bets.
</div>
</div>
) : (
<div
className={cn(
"overflow-hidden rounded-xl border border-[#e6edf8] bg-white shadow-[0_8px_28px_rgba(15,23,42,0.05)] transition-opacity",
tableDisabled && "opacity-55",
)}
>
<div className="overflow-x-auto">
<table className={cn("w-full border-collapse text-sm", activeCategory === "D4" ? "min-w-[760px]" : "min-w-[460px]")}>
<thead>
<tr className="border-b border-[#e8eef7] bg-[#f5f8fd] text-[11px] font-semibold text-[#32518d]">
<th className="sticky left-0 z-20 w-12 bg-[#f5f8fd] px-2 py-3 text-center">
No.
</th>
<th className="sticky left-12 z-20 w-24 bg-[#f5f8fd] px-2 py-3 text-center">
Number
<span className="block text-[10px] font-normal text-[#6b7896]">
({numberPlaceholder})
</span>
</th>
{activeCategory === "D4" ? (
d4Columns.map((play) => (
<th key={play.play_code} className="min-w-20 px-2 py-3 text-center">
{pickDisplayName(play)}
</th>
))
) : (
<>
<th className="min-w-28 px-2 py-3 text-center">Stake Amount</th>
<th className="min-w-28 px-2 py-3 text-center">Commission / Rebate</th>
<th className="min-w-28 px-2 py-3 text-center">Actual Deduction</th>
</>
)}
<th className="w-10 px-2 py-3" aria-label="Delete" />
</tr>
</thead>
<tbody>
{rows.map((row, index) => {
const simpleAmount = parseDecimalInputToMinor(
row.amounts[simplePlayCode] ?? "",
);
const simpleRebate =
simpleAmount !== null && simplePlay
? Math.round(simpleAmount * parseRebateRate(simplePlay.odds?.rebate_rate))
: 0;
const simpleActual =
simpleAmount !== null ? Math.max(0, simpleAmount - simpleRebate) : 0;
return (
<tr
key={row.id}
className="border-b border-[#eef2f8] last:border-b-0"
>
<td className="sticky left-0 z-10 bg-white px-2 py-3 text-center font-semibold text-[#17408d]">
{index + 1}
</td>
<td className="sticky left-12 z-10 bg-white px-2 py-3">
<Input
value={row.number}
disabled={tableDisabled}
inputMode="numeric"
placeholder={numberPlaceholder}
onChange={(event) => updateRowNumber(row.id, event.target.value)}
className="h-10 rounded-lg border-[#e2e8f0] bg-white text-center font-mono text-sm font-semibold text-slate-900 shadow-sm"
/>
</td>
{activeCategory === "D4" ? (
d4Columns.map((play) => (
<td key={play.play_code} className="px-1.5 py-3">
<Input
value={row.amounts[play.play_code] ?? ""}
disabled={tableDisabled}
inputMode="decimal"
placeholder="-"
onChange={(event) =>
updateAmount(row.id, play.play_code, event.target.value)
}
className="h-10 rounded-lg border-[#e2e8f0] bg-white text-center text-xs tabular-nums shadow-sm"
/>
</td>
))
) : (
<>
<td className="px-2 py-3">
<Input
value={row.amounts[simplePlayCode] ?? ""}
disabled={tableDisabled || !simplePlay}
inputMode="decimal"
placeholder="0.00"
onChange={(event) =>
updateAmount(row.id, simplePlayCode, event.target.value)
}
className="h-10 rounded-lg border-[#e2e8f0] bg-white text-center text-sm tabular-nums shadow-sm"
/>
</td>
<td className="px-2 py-3 text-center leading-tight">
<span className="block font-semibold tabular-nums text-[#34a853]">
-{simpleRebate > 0 ? amountToDisplay(simpleRebate) : "0.00"}
</span>
<span className="text-xs text-slate-500">
({formatRatePercent(simplePlay?.odds?.rebate_rate)})
</span>
</td>
<td className="px-2 py-3 text-center tabular-nums text-slate-700">
{simpleActual > 0 ? amountToDisplay(simpleActual) : "0.00"}
</td>
</>
)}
<td className="px-1 py-3 text-center">
<button
type="button"
disabled={tableDisabled || rows.length <= 1}
onClick={() => removeRow(row.id)}
className="inline-flex size-8 items-center justify-center rounded-full text-[#ff4d4f] hover:bg-red-50 disabled:text-slate-300 disabled:hover:bg-transparent"
aria-label={`删除第 ${index + 1}`}
>
<Trash2 className="size-4" aria-hidden />
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<button
type="button"
disabled={tableDisabled || rows.length >= MAX_ROWS}
onClick={addRow}
className="flex h-11 w-full items-center justify-center gap-1.5 border-t border-[#edf2f9] text-sm font-semibold text-[#1d57b7] hover:bg-[#f7faff] disabled:text-slate-300"
>
<CirclePlus className="size-4" aria-hidden />
Add Row
</button>
</div>
)}
>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">{body}</CardContent>
</Card>
<div className="flex items-center justify-between rounded-xl border border-[#e9eef7] bg-[#f8fbff] px-4 py-3 text-sm shadow-[0_6px_20px_rgba(15,23,42,0.04)]">
<span className="font-medium text-slate-800">Draft Total</span>
<span className="text-lg font-bold tabular-nums text-[#0b3f96]">
{formatMinorAsCurrency(draftSummary.actual, currencyCode)}
</span>
</div>
{sealedBetUi ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-600">
</p>
) : null}
<Button
type="button"
disabled={tableDisabled || previewLoading || draftEntries.length === 0}
onClick={() => void handlePreview()}
className="h-12 w-full rounded-xl border-0 bg-[#e5002c] text-base font-bold text-white shadow-[0_8px_20px_rgba(229,0,44,0.26)] hover:bg-[#d10028]"
>
<Ticket className="size-5" aria-hidden />
{previewLoading ? "Previewing..." : activeCategory === "JACKPOT" ? "Closed" : "Submit Bet"}
</Button>
</section>
<HallBetPreviewDialog
open={previewOpen}
onOpenChange={(o) => {
setPreviewOpen(o);
if (!o) setPreviewData(null);
onOpenChange={(open) => {
setPreviewOpen(open);
if (!open) setPreviewData(null);
}}
currencyCode={currencyCode}
data={previewData}