diff --git a/public/logo.png b/public/logo.png index 494e884..2294dc4 100644 Binary files a/public/logo.png and b/public/logo.png differ diff --git a/src/features/hall/hall-bet-preview-dialog.tsx b/src/features/hall/hall-bet-preview-dialog.tsx index c03bf73..7b17414 100644 --- a/src/features/hall/hall-bet-preview-dialog.tsx +++ b/src/features/hall/hall-bet-preview-dialog.tsx @@ -33,7 +33,7 @@ function WarningsBlock({ warnings }: { warnings: TicketPreviewWarning[] }) { if (warnings.length === 0) return null; return ( - + {t("hall.preview.warningsTitle")} @@ -70,16 +70,18 @@ export function HallBetPreviewDialog({ return ( - +
- {t("hall.preview.title")} - + + {t("hall.preview.title")} + + {t("hall.preview.description")} {!allowSubmit ? ( - + {t("hall.preview.sealedTitle")} @@ -89,42 +91,42 @@ export function HallBetPreviewDialog({ ) : null}
- +
{!data ? ( -

{t("hall.preview.empty")}

+

{t("hall.preview.empty")}

) : ( <> -
-

+

+

{t("hall.preview.draw")}{" "} - {data.draw.draw_id} ·{" "} + {data.draw.draw_id} ·{" "} {t("hall.preview.status")}{" "} - {data.draw.status} + {data.draw.status}

{summary ? ( -
    +
    • {t("hall.preview.totalBet")}{" "} - + {formatMinorAsCurrency(summary.total_bet_amount, currencyCode)}
    • {t("hall.preview.rebateDeduct")}{" "} - + {formatMinorAsCurrency(summary.total_rebate_amount, currencyCode)}
    • {t("hall.preview.actualDeduct")}{" "} - + {formatMinorAsCurrency(summary.total_actual_deduct, currencyCode)}
    • {t("hall.preview.estimatedPayout")}{" "} - + {formatMinorAsCurrency(summary.total_estimated_payout, currencyCode)}
    • @@ -135,39 +137,41 @@ export function HallBetPreviewDialog({
      -

      +

      {t("hall.preview.lines")}

        {lines.map((ln) => (
      • - + #{ln.client_line_no} - {ln.play_code} + + {ln.play_code} +
        -

        {ln.number}

        - +

        {ln.number}

        +
        - + {t("hall.preview.normalizedNumber")} {ln.normalized_number} - + {t("hall.preview.combinationCount")} {ln.combination_count} - + {t("hall.preview.actual")} - + {formatMinorAsCurrency(ln.actual_deduct_amount, currencyCode)} - + {t("hall.preview.estimatedMax")} @@ -183,11 +187,22 @@ export function HallBetPreviewDialog({
        -
        - - +
        + +
+ ); +} diff --git a/src/features/hall/hall-betting-grid.tsx b/src/features/hall/hall-betting-grid.tsx index 5f626e2..71c753e 100644 --- a/src/features/hall/hall-betting-grid.tsx +++ b/src/features/hall/hall-betting-grid.tsx @@ -1,37 +1,39 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { CirclePlus, Cuboid, PackageOpen, Ticket, Trash2 } from "lucide-react"; +import { ChevronRight, 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 { useHallDrawLive } from "@/features/hall/use-hall-draw-live"; +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, TicketPreviewData } from "@/types/api/ticket"; +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 BoxMode = "ibox" | "box"; type DraftRow = { id: string; @@ -48,6 +50,9 @@ type DraftEntry = { 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" }, @@ -55,22 +60,33 @@ const categoryTabs: { value: HallCategory; label: string }[] = [ { value: "JACKPOT", label: "Jackpot" }, ]; -const preferredD4Columns = [ +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_3c", - "pos_3a", "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 simpleCategoryPreferred: Record<"D2" | "D3", string[]> = { - D2: ["pos_2a", "pos_2b", "pos_2c", "pos_2abc"], - D3: ["pos_3a", "pos_3b", "pos_3c", "pos_3abc"], +const categoryPlayOrders: Record, readonly string[]> = { + D2: D2_PLAY_ORDER, + D3: D3_PLAY_ORDER, + D4: D4_PLAY_ORDER, }; function newDraftRow(): DraftRow { @@ -82,29 +98,19 @@ function newDraftRow(): DraftRow { } function isPlayOpenForPlayer(row: PlayEffectivePlayRow): boolean { - if (!row.master_enabled || row.config === null) { - return false; - } - return row.config.is_enabled; + 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; - } + 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): HallCategory { +function inferCategory(row: PlayEffectivePlayRow): Exclude { if (row.play_code.startsWith("pos_2")) return "D2"; if (row.play_code.startsWith("pos_3")) return "D3"; - if (row.category.toLowerCase().includes("jackpot")) return "JACKPOT"; - if (row.dimension === 2) return "D2"; - if (row.dimension === 3) return "D3"; return "D4"; } @@ -116,8 +122,10 @@ function categoryDigits(category: HallCategory): number { } function sanitizeNumber(raw: string, category: HallCategory): string { - const max = categoryDigits(category); - return raw.replace(/\D/g, "").slice(0, max); + 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 { @@ -130,11 +138,6 @@ function parseRebateRate(rate: string | undefined): number { return n > 1 ? n / 100 : n; } -function formatRatePercent(rate: string | undefined): string { - const n = parseRebateRate(rate); - return `${(n * 100).toFixed(2)}%`; -} - function amountToDisplay(minor: number): string { return (minor / 100).toLocaleString(undefined, { minimumFractionDigits: 2, @@ -158,8 +161,13 @@ function normalizeNumberForPlay(number: string, playCode: string): string { return number; } +function pickDigitSlot(category: HallCategory): number { + if (category === "D2") return 3; + return 3; +} + function lineForPlay( - category: "D2" | "D3" | "D4", + category: Exclude, play: PlayEffectivePlayRow, displayNumber: string, amountMinor: number, @@ -180,57 +188,202 @@ function lineForPlay( line.dimension = category; } if (playNeedsDigitSlot(play.play_code)) { - line.digit_slot = 3; + line.digit_slot = pickDigitSlot(category); } return line; } -function findPlayByCode( - plays: PlayEffectivePlayRow[], +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, -): PlayEffectivePlayRow | undefined { - return plays.find((p) => p.play_code === playCode); + 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 pickSimplePlay( - plays: PlayEffectivePlayRow[], - category: "D2" | "D3", -): PlayEffectivePlayRow | undefined { - const preferred = simpleCategoryPreferred[category]; - return ( - preferred.map((code) => findPlayByCode(plays, code)).find(Boolean) ?? - plays.find((p) => inferCategory(p) === category) - ); +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"; } -export function HallBettingGrid() { - const { display, isBettable, reload: reloadDraw } = useHallDrawLive(); +function tableCellClass(status: CellRiskState, disabled: boolean): string { + if (disabled) { + return "opacity-50"; + } + if (status === "sold_out") { + return "bg-slate-100 text-slate-400 line-through"; + } + if (status === "warning") { + return "bg-amber-50 text-amber-700"; + } + return ""; +} + +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 [boxMode, setBoxMode] = useState("ibox"); const [rows, setRows] = useState(() => [ { ...newDraftRow(), number: "23", amounts: {} }, { ...newDraftRow(), number: "75", amounts: {} }, { ...newDraftRow(), number: "08", amounts: {} }, { ...newDraftRow(), number: "46", amounts: {} }, ]); - - const currencyParam = useMemo(() => { - const fromEnv = process.env.NEXT_PUBLIC_LOTTERY_PLAY_CURRENCY?.trim(); - return fromEnv !== undefined && fromEnv !== "" ? fromEnv : undefined; - }, []); - + 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" })); @@ -240,54 +393,73 @@ export function HallBettingGrid() { ); setCatalogState({ kind: "ok", data }); } catch (e) { - const msg = - e instanceof LotteryApiBizError ? e.message : t("hall.loadingError"); + 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]); + }, [loadCatalog, refreshWallet]); useEffect(() => { const id = window.setInterval(() => { void loadCatalog(); + void refreshWallet(); }, DEFAULT_POLL_MS); return () => window.clearInterval(id); - }, [loadCatalog]); + }, [loadCatalog, refreshWallet]); const openPlays = useMemo(() => { if (catalogState.kind !== "ok") return []; - return [...catalogState.data.plays] - .filter(isPlayOpenForPlayer) - .filter((p) => p.play_code !== "half_box" && p.play_code !== "roll") - .sort((a, b) => a.sort_order - b.sort_order || a.play_code.localeCompare(b.play_code)); - }, [catalogState]); + const 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 currencyCode = catalogState.kind === "ok" ? catalogState.data.currency_code : "NPR"; - const simplePlay = useMemo(() => { - if (activeCategory !== "D2" && activeCategory !== "D3") return undefined; - return pickSimplePlay(openPlays, activeCategory); - }, [activeCategory, openPlays]); + 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 d4Columns = useMemo(() => { - if (activeCategory !== "D4") return []; - const first = findPlayByCode(openPlays, boxMode); - const preferred = preferredD4Columns - .map((code) => findPlayByCode(openPlays, code)) - .filter((p): p is PlayEffectivePlayRow => Boolean(p)); - const merged = first ? [first, ...preferred] : preferred; - return merged.filter((p, i, arr) => arr.findIndex((x) => x.play_code === p.play_code) === i); - }, [activeCategory, boxMode, openPlays]); + const activeRow = useMemo( + () => rows.find((row) => row.id === activeRowId) ?? rows[0] ?? null, + [activeRowId, rows], + ); - const tableDisabled = - activeCategory === "JACKPOT" || !isBettable || catalogState.kind !== "ok"; + 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) => @@ -295,51 +467,95 @@ export function HallBettingGrid() { 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, amounts: { ...row.amounts, [playCode]: sanitizeAmount(value) } } : row, ), ); }; const addRow = () => { - setRows((current) => (current.length >= MAX_ROWS ? current : [...current, newDraftRow()])); + setRows((current) => { + if (current.length >= MAX_ROWS) return current; + const next = [...current, newDraftRow()]; + setActiveRowId(next[next.length - 1].id); + return next; + }); }; const removeRow = (id: string) => { - setRows((current) => - current.length <= 1 ? current : current.filter((row) => row.id !== id), - ); + 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[] = []; - const plays = - activeCategory === "D4" ? d4Columns : simplePlay ? [simplePlay] : []; - rows.forEach((row, rowIndex) => { - plays.forEach((play) => { + categoryPlays.forEach((play) => { const amount = parseDecimalInputToMinor(row.amounts[play.play_code] ?? ""); if (amount === null || amount <= 0) return; - - const line = lineForPlay( - activeCategory as "D2" | "D3" | "D4", - play, - row.number, - amount, - ); + const line = lineForPlay(activeCategory, play, row.number, amount); if (!line) return; - entries.push({ rowId: row.id, rowNo: rowIndex + 1, @@ -350,9 +566,8 @@ export function HallBettingGrid() { }); }); }); - return entries; - }, [activeCategory, d4Columns, rows, simplePlay]); + }, [activeCategory, categoryPlays, rows]); const draftEntries = collectEntries(); const draftSummary = useMemo(() => { @@ -369,9 +584,14 @@ export function HallBettingGrid() { ); }, [draftEntries]); - const buildLines = (): TicketLineInput[] => { - return collectEntries().map((entry) => entry.line); - }; + 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) { @@ -398,11 +618,18 @@ export function HallBettingGrid() { 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())}`, + 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"); @@ -437,17 +664,21 @@ export function HallBettingGrid() { 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), }), ); - setPreviewOpen(false); - setPreviewData(null); - setRows([newDraftRow()]); - triggerWalletPollingAfterBet(); - void reloadDraw(); } catch (e) { const code = e instanceof LotteryApiBizError ? e.code : 0; const msg = e instanceof LotteryApiBizError ? e.message : t("hall.placeFailed"); @@ -457,6 +688,12 @@ export function HallBettingGrid() { } }; + useEffect(() => { + const onRefresh = () => void refreshWallet(); + window.addEventListener("lottery-wallet-refresh", onRefresh); + return () => window.removeEventListener("lottery-wallet-refresh", onRefresh); + }, [refreshWallet]); + if (catalogState.kind === "loading") { return (
@@ -484,100 +721,44 @@ export function HallBettingGrid() { ); } - const simplePlayCode = simplePlay?.play_code ?? ""; - const numberPlaceholder = - activeCategory === "D2" ? "00" : activeCategory === "D3" ? "000" : "0000"; + 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 === "D4" ? ( -
- - +
+
+ {categoryTabs.map((tab) => { + const active = activeCategory === tab.value; + return ( + + ); + })}
- ) : null} +
{activeCategory === "JACKPOT" ? (
@@ -593,174 +774,316 @@ export function HallBettingGrid() {
) : ( -
+
+
+
+

+ {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.scrollHint")} +

+ ) : ( +
+ {t("hall.table.noPlaysInCategory")} +
)} - > -
- - - - - - {activeCategory === "D4" ? ( - d4Columns.map((play) => ( - + ); + })} + + + ); + })} + +
- {t("hall.table.no")} - - {t("hall.table.number")} - - ({numberPlaceholder}) - - + +
+
+ + + + + + {categoryPlays.map((play) => ( + - )) - ) : ( - <> - - - - - )} - - - - {rows.map((row, index) => { - const simpleAmount = parseDecimalInputToMinor( - row.amounts[simplePlayCode] ?? "", - ); - const simpleRebate = - simpleAmount !== null && simplePlay - ? Math.round(simpleAmount * parseRebateRate(simplePlay.odds?.rebate_rate)) - : 0; - const simpleActual = - simpleAmount !== null ? Math.max(0, simpleAmount - simpleRebate) : 0; + ))} + + + + {rows.map((row, index) => { + const rowKey = row.id; + return ( + + + + {categoryPlays.map((play) => { + const amountText = row.amounts[play.play_code] ?? ""; + const amountMinor = parseDecimalInputToMinor(amountText); + const rebate = amountMinor != null + ? Math.round(amountMinor * parseRebateRate(play.odds?.rebate_rate)) + : 0; + const actual = amountMinor != null ? Math.max(0, amountMinor - rebate) : 0; + const status = cellRiskState( + play, + row.number, + activeCategory as Exclude, + alertRows, + ); + const disabled = tableDisabled || status === "sold_out" || (play.config !== null && !play.config.is_enabled); - return ( - - - - {activeCategory === "D4" ? ( - d4Columns.map((play) => ( - - )) - ) : ( - <> - - - - - )} - - - ); - })} - -
+ {t("hall.table.no", { defaultValue: "No." })} + + {t("hall.table.number", { defaultValue: "Number" })} + + ({numberPlaceholder}) + + {pickDisplayName(play)} - {t("hall.table.stake")} - - {t("hall.table.rebate")} - - {t("hall.table.actual")} - -
+
+ {index + 1} + + setActiveRowId(row.id)} + onClick={() => setActiveRowId(row.id)} + onChange={(event) => updateRowNumber(row.id, event.target.value)} + className="h-10 rounded-lg border-[#e2e8f0] bg-white text-center font-mono text-sm font-semibold text-slate-900 shadow-sm" + /> +
- {index + 1} - - updateRowNumber(row.id, event.target.value)} - className="h-10 rounded-lg border-[#e2e8f0] bg-white text-center font-mono text-sm font-semibold text-slate-900 shadow-sm" - /> - - - updateAmount(row.id, play.play_code, event.target.value) - } - className="h-10 rounded-lg border-[#e2e8f0] bg-white text-center text-xs tabular-nums shadow-sm" - /> - - - updateAmount(row.id, simplePlayCode, event.target.value) - } - className="h-10 rounded-lg border-[#e2e8f0] bg-white text-center text-sm tabular-nums shadow-sm" - /> - - - -{simpleRebate > 0 ? amountToDisplay(simpleRebate) : "0.00"} - - - ({formatRatePercent(simplePlay?.odds?.rebate_rate)}) - - - {simpleActual > 0 ? amountToDisplay(simpleActual) : "0.00"} - - -
+ return ( +
+
+ setActiveRowId(row.id)} + onClick={() => setActiveRowId(row.id)} + onChange={(event) => updateAmount(row.id, play.play_code, event.target.value)} + className="h-10 rounded-lg border-[#e2e8f0] bg-white text-center text-xs tabular-nums shadow-sm" + /> + {status === "sold_out" ? ( +

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

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

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

+ ) : activeCategory !== "D4" && amountMinor !== null ? ( +

+ {t("hall.table.actual", { defaultValue: "实扣" })}{" "} + {amountToDisplay(actual)} +

+ ) : null} +
+
+ +
+
+
- - + + {previewLoading + ? t("hall.table.previewing", { defaultValue: "预览中..." }) + : !isBettable + ? t("hall.closed.title") + : availableMinor < debouncedSummary.actual + ? t("hall.table.insufficientBalance", { defaultValue: "余额不足" }) + : t("hall.table.submitBet", { defaultValue: "提交下注" })} + + )} - -
- {t("hall.table.draftTotal")} - - {formatMinorAsCurrency(draftSummary.actual, currencyCode)} - -
- - {sealedBetUi ? ( -

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

- ) : null} - -
= debouncedSummary.actual} onConfirmPlace={() => void handlePlace()} /> + + { + setResultOpen(open); + if (!open) setResultData(null); + }} + currencyCode={currencyCode} + data={resultData} + /> ); } diff --git a/src/features/hall/hall-draw-panel.tsx b/src/features/hall/hall-draw-panel.tsx index 489664c..5ea3577 100644 --- a/src/features/hall/hall-draw-panel.tsx +++ b/src/features/hall/hall-draw-panel.tsx @@ -1,12 +1,12 @@ "use client"; -import { Hourglass, Landmark, TimerReset, WalletCards } from "lucide-react"; +import { Hourglass, Landmark, TimerReset } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { drawStatusHud, isHallSealedCountdownUi } from "@/features/draw/draw-status-meta"; -import { useHallDrawLive } from "@/features/hall/use-hall-draw-live"; +import type { HallDrawLiveSnapshot } from "@/features/hall/use-hall-draw-live"; import { formatSecondsClock } from "@/lib/format-gmt"; import { formatLotteryInstant } from "@/lib/player-datetime"; import { cn } from "@/lib/utils"; @@ -69,8 +69,8 @@ function CloseTime({ ); } -export function HallDrawPanel() { - const { raw, display, error, reload } = useHallDrawLive(); +export function HallDrawPanel({ drawLive }: { drawLive: HallDrawLiveSnapshot }) { + const { raw, display, error, reload } = drawLive; const { t } = useTranslation("player"); if (error) { @@ -122,13 +122,10 @@ export function HallDrawPanel() { aria-label={t("draw.currentIssue")} >
-
- - - -
+
+

{t("draw.issueNo")}

-

+

{display.draw_no}

diff --git a/src/features/hall/hall-screen.tsx b/src/features/hall/hall-screen.tsx index 6ec7925..11874c0 100644 --- a/src/features/hall/hall-screen.tsx +++ b/src/features/hall/hall-screen.tsx @@ -1,33 +1,36 @@ "use client"; import { Bell } from "lucide-react"; +import Image from "next/image"; import { useTranslation } from "react-i18next"; import { LanguageSwitcher } from "@/components/language-switcher"; import { HallBettingGrid } from "@/features/hall/hall-betting-grid"; import { HallDrawPanel } from "@/features/hall/hall-draw-panel"; import { HallWalletStrip } from "@/features/hall/hall-wallet-strip"; +import { useHallDrawLive } from "@/features/hall/use-hall-draw-live"; /** * 下注大厅:钱包条 §4 + 当期期号 §4.2(封盘置灰 / 倒计时错误色 / WS+轮询);玩法目录 §12.3;下注表格 §13.3。 */ export function HallScreen() { const { t } = useTranslation("common"); + const drawLive = useHallDrawLive(); return (
-
-
-
- - N - - N -
-
- N{" "} - lotto +
+
+
+ Nlotto
- + - +
); diff --git a/src/features/hall/use-hall-draw-live.ts b/src/features/hall/use-hall-draw-live.ts index efb5edc..ab7f642 100644 --- a/src/features/hall/use-hall-draw-live.ts +++ b/src/features/hall/use-hall-draw-live.ts @@ -7,6 +7,15 @@ import { getLotteryEcho } from "@/lib/lottery-echo"; import { useNetworkConnectionStore } from "@/stores/network-connection-store"; import type { DrawCurrentPayload } from "@/types/api/draw-current"; +/** 大厅共享的当期快照(由 {@link useHallDrawLive} 产出,供期号条与下注表共用)。 */ +export type HallDrawLiveSnapshot = { + raw: DrawCurrentPayload | null | undefined; + display: DrawCurrentPayload | null | undefined; + error: string | null; + reload: () => Promise; + isBettable: boolean; +}; + /** 界面文档 §2.1:`draw.countdown` / `draw.status_change` / `result.published` 载荷 */ export type HallWsEnvelope = { data: DrawCurrentPayload | null; @@ -34,16 +43,10 @@ function applySnapshotDrift( } /** - * 大厅期号:WebSocket `lottery-hall` + 轮询降级(与 {@link HallDrawPanel} 同源逻辑)。 + * 大厅期号:WebSocket `lottery-hall` + 轮询降级;由 {@link HallScreen} 调用一次,注入 {@link HallDrawPanel} 与 {@link HallBettingGrid}。 * 已集成网络连接管理,WebSocket断开时自动切换到轮询模式。 */ -export function useHallDrawLive(): { - raw: DrawCurrentPayload | null | undefined; - display: DrawCurrentPayload | null | undefined; - error: string | null; - reload: () => Promise; - isBettable: boolean; -} { +export function useHallDrawLive(): HallDrawLiveSnapshot { const [raw, setRaw] = useState(undefined); const [emittedAtMs, setEmittedAtMs] = useState(() => Date.now()); const [nowMs, setNowMs] = useState(() => Date.now()); diff --git a/src/features/orders/ticket-order-detail-screen.tsx b/src/features/orders/ticket-order-detail-screen.tsx index 06493f1..14f1e6f 100644 --- a/src/features/orders/ticket-order-detail-screen.tsx +++ b/src/features/orders/ticket-order-detail-screen.tsx @@ -5,7 +5,7 @@ import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { getTicketItemDetail } from "@/api/ticket-items"; -import { Button, buttonVariants } from "@/components/ui/button"; +import { Button } from "@/components/ui/button"; import { PlayerPanel } from "@/components/layout/player-panel"; import { Card, @@ -74,7 +74,7 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) { @@ -91,13 +91,18 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {

{error ?? t("orders.noData")}

-
@@ -145,72 +150,87 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
- - + +
- {t("orders.detailTitle")} + + {t("orders.detailTitle")} +
- + {t("orders.ticketNo", { ticketNo: data.ticket_no })} ·{" "} {t("orders.orderNo", { orderNo: data.order_no ?? "—" })}
- -
-

- {t("orders.drawNo")}{" "} - {data.draw_no ?? "—"} -

-

- {t("orders.placedAt")}{" "} - {formatLotteryInstant(data.placed_at ?? null)} -

-

- {t("orders.number")}{" "} - {data.original_number ?? "—"} -

-

- {t("orders.play")}{" "} - {playLabel(data.play_code, t)} ( - {data.dimension ?? "—"}D) -

-

- {t("orders.amount")}{" "} - {formatMinorAsCurrency(data.total_bet_amount, cur)} -

-

- {t("orders.rebateRate")}{" "} - {(Number(data.rebate_rate_snapshot) * 100).toFixed(1)}% -

-

- {t("orders.actualDeduct")}{" "} - {formatMinorAsCurrency(data.actual_deduct_amount, cur)} + +

+
+ {t("orders.drawNo")} + + {data.draw_no ?? "—"} + +
+
+ {t("orders.placedAt")} + + {formatLotteryInstant(data.placed_at ?? null)} + +
+
+ {t("orders.number")} + + {data.original_number ?? "—"} + +
+
+ {t("orders.play")} + + {playLabel(data.play_code, t)} ({data.dimension ?? "—"}D) + +
+
+ {t("orders.amount")} + + {formatMinorAsCurrency(data.total_bet_amount, cur)} + +
+
+ {t("orders.rebateRate")} + + {(Number(data.rebate_rate_snapshot) * 100).toFixed(1)}% + +
+
+ {t("orders.actualDeduct")} + + {formatMinorAsCurrency(data.actual_deduct_amount, cur)} + +
+
+ +
+

{t("orders.oddsSnapshot")}

+

+ {formatOddsSnapshot(data.odds_snapshot_json, t)}

-
-

{t("orders.oddsSnapshot")}

-

- {formatOddsSnapshot(data.odds_snapshot_json, t)} -

-
- {pub?.results ? (
-

{t("orders.drawNumbers")}

+

{t("orders.drawNumbers")}

{first ? ( -

+

{t("orders.firstPrize")}{" "} - {first} + {first} {comboHits.length > 0 ? ( - + {" "} ← {t("orders.hit")} @@ -219,15 +239,17 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) { ) : null}

) : ( -

{t("orders.notPublished")}

+

+ {t("orders.notPublished")} +

)} {data.settlement && tierLabel ? ( -
-

+

+

{t("orders.matchWin", { tier: tierLabel })}

-

+

{t("orders.winAmount", { amount: formatMinorAsCurrency(data.settlement.win_amount_minor, cur), })} @@ -244,32 +266,39 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) { ) : null}

-

+

{t("orders.payoutTotal", { amount: formatMinorAsCurrency(totalWin, cur) })}

) : data.status === "settled_lose" ? ( -

{t("orders.matchLose")}

+

{t("orders.matchLose")}

) : null} {data.settled_at ? ( -

+

{t("orders.settledAt", { time: formatLotteryInstant(data.settled_at) })}

) : null} -
+
{data.draw_no ? ( {t("orders.viewDraw")} ) : null} - + {t("orders.backToOrders")}
diff --git a/src/i18n/locales/en/player.json b/src/i18n/locales/en/player.json index 6005a27..bfea7b4 100644 --- a/src/i18n/locales/en/player.json +++ b/src/i18n/locales/en/player.json @@ -121,9 +121,14 @@ "delete": "Delete", "addRow": "Add Row", "draftTotal": "Draft Total", + "totalBet": "Total", + "totalRebate": "Rebate", + "actualTotal": "Estimated Deduction", "sealedHint": "Closed: this table is locked. Please wait for the next issue.", "previewing": "Previewing...", - "submitBet": "Submit Bet" + "submitBet": "Submit Bet", + "scrollHint": "This table is wide: swipe or scroll horizontally, then enter your stake in each play column on the right (e.g. Big/Small, position plays).", + "noPlaysInCategory": "No open play types in this tab, so there are no amount fields. Try 2D / 3D / 4D, or ask an admin to enable plays for this category." }, "preview": { "title": "Confirm bet", diff --git a/src/i18n/locales/ne/player.json b/src/i18n/locales/ne/player.json index 5eabe18..e58f1ce 100644 --- a/src/i18n/locales/ne/player.json +++ b/src/i18n/locales/ne/player.json @@ -121,9 +121,14 @@ "delete": "हटाउनुहोस्", "addRow": "पंक्ति थप्नुहोस्", "draftTotal": "ड्राफ्ट जम्मा", + "totalBet": "जम्मा", + "totalRebate": "रिबेट", + "actualTotal": "अनुमानित कट्टा", "sealedHint": "बन्द: यो तालिका लक छ। कृपया अर्को इश्यू पर्खनुहोस्।", "previewing": "पूर्वावलोकन...", - "submitBet": "बेट पेश गर्नुहोस्" + "submitBet": "बेट पेश गर्नुहोस्", + "scrollHint": "तालिका फराकिलो छ: दायाँतिर स्क्रोल गर्नुहोस्, दायाँका प्रत्येक खेल स्तम्भमा बेट रकम लेख्नुहोस्।", + "noPlaysInCategory": "यस ट्याबमा खुला खेल प्रकार छैन। २D / ३D / ४D प्रयास गर्नुहोस् वा व्यवस्थापकले खेल खोल्नुपर्छ।" }, "preview": { "title": "बेट पुष्टि गर्नुहोस्", diff --git a/src/i18n/locales/zh/player.json b/src/i18n/locales/zh/player.json index 11f5e11..361e88d 100644 --- a/src/i18n/locales/zh/player.json +++ b/src/i18n/locales/zh/player.json @@ -121,9 +121,14 @@ "delete": "删除", "addRow": "添加一行", "draftTotal": "草稿合计", + "totalBet": "合计", + "totalRebate": "回水", + "actualTotal": "预计扣款", "sealedHint": "已封盘:当前表格不可编辑,请等待下一期。", "previewing": "预览中...", - "submitBet": "提交下注" + "submitBet": "提交下注", + "scrollHint": "表格较宽:请向右滑动,在右侧各玩法列(如 Big / Small、位置玩法等)输入下注金额。", + "noPlaysInCategory": "当前分类没有已开放的玩法,无法填写金额。请尝试切换 2D / 3D / 4D,或在后台开放对应玩法。" }, "preview": { "title": "确认下注", diff --git a/src/types/api/draw-current.ts b/src/types/api/draw-current.ts index ace2b24..9b57f03 100644 --- a/src/types/api/draw-current.ts +++ b/src/types/api/draw-current.ts @@ -9,6 +9,16 @@ export type DrawCurrentResultItem = { tail_digit: number | null; }; +export type DrawCurrentRiskPoolAlert = { + normalized_number: string; + total_cap_amount: number; + locked_amount: number; + remaining_amount: number; + sold_out_status: number; + is_sold_out: boolean; + usage_ratio: number | null; +}; + export type DrawCurrentPayload = { draw_no: string; business_date: string; @@ -21,6 +31,7 @@ export type DrawCurrentPayload = { seconds_to_draw: number; cooling_end_time: string | null; seconds_remaining_in_cooldown: number | null; + risk_pool_alerts?: DrawCurrentRiskPoolAlert[]; result_items?: DrawCurrentResultItem[]; result_version?: number; result_source?: string | null; diff --git a/src/types/api/ticket.ts b/src/types/api/ticket.ts index 6930a49..a7ad44a 100644 --- a/src/types/api/ticket.ts +++ b/src/types/api/ticket.ts @@ -72,5 +72,6 @@ export type TicketPlaceData = { order_no: string; draw: { draw_id: string; status: string }; summary: TicketPreviewData["summary"]; + balance_after: number; items: TicketPlaceItem[]; };