"use client"; import { Lock, Ticket, Trash2 } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { getBetProviders } from "@/api/bet-providers"; import { getPlayEffective } from "@/api/play"; import { getWalletBalance } from "@/api/wallet"; import { postTicketPlace, postTicketPreview } from "@/api/ticket"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { isHallSealedCountdownUi } from "@/features/draw/draw-status-meta"; import { useIsMobile } from "@/hooks/use-mobile"; 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 { HallBettingTable } from "@/features/hall/hall-betting-table"; import { HallMobileQuickFill } from "@/features/hall/hall-mobile-quick-fill"; import { HallPlaySummaryGrid } from "@/features/hall/hall-play-summary-grid"; import { HallSelectionConfirmDialog } from "@/features/hall/hall-selection-confirm-dialog"; import { FALLBACK_BET_PROVIDERS, cellRiskState, digitSlotOptions, numberMaxCharsForCategory, playCategory, playColumnHeaderLabel, sanitizeNumber, type DraftRow, type HallCategory, type PlayColumn, type PlayHallCategory, } from "@/features/hall/hall-betting-grid-model"; import { HallDesktopReviewPanel, HallDesktopWorkflowBar, type HallDesktopReviewModel, } from "@/features/hall/hall-desktop-workspace"; import { draftLineIssueReason, playNeedsDigitSlot, playNeedsDimension, ticketNumberSpec, type DraftLineIssueReason, } from "@/features/hall/hall-bet-rules"; import { isFullCoverAmountDivisible, isHighCostSelectionType, resolveSelectionTotalBet, selectionCombinationCount, selectionTypesForCategory, type SelectionType, } from "@/features/hall/selection-type"; import type { HallDrawLiveSnapshot } from "@/features/hall/use-hall-draw-live"; import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency"; import { useAsyncEffect } from "@/hooks/use-async-effect"; import { triggerWalletPollingAfterBet } from "@/hooks/use-wallet-polling"; import { usePlayerSessionStore } from "@/stores/player-session-store"; import { getLotteryEcho } from "@/lib/lottery-echo"; import { formatMinorAmount, formatMinorAsCurrency, parseDecimalInputToMinor, } from "@/lib/money"; import { playLabel } from "@/lib/play-labels"; import { playerViewportFixedBarClass } from "@/lib/player-viewport"; import { PLAY_CATALOG_REFRESH_EVENT, type PlayCatalogRefreshSource, } from "@/lib/play-catalog-events"; import { isCreditFundingPlayer } from "@/lib/player-funding-mode"; 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 { BetProviderRow } from "@/types/api/bet-provider"; const TRADITIONAL_PLAY_CODES: Record = { D4: ["big", "small", "pos_4a", "pos_4b", "pos_4c", "pos_4d", "pos_4e", "four_any", "four_top", "four_lower", "five_d", "six_d"], D3: ["pos_3a", "pos_3lower", "pos_3b", "pos_3c", "pos_3d", "pos_3e"], D2: ["pos_2a", "pos_2b", "pos_2c", "pos_2d", "pos_2e", "pos_2any"], }; type PendingSelectionChange = | { mode: "row"; rowId: string; next: SelectionType; prev: SelectionType } | { mode: "all"; next: SelectionType }; type DraftEntry = { rowId: string; rowNo: number; amountKey: string; play: PlayEffectivePlayRow; digitSlot?: number; number: string; amountMinor: number; line: TicketLineInput; }; type DraftLineIssue = { rowNo: number; playCode: string; reason: DraftLineIssueReason; }; type ClosedPlayCleanupData = { cleanup_hint?: string; cleanup_lines?: Array<{ client_line_no?: number; play_code?: string }>; }; type TicketPreviewSubmissionSnapshot = { drawId: string; currencyCode: string; clientTraceId: string; lines: TicketLineInput[]; expectedConfigVersions: TicketPreviewData["config_versions"]; }; type PlayToggleWsEvent = { play_code?: string; enabled?: boolean; action?: string; }; type OddsUpdateWsEvent = { message?: string; }; type RiskSoldOutWsEvent = { draw_id?: number; draw_no?: string; normalized_number?: string; }; type RiskWarningWsEvent = { draw_id?: number; draw_no?: string; normalized_number?: string; usage_ratio?: number; usage_percent?: number; }; type QuickFillState = Record; const DEFAULT_PROVIDER_CODE = "SG"; const categoryTabs: { value: PlayHallCategory; label: string }[] = [ { value: "D4", label: "4D" }, { value: "D3", label: "3D" }, { value: "D2", label: "2D" }, ]; 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; /** 按大厅 Tab 对应的投注列顺序。 */ const PLAY_ORDER_BY_CATEGORY: Record = { D2: D2_PLAY_ORDER, D3: D3_PLAY_ORDER, D4: D4_PLAY_ORDER, }; const CATEGORY_ORDER: readonly PlayHallCategory[] = ["D4", "D3", "D2"]; const SUMMARY_PLAY_ORDER = CATEGORY_ORDER.flatMap( (category) => PLAY_ORDER_BY_CATEGORY[category], ); const DEFAULT_DRAFT_ROW_COUNT = 20; function playOrderForActiveCategory(activeCategory: PlayHallCategory): readonly string[] { const rest = CATEGORY_ORDER.filter((category) => category !== activeCategory); return [ ...PLAY_ORDER_BY_CATEGORY[activeCategory], ...rest.flatMap((category) => PLAY_ORDER_BY_CATEGORY[category]), ]; } function newDraftRows(count = DEFAULT_DRAFT_ROW_COUNT): DraftRow[] { return Array.from({ length: count }, newDraftRow); } 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: {}, providerCodes: [DEFAULT_PROVIDER_CODE], selectionType: "straight" }; } function isPlayOpenForPlayer(row: PlayEffectivePlayRow): boolean { return Boolean(row.master_enabled && row.config?.is_enabled); } function amountKeyForPlay(playCode: string, digitSlot?: number): string { return digitSlot === undefined ? playCode : `${playCode}@${digitSlot}`; } function playColumnsForCategory( plays: PlayEffectivePlayRow[], category: PlayHallCategory, ): PlayColumn[] { return plays.flatMap((play) => { if (!playNeedsDigitSlot(play.play_code)) { return [{ key: amountKeyForPlay(play.play_code), play }]; } return digitSlotOptions(category).map((digitSlot) => ({ key: amountKeyForPlay(play.play_code, digitSlot), play, digitSlot, })); }); } 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 lineForPlay( play: PlayEffectivePlayRow, displayNumber: string, amountMinor: number, digitSlot?: number, selectionType: SelectionType = "straight", ): TicketLineInput | null { const number = normalizeNumberForPlay(displayNumber, play.play_code); if (draftLineIssueReason(play.play_code, displayNumber, digitSlot) !== null) { return null; } const spec = ticketNumberSpec(play.play_code); if (number.length !== spec.maxChars) { return null; } const line: TicketLineInput = { number, play_code: play.play_code, amount: amountMinor, selection_type: selectionType, }; if (playNeedsDimension(play.play_code)) { line.dimension = playCategory(play.play_code); } if (playNeedsDigitSlot(play.play_code)) { if (digitSlot === undefined) return null; line.digit_slot = digitSlot; } 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 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 { activeCurrency: currencyParam } = useActivePlayerCurrency(); const creditMode = isCreditFundingPlayer(usePlayerSessionStore((state) => state.profile)); const isMobile = useIsMobile(); const [rows, setRows] = useState(() => newDraftRows()); 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 [activeCategory, setActiveCategory] = useState("D4"); const [betProviders, setBetProviders] = useState(FALLBACK_BET_PROVIDERS); const [syncAmountColumns, setSyncAmountColumns] = useState>({}); const [quickFillState, setQuickFillState] = useState(() => loadQuickFillState()); const [quickFillExpanded, setQuickFillExpanded] = useState(false); const [liveSoldOutNumbers, setLiveSoldOutNumbers] = useState>(() => new Set()); const [liveWarningNumbers, setLiveWarningNumbers] = useState>(() => new Set()); const [debouncedSummary, setDebouncedSummary] = useState({ bet: 0, rebate: 0, actual: 0 }); const [pendingSelectionChange, setPendingSelectionChange] = useState( null, ); /** 单次预览→确认共用,重试 place 复用,避免重复扣款 */ const placeTraceIdRef = useRef(null); const previewRequestSeqRef = useRef(0); const previewSubmissionRef = useRef(null); const newPlaceTraceId = (): string => typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `pl-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; const clearPlaceTraceId = useCallback(() => { placeTraceIdRef.current = null; }, []); const invalidatePreview = useCallback(() => { previewRequestSeqRef.current += 1; previewSubmissionRef.current = null; setPreviewOpen(false); setPreviewData(null); clearPlaceTraceId(); }, [clearPlaceTraceId]); const catalogSeqRef = useRef(0); const walletSeqRef = useRef(0); const selectedCatalogProviderCode = betProviders.find((provider) => provider.is_default)?.code ?? betProviders[0]?.code; const loadCatalog = useCallback(async () => { const seq = ++catalogSeqRef.current; setCatalogState({ kind: "loading" }); try { const data = await getPlayEffective({ currency: currencyParam, provider_code: selectedCatalogProviderCode, }); if (seq !== catalogSeqRef.current) return; setCatalogState({ kind: "ok", data }); } catch (e) { if (seq !== catalogSeqRef.current) return; const msg = e instanceof LotteryApiBizError ? e.message : t("hall.loadingError"); setCatalogState({ kind: "error", message: msg }); } }, [currencyParam, selectedCatalogProviderCode, t]); const refreshWallet = useCallback(async () => { const seq = ++walletSeqRef.current; try { const wallet = await getWalletBalance({ currency: currencyParam }); if (seq !== walletSeqRef.current) return; setAvailableMinor(Number(wallet.available_balance ?? 0)); } catch { // 保留上次可用余额,避免短暂失败导致误报余额不足 } }, [currencyParam]); useAsyncEffect(() => { void loadCatalog(); void refreshWallet(); }, [loadCatalog, refreshWallet]); useEffect(() => { let cancelled = false; void getBetProviders() .then((data) => { if (cancelled) return; const items = data.items.length > 0 ? data.items : FALLBACK_BET_PROVIDERS; setBetProviders(items); setRows((current) => { const available = new Set(items.map((item) => item.code)); const fallback = items.find((item) => item.is_default)?.code ?? items[0]?.code; return current.map((row) => { const selected = row.providerCodes.filter((code) => available.has(code)); const wasInitialDefault = row.providerCodes.length === 1 && row.providerCodes[0] === DEFAULT_PROVIDER_CODE; return selected.length === 0 && wasInitialDefault && fallback ? { ...row, providerCodes: [fallback] } : { ...row, providerCodes: selected }; }); }); }) .catch(() => { if (cancelled) return; setBetProviders(FALLBACK_BET_PROVIDERS); }); return () => { cancelled = true; }; }, []); useEffect(() => { const onCatalogRefresh = (ev: Event) => { void loadCatalog(); const source = (ev as CustomEvent<{ source?: PlayCatalogRefreshSource }>).detail ?.source; if (source !== undefined) { invalidatePreview(); toast.message(t("hall.playConfig.updated")); } }; window.addEventListener(PLAY_CATALOG_REFRESH_EVENT, onCatalogRefresh); return () => window.removeEventListener(PLAY_CATALOG_REFRESH_EVENT, onCatalogRefresh); }, [invalidatePreview, loadCatalog, t]); const openPlays = useMemo(() => { if (catalogState.kind !== "ok") return []; // 投注表格列以当前 Tab 玩法为先;顶部汇总另用稳定的全玩法顺序。 const order = playOrderForActiveCategory(activeCategory); const orderSet = new Set(order); return sortByPlayOrder( catalogState.data.plays .filter(isPlayOpenForPlayer) .filter((p) => orderSet.has(p.play_code)), order, ); }, [activeCategory, catalogState]); const activeCategoryPlays = useMemo( () => openPlays.filter((play) => TRADITIONAL_PLAY_CODES[activeCategory].includes(play.play_code)), [activeCategory, openPlays], ); const availableCategories = useMemo( () => new Set(openPlays.map((play) => playCategory(play.play_code))), [openPlays], ); const currencyCode = catalogState.kind === "ok" ? catalogState.data.currency_code : currencyParam; const allPlayColumns = useMemo(() => { return openPlays.flatMap((play) => { const category = playCategory(play.play_code); if (!playNeedsDigitSlot(play.play_code)) { return [{ key: amountKeyForPlay(play.play_code), play }]; } return digitSlotOptions(category).map((digitSlot) => ({ key: amountKeyForPlay(play.play_code, digitSlot), play, digitSlot, })); }); }, [openPlays]); const summaryPlayColumns = useMemo(() => { return sortByPlayOrder(openPlays, SUMMARY_PLAY_ORDER).flatMap((play) => { const category = playCategory(play.play_code); if (!playNeedsDigitSlot(play.play_code)) { return [{ key: amountKeyForPlay(play.play_code), play }]; } return digitSlotOptions(category).map((digitSlot) => ({ key: amountKeyForPlay(play.play_code, digitSlot), play, digitSlot, })); }); }, [openPlays]); const playColumns = useMemo(() => { return playColumnsForCategory(activeCategoryPlays, activeCategory); }, [activeCategory, activeCategoryPlays]); const mobileIndexColClass = "w-[1.75rem] min-w-[1.75rem]"; const mobileNumberColClass = "w-[4.25rem] min-w-[4.25rem]"; const mobileSelectionTypeColClass = "w-[4rem] min-w-[4rem]"; const desktopIndexColClass = "w-9 min-w-9"; const desktopNumberColClass = "w-[5.25rem] min-w-[5.25rem]"; const desktopSelectionTypeColClass = "w-[4.5rem] min-w-[4.5rem]"; const indexColClass = isMobile ? mobileIndexColClass : desktopIndexColClass; const numberColClass = isMobile ? mobileNumberColClass : desktopNumberColClass; const selectionTypeColClass = isMobile ? mobileSelectionTypeColClass : desktopSelectionTypeColClass; const showSelectionTypeColumn = activeCategory !== "D2"; const stickyNumberLeftClass = isMobile ? "left-[1.75rem]" : "left-9"; const amountColClass = !isMobile ? "w-14 min-w-14" : "w-[4rem] min-w-[4rem]"; const providerColClass = !isMobile ? "w-11 min-w-11" : "w-[2.7rem] min-w-[2.7rem]"; const rowTotalColClass = !isMobile ? "w-20 min-w-20" : "w-[5rem] min-w-[5rem]"; const tableWidthPx = useMemo(() => { const indexCol = !isMobile ? 36 : 28; const numberCol = !isMobile ? 84 : 68; const selectionTypeCol = !isMobile ? 72 : 64; const providerCol = !isMobile ? 44 : 43; const rowTotalCol = !isMobile ? 80 : 80; const amountCol = !isMobile ? 56 : 64; return indexCol + numberCol + (showSelectionTypeColumn ? selectionTypeCol : 0) + betProviders.length * providerCol + rowTotalCol + playColumns.length * amountCol; }, [betProviders.length, isMobile, playColumns.length, showSelectionTypeColumn]); const showWideTableHint = isMobile && playColumns.length > 8; const activeRow = useMemo( () => rows.find((row) => row.id === activeRowId) ?? rows[0] ?? null, [activeRowId, rows], ); const drawNo = display?.draw_no ?? null; useEffect(() => { let cancelled = false; queueMicrotask(() => { if (cancelled) return; setLiveSoldOutNumbers(new Set()); setLiveWarningNumbers(new Set()); invalidatePreview(); }); return () => { cancelled = true; }; }, [currencyCode, drawNo, invalidatePreview]); const alertRows = useMemo( () => display?.risk_pool_alerts ?? [], [display?.risk_pool_alerts], ); const jackpot = display?.jackpot; const currentQuickFill = quickFillState[activeCategory] ?? { favorites: [], history: [] }; const favorites = currentQuickFill.favorites; const historyNumbers = currentQuickFill.history; const tableDisabled = !isBettable || catalogState.kind !== "ok"; const sealedBetUi = Boolean(display && isHallSealedCountdownUi(display.status)); const numberPlaceholder = activeCategory === "D2" ? "00" : activeCategory === "D3" ? "000" : "0000"; const numberMaxChars = numberMaxCharsForCategory(activeCategory); const updateRowNumber = useCallback((id: string, value: string) => { setRows((current) => current.map((row) => row.id === id ? { ...row, number: sanitizeNumber(value, activeCategory) } : row, ), ); setActiveRowId(id); }, [activeCategory]); const applyRowSelectionType = useCallback((id: string, selectionType: SelectionType) => { setRows((current) => current.map((row) => (row.id === id ? { ...row, selectionType } : row))); }, []); const applyAllSelectionTypes = useCallback((selectionType: SelectionType) => { setRows((current) => current.map((row) => ({ ...row, selectionType }))); }, []); const requestRowSelectionType = useCallback( (id: string, selectionType: SelectionType) => { const row = rows.find((item) => item.id === id); if (!row || row.selectionType === selectionType) return; if (isHighCostSelectionType(selectionType) && !isHighCostSelectionType(row.selectionType)) { setPendingSelectionChange({ mode: "row", rowId: id, next: selectionType, prev: row.selectionType, }); return; } applyRowSelectionType(id, selectionType); }, [applyRowSelectionType, rows], ); const requestAllSelectionTypes = useCallback( (selectionType: SelectionType) => { if (isHighCostSelectionType(selectionType)) { setPendingSelectionChange({ mode: "all", next: selectionType }); return; } applyAllSelectionTypes(selectionType); }, [applyAllSelectionTypes], ); const confirmPendingSelectionChange = useCallback(() => { if (!pendingSelectionChange) return; if (pendingSelectionChange.mode === "row") { applyRowSelectionType(pendingSelectionChange.rowId, pendingSelectionChange.next); } else { applyAllSelectionTypes(pendingSelectionChange.next); } setPendingSelectionChange(null); }, [applyAllSelectionTypes, applyRowSelectionType, pendingSelectionChange]); const cancelPendingSelectionChange = useCallback(() => { setPendingSelectionChange(null); }, []); const updateAmount = useCallback((rowId: string, playCode: string, value: string) => { const amount = sanitizeAmount(value); const column = allPlayColumns.find((item) => item.key === playCode); const syncColumn = syncAmountColumns[playCode] === true && column !== undefined; setRows((current) => current.map((row) => { if (!syncColumn) { return row.id === rowId ? { ...row, amounts: { ...row.amounts, [playCode]: amount } } : row; } const validNumber = draftLineIssueReason(column.play.play_code, row.number, column.digitSlot) === null; const status = cellRiskState( column.play, row.number, playCategory(column.play.play_code), alertRows, liveSoldOutNumbers, liveWarningNumbers, column.digitSlot, ); if (!validNumber || status === "sold_out" || (column.play.config !== null && !column.play.config.is_enabled)) { return row; } return { ...row, amounts: { ...row.amounts, [playCode]: amount } }; })); setActiveRowId(rowId); }, [alertRows, allPlayColumns, liveSoldOutNumbers, liveWarningNumbers, syncAmountColumns]); const toggleSyncAmountColumn = useCallback((column: PlayColumn, checked: boolean) => { setSyncAmountColumns((current) => ({ ...current, [column.key]: checked, })); setRows((current) => { if (!checked) { return current.map((row) => ({ ...row, amounts: { ...row.amounts, [column.key]: "" }, })); } const sourceAmount = current.find((row) => { const amount = row.amounts[column.key]; return Boolean( amount && draftLineIssueReason( column.play.play_code, row.number, column.digitSlot, ) === null, ); })?.amounts[column.key]; if (!sourceAmount) return current; return current.map((row) => { const validNumber = draftLineIssueReason( column.play.play_code, row.number, column.digitSlot, ) === null; const status = cellRiskState( column.play, row.number, playCategory(column.play.play_code), alertRows, liveSoldOutNumbers, liveWarningNumbers, column.digitSlot, ); if ( !validNumber || status === "sold_out" || (column.play.config !== null && !column.play.config.is_enabled) ) { return row; } return { ...row, amounts: { ...row.amounts, [column.key]: sourceAmount }, }; }); }); }, [alertRows, liveSoldOutNumbers, liveWarningNumbers]); const clearAllRows = () => { if (tableDisabled) return; setRows((current) => current.map((row) => ({ ...row, number: "", amounts: {}, })), ); setActiveRowId((current) => current ?? rows[0]?.id ?? null); }; const clearActiveRowAmounts = useCallback(() => { const targetId = activeRowId ?? rows[0]?.id; if (!targetId || tableDisabled) return; setRows((current) => current.map((row) => { if (row.id !== targetId) return row; const nextAmounts = { ...row.amounts }; playColumns.forEach((column) => { nextAmounts[column.key] = ""; }); return { ...row, amounts: nextAmounts }; }), ); }, [activeRowId, playColumns, rows, tableDisabled]); const fillCurrentRow = (number: string) => { if (tableDisabled) return; const targetId = activeRowId ?? rows[0]?.id; if (!targetId) return; updateRowNumber(targetId, number); }; const applyQuickAmountToActiveRow = useCallback( (amount: string) => { const targetId = activeRowId ?? rows[0]?.id; if (!targetId || tableDisabled) return; setRows((current) => current.map((row) => { if (row.id !== targetId) return row; if ( row.number.trim().length === 0 || playColumns.every( (column) => draftLineIssueReason(column.play.play_code, row.number, column.digitSlot) !== null, ) ) { return row; } const nextAmounts = { ...row.amounts }; playColumns.forEach((column) => { const isInputValid = draftLineIssueReason(column.play.play_code, row.number, column.digitSlot) === null; const status = cellRiskState( column.play, row.number, playCategory(column.play.play_code), alertRows, liveSoldOutNumbers, liveWarningNumbers, column.digitSlot, ); if (!isInputValid || status === "sold_out") return; nextAmounts[column.key] = amount; }); return { ...row, amounts: nextAmounts }; }), ); }, [ activeRowId, alertRows, liveSoldOutNumbers, liveWarningNumbers, playColumns, rows, tableDisabled, ], ); const copyPreviousRowToActiveRow = useCallback(() => { const targetId = activeRowId ?? rows[0]?.id; if (!targetId || tableDisabled) return; const targetIndex = rows.findIndex((row) => row.id === targetId); if (targetIndex <= 0) return; const previousRow = rows[targetIndex - 1]; if (!previousRow) return; setRows((current) => current.map((row, index) => { if (index !== targetIndex) return row; const nextAmounts = { ...row.amounts }; playColumns.forEach((column) => { nextAmounts[column.key] = previousRow.amounts[column.key] ?? ""; }); return { ...row, number: previousRow.number, amounts: nextAmounts, providerCodes: previousRow.providerCodes, }; }), ); }, [activeRowId, playColumns, rows, tableDisabled]); 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 toggleRowProvider = useCallback((rowId: string, code: string) => { setRows((current) => current.map((row) => { if (row.id !== rowId) return row; return row.providerCodes.includes(code) ? { ...row, providerCodes: row.providerCodes.filter((item) => item !== code) } : { ...row, providerCodes: [...row.providerCodes, code] }; })); }, []); const toggleProviderColumn = useCallback((code: string, checked: boolean) => { setRows((current) => current.map((row) => { const hasCode = row.providerCodes.includes(code); if (checked && !hasCode) return { ...row, providerCodes: [...row.providerCodes, code] }; if (!checked && hasCode) return { ...row, providerCodes: row.providerCodes.filter((item) => item !== code) }; return row; })); }, []); const hasAmountsForPlay = useCallback( (playCode: string): boolean => rows.some((row) => Object.entries(row.amounts).some( ([amountKey, amountValue]) => amountKey.split("@")[0] === playCode && Boolean(amountValue?.trim()), ), ), [rows], ); const clearAmountsForPlay = useCallback((playCode: string) => { setRows((current) => current.map((row) => { const nextAmounts = { ...row.amounts }; let changed = false; Object.keys(nextAmounts).forEach((amountKey) => { const keyPlayCode = amountKey.split("@")[0]; if (keyPlayCode !== playCode || !nextAmounts[amountKey]) return; nextAmounts[amountKey] = ""; changed = true; }); return changed ? { ...row, amounts: nextAmounts } : row; }), ); }, []); useEffect(() => { const echo = getLotteryEcho(); if (!echo) return; const channel = echo.channel("lottery-hall"); const onPlayToggle = (evt: PlayToggleWsEvent) => { if (evt.enabled === false && typeof evt.play_code === "string") { const removed = hasAmountsForPlay(evt.play_code); clearAmountsForPlay(evt.play_code); invalidatePreview(); toast.warning( removed ? t("hall.playConfig.playClosedDraftCleared", { playCode: evt.play_code, }) : t("hall.playConfig.playClosed", { playCode: evt.play_code, }), ); } }; const onOddsUpdate = (evt: OddsUpdateWsEvent) => { invalidatePreview(); toast.message(evt.message ?? t("hall.playConfig.oddsUpdated")); }; const onRiskSoldOut = (evt: RiskSoldOutWsEvent) => { const normalized = evt.normalized_number?.trim().toUpperCase(); if (!normalized) return; if (drawNo !== null && evt.draw_no !== undefined && evt.draw_no !== drawNo) { return; } setLiveSoldOutNumbers((prev) => { const next = new Set(prev); next.add(normalized); return next; }); setLiveWarningNumbers((prev) => { const next = new Set(prev); next.delete(normalized); return next; }); invalidatePreview(); void reloadDraw(); }; const onRiskWarning = (evt: RiskWarningWsEvent) => { const normalized = evt.normalized_number?.trim().toUpperCase(); if (!normalized) return; if (drawNo !== null && evt.draw_no !== undefined && evt.draw_no !== drawNo) { return; } setLiveWarningNumbers((prev) => { const next = new Set(prev); next.add(normalized); return next; }); }; channel.listen(".play.toggle", onPlayToggle); channel.listen(".odds.update", onOddsUpdate); channel.listen(".risk.sold_out", onRiskSoldOut); channel.listen(".risk.warning", onRiskWarning); return () => { channel.stopListening(".play.toggle"); channel.stopListening(".odds.update"); channel.stopListening(".risk.sold_out"); channel.stopListening(".risk.warning"); }; }, [clearAmountsForPlay, drawNo, hasAmountsForPlay, invalidatePreview, reloadDraw, t]); const collectDraftLineIssues = useCallback((): DraftLineIssue[] => { const issues: DraftLineIssue[] = []; rows.forEach((row, rowIndex) => { allPlayColumns.forEach((column) => { const amount = parseDecimalInputToMinor(row.amounts[column.key] ?? "", currencyCode); if (amount === null || amount <= 0) return; const reason = draftLineIssueReason(column.play.play_code, row.number, column.digitSlot); if (reason !== null) { issues.push({ rowNo: rowIndex + 1, playCode: column.play.play_code, reason, }); return; } const comboCount = selectionCombinationCount(row.number, row.selectionType); if ( row.selectionType === "full_cover" && !isFullCoverAmountDivisible(amount, comboCount) ) { issues.push({ rowNo: rowIndex + 1, playCode: column.play.play_code, reason: "full_cover_amount_not_divisible", }); } }); }); return issues; }, [allPlayColumns, currencyCode, rows]); const providerMissingRow = useCallback((): number | null => { for (const [index, row] of rows.entries()) { const hasAmount = Object.values(row.amounts).some((value) => { const amount = parseDecimalInputToMinor(value, currencyCode); return amount !== null && amount > 0; }); if (hasAmount && row.providerCodes.length === 0) return index + 1; } return null; }, [currencyCode, rows]); const formatDraftLineIssue = useCallback( (issue: DraftLineIssue): string => { const label = playLabel(issue.playCode, t); return t(`hall.lineIssue.${issue.reason}`, { row: issue.rowNo, play: label }); }, [t], ); const collectEntries = useCallback((): DraftEntry[] => { const entries: DraftEntry[] = []; rows.forEach((row, rowIndex) => { allPlayColumns.forEach((column) => { const amount = parseDecimalInputToMinor(row.amounts[column.key] ?? "", currencyCode); if (amount === null || amount <= 0) return; const line = lineForPlay(column.play, row.number, amount, column.digitSlot, row.selectionType); if (!line) return; line.provider_codes = row.providerCodes; entries.push({ rowId: row.id, rowNo: rowIndex + 1, amountKey: column.key, play: column.play, digitSlot: column.digitSlot, number: row.number, amountMinor: amount, line, }); }); }); return entries; }, [allPlayColumns, currencyCode, rows]); const draftEntries = collectEntries(); const draftSummary = useMemo(() => { return draftEntries.reduce( (acc, entry) => { const selectionType = entry.line.selection_type ?? "straight"; const comboCount = selectionCombinationCount(entry.number, selectionType); const totalBet = resolveSelectionTotalBet(entry.amountMinor, selectionType, comboCount); const rebateRate = parseRebateRate(entry.play.odds?.rebate_rate); const rebate = Math.round(totalBet * rebateRate); const providerCount = entry.line.provider_codes?.length ?? 0; acc.bet += totalBet * providerCount; acc.rebate += rebate * providerCount; acc.actual += Math.max(0, totalBet - rebate) * providerCount; return acc; }, { bet: 0, rebate: 0, actual: 0 }, ); }, [draftEntries]); const selectionTypeOptions = useMemo( () => selectionTypesForCategory(activeCategory), [activeCategory], ); const pendingSelectionPreview = useMemo(() => { if (!pendingSelectionChange) return null; const next = pendingSelectionChange.next; const rowStake = ( row: DraftRow, selectionType: SelectionType, mode: "resolved" | "raw", ): number => { const comboCount = selectionCombinationCount(row.number, selectionType); const providerCount = Math.max(1, row.providerCodes.length); const stake = playColumns.reduce((total, column) => { if (draftLineIssueReason(column.play.play_code, row.number, column.digitSlot) !== null) { return total; } const amount = parseDecimalInputToMinor(row.amounts[column.key] ?? "", currencyCode) ?? 0; if (amount <= 0) return total; if (mode === "raw") return total + amount; return total + resolveSelectionTotalBet(amount, selectionType, comboCount); }, 0); return stake * providerCount; }; if (pendingSelectionChange.mode === "row") { const row = rows.find((item) => item.id === pendingSelectionChange.rowId); if (!row) return null; const comboCount = selectionCombinationCount(row.number, next); const fromMinor = rowStake(row, pendingSelectionChange.prev, "resolved"); const toMinor = next === "full_cover" ? rowStake(row, next, "raw") : rowStake(row, next, "resolved"); return { next, comboCount, number: row.number, fromMinor, toMinor, scope: "row" as const, }; } let fromMinor = 0; let toMinor = 0; let maxCombo = 1; rows.forEach((row) => { maxCombo = Math.max(maxCombo, selectionCombinationCount(row.number, next)); fromMinor += rowStake(row, row.selectionType, "resolved"); toMinor += next === "full_cover" ? rowStake(row, next, "raw") : rowStake(row, next, "resolved"); }); return { next, comboCount: maxCombo, number: "", fromMinor, toMinor, scope: "all" as const, }; }, [currencyCode, pendingSelectionChange, playColumns, rows]); useEffect(() => { const id = window.setTimeout(() => { setDebouncedSummary(draftSummary); }, 300); return () => window.clearTimeout(id); }, [draftSummary]); const buildLines = (): TicketLineInput[] => collectEntries().map((entry) => entry.line); const applyClosedPlayCleanup = (data: unknown): boolean => { const payload = data as ClosedPlayCleanupData | null; const cleanupLines = Array.isArray(payload?.cleanup_lines) ? payload.cleanup_lines : []; if (cleanupLines.length === 0) return false; const entries = collectEntries(); const cleanupPairs = new Set(); cleanupLines.forEach((item) => { const clientLineNo = Number(item?.client_line_no ?? 0); const playCode = String(item?.play_code ?? ""); if (!Number.isInteger(clientLineNo) || clientLineNo <= 0 || playCode.trim() === "") return; const entry = entries[clientLineNo - 1]; if (!entry || entry.play.play_code !== playCode) return; cleanupPairs.add(`${entry.rowId}::${entry.amountKey}`); }); if (cleanupPairs.size === 0) return false; setRows((current) => current.map((row) => { const nextAmounts = { ...row.amounts }; let changed = false; Object.keys(nextAmounts).forEach((amountKey) => { if (!cleanupPairs.has(`${row.id}::${amountKey}`)) return; nextAmounts[amountKey] = ""; changed = true; }); return changed ? { ...row, amounts: nextAmounts } : row; }), ); return true; }; 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 lineIssues = collectDraftLineIssues(); const missingProviderAt = providerMissingRow(); if (missingProviderAt !== null) { toast.error(t("hall.providers.rowRequired", { row: missingProviderAt, defaultValue: `第 ${missingProviderAt} 行请选择至少一个开注商` })); return; } if (lineIssues.length > 0) { toast.error(formatDraftLineIssue(lineIssues[0])); return; } const lines = buildLines(); if (lines.length === 0) { toast.error(t("hall.emptyLines")); return; } if (previewLoading || placeLoading) { return; } setPreviewLoading(true); const requestSeq = ++previewRequestSeqRef.current; const traceId = newPlaceTraceId(); const drawId = display.draw_no; const previewCurrencyCode = currencyCode; const frozenLines = lines.map((line) => ({ ...line, provider_codes: line.provider_codes ? [...line.provider_codes] : undefined, })); placeTraceIdRef.current = traceId; try { const data = await postTicketPreview({ draw_id: drawId, currency_code: previewCurrencyCode, client_trace_id: traceId, lines: frozenLines, }); if (requestSeq !== previewRequestSeqRef.current) return; previewSubmissionRef.current = { drawId: data.draw.draw_id.trim() || drawId, currencyCode: previewCurrencyCode, clientTraceId: traceId, lines: frozenLines, expectedConfigVersions: data.config_versions, }; 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"); if (e instanceof LotteryApiBizError && code === 2002 && applyClosedPlayCleanup(e.data)) { const payload = e.data as ClosedPlayCleanupData; toast.error(payload.cleanup_hint ?? t("hall.ticketError.2002")); return; } if (e instanceof LotteryApiBizError && code === 2008) { invalidatePreview(); } if (e instanceof LotteryApiBizError && (code === 2001 || code === 2006)) { void reloadDraw(); } toast.error(mapTicketBetError(code, msg, t)); } finally { setPreviewLoading(false); } }; const handlePlace = async () => { if (!previewData) return; if (placeLoading) { return; } if (!isBettable) { toast.error(t("hall.closedSubmit")); return; } const snapshot = previewSubmissionRef.current; if (snapshot === null) { toast.error(t("hall.changedBeforeSubmit")); return; } if (snapshot.drawId === "") { toast.error(t("hall.notBettable")); return; } setPlaceLoading(true); try { const data = await postTicketPlace({ draw_id: snapshot.drawId, currency_code: snapshot.currencyCode, client_trace_id: snapshot.clientTraceId, lines: snapshot.lines, expected_config_versions: snapshot.expectedConfigVersions, }); previewSubmissionRef.current = null; clearPlaceTraceId(); setPreviewOpen(false); setPreviewData(null); setResultData(data); setResultOpen(true); setRows(newDraftRows()); setActiveRowId(null); triggerWalletPollingAfterBet(); void refreshWallet(); void reloadDraw(); const failureCount = data.summary.failure_count ?? 0; const successCount = data.summary.success_count ?? 0; if (failureCount > 0 && successCount === 0) { toast.error( t("hall.placeAllFailed", { failed: failureCount, }), ); } else if (failureCount > 0) { toast.warning( t("hall.placePartialFailed", { success: successCount, failed: failureCount, }), ); } else { 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"); if (e instanceof LotteryApiBizError && code === 2002 && applyClosedPlayCleanup(e.data)) { const payload = e.data as ClosedPlayCleanupData; toast.error(payload.cleanup_hint ?? t("hall.ticketError.2002")); invalidatePreview(); return; } if (e instanceof LotteryApiBizError && (code === 2008 || code === 2009)) { invalidatePreview(); } if (e instanceof LotteryApiBizError && (code === 2001 || code === 2006)) { void reloadDraw(); } 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 submitActualMinor = previewData?.summary.total_actual_deduct ?? debouncedSummary.actual; const activeDraftRowIds = new Set(draftEntries.map((entry) => entry.rowId)); const selectedProviderCount = new Set( rows .filter((row) => activeDraftRowIds.has(row.id)) .flatMap((row) => row.providerCodes), ).size; const warningRowCount = rows.filter( (row) => row.number.trim().length > 0 && playColumns.some( (column) => cellRiskState( column.play, row.number, playCategory(column.play.play_code), alertRows, liveSoldOutNumbers, liveWarningNumbers, column.digitSlot, ) === "warning", ), ).length; const canSubmit = !tableDisabled && draftEntries.length > 0 && providerMissingRow() === null && availableMinor >= submitActualMinor; const favoriteChips = favorites.slice(0, 10); const historyChips = historyNumbers.slice(0, 20); const summaryItems = [ { key: "total", label: t("hall.table.total", { defaultValue: "共" }), totalMinor: submitActualMinor, }, ...summaryPlayColumns.map((column) => ({ key: column.key, label: playColumnHeaderLabel( column.play, playCategory(column.play.play_code), column.digitSlot, t, ), totalMinor: rows.reduce((sum, row) => { const amount = row.amounts[column.key]; if ( !amount || row.number.trim().length === 0 || draftLineIssueReason( column.play.play_code, row.number, column.digitSlot, ) !== null ) { return sum; } return sum + (parseDecimalInputToMinor(amount, currencyCode) ?? 0); }, 0), })), ]; const desktopReviewModel: HallDesktopReviewModel = { lineCount: draftEntries.length, providerCount: selectedProviderCount, actualAmount: formatMinorAmount(submitActualMinor), availableCredit: formatMinorAmount(availableMinor), remainingCredit: formatMinorAmount(Math.max(0, availableMinor - submitActualMinor)), warningCount: warningRowCount, creditSufficient: availableMinor >= submitActualMinor, }; return ( <>
{jackpot?.enabled ? (

{t("results.jackpotLabel", { defaultValue: "Jackpot" })}

{formatMinorAmount(jackpot.current_amount_minor)}

{jackpot.draws_since_last_burst !== null ? (

{t("results.jackpotGap", { count: jackpot.draws_since_last_burst, })}

) : null}
) : null} {categoryTabs.map((tab) => { const hasPlays = openPlays.some( (play) => playCategory(play.play_code) === tab.value, ); const active = activeCategory === tab.value; return ( ); })}
)} fillControls={(
{favoriteChips.length > 0 ? ( <> {t("hall.quickFill.favorites")} {favoriteChips.slice(0, 5).map((number) => ( ))} ) : null} {t("hall.quickFill.history")} {historyChips.length > 0 ? ( historyChips.slice(0, 6).map((number) => ( )) ) : ( {t("hall.quickFill.emptyHistory")} )}
)} reviewControls={(
)} /> {isMobile ? ( ) : null} {activeCategoryPlays.length === 0 ? (
{t("hall.table.noPlaysInCategory")}
) : null}
{!isMobile ? ( void handlePreview()} labels={{ title: t("hall.desktop.reviewTitle", { defaultValue: "核对与提交" }), lineCount: t("hall.table.lineCount"), providerCount: t("hall.table.providerCount"), actualTotal: t("hall.table.actualTotal"), availableCredit: t("hall.desktop.availableCredit", { defaultValue: "可用信用" }), remainingCredit: t("hall.desktop.remainingCredit", { defaultValue: "下注后可用" }), noWarnings: t("hall.desktop.noWarnings", { defaultValue: "暂无风险提醒" }), warningCount: t("hall.desktop.warningCount", { defaultValue: "{{count}} 行接近售罄", count: warningRowCount, }), creditEnough: t("hall.desktop.creditEnough", { defaultValue: "可用信用充足" }), creditInsufficient: t("hall.desktop.creditInsufficient", { defaultValue: "可用信用不足" }), submit: previewLoading ? t("hall.table.previewing") : !isBettable ? t("hall.closed.title") : availableMinor < submitActualMinor ? t("hall.table.insufficientBalance") : t("hall.table.submitBet"), }} /> ) : null}
{sealedBetUi ? (

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

) : null}
{isMobile ? (

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

{formatMinorAsCurrency(debouncedSummary.actual, currencyCode)}

) : null} { setPreviewOpen(open); if (!open) { setPreviewData(null); if (!placeLoading) { previewRequestSeqRef.current += 1; previewSubmissionRef.current = null; clearPlaceTraceId(); } } }} currencyCode={currencyCode} data={previewData} placing={placeLoading} jackpotEnabled={Boolean(jackpot?.enabled)} allowSubmit={isBettable && availableMinor >= submitActualMinor} onConfirmPlace={() => void handlePlace()} /> { setResultOpen(open); if (!open) setResultData(null); }} currencyCode={currencyCode} data={resultData} jackpotEnabled={Boolean(jackpot?.enabled)} creditMode={creditMode} /> ); }