diff --git a/src/features/hall/hall-betting-grid.tsx b/src/features/hall/hall-betting-grid.tsx index 9e0a09b..0e7bc3f 100644 --- a/src/features/hall/hall-betting-grid.tsx +++ b/src/features/hall/hall-betting-grid.tsx @@ -1,7 +1,7 @@ "use client"; import { CirclePlus, Lock, Ticket, Trash2, Star } from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, memo } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; @@ -12,6 +12,7 @@ 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 { 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"; @@ -41,7 +42,7 @@ import type { PlayEffectivePayload, PlayEffectivePlayRow } from "@/types/api/pla import type { TicketLineInput, TicketPlaceData, TicketPreviewData } from "@/types/api/ticket"; import type { DrawCurrentRiskPoolAlert } from "@/types/api/draw-current"; -const MAX_ROWS = 20; +const MAX_ROWS = 50; type HallCategory = "D2" | "D3" | "D4" | "JACKPOT"; @@ -74,6 +75,12 @@ type PlayColumn = { digitSlot?: number; }; +function playCategory(playCode: string): Exclude { + if (playCode.startsWith("pos_3")) return "D3"; + if (playCode.startsWith("pos_2")) return "D2"; + return "D4"; +} + type ClosedPlayCleanupData = { cleanup_hint?: string; cleanup_lines?: Array<{ client_line_no?: number; play_code?: string }>; @@ -135,29 +142,6 @@ const D4_PLAY_ORDER = [ "digit_small", ] as const; -type D4PlayGroupId = - | "big_small" - | "position" - | "combo" - | "roll_straight" - | "head_tail_odd_even" - | "digit_big_small"; - -const D4_PLAY_GROUPS: { id: D4PlayGroupId; labelKey: string; playCodes: readonly string[] }[] = [ - { id: "big_small", labelKey: "hall.d4Group.big_small", playCodes: ["big", "small"] }, - { id: "position", labelKey: "hall.d4Group.position", playCodes: ["pos_4a", "pos_4b", "pos_4c", "pos_4d", "pos_4e"] }, - { id: "combo", labelKey: "hall.d4Group.combo", playCodes: ["box", "ibox", "mbox"] }, - { id: "roll_straight", labelKey: "hall.d4Group.roll_straight", playCodes: ["roll", "straight"] }, - { id: "head_tail_odd_even", labelKey: "hall.d4Group.head_tail_odd_even", playCodes: ["head", "tail", "odd", "even"] }, - { id: "digit_big_small", labelKey: "hall.d4Group.digit_big_small", playCodes: ["digit_big", "digit_small"] }, -]; - -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 @@ -222,24 +206,8 @@ function playColumnsForCategory( }); } -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 sanitizeNumber(raw: string): string { + return raw.replace(/[^0-9Rr]/g, "").toUpperCase().slice(0, 4); } function sanitizeAmount(raw: string): string { @@ -269,7 +237,6 @@ function normalizeNumberForPlay(number: string, playCode: string): string { } function lineForPlay( - category: Exclude, play: PlayEffectivePlayRow, displayNumber: string, amountMinor: number, @@ -291,7 +258,7 @@ function lineForPlay( }; if (playNeedsDimension(play.play_code)) { - line.dimension = category; + line.dimension = playCategory(play.play_code); } if (playNeedsDigitSlot(play.play_code)) { if (digitSlot === undefined) return null; @@ -461,10 +428,9 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } const { t } = useTranslation("player"); const { activeCurrency: currencyParam } = useActivePlayerCurrency(); const creditMode = isCreditFundingPlayer(usePlayerSessionStore((state) => state.profile)); + const isMobile = useIsMobile(); - const [activeCategory, setActiveCategory] = useState("D2"); - const [d4PlayGroup, setD4PlayGroup] = useState("big_small"); - const [rows, setRows] = useState(() => [newDraftRow()]); + const [rows, setRows] = useState(() => Array.from({ length: 20 }, newDraftRow)); const [activeRowId, setActiveRowId] = useState(null); const [catalogState, setCatalogState] = useState< | { kind: "loading" } @@ -478,6 +444,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } const [placeLoading, setPlaceLoading] = useState(false); const [resultOpen, setResultOpen] = useState(false); const [resultData, setResultData] = useState(null); + const [activeCategory, setActiveCategory] = useState>("D4"); const [quickFillState, setQuickFillState] = useState(() => loadQuickFillState()); const [riskStateDrawNo, setRiskStateDrawNo] = useState(display?.draw_no ?? null); const [liveSoldOutNumbers, setLiveSoldOutNumbers] = useState>(() => new Set()); @@ -552,55 +519,54 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } const openPlays = useMemo(() => { if (catalogState.kind !== "ok") return []; - const order = categoryPlayOrders[activeCategory === "JACKPOT" ? "D4" : activeCategory]; + const order = [...D2_PLAY_ORDER, ...D3_PLAY_ORDER, ...D4_PLAY_ORDER]; return sortByPlayOrder( catalogState.data.plays .filter(isPlayOpenForPlayer) .filter((p) => order.includes(p.play_code)), order, ); - }, [activeCategory, catalogState]); + }, [catalogState]); + + const activeCategoryPlays = useMemo( + () => openPlays.filter((play) => playCategory(play.play_code) === activeCategory), + [activeCategory, openPlays], + ); const currencyCode = catalogState.kind === "ok" ? catalogState.data.currency_code : currencyParam; - 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 allPlayColumns = useMemo(() => { - if (activeCategory === "JACKPOT") return []; - return playColumnsForCategory(categoryPlays, activeCategory); - }, [activeCategory, categoryPlays]); + 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 playColumns = useMemo(() => { - if (activeCategory !== "D4") return allPlayColumns; - const group = D4_PLAY_GROUPS.find((g) => g.id === d4PlayGroup); - if (!group) return allPlayColumns; - const allowedCodes = new Set(group.playCodes); - return allPlayColumns.filter((col) => allowedCodes.has(col.play.play_code)); - }, [activeCategory, allPlayColumns, d4PlayGroup]); + return playColumnsForCategory(activeCategoryPlays, activeCategory); + }, [activeCategory, activeCategoryPlays]); - const compactTable = playColumns.length > 6; - const amountColClass = compactTable - ? "w-[3.75rem] min-w-[3.75rem] max-w-[3.75rem]" - : "w-[4.5rem] min-w-[4.5rem] max-w-[4.5rem]"; + const amountColClass = !isMobile + ? "w-[3.9rem] min-w-[3.9rem]" + : "w-[4rem] min-w-[4rem]"; const tableMinWidthPx = useMemo(() => { - const indexCol = 40; - const numberCol = activeCategory === "D4" ? 112 : activeCategory === "D3" ? 88 : 72; - const amountCol = compactTable ? 60 : 72; - const deleteCol = 36; + const indexCol = 34; + const numberCol = 88; + const amountCol = !isMobile ? 62 : 64; + const deleteCol = 32; return indexCol + numberCol + playColumns.length * amountCol + deleteCol; - }, [activeCategory, compactTable, playColumns.length]); + }, [playColumns.length, isMobile]); - const showWideTableHint = activeCategory === "D4" && allPlayColumns.length > 8 && playColumns.length > 8; + const showWideTableHint = isMobile && playColumns.length > 8; const activeRow = useMemo( () => rows.find((row) => row.id === activeRowId) ?? rows[0] ?? null, @@ -622,46 +588,19 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } const historyNumbers = currentQuickFill.history; const tableDisabled = !isBettable || catalogState.kind !== "ok"; const sealedBetUi = Boolean(display && isHallSealedCountdownUi(display.status)); - const defaultNumberPlaceholder = + const numberPlaceholder = activeCategory === "D2" ? "00" : activeCategory === "D3" ? "000" : "0000"; - const numberPlaceholder = useMemo(() => { - if (activeCategory !== "D4") return defaultNumberPlaceholder; - const targetRow = activeRow ?? rows[0]; - if (!targetRow) return defaultNumberPlaceholder; - const hasRollStake = allPlayColumns.some((column) => { - if (column.play.play_code !== "roll") return false; - const amount = parseDecimalInputToMinor(targetRow.amounts[column.key] ?? "", currencyCode); - return amount !== null && amount > 0; - }); - return hasRollStake ? t("hall.numberInput.rollPlaceholder") : defaultNumberPlaceholder; - }, [ - activeCategory, - activeRow, - currencyCode, - defaultNumberPlaceholder, - allPlayColumns, - rows, - t, - ]); - const availableD4Groups = useMemo(() => { - if (activeCategory !== "D4") return []; - return D4_PLAY_GROUPS.filter((group) => { - const allowedCodes = new Set(group.playCodes); - return allPlayColumns.some((col) => allowedCodes.has(col.play.play_code)); - }); - }, [activeCategory, allPlayColumns]); - - const updateRowNumber = (id: string, value: string) => { + const updateRowNumber = useCallback((id: string, value: string) => { setRows((current) => current.map((row) => - row.id === id ? { ...row, number: sanitizeNumber(value, activeCategory) } : row, + row.id === id ? { ...row, number: sanitizeNumber(value) } : row, ), ); setActiveRowId(id); - }; + }, []); - const updateAmount = (rowId: string, playCode: string, value: string) => { + const updateAmount = useCallback((rowId: string, playCode: string, value: string) => { setRows((current) => current.map((row) => row.id === rowId @@ -670,25 +609,27 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } ), ); setActiveRowId(rowId); - }; + }, []); - const addRow = () => { + const addRow = useCallback(() => { setRows((current) => { if (current.length >= MAX_ROWS) return current; const row = newDraftRow(); setActiveRowId(row.id); return [...current, row]; }); - }; + }, []); - const removeRow = (id: string) => { + const removeRow = useCallback((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; + return current.filter((row) => row.id !== id); }); - }; + setActiveRowId((prev) => { + if (prev === id) return null; + return prev; + }); + }, []); const clearAllRows = () => { if (tableDisabled) return; @@ -844,7 +785,6 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } }, [clearAmountsForPlay, clearPlaceTraceId, drawNo, hasAmountsForPlay, reloadDraw, t]); const collectDraftLineIssues = useCallback((): DraftLineIssue[] => { - if (activeCategory === "JACKPOT") return []; const issues: DraftLineIssue[] = []; rows.forEach((row, rowIndex) => { allPlayColumns.forEach((column) => { @@ -861,7 +801,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } }); }); return issues; - }, [activeCategory, currencyCode, allPlayColumns, rows]); + }, [allPlayColumns, currencyCode, rows]); const formatDraftLineIssue = useCallback( (issue: DraftLineIssue): string => { @@ -872,13 +812,12 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } ); const collectEntries = useCallback((): DraftEntry[] => { - if (activeCategory === "JACKPOT") return []; 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(activeCategory, column.play, row.number, amount, column.digitSlot); + const line = lineForPlay(column.play, row.number, amount, column.digitSlot); if (!line) return; entries.push({ rowId: row.id, @@ -893,7 +832,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } }); }); return entries; - }, [activeCategory, currencyCode, allPlayColumns, rows]); + }, [allPlayColumns, currencyCode, rows]); const draftEntries = collectEntries(); const draftSummary = useMemo(() => { @@ -1161,60 +1100,6 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } return ( <>
-
-
- {categoryTabs.map((tab) => { - const active = activeCategory === tab.value; - return ( - - ); - })} -
-
- - {activeCategory === "D4" && availableD4Groups.length > 1 ? ( -
- {availableD4Groups.map((group) => { - const active = d4PlayGroup === group.id; - return ( - - ); - })} -
- ) : null} {jackpot?.enabled ? (
@@ -1369,7 +1254,38 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
- {categoryPlays.length === 0 ? ( +
+ {categoryTabs.map((tab) => { + const hasPlays = openPlays.some( + (play) => playCategory(play.play_code) === tab.value, + ); + const active = activeCategory === tab.value; + + return ( + + ); + })} +
+ + {activeCategoryPlays.length === 0 ? (
+
+ + + + + {allPlayColumns.map((column) => ( + + ))} + + + + + + {allPlayColumns.map((column) => { + const colTotalMinor = rows.reduce((sum, row) => { + const amtStr = row.amounts[column.key]; + if (!amtStr) return sum; + if ( + row.number.trim().length === 0 || + draftLineIssueReason(column.play.play_code, row.number, column.digitSlot) !== null + ) { + return sum; + } + const minorAmt = parseDecimalInputToMinor(amtStr, currencyCode); + return sum + (minorAmt ?? 0); + }, 0); + + return ( + + ); + })} + + +
+ {t("hall.table.total", { defaultValue: "共" })} + + {playColumnHeaderLabel( + column.play, + playCategory(column.play.play_code), + column.digitSlot, + t, + )} +
+ {formatMinorAsCurrency(submitActualMinor, currencyCode)} + + {formatMinorAsCurrency(colTotalMinor, currencyCode)} +
+
+
- {playColumns.map((column) => ( ))} - - {rows.map((row, index) => { - const rowKey = row.id; - const rowActive = activeRowId === row.id; - return ( - - - - {playColumns.map((column) => { - const { play } = column; - const amountText = row.amounts[column.key] ?? ""; - const status = cellRiskState( - play, - row.number, - activeCategory as Exclude, - alertRows, - liveSoldOutNumbers, - liveWarningNumbers, - column.digitSlot, - ); - const disabled = tableDisabled || status === "sold_out" || (play.config !== null && !play.config.is_enabled); - const hasAmount = amountText.trim().length > 0; - - return ( - - ); - })} - - - ); - })} + {rows.map((row, index) => ( + 1} + /> + ))}
+ {t("hall.table.no", { defaultValue: "No." })} {t("hall.table.number", { defaultValue: "Number" })} - + {numberPlaceholder} - + {playColumnHeaderLabel( column.play, - activeCategory as Exclude, + playCategory(column.play.play_code), column.digitSlot, t, )} - + {t("hall.table.amountPlaceholder")} +
- {index + 1} - - setActiveRowId(row.id)} - onClick={() => setActiveRowId(row.id)} - onChange={(event) => updateRowNumber(row.id, event.target.value)} - className={cn( - "h-9 w-full rounded-md border-[#e1e8f3] bg-white px-2 text-center font-mono text-base font-bold tabular-nums text-slate-950 shadow-sm focus-visible:ring-[#1d57b7]", - activeCategory === "D4" && "tracking-[0.2em]", - )} - /> - - setActiveRowId(row.id)} - onClick={() => setActiveRowId(row.id)} - onChange={(event) => updateAmount(row.id, column.key, event.target.value)} - className={cn( - "h-9 w-full rounded-md border-[#e1e8f3] bg-white px-1 text-center text-base 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")} -

- ) : status === "warning" ? ( -

- {t("hall.table.warning")} -

- ) : null} -
- -
@@ -1638,3 +1507,152 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } ); } + +const DraftRowItem = memo(function DraftRowItem({ + row, + index, + rowActive, + tableDisabled, + numberPlaceholder, + playColumns, + alertRows, + liveSoldOutNumbers, + liveWarningNumbers, + updateRowNumber, + updateAmount, + setActiveRowId, + removeRow, + t, + isMobile, + removable +}: { + row: DraftRow; + index: number; + rowActive: boolean; + tableDisabled: boolean; + numberPlaceholder: string; + playColumns: PlayColumn[]; + alertRows: DrawCurrentRiskPoolAlert[]; + liveSoldOutNumbers: Set; + liveWarningNumbers: Set; + updateRowNumber: (id: string, value: string) => void; + updateAmount: (rowId: string, playCode: string, value: string) => void; + setActiveRowId: (id: string) => void; + removeRow: (id: string) => void; + t: HallTranslate; + isMobile: boolean; + removable: boolean; +}) { + return ( + + + {index + 1} + + + setActiveRowId(row.id)} + onClick={() => setActiveRowId(row.id)} + onChange={(event) => updateRowNumber(row.id, event.target.value)} + className={cn( + "h-8 w-full rounded-md border-[#e1e8f3] bg-white px-1 text-center font-mono font-bold tabular-nums text-slate-950 shadow-sm focus-visible:ring-[#1d57b7]", + !isMobile ? "text-[13px]" : "text-sm", + "tracking-[0.12em]", + )} + /> + + {playColumns.map((column) => { + const { play } = column; + const amountText = row.amounts[column.key] ?? ""; + const status = cellRiskState( + play, + row.number, + playCategory(play.play_code), + alertRows, + liveSoldOutNumbers, + liveWarningNumbers, + column.digitSlot, + ); + const disabled = tableDisabled || status === "sold_out" || (play.config !== null && !play.config.is_enabled); + const hasAmount = amountText.trim().length > 0; + const isInputValidForPlay = row.number.trim().length > 0 && draftLineIssueReason(play.play_code, row.number, column.digitSlot) === null; + const cellDisabled = disabled || !isInputValidForPlay; + + return ( + + {isInputValidForPlay ? ( + setActiveRowId(row.id)} + onClick={() => setActiveRowId(row.id)} + onChange={(event) => updateAmount(row.id, column.key, event.target.value)} + className={cn( + "h-8 w-full rounded-md border-[#e1e8f3] bg-white px-0.5 text-center font-bold tabular-nums shadow-sm focus-visible:ring-[#1d57b7]", + !isMobile ? "text-[13px]" : "text-sm", + 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", + )} + /> + ) : ( +
+ )} + {isInputValidForPlay && status === "sold_out" ? ( +

+ {t("hall.table.soldOut")} +

+ ) : status === "warning" ? ( +

+ {t("hall.table.warning")} +

+ ) : null} + + ); + })} + + + + + ); +});