"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; }; type DraftEntry = { rowId: string; rowNo: number; play: PlayEffectivePlayRow; number: string; amountMinor: number; line: TicketLineInput; }; type CellRiskState = "open" | "warning" | "sold_out"; type QuickFillState = Record; 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, 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 { 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, 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, ): 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, 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("D2"); const [rows, setRows] = useState(() => [newDraftRow()]); const [activeRowId, setActiveRowId] = useState(null); const [catalogState, setCatalogState] = useState< | { kind: "loading" } | { kind: "ok"; data: PlayEffectivePayload } | { kind: "error"; message: string } >({ kind: "loading" }); const [availableMinor, setAvailableMinor] = useState(0); const [previewOpen, setPreviewOpen] = useState(false); const [previewData, setPreviewData] = useState(null); const [previewLoading, setPreviewLoading] = useState(false); const [placeLoading, setPlaceLoading] = useState(false); const [resultOpen, setResultOpen] = useState(false); const [resultData, setResultData] = useState(null); const [quickFillState, setQuickFillState] = useState(() => 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 (
); } if (catalogState.kind === "error") { return (

{catalogState.message}

); } const canSubmit = !tableDisabled && draftEntries.length > 0 && availableMinor >= debouncedSummary.actual; const favoriteChips = favorites.slice(0, 10); const historyChips = historyNumbers.slice(0, 20); return ( <>
{categoryTabs.map((tab) => { const active = activeCategory === tab.value; return ( ); })}
{activeCategory === "JACKPOT" ? (

{t("hall.closed.title")}

{t("hall.closed.subtitle")}

{t("hall.closed.description")}
) : ( <>

{t("hall.quickFill.title", { defaultValue: "快速填单" })}

{t("hall.quickFill.description", { defaultValue: "收藏号码、最近号码可一键填入当前行。", })}

{activeRow?.number ? ( ) : null}

{t("hall.quickFill.favorites", { defaultValue: "收藏" })}

{favoriteChips.length > 0 ? ( favoriteChips.map((number) => ( )) ) : ( {t("hall.quickFill.emptyFavorites", { defaultValue: "暂无收藏" })} )}

{t("hall.quickFill.history", { defaultValue: "最近 20 个历史号码" })}

{historyChips.length > 0 ? ( historyChips.map((number) => ( )) ) : ( {t("hall.quickFill.emptyHistory", { defaultValue: "暂无历史号码" })} )}
{categoryPlays.length === 0 ? (
{t("hall.table.noPlaysInCategory")}
) : null}
{categoryPlays.map((play) => ( ))} {rows.map((row, index) => { const rowKey = row.id; return ( {categoryPlays.map((play) => { const amountText = row.amounts[play.play_code] ?? ""; const status = cellRiskState( play, row.number, activeCategory as Exclude, alertRows, ); const disabled = tableDisabled || status === "sold_out" || (play.config !== null && !play.config.is_enabled); const hasAmount = amountText.trim().length > 0; return ( ); })} ); })}
{t("hall.table.no", { defaultValue: "No." })} {t("hall.table.number", { defaultValue: "Number" })} ({numberPlaceholder}) {pickDisplayName(play)} {t("hall.table.amountPlaceholder", { defaultValue: "金额" })}
{index + 1} 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]" /> 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" ? (

{t("hall.table.soldOut", { defaultValue: "售罄" })}

) : status === "warning" ? (

{t("hall.table.warning", { defaultValue: "接近售罄" })}

) : null}
{t("hall.table.draftTotal", { defaultValue: "草稿合计" })} {formatMinorAsCurrency(debouncedSummary.actual, currencyCode)}
{sealedBetUi ? (

{t("hall.table.sealedHint")}

) : null} )}
{ setPreviewOpen(open); if (!open) setPreviewData(null); }} currencyCode={currencyCode} data={previewData} placing={placeLoading} allowSubmit={isBettable && availableMinor >= debouncedSummary.actual} onConfirmPlace={() => void handlePlace()} /> { setResultOpen(open); if (!open) setResultData(null); }} currencyCode={currencyCode} data={resultData} /> ); }