- 精简大厅下注表格布局,缩小列宽与输入框,优化移动端可读性 - 调整默认草稿行与行激活逻辑,简化草稿合计展示 - 新增开奖结果日期选择器、清除日期与加载更多功能 - 支持开奖结果分页滚动加载与无更多数据提示 - 新增 react-day-picker 与 date-fns 依赖 - 补充下注表格相关多语言文案
1067 lines
39 KiB
TypeScript
1067 lines
39 KiB
TypeScript
"use client";
|
|
|
|
import { CirclePlus, Ticket, Trash2, Star } from "lucide-react";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { toast } from "sonner";
|
|
|
|
import { getPlayEffective } from "@/api/play";
|
|
import { getWalletBalance } from "@/api/wallet";
|
|
import { postTicketPlace, postTicketPreview } from "@/api/ticket";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { isHallSealedCountdownUi } from "@/features/draw/draw-status-meta";
|
|
import { HallBetPreviewDialog } from "@/features/hall/hall-bet-preview-dialog";
|
|
import { HallBetResultDialog } from "@/features/hall/hall-bet-result-dialog";
|
|
import { mapTicketBetError } from "@/features/hall/hall-bet-errors";
|
|
import {
|
|
playNeedsDigitSlot,
|
|
playNeedsDimension,
|
|
ticketNumberSpec,
|
|
} from "@/features/hall/hall-bet-rules";
|
|
import type { HallDrawLiveSnapshot } 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";
|
|
import { LotteryApiBizError } from "@/types/api/errors";
|
|
import type { PlayEffectivePayload, PlayEffectivePlayRow } from "@/types/api/play-effective";
|
|
import type { TicketLineInput, TicketPlaceData, TicketPreviewData } from "@/types/api/ticket";
|
|
import type { DrawCurrentRiskPoolAlert } from "@/types/api/draw-current";
|
|
|
|
const DEFAULT_POLL_MS = 120_000;
|
|
const MAX_ROWS = 20;
|
|
|
|
type HallCategory = "D2" | "D3" | "D4" | "JACKPOT";
|
|
|
|
type DraftRow = {
|
|
id: string;
|
|
number: string;
|
|
amounts: Record<string, string>;
|
|
};
|
|
|
|
type DraftEntry = {
|
|
rowId: string;
|
|
rowNo: number;
|
|
play: PlayEffectivePlayRow;
|
|
number: string;
|
|
amountMinor: number;
|
|
line: TicketLineInput;
|
|
};
|
|
|
|
type CellRiskState = "open" | "warning" | "sold_out";
|
|
type QuickFillState = Record<HallCategory, { favorites: string[]; history: string[] }>;
|
|
|
|
const categoryTabs: { value: HallCategory; label: string }[] = [
|
|
{ value: "D2", label: "2D" },
|
|
{ value: "D3", label: "3D" },
|
|
{ value: "D4", label: "4D" },
|
|
{ value: "JACKPOT", label: "Jackpot" },
|
|
];
|
|
|
|
const D2_PLAY_ORDER = ["pos_2a", "pos_2b", "pos_2c", "pos_2abc"] as const;
|
|
const D3_PLAY_ORDER = ["pos_3a", "pos_3b", "pos_3c", "pos_3abc"] as const;
|
|
const D4_PLAY_ORDER = [
|
|
"big",
|
|
"small",
|
|
"pos_4a",
|
|
"pos_4b",
|
|
"pos_4c",
|
|
"pos_4d",
|
|
"pos_4e",
|
|
"box",
|
|
"ibox",
|
|
"mbox",
|
|
"roll",
|
|
"straight",
|
|
"head",
|
|
"tail",
|
|
"odd",
|
|
"even",
|
|
"digit_big",
|
|
"digit_small",
|
|
] as const;
|
|
|
|
const categoryPlayOrders: Record<Exclude<HallCategory, "JACKPOT">, readonly string[]> = {
|
|
D2: D2_PLAY_ORDER,
|
|
D3: D3_PLAY_ORDER,
|
|
D4: D4_PLAY_ORDER,
|
|
};
|
|
|
|
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 {
|
|
return Boolean(row.master_enabled && row.config?.is_enabled);
|
|
}
|
|
|
|
function pickDisplayName(row: PlayEffectivePlayRow): string {
|
|
const loc = getLotteryRequestLocale();
|
|
if (loc === "zh") return row.display_name_zh ?? row.display_name_en ?? row.play_code;
|
|
if (loc === "ne") return row.display_name_ne ?? row.display_name_en ?? row.play_code;
|
|
return row.display_name_en ?? row.display_name_zh ?? row.play_code;
|
|
}
|
|
|
|
function inferCategory(row: PlayEffectivePlayRow): Exclude<HallCategory, "JACKPOT"> {
|
|
if (row.play_code.startsWith("pos_2")) return "D2";
|
|
if (row.play_code.startsWith("pos_3")) return "D3";
|
|
return "D4";
|
|
}
|
|
|
|
function categoryDigits(category: HallCategory): number {
|
|
if (category === "D2") return 2;
|
|
if (category === "D3") return 3;
|
|
if (category === "D4") return 4;
|
|
return 0;
|
|
}
|
|
|
|
function sanitizeNumber(raw: string, category: HallCategory): string {
|
|
if (category === "D4") {
|
|
return raw.replace(/[^0-9Rr]/g, "").toUpperCase().slice(0, 4);
|
|
}
|
|
return raw.replace(/\D/g, "").slice(0, categoryDigits(category));
|
|
}
|
|
|
|
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 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 pickDigitSlot(category: HallCategory): number {
|
|
if (category === "D2") return 3;
|
|
return 3;
|
|
}
|
|
|
|
function lineForPlay(
|
|
category: Exclude<HallCategory, "JACKPOT">,
|
|
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 = pickDigitSlot(category);
|
|
}
|
|
|
|
return line;
|
|
}
|
|
|
|
function sortByPlayOrder(plays: PlayEffectivePlayRow[], order: readonly string[]): PlayEffectivePlayRow[] {
|
|
const orderMap = new Map(order.map((code, idx) => [code, idx]));
|
|
return [...plays].sort((a, b) => {
|
|
const ai = orderMap.get(a.play_code) ?? 999;
|
|
const bi = orderMap.get(b.play_code) ?? 999;
|
|
return ai - bi || a.sort_order - b.sort_order || a.play_code.localeCompare(b.play_code);
|
|
});
|
|
}
|
|
|
|
function loadStringArray(key: string): string[] {
|
|
if (typeof window === "undefined") return [];
|
|
try {
|
|
const raw = window.localStorage.getItem(key);
|
|
if (!raw) return [];
|
|
const parsed = JSON.parse(raw);
|
|
if (!Array.isArray(parsed)) return [];
|
|
return parsed.filter((v): v is string => typeof v === "string");
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function saveStringArray(key: string, values: string[]): void {
|
|
if (typeof window === "undefined") return;
|
|
window.localStorage.setItem(key, JSON.stringify(values));
|
|
}
|
|
|
|
function loadQuickFillState(): QuickFillState {
|
|
return {
|
|
D2: {
|
|
favorites: loadStringArray(quickFillKeys("D2").favorites),
|
|
history: loadStringArray(quickFillKeys("D2").history),
|
|
},
|
|
D3: {
|
|
favorites: loadStringArray(quickFillKeys("D3").favorites),
|
|
history: loadStringArray(quickFillKeys("D3").history),
|
|
},
|
|
D4: {
|
|
favorites: loadStringArray(quickFillKeys("D4").favorites),
|
|
history: loadStringArray(quickFillKeys("D4").history),
|
|
},
|
|
JACKPOT: {
|
|
favorites: [],
|
|
history: [],
|
|
},
|
|
};
|
|
}
|
|
|
|
function appendUnique(values: string[], value: string, limit = 20): string[] {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return values;
|
|
const next = [trimmed, ...values.filter((v) => v !== trimmed)];
|
|
return next.slice(0, limit);
|
|
}
|
|
|
|
function sortedDigits(value: string): string {
|
|
return value.split("").sort().join("");
|
|
}
|
|
|
|
function matchesRiskAlert(
|
|
alertNumber: string,
|
|
playCode: string,
|
|
rowNumber: string,
|
|
category: Exclude<HallCategory, "JACKPOT">,
|
|
): boolean {
|
|
const normalizedRow = rowNumber.toUpperCase();
|
|
|
|
if (playCode === "big" || playCode === "small" || playCode === "straight") {
|
|
return alertNumber === normalizedRow.slice(0, 4);
|
|
}
|
|
|
|
if (playCode === "box" || playCode === "ibox" || playCode === "mbox") {
|
|
return sortedDigits(alertNumber) === sortedDigits(normalizedRow.slice(0, 4));
|
|
}
|
|
|
|
if (playCode === "roll") {
|
|
const regex = new RegExp(`^${normalizedRow.replace(/R/g, "[0-9]")}$`);
|
|
return regex.test(alertNumber);
|
|
}
|
|
|
|
if (playCode.startsWith("pos_4")) {
|
|
return alertNumber === normalizedRow.slice(0, 4);
|
|
}
|
|
|
|
if (playCode.startsWith("pos_3")) {
|
|
return alertNumber.endsWith(normalizedRow.slice(-3));
|
|
}
|
|
|
|
if (playCode.startsWith("pos_2")) {
|
|
return alertNumber.endsWith(normalizedRow.slice(-2));
|
|
}
|
|
|
|
if (playCode === "head") {
|
|
return ["5", "6", "7", "8", "9"].includes(alertNumber[0] ?? "");
|
|
}
|
|
if (playCode === "tail") {
|
|
return ["0", "1", "2", "3", "4"].includes(alertNumber[0] ?? "");
|
|
}
|
|
if (playCode === "odd" || playCode === "even") {
|
|
const last = alertNumber[3] ?? "";
|
|
return playCode === "odd"
|
|
? ["1", "3", "5", "7", "9"].includes(last)
|
|
: ["0", "2", "4", "6", "8"].includes(last);
|
|
}
|
|
if (playCode === "digit_big" || playCode === "digit_small") {
|
|
const last = alertNumber[pickDigitSlot(category)] ?? "";
|
|
return playCode === "digit_big"
|
|
? ["5", "6", "7", "8", "9"].includes(last)
|
|
: ["0", "1", "2", "3", "4"].includes(last);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function cellRiskState(
|
|
play: PlayEffectivePlayRow,
|
|
rowNumber: string,
|
|
category: Exclude<HallCategory, "JACKPOT">,
|
|
alertRows: DrawCurrentRiskPoolAlert[] | undefined,
|
|
): CellRiskState {
|
|
const alerts = alertRows ?? [];
|
|
if (alerts.length === 0) return "open";
|
|
const normalizedRow = rowNumber.trim().toUpperCase();
|
|
if (!normalizedRow) return "open";
|
|
|
|
for (const alert of alerts) {
|
|
if (matchesRiskAlert(alert.normalized_number, play.play_code, normalizedRow, category)) {
|
|
return alert.is_sold_out ? "sold_out" : "warning";
|
|
}
|
|
}
|
|
|
|
return "open";
|
|
}
|
|
|
|
function quickFillKeys(category: HallCategory): { favorites: string; history: string } {
|
|
return {
|
|
favorites: `lottery.hall.quickfill.favorites.${category}`,
|
|
history: `lottery.hall.quickfill.history.${category}`,
|
|
};
|
|
}
|
|
|
|
export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }) {
|
|
const { display, isBettable, reload: reloadDraw } = drawLive;
|
|
const { t } = useTranslation("player");
|
|
|
|
const [activeCategory, setActiveCategory] = useState<HallCategory>("D2");
|
|
const [rows, setRows] = useState<DraftRow[]>(() => [newDraftRow()]);
|
|
const [activeRowId, setActiveRowId] = useState<string | null>(null);
|
|
const [catalogState, setCatalogState] = useState<
|
|
| { kind: "loading" }
|
|
| { kind: "ok"; data: PlayEffectivePayload }
|
|
| { kind: "error"; message: string }
|
|
>({ kind: "loading" });
|
|
const [availableMinor, setAvailableMinor] = useState<number>(0);
|
|
const [previewOpen, setPreviewOpen] = useState(false);
|
|
const [previewData, setPreviewData] = useState<TicketPreviewData | null>(null);
|
|
const [previewLoading, setPreviewLoading] = useState(false);
|
|
const [placeLoading, setPlaceLoading] = useState(false);
|
|
const [resultOpen, setResultOpen] = useState(false);
|
|
const [resultData, setResultData] = useState<TicketPlaceData | null>(null);
|
|
const [quickFillState, setQuickFillState] = useState<QuickFillState>(() => loadQuickFillState());
|
|
const [debouncedSummary, setDebouncedSummary] = useState({ bet: 0, rebate: 0, actual: 0 });
|
|
const holdFavoriteRef = useRef<{ timer: number | null; number: string | null; longPress: boolean }>({
|
|
timer: null,
|
|
number: null,
|
|
longPress: false,
|
|
});
|
|
|
|
const currencyParam = useMemo(() => {
|
|
const fromEnv = process.env.NEXT_PUBLIC_LOTTERY_PLAY_CURRENCY?.trim();
|
|
return fromEnv !== undefined && fromEnv !== "" ? fromEnv : undefined;
|
|
}, []);
|
|
|
|
const loadCatalog = useCallback(async () => {
|
|
setCatalogState((s) => (s.kind === "ok" ? s : { kind: "loading" }));
|
|
try {
|
|
const data = await getPlayEffective(
|
|
currencyParam !== undefined ? { currency: currencyParam } : undefined,
|
|
);
|
|
setCatalogState({ kind: "ok", data });
|
|
} catch (e) {
|
|
const msg = e instanceof LotteryApiBizError ? e.message : t("hall.loadingError");
|
|
setCatalogState({ kind: "error", message: msg });
|
|
}
|
|
}, [currencyParam, t]);
|
|
|
|
const refreshWallet = useCallback(async () => {
|
|
try {
|
|
const wallet = await getWalletBalance(
|
|
currencyParam !== undefined ? { currency: currencyParam } : undefined,
|
|
);
|
|
setAvailableMinor(Number(wallet.available_balance ?? 0));
|
|
} catch {
|
|
setAvailableMinor(0);
|
|
}
|
|
}, [currencyParam]);
|
|
|
|
useEffect(() => {
|
|
queueMicrotask(() => {
|
|
void loadCatalog();
|
|
void refreshWallet();
|
|
});
|
|
}, [loadCatalog, refreshWallet]);
|
|
|
|
useEffect(() => {
|
|
const id = window.setInterval(() => {
|
|
void loadCatalog();
|
|
void refreshWallet();
|
|
}, DEFAULT_POLL_MS);
|
|
return () => window.clearInterval(id);
|
|
}, [loadCatalog, refreshWallet]);
|
|
|
|
const openPlays = useMemo(() => {
|
|
if (catalogState.kind !== "ok") return [];
|
|
const order = categoryPlayOrders[activeCategory === "JACKPOT" ? "D4" : activeCategory];
|
|
return sortByPlayOrder(
|
|
catalogState.data.plays
|
|
.filter(isPlayOpenForPlayer)
|
|
.filter((p) => order.includes(p.play_code)),
|
|
order,
|
|
);
|
|
}, [activeCategory, catalogState]);
|
|
|
|
const currencyCode = catalogState.kind === "ok" ? catalogState.data.currency_code : "NPR";
|
|
|
|
const categoryPlays = useMemo(() => {
|
|
if (catalogState.kind !== "ok") return [];
|
|
if (activeCategory === "JACKPOT") return [];
|
|
const order = categoryPlayOrders[activeCategory];
|
|
return sortByPlayOrder(
|
|
openPlays.filter((p) => inferCategory(p) === activeCategory || activeCategory === "D4"),
|
|
order,
|
|
);
|
|
}, [activeCategory, catalogState, openPlays]);
|
|
|
|
const activeRow = useMemo(
|
|
() => rows.find((row) => row.id === activeRowId) ?? rows[0] ?? null,
|
|
[activeRowId, rows],
|
|
);
|
|
|
|
const alertRows = display?.risk_pool_alerts ?? [];
|
|
const currentQuickFill = quickFillState[activeCategory] ?? { favorites: [], history: [] };
|
|
const favorites = currentQuickFill.favorites;
|
|
const historyNumbers = currentQuickFill.history;
|
|
|
|
const tableDisabled = activeCategory === "JACKPOT" || !isBettable || catalogState.kind !== "ok";
|
|
const sealedBetUi = Boolean(display && isHallSealedCountdownUi(display.status));
|
|
const numberPlaceholder = activeCategory === "D2" ? "00" : activeCategory === "D3" ? "000" : "0000";
|
|
|
|
const updateRowNumber = (id: string, value: string) => {
|
|
setRows((current) =>
|
|
current.map((row) =>
|
|
row.id === id ? { ...row, number: sanitizeNumber(value, activeCategory) } : row,
|
|
),
|
|
);
|
|
setActiveRowId(id);
|
|
};
|
|
|
|
const updateAmount = (rowId: string, playCode: string, value: string) => {
|
|
setRows((current) =>
|
|
current.map((row) =>
|
|
row.id === rowId
|
|
? { ...row, amounts: { ...row.amounts, [playCode]: sanitizeAmount(value) } }
|
|
: row,
|
|
),
|
|
);
|
|
setActiveRowId(rowId);
|
|
};
|
|
|
|
const addRow = () => {
|
|
setRows((current) => {
|
|
if (current.length >= MAX_ROWS) return current;
|
|
const row = newDraftRow();
|
|
setActiveRowId(row.id);
|
|
return [...current, row];
|
|
});
|
|
};
|
|
|
|
const removeRow = (id: string) => {
|
|
setRows((current) => {
|
|
if (current.length <= 1) return current;
|
|
const next = current.filter((row) => row.id !== id);
|
|
setActiveRowId((prev) => (prev === id ? next[0]?.id ?? null : prev));
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const clearAllRows = () => {
|
|
if (tableDisabled) return;
|
|
const next = [newDraftRow()];
|
|
setRows(next);
|
|
setActiveRowId(next[0].id);
|
|
};
|
|
|
|
const fillCurrentRow = (number: string) => {
|
|
if (tableDisabled) return;
|
|
const targetId = activeRowId ?? rows[0]?.id;
|
|
if (!targetId) return;
|
|
updateRowNumber(targetId, number);
|
|
};
|
|
|
|
const toggleFavoriteNumber = (number: string) => {
|
|
const keys = quickFillKeys(activeCategory);
|
|
setQuickFillState((current) => {
|
|
const currentFavorites = current[activeCategory]?.favorites ?? [];
|
|
const exists = currentFavorites.includes(number);
|
|
const next = exists
|
|
? currentFavorites.filter((n) => n !== number)
|
|
: [number, ...currentFavorites].slice(0, 20);
|
|
saveStringArray(keys.favorites, next);
|
|
return {
|
|
...current,
|
|
[activeCategory]: {
|
|
...(current[activeCategory] ?? { favorites: [], history: [] }),
|
|
favorites: next,
|
|
},
|
|
};
|
|
});
|
|
};
|
|
|
|
const pushHistory = (number: string) => {
|
|
const keys = quickFillKeys(activeCategory);
|
|
setQuickFillState((current) => {
|
|
const currentHistory = current[activeCategory]?.history ?? [];
|
|
const next = appendUnique(currentHistory, number, 20);
|
|
saveStringArray(keys.history, next);
|
|
return {
|
|
...current,
|
|
[activeCategory]: {
|
|
...(current[activeCategory] ?? { favorites: [], history: [] }),
|
|
history: next,
|
|
},
|
|
};
|
|
});
|
|
};
|
|
|
|
const collectEntries = useCallback((): DraftEntry[] => {
|
|
if (activeCategory === "JACKPOT") return [];
|
|
const entries: DraftEntry[] = [];
|
|
rows.forEach((row, rowIndex) => {
|
|
categoryPlays.forEach((play) => {
|
|
const amount = parseDecimalInputToMinor(row.amounts[play.play_code] ?? "");
|
|
if (amount === null || amount <= 0) return;
|
|
const line = lineForPlay(activeCategory, 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, categoryPlays, rows]);
|
|
|
|
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]);
|
|
|
|
useEffect(() => {
|
|
const id = window.setTimeout(() => {
|
|
setDebouncedSummary(draftSummary);
|
|
}, 300);
|
|
return () => window.clearTimeout(id);
|
|
}, [draftSummary]);
|
|
|
|
const buildLines = (): TicketLineInput[] => collectEntries().map((entry) => entry.line);
|
|
|
|
const handlePreview = async () => {
|
|
if (!display) {
|
|
toast.error(t("hall.noDraw"));
|
|
return;
|
|
}
|
|
if (!isBettable) {
|
|
toast.error(t("hall.notBettable"));
|
|
return;
|
|
}
|
|
if (catalogState.kind !== "ok") {
|
|
toast.error(t("hall.catalogNotReady"));
|
|
return;
|
|
}
|
|
|
|
const lines = buildLines();
|
|
if (lines.length === 0) {
|
|
toast.error(t("hall.emptyLines"));
|
|
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,
|
|
});
|
|
setPreviewData(data);
|
|
setPreviewOpen(true);
|
|
rows.forEach((row) => {
|
|
if (row.number.trim()) pushHistory(row.number.trim());
|
|
});
|
|
} catch (e) {
|
|
const code = e instanceof LotteryApiBizError ? e.code : 0;
|
|
const msg = e instanceof LotteryApiBizError ? e.message : t("hall.previewFailed");
|
|
toast.error(mapTicketBetError(code, msg, t));
|
|
} finally {
|
|
setPreviewLoading(false);
|
|
}
|
|
};
|
|
|
|
const handlePlace = async () => {
|
|
if (!display || !previewData) return;
|
|
if (!isBettable) {
|
|
toast.error(t("hall.closedSubmit"));
|
|
return;
|
|
}
|
|
|
|
const lines = buildLines();
|
|
if (lines.length === 0) {
|
|
toast.error(t("hall.changedBeforeSubmit"));
|
|
return;
|
|
}
|
|
|
|
setPlaceLoading(true);
|
|
try {
|
|
const data = await postTicketPlace({
|
|
draw_id: display.draw_no,
|
|
currency_code: currencyCode,
|
|
client_trace_id:
|
|
typeof crypto !== "undefined" && crypto.randomUUID
|
|
? crypto.randomUUID()
|
|
: `pl-${Date.now()}`,
|
|
lines,
|
|
expected_config_versions: previewData.config_versions,
|
|
});
|
|
setPreviewOpen(false);
|
|
setPreviewData(null);
|
|
setResultData(data);
|
|
setResultOpen(true);
|
|
setRows([newDraftRow()]);
|
|
setActiveRowId(null);
|
|
triggerWalletPollingAfterBet();
|
|
void refreshWallet();
|
|
void reloadDraw();
|
|
toast.success(
|
|
t("hall.placeSuccess", {
|
|
orderNo: data.order_no,
|
|
amount: formatMinorAsCurrency(data.summary.total_actual_deduct, currencyCode),
|
|
}),
|
|
);
|
|
} catch (e) {
|
|
const code = e instanceof LotteryApiBizError ? e.code : 0;
|
|
const msg = e instanceof LotteryApiBizError ? e.message : t("hall.placeFailed");
|
|
toast.error(mapTicketBetError(code, msg, t));
|
|
} finally {
|
|
setPlaceLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
const onRefresh = () => void refreshWallet();
|
|
window.addEventListener("lottery-wallet-refresh", onRefresh);
|
|
return () => window.removeEventListener("lottery-wallet-refresh", onRefresh);
|
|
}, [refreshWallet]);
|
|
|
|
if (catalogState.kind === "loading") {
|
|
return (
|
|
<section className="space-y-3" aria-label={t("hall.aria")}>
|
|
<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()}
|
|
>
|
|
{t("actions.retry")}
|
|
</Button>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
const canSubmit = !tableDisabled && draftEntries.length > 0 && availableMinor >= debouncedSummary.actual;
|
|
const favoriteChips = favorites.slice(0, 10);
|
|
const historyChips = historyNumbers.slice(0, 20);
|
|
|
|
return (
|
|
<>
|
|
<section className="space-y-4" aria-label={t("hall.aria")}>
|
|
<div className="rounded-xl border border-[#e8eef7] bg-white p-1 shadow-[0_6px_18px_rgba(30,64,175,0.06)]">
|
|
<div className="grid grid-cols-4 gap-1">
|
|
{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>
|
|
</div>
|
|
|
|
{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">
|
|
{t("hall.closed.title")}
|
|
</p>
|
|
<p className="mt-1 text-xs">{t("hall.closed.subtitle")}</p>
|
|
<div className="mt-5 rounded-lg border border-[#cbdcf7] bg-white px-3 py-3 text-left text-xs text-[#315a9f]">
|
|
{t("hall.closed.description")}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="space-y-3 rounded-xl border border-[#e9eef7] bg-white p-4 shadow-[0_8px_28px_rgba(15,23,42,0.05)]">
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<div>
|
|
<p className="text-sm font-semibold text-slate-900">
|
|
{t("hall.quickFill.title", { defaultValue: "快速填单" })}
|
|
</p>
|
|
<p className="text-xs text-slate-500">
|
|
{t("hall.quickFill.description", {
|
|
defaultValue: "收藏号码、最近号码可一键填入当前行。",
|
|
})}
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button type="button" variant="outline" size="sm" onClick={clearAllRows}>
|
|
{t("hall.quickFill.clearAll", { defaultValue: "批量清空" })}
|
|
</Button>
|
|
{activeRow?.number ? (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => toggleFavoriteNumber(activeRow.number)}
|
|
>
|
|
<Star className="mr-1 size-4" />
|
|
{favorites.includes(activeRow.number)
|
|
? t("hall.quickFill.unfavorite", { defaultValue: "取消收藏" })
|
|
: t("hall.quickFill.favorite", { defaultValue: "收藏号码" })}
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<p className="text-[11px] font-medium uppercase tracking-wide text-slate-400">
|
|
{t("hall.quickFill.favorites", { defaultValue: "收藏" })}
|
|
</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{favoriteChips.length > 0 ? (
|
|
favoriteChips.map((number) => (
|
|
<button
|
|
key={`fav-${number}`}
|
|
type="button"
|
|
className="inline-flex items-center gap-1 rounded-full border border-[#ffd7db] bg-[#fff3f5] px-3 py-1 text-xs font-medium text-[#d81435]"
|
|
onPointerDown={() => {
|
|
const current = holdFavoriteRef.current;
|
|
current.number = number;
|
|
current.longPress = false;
|
|
if (current.timer) window.clearTimeout(current.timer);
|
|
current.timer = window.setTimeout(() => {
|
|
current.longPress = true;
|
|
toggleFavoriteNumber(number);
|
|
}, 500);
|
|
}}
|
|
onPointerUp={() => {
|
|
const current = holdFavoriteRef.current;
|
|
if (current.timer) {
|
|
window.clearTimeout(current.timer);
|
|
current.timer = null;
|
|
}
|
|
if (!current.longPress) {
|
|
fillCurrentRow(number);
|
|
}
|
|
current.longPress = false;
|
|
}}
|
|
onPointerLeave={() => {
|
|
const current = holdFavoriteRef.current;
|
|
if (current.timer) {
|
|
window.clearTimeout(current.timer);
|
|
current.timer = null;
|
|
}
|
|
current.longPress = false;
|
|
}}
|
|
>
|
|
{number}
|
|
<span className="text-[10px] opacity-70">
|
|
{t("hall.quickFill.tapHold", { defaultValue: "长按取消" })}
|
|
</span>
|
|
</button>
|
|
))
|
|
) : (
|
|
<span className="text-xs text-slate-400">
|
|
{t("hall.quickFill.emptyFavorites", { defaultValue: "暂无收藏" })}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<p className="text-[11px] font-medium uppercase tracking-wide text-slate-400">
|
|
{t("hall.quickFill.history", { defaultValue: "最近 20 个历史号码" })}
|
|
</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{historyChips.length > 0 ? (
|
|
historyChips.map((number) => (
|
|
<button
|
|
key={`his-${number}`}
|
|
type="button"
|
|
className="inline-flex items-center rounded-full border border-[#dce7f7] bg-[#f8fbff] px-3 py-1 text-xs font-medium text-[#07459f]"
|
|
onClick={() => fillCurrentRow(number)}
|
|
>
|
|
{number}
|
|
</button>
|
|
))
|
|
) : (
|
|
<span className="text-xs text-slate-400">
|
|
{t("hall.quickFill.emptyHistory", { defaultValue: "暂无历史号码" })}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{categoryPlays.length === 0 ? (
|
|
<div
|
|
className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2.5 text-center text-xs text-amber-950"
|
|
role="status"
|
|
>
|
|
{t("hall.table.noPlaysInCategory")}
|
|
</div>
|
|
) : null}
|
|
|
|
<div
|
|
className={cn(
|
|
"overflow-hidden rounded-xl border border-[#e6edf8] bg-white shadow-[0_8px_24px_rgba(15,23,42,0.05)] transition-opacity",
|
|
tableDisabled && "opacity-55",
|
|
)}
|
|
>
|
|
<div className="overflow-x-auto">
|
|
<table
|
|
className={cn(
|
|
"w-full border-collapse text-[11px]",
|
|
activeCategory === "D4" ? "min-w-[760px]" : "min-w-[460px]",
|
|
)}
|
|
>
|
|
<thead>
|
|
<tr className="border-b border-[#edf2f8] bg-[#f8fafd] text-[#58709d]">
|
|
<th className="w-8 px-1.5 py-2 text-center font-bold">
|
|
{t("hall.table.no", { defaultValue: "No." })}
|
|
</th>
|
|
<th className="w-20 px-1.5 py-2 text-center font-bold">
|
|
<span className="block">{t("hall.table.number", { defaultValue: "Number" })}</span>
|
|
<span className="block text-[9px] font-medium text-[#9aa8bd]">({numberPlaceholder})</span>
|
|
</th>
|
|
{categoryPlays.map((play) => (
|
|
<th key={play.play_code} className="min-w-16 px-1 py-2 text-center font-bold">
|
|
<span className="block truncate">{pickDisplayName(play)}</span>
|
|
<span className="block text-[9px] font-medium text-[#9aa8bd]">
|
|
{t("hall.table.amountPlaceholder", { defaultValue: "金额" })}
|
|
</span>
|
|
</th>
|
|
))}
|
|
<th className="w-7 px-1 py-2" aria-label={t("hall.table.delete")} />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map((row, index) => {
|
|
const rowKey = row.id;
|
|
return (
|
|
<tr key={rowKey} className="border-b border-[#f0f3f8] last:border-b-0">
|
|
<td className="px-1.5 py-2 text-center font-black text-[#17408d]">
|
|
{index + 1}
|
|
</td>
|
|
<td className="px-1.5 py-2">
|
|
<Input
|
|
value={row.number}
|
|
disabled={tableDisabled}
|
|
inputMode="text"
|
|
placeholder={numberPlaceholder}
|
|
onFocus={() => setActiveRowId(row.id)}
|
|
onClick={() => setActiveRowId(row.id)}
|
|
onChange={(event) => updateRowNumber(row.id, event.target.value)}
|
|
className="h-8 rounded-md border-[#e1e8f3] bg-white px-1 text-center font-mono text-sm font-black tracking-[0.1em] text-slate-950 shadow-sm focus-visible:ring-[#1d57b7]"
|
|
/>
|
|
</td>
|
|
{categoryPlays.map((play) => {
|
|
const amountText = row.amounts[play.play_code] ?? "";
|
|
const status = cellRiskState(
|
|
play,
|
|
row.number,
|
|
activeCategory as Exclude<HallCategory, "JACKPOT">,
|
|
alertRows,
|
|
);
|
|
const disabled = tableDisabled || status === "sold_out" || (play.config !== null && !play.config.is_enabled);
|
|
const hasAmount = amountText.trim().length > 0;
|
|
|
|
return (
|
|
<td
|
|
key={`${rowKey}-${play.play_code}`}
|
|
className={cn(
|
|
"px-1 py-2 align-top",
|
|
status === "warning" && "bg-amber-50/70",
|
|
status === "sold_out" && "bg-slate-100 text-slate-400",
|
|
)}
|
|
>
|
|
<Input
|
|
value={amountText}
|
|
disabled={disabled}
|
|
inputMode="decimal"
|
|
placeholder={
|
|
status === "sold_out"
|
|
? t("hall.table.soldOut", { defaultValue: "售罄" })
|
|
: "-"
|
|
}
|
|
onFocus={() => setActiveRowId(row.id)}
|
|
onClick={() => setActiveRowId(row.id)}
|
|
onChange={(event) => updateAmount(row.id, play.play_code, event.target.value)}
|
|
className={cn(
|
|
"h-8 rounded-md border-[#e1e8f3] bg-white px-1 text-center text-xs font-bold tabular-nums shadow-sm focus-visible:ring-[#1d57b7]",
|
|
hasAmount && "border-[#9bbcff] bg-[#f5f9ff] text-[#0b3f96]",
|
|
status === "warning" && "border-amber-200 bg-amber-50 text-amber-800",
|
|
status === "sold_out" && "border-slate-200 bg-slate-100 text-slate-400",
|
|
)}
|
|
/>
|
|
{status === "sold_out" ? (
|
|
<p className="mt-0.5 text-center text-[9px] font-bold text-slate-500">
|
|
{t("hall.table.soldOut", { defaultValue: "售罄" })}
|
|
</p>
|
|
) : status === "warning" ? (
|
|
<p className="mt-0.5 text-center text-[9px] font-bold text-amber-700">
|
|
{t("hall.table.warning", { defaultValue: "接近售罄" })}
|
|
</p>
|
|
) : null}
|
|
</td>
|
|
);
|
|
})}
|
|
<td className="px-1 py-2 text-center align-middle">
|
|
<button
|
|
type="button"
|
|
disabled={tableDisabled || rows.length <= 1}
|
|
onClick={() => removeRow(row.id)}
|
|
className="inline-flex size-6 items-center justify-center rounded-full text-[#e5002c] hover:bg-red-50 disabled:text-slate-300 disabled:hover:bg-transparent"
|
|
aria-label={t("actions.deleteRow", { row: index + 1 })}
|
|
>
|
|
<Trash2 className="size-3.5" aria-hidden />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
disabled={tableDisabled || rows.length >= MAX_ROWS}
|
|
onClick={addRow}
|
|
className="flex h-10 w-full items-center justify-center gap-1.5 border-t border-[#edf2f9] bg-white text-xs font-bold text-[#1d57b7] hover:bg-[#f7faff] disabled:text-slate-300"
|
|
>
|
|
<CirclePlus className="size-4" aria-hidden />
|
|
{t("hall.table.addRow", { defaultValue: "添加一行" })}
|
|
</button>
|
|
</div>
|
|
|
|
<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="text-xs font-medium text-slate-500">
|
|
{t("hall.table.draftTotal", { defaultValue: "草稿合计" })}
|
|
</span>
|
|
<span className="text-base font-black tabular-nums text-[#0b3f96]">
|
|
{formatMinorAsCurrency(debouncedSummary.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">
|
|
{t("hall.table.sealedHint")}
|
|
</p>
|
|
) : null}
|
|
|
|
<Button
|
|
type="button"
|
|
disabled={!canSubmit || previewLoading}
|
|
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
|
|
? t("hall.table.previewing", { defaultValue: "预览中..." })
|
|
: !isBettable
|
|
? t("hall.closed.title")
|
|
: availableMinor < debouncedSummary.actual
|
|
? t("hall.table.insufficientBalance", { defaultValue: "余额不足" })
|
|
: t("hall.table.submitBet", { defaultValue: "提交下注" })}
|
|
</Button>
|
|
</>
|
|
)}
|
|
</section>
|
|
|
|
<HallBetPreviewDialog
|
|
open={previewOpen}
|
|
onOpenChange={(open) => {
|
|
setPreviewOpen(open);
|
|
if (!open) setPreviewData(null);
|
|
}}
|
|
currencyCode={currencyCode}
|
|
data={previewData}
|
|
placing={placeLoading}
|
|
allowSubmit={isBettable && availableMinor >= debouncedSummary.actual}
|
|
onConfirmPlace={() => void handlePlace()}
|
|
/>
|
|
|
|
<HallBetResultDialog
|
|
open={resultOpen}
|
|
onOpenChange={(open) => {
|
|
setResultOpen(open);
|
|
if (!open) setResultData(null);
|
|
}}
|
|
currencyCode={currencyCode}
|
|
data={resultData}
|
|
/>
|
|
</>
|
|
);
|
|
}
|