fix(player): harden realtime auth and refactor hall betting

This commit is contained in:
wchino
2026-07-22 01:40:37 +08:00
parent 5ef2e09b04
commit 508b28df22
12 changed files with 1319 additions and 1093 deletions

View File

@@ -1,32 +1,38 @@
"use client";
import { ChevronDown, ChevronUp, CircleHelp, Lock, Ticket, Trash2, Star } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState, memo } from "react";
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 { Tooltip } from "@base-ui/react/tooltip";
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 { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
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";
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,
@@ -69,26 +75,14 @@ import { cn } from "@/lib/utils";
import { LotteryApiBizError } from "@/types/api/errors";
import type { PlayEffectivePayload, PlayEffectivePlayRow } from "@/types/api/play-effective";
import type { TicketLineInput, TicketPlaceData, TicketPreviewData } from "@/types/api/ticket";
import type { DrawCurrentRiskPoolAlert } from "@/types/api/draw-current";
import type { BetProviderRow } from "@/types/api/bet-provider";
type HallCategory = "D2" | "D3" | "D4" | "JACKPOT";
type PlayHallCategory = Exclude<HallCategory, "JACKPOT">;
const TRADITIONAL_PLAY_CODES: Record<PlayHallCategory, readonly string[]> = {
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 DraftRow = {
id: string;
number: string;
amounts: Record<string, string>;
providerCodes: string[];
selectionType: SelectionType;
};
type PendingSelectionChange =
| { mode: "row"; rowId: string; next: SelectionType; prev: SelectionType }
| { mode: "all"; next: SelectionType };
@@ -110,75 +104,6 @@ type DraftLineIssue = {
reason: DraftLineIssueReason;
};
type PlayColumn = {
key: string;
play: PlayEffectivePlayRow;
digitSlot?: number;
};
type HallSummaryItem = {
key: string;
label: string;
totalMinor: number;
};
function HallPlaySummaryGrid({
items,
className,
}: {
items: HallSummaryItem[];
className?: string;
}) {
return (
<div
className={cn(
"grid grid-cols-6 overflow-hidden rounded-lg border border-[#dfe6f0] bg-[#f8fafc] text-center tabular-nums shadow-sm sm:grid-cols-8 md:grid-cols-10 lg:grid-cols-[repeat(auto-fit,minmax(3.5rem,1fr))]",
className,
)}
>
{items.map((item) => {
const hasValue = item.totalMinor > 0;
return (
<div
key={`summary-${item.key}`}
className={cn(
"min-w-0 border-b border-r transition-colors",
hasValue
? "border-[#cbdcf7] bg-[#eef5ff]"
: "border-slate-200 bg-slate-50",
)}
>
<p
className={cn(
"flex min-h-7 items-center justify-center px-1 text-[10px] font-bold leading-tight transition-colors lg:min-h-8 lg:text-[11px]",
hasValue
? "bg-[#2d63e2] text-white"
: "bg-slate-200 text-slate-500",
)}
>
{item.label}
</p>
<p
className={cn(
"px-1 py-1 text-[11px] font-black transition-colors lg:py-1.5 lg:text-xs",
hasValue ? "text-[#0b3f96]" : "text-slate-400",
)}
>
{formatMinorAmount(item.totalMinor)}
</p>
</div>
);
})}
</div>
);
}
function playCategory(playCode: string): PlayHallCategory {
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 }>;
@@ -216,26 +141,8 @@ type RiskWarningWsEvent = {
usage_percent?: number;
};
type CellRiskState = "open" | "warning" | "sold_out";
type QuickFillState = Record<HallCategory, { favorites: string[]; history: string[] }>;
const FALLBACK_BET_PROVIDERS: BetProviderRow[] = [
{ code: "SG", name: "Singapore", short_code: "S", sort_order: 10, is_default: true },
{ code: "MY", name: "Malaysia", short_code: "M", sort_order: 20, is_default: false },
{ code: "TH", name: "Thailand", short_code: "T", sort_order: 30, is_default: false },
];
const DEFAULT_PROVIDER_CODE = "SG";
const PROVIDER_COLUMN_TONES = [
"bg-[#fff875] text-slate-950",
"bg-[#b7d9ff] text-slate-950",
"bg-[#ffc7ca] text-slate-950",
"bg-[#d9d8ff] text-slate-950",
"bg-[#a8f8a4] text-slate-950",
] as const;
function providerColumnTone(index: number): string {
return PROVIDER_COLUMN_TONES[index % PROVIDER_COLUMN_TONES.length] ?? "bg-slate-100 text-slate-950";
}
const categoryTabs: { value: PlayHallCategory; label: string }[] = [
{ value: "D4", label: "4D" },
@@ -275,7 +182,6 @@ const CATEGORY_ORDER: readonly PlayHallCategory[] = ["D4", "D3", "D2"];
const SUMMARY_PLAY_ORDER = CATEGORY_ORDER.flatMap(
(category) => PLAY_ORDER_BY_CATEGORY[category],
);
const MOBILE_QUICK_AMOUNT_PRESETS = ["10", "50", "100"] as const;
const DEFAULT_DRAFT_ROW_COUNT = 20;
function playOrderForActiveCategory(activeCategory: PlayHallCategory): readonly string[] {
@@ -302,37 +208,6 @@ function isPlayOpenForPlayer(row: PlayEffectivePlayRow): boolean {
return Boolean(row.master_enabled && row.config?.is_enabled);
}
type HallTranslate = (key: string, options?: Record<string, unknown>) => string;
/** 表头用短标签,避免 digit_big + 千/百/十/个 挤成一团。 */
function playColumnHeaderLabel(
play: PlayEffectivePlayRow,
category: PlayHallCategory,
digitSlot: number | undefined,
t: HallTranslate,
): string {
if (digitSlot !== undefined) {
const kind = play.play_code === "digit_big" ? "big" : "small";
return `${t(`hall.table.digitShort.${kind}`)}·${digitSlotLabel(category, digitSlot)}`;
}
return playLabel(play.play_code, t);
}
function digitSlotOptions(category: PlayHallCategory): number[] {
if (category === "D2") return [2, 3];
if (category === "D3") return [1, 2, 3];
return [0, 1, 2, 3];
}
function digitSlotLabel(category: PlayHallCategory, slot: number): string {
const labels: Record<PlayHallCategory, Record<number, string>> = {
D2: { 2: "十", 3: "个" },
D3: { 1: "百", 2: "十", 3: "个" },
D4: { 0: "千", 1: "百", 2: "十", 3: "个" },
};
return labels[category][slot] ?? String(slot + 1);
}
function amountKeyForPlay(playCode: string, digitSlot?: number): string {
return digitSlot === undefined ? playCode : `${playCode}@${digitSlot}`;
}
@@ -354,20 +229,6 @@ function playColumnsForCategory(
});
}
function numberMaxCharsForCategory(category: PlayHallCategory): number {
return category === "D2" ? 2 : category === "D3" ? 3 : 4;
}
function sanitizeNumber(raw: string, category: PlayHallCategory): string {
const normalized =
category === "D4"
? raw.replace(/[^0-9Rr]/g, "").toUpperCase()
: raw.replace(/\D/g, "");
const maxChars = numberMaxCharsForCategory(category);
return category === "D4" ? normalized.slice(0, maxChars) : normalized.slice(-maxChars);
}
function sanitizeAmount(raw: string): string {
return raw.replace(/[^\d.]/g, "").replace(/(\..*)\./g, "$1").slice(0, 12);
}
@@ -483,99 +344,6 @@ function appendUnique(values: string[], value: string, limit = 20): string[] {
return next.slice(0, limit);
}
function sortedDigits(value: string): string {
return value.split("").sort().join("");
}
function matchesRiskAlert(
alertNumber: string,
playCode: string,
rowNumber: string,
category: Exclude<HallCategory, "JACKPOT">,
digitSlot?: number,
): 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 slot = digitSlot ?? digitSlotOptions(category).at(-1) ?? 3;
const last = alertNumber[slot] ?? "";
return playCode === "digit_big"
? ["5", "6", "7", "8", "9"].includes(last)
: ["0", "1", "2", "3", "4"].includes(last);
}
return false;
}
function cellRiskState(
play: PlayEffectivePlayRow,
rowNumber: string,
category: Exclude<HallCategory, "JACKPOT">,
alertRows: DrawCurrentRiskPoolAlert[] | undefined,
liveSoldOutNumbers: ReadonlySet<string>,
liveWarningNumbers: ReadonlySet<string>,
digitSlot?: number,
): CellRiskState {
const normalizedRow = rowNumber.trim().toUpperCase();
if (!normalizedRow) return "open";
if (liveSoldOutNumbers.has(normalizedRow)) {
return "sold_out";
}
if (liveWarningNumbers.has(normalizedRow)) {
return "warning";
}
const alerts = alertRows ?? [];
if (alerts.length === 0) return "open";
for (const alert of alerts) {
if (matchesRiskAlert(alert.normalized_number, play.play_code, normalizedRow, category, digitSlot)) {
return alert.status === "sold_out" ? "sold_out" : "warning";
}
}
return "open";
}
function quickFillKeys(category: HallCategory): { favorites: string; history: string } {
return {
favorites: `lottery.hall.quickfill.favorites.${category}`,
@@ -615,11 +383,6 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
const [pendingSelectionChange, setPendingSelectionChange] = useState<PendingSelectionChange | null>(
null,
);
const holdFavoriteRef = useRef<{ timer: number | null; number: string | null; longPress: boolean }>({
timer: null,
number: null,
longPress: false,
});
/** 单次预览→确认共用,重试 place 复用,避免重复扣款 */
const placeTraceIdRef = useRef<string | null>(null);
const previewRequestSeqRef = useRef(0);
@@ -739,6 +502,10 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
() => 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;
@@ -1846,337 +1613,26 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
<HallPlaySummaryGrid items={summaryItems} className="lg:hidden" />
<div
className={cn(
isMobile ? "space-y-3" : "hidden",
)}
>
<div
className={cn(
"bg-white",
isMobile
? "rounded-lg border border-[#e8eef7] px-3 py-2.5"
: "min-w-0 flex-1 px-2 py-0.5",
)}
>
<div className={cn("flex gap-3", isMobile ? "items-start justify-between" : "items-center justify-between")}>
<div className={cn("min-w-0", !isMobile && "flex flex-1 items-center gap-4")}>
<div className={cn("shrink-0", !isMobile && "min-w-[5.5rem]")}>
<p className="text-sm font-bold leading-5 text-slate-950">
{t("hall.quickFill.title")}
</p>
{isMobile ? (
<p className="mt-0.5 text-[11px] leading-5 text-slate-500">
{t("hall.mobile.quickFillSummary", {
defaultValue: "收藏 {{favorites}} 个,历史 {{history}} 个",
favorites: favorites.length,
history: historyNumbers.length,
})}
</p>
) : null}
</div>
{!isMobile ? (
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-4 gap-y-1.5">
{favoriteChips.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5">
<span className="mr-0.5 text-[11px] font-semibold text-[#d81435]">
{t("hall.quickFill.favorites")}
</span>
{favoriteChips.map((number) => (
<button
key={`fav-${number}`}
type="button"
className="inline-flex h-7 touch-manipulation items-center gap-1 rounded-full border border-[#ffd7db] bg-[#fff3f5] px-2.5 text-xs font-semibold text-[#d81435] transition-colors hover:bg-[#ffe9ed]"
onPointerDown={() => {
const current = holdFavoriteRef.current;
current.number = number;
current.longPress = false;
if (current.timer) window.clearTimeout(current.timer);
current.timer = window.setTimeout(() => {
current.longPress = true;
toggleFavoriteNumber(number);
}, 500);
}}
onPointerUp={() => {
const current = holdFavoriteRef.current;
if (current.timer) {
window.clearTimeout(current.timer);
current.timer = null;
}
if (!current.longPress) {
fillCurrentRow(number);
}
current.longPress = false;
}}
onPointerLeave={() => {
const current = holdFavoriteRef.current;
if (current.timer) {
window.clearTimeout(current.timer);
current.timer = null;
}
current.longPress = false;
}}
>
{number}
</button>
))}
</div>
) : null}
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<span className="mr-0.5 text-[11px] font-semibold text-slate-400">
{t("hall.quickFill.history")}
</span>
{historyChips.length > 0 ? (
historyChips.map((number) => (
<button
key={`his-${number}`}
type="button"
className="inline-flex h-7 min-w-9 items-center justify-center rounded-full border border-[#d7e5f8] bg-[#f8fbff] px-2.5 text-xs font-bold text-[#07459f] transition-colors hover:border-[#b9d0f3] hover:bg-[#eef6ff]"
onClick={() => fillCurrentRow(number)}
>
{number}
</button>
))
) : (
<span className="inline-flex h-7 items-center rounded-full border border-dashed border-slate-200 bg-slate-50 px-2.5 text-xs text-slate-400">
{t("hall.quickFill.emptyHistory")}
</span>
)}
</div>
</div>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1.5">
{isMobile ? (
<Button
type="button"
variant="ghost"
size="icon"
aria-label={
quickFillExpanded
? t("hall.mobile.collapseQuickFill", { defaultValue: "收起快捷填单" })
: t("hall.mobile.expandQuickFill", { defaultValue: "展开快捷填单" })
}
aria-pressed={quickFillExpanded}
className="size-8 rounded-full border border-[#dfe6f0] bg-[#f8fafc] text-slate-500 hover:bg-slate-100 hover:text-slate-900"
onClick={() => setQuickFillExpanded((current) => !current)}
>
{quickFillExpanded ? (
<ChevronUp className="size-4" aria-hidden />
) : (
<ChevronDown className="size-4" aria-hidden />
)}
</Button>
) : null}
{activeRow?.number ? (
<Button
type="button"
variant="ghost"
size="icon"
aria-label={
favorites.includes(activeRow.number)
? t("hall.quickFill.unfavorite")
: t("hall.quickFill.favorite")
}
aria-pressed={favorites.includes(activeRow.number)}
title={
favorites.includes(activeRow.number)
? t("hall.quickFill.unfavorite")
: t("hall.quickFill.favorite")
}
className={cn(
"size-8 rounded-full border border-[#ffd7db] bg-[#fff7f8] text-[#d81435] hover:bg-[#fff1f3] hover:text-[#b80f2b]",
favorites.includes(activeRow.number) &&
"border-[#d81435] bg-[#d81435] text-white hover:bg-[#c51230] hover:text-white",
)}
onClick={() => toggleFavoriteNumber(activeRow.number)}
>
<Star
className={cn(
"size-4",
favorites.includes(activeRow.number) && "fill-current",
)}
aria-hidden
/>
</Button>
) : null}
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t("hall.quickFill.clearAll")}
title={t("hall.quickFill.clearAll")}
className="size-8 rounded-full border border-[#dfe6f0] bg-[#f8fafc] text-slate-500 hover:bg-slate-100 hover:text-slate-900"
onClick={clearAllRows}
>
<Trash2 className="size-4" aria-hidden />
</Button>
</div>
</div>
{isMobile ? (
<div className="mt-2 space-y-2">
{quickFillExpanded ? (
<div className="rounded-xl border border-[#e7eef8] bg-[#f8fbff] px-3 py-2.5">
<div className="flex flex-wrap items-center gap-2">
<span className="text-[11px] font-semibold text-[#5b6f95]">
{t("hall.quickFill.batchTitle", { defaultValue: "Quick amount" })}
</span>
{MOBILE_QUICK_AMOUNT_PRESETS.map((preset) => (
<button
key={preset}
type="button"
disabled={tableDisabled}
onClick={() => applyQuickAmountToActiveRow(preset)}
className="inline-flex h-8 min-w-12 items-center justify-center rounded-full border border-[#cfe0fb] bg-white px-3 text-xs font-bold text-[#0b4ab3] transition-colors hover:border-[#aac7f5] hover:bg-[#edf5ff] disabled:opacity-40"
>
{preset}
</button>
))}
<button
type="button"
disabled={tableDisabled || !activeRow?.number}
onClick={copyPreviousRowToActiveRow}
className="inline-flex h-8 items-center justify-center rounded-full border border-[#dfe6f0] bg-white px-3 text-xs font-semibold text-slate-600 transition-colors hover:bg-slate-50 disabled:opacity-40"
>
{t("hall.quickFill.copyPrev", { defaultValue: "Copy prev row" })}
</button>
<button
type="button"
disabled={tableDisabled}
onClick={clearActiveRowAmounts}
className="inline-flex h-8 items-center justify-center rounded-full border border-[#ffe0e5] bg-white px-3 text-xs font-semibold text-[#d81435] transition-colors hover:bg-[#fff4f6] disabled:opacity-40"
>
{t("hall.quickFill.clearRow", { defaultValue: "Clear row amounts" })}
</button>
</div>
</div>
) : null}
{quickFillExpanded ? (
<>
{favoriteChips.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5">
<span className="mr-0.5 text-[11px] font-semibold text-[#d81435]">
{t("hall.quickFill.favorites")}
</span>
{favoriteChips.map((number) => (
<button
key={`fav-${number}`}
type="button"
className="inline-flex h-8 touch-manipulation items-center gap-1 rounded-full border border-[#ffd7db] bg-[#fff3f5] px-2.5 text-xs font-semibold text-[#d81435] transition-colors hover:bg-[#ffe9ed]"
onPointerDown={() => {
const current = holdFavoriteRef.current;
current.number = number;
current.longPress = false;
if (current.timer) window.clearTimeout(current.timer);
current.timer = window.setTimeout(() => {
current.longPress = true;
toggleFavoriteNumber(number);
}, 500);
}}
onPointerUp={() => {
const current = holdFavoriteRef.current;
if (current.timer) {
window.clearTimeout(current.timer);
current.timer = null;
}
if (!current.longPress) {
fillCurrentRow(number);
}
current.longPress = false;
}}
onPointerLeave={() => {
const current = holdFavoriteRef.current;
if (current.timer) {
window.clearTimeout(current.timer);
current.timer = null;
}
current.longPress = false;
}}
>
{number}
<span className="text-[11px] font-medium opacity-70">
{t("hall.quickFill.tapHold")}
</span>
</button>
))}
</div>
) : null}
<div className="flex flex-wrap items-center gap-1.5">
<span className="mr-0.5 text-[11px] font-semibold text-slate-400">
{t("hall.quickFill.history")}
</span>
{historyChips.length > 0 ? (
historyChips.map((number) => (
<button
key={`his-${number}`}
type="button"
className="inline-flex h-8 min-w-10 items-center justify-center rounded-full border border-[#d7e5f8] bg-[#f8fbff] px-3 text-xs font-bold text-[#07459f] transition-colors hover:border-[#b9d0f3] hover:bg-[#eef6ff]"
onClick={() => fillCurrentRow(number)}
>
{number}
</button>
))
) : (
<span className="inline-flex h-8 items-center rounded-full border border-dashed border-slate-200 bg-slate-50 px-3 text-xs text-slate-400">
{t("hall.quickFill.emptyHistory")}
</span>
)}
</div>
</>
) : null}
</div>
) : null}
</div>
<div
className={cn(
"overflow-x-auto",
isMobile
? "-mx-1 flex gap-1.5 px-1 pb-1"
: "hidden",
)}
>
{categoryTabs.map((tab) => {
const hasPlays = openPlays.some(
(play) => playCategory(play.play_code) === tab.value,
);
const active = activeCategory === tab.value;
return (
<button
key={tab.value}
type="button"
disabled={!hasPlays}
onClick={() => setActiveCategory(tab.value)}
className={cn(
"inline-flex items-center justify-center font-bold transition-colors",
isMobile
? "min-w-[5.25rem] rounded-t-xl border border-b-0 px-4 py-3 text-sm"
: "min-w-[4.5rem] rounded-lg px-3.5 py-2 text-sm",
isMobile
? active
? "border-[#d7e1f3] border-b-white bg-white text-[#21335b] shadow-[0_-1px_0_rgba(255,255,255,0.8)]"
: "border-transparent bg-transparent text-[#6d8fd6] hover:text-[#2d63e2]"
: active
? "bg-[#2d63e2] text-white shadow-[0_4px_12px_rgba(45,99,226,0.28)]"
: "bg-transparent text-[#5b7fbf] hover:bg-[#f3f7ff] hover:text-[#2d63e2]",
!hasPlays && "cursor-not-allowed opacity-40",
)}
aria-pressed={active}
>
<span className={cn("mr-2 text-[13px]", !isMobile && active && "text-white/90")} aria-hidden>
</span>
{tab.label}
</button>
);
})}
</div>
</div>
{isMobile ? (
<HallMobileQuickFill
activeNumber={activeRow?.number ?? ""}
favorites={favoriteChips}
history={historyChips}
expanded={quickFillExpanded}
tableDisabled={tableDisabled}
activeCategory={activeCategory}
availableCategories={availableCategories}
onExpandedChange={setQuickFillExpanded}
onCategoryChange={setActiveCategory}
onFillNumber={fillCurrentRow}
onToggleFavorite={toggleFavoriteNumber}
onClearAll={clearAllRows}
onApplyQuickAmount={applyQuickAmountToActiveRow}
onCopyPreviousRow={copyPreviousRowToActiveRow}
onClearActiveRowAmounts={clearActiveRowAmounts}
t={t}
/>
) : null}
{activeCategoryPlays.length === 0 ? (
<div
@@ -2202,143 +1658,41 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
showWideTableHint && "player-table-scroll-wrap",
)}
>
<div className="player-table-scroll overflow-x-auto overscroll-x-contain">
<table
className={cn("mx-auto w-max border-collapse", isMobile ? "text-[11px]" : "text-sm")}
style={{ width: tableWidthPx }}
>
<thead>
<tr className="border-b border-[#edf2f8] bg-[#f8fafd] text-[#58709d]">
<th className={cn("sticky left-0 z-30 bg-[#f8fafd] px-0.5 text-center font-bold shadow-[2px_0_6px_rgba(15,23,42,0.04)]", isMobile ? "py-1.5" : "py-2.5", indexColClass)}>
{t("hall.table.no", { defaultValue: "No." })}
</th>
<th
className={cn(
"sticky z-30 bg-[#f8fafd] px-1 text-center font-bold shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
isMobile ? "py-1.5" : "py-2.5",
stickyNumberLeftClass,
numberColClass,
)}
>
<span className="block text-xs">{t("hall.table.number", { defaultValue: "Number" })}</span>
<span className="mt-0.5 block font-mono text-[10px] font-medium text-[#9aa8bd]">
{numberPlaceholder}
</span>
</th>
{playColumns.map((column) => (
<th
key={column.key}
className={cn(amountColClass, "px-0.5 text-center font-bold", isMobile ? "py-1.5" : "py-2.5")}
>
<span className="block whitespace-nowrap text-[10px] leading-tight">
{playColumnHeaderLabel(
column.play,
playCategory(column.play.play_code),
column.digitSlot,
t,
)}
</span>
<Checkbox
checked={syncAmountColumns[column.key] === true}
disabled={tableDisabled}
onCheckedChange={(checked) =>
toggleSyncAmountColumn(column, checked === true)
}
aria-label={t("hall.table.syncColumnAmount", {
column: playColumnHeaderLabel(column.play, playCategory(column.play.play_code), column.digitSlot, t),
})}
title={t("hall.table.syncColumnAmount", {
column: playColumnHeaderLabel(column.play, playCategory(column.play.play_code), column.digitSlot, t),
})}
className={cn("mx-auto mt-1", isMobile ? "size-3.5" : "size-4")}
/>
</th>
))}
{showSelectionTypeColumn ? (
<th className={cn(selectionTypeColClass, "px-1 py-1 text-center font-bold")}>
<span className="block text-xs">{t("hall.table.selectionType", { defaultValue: "Type" })}</span>
<select
defaultValue=""
disabled={tableDisabled}
onChange={(event) => {
const selectionType = event.target.value as SelectionType | "";
if (selectionType) requestAllSelectionTypes(selectionType);
event.currentTarget.value = "";
}}
className="mx-auto mt-1 h-5 w-full rounded border border-[#d7e1f3] bg-white px-0.5 text-[10px] font-semibold text-[#304f86]"
aria-label={t("hall.table.selectAllTypes")}
title={t("hall.table.setAllTypes")}
>
<option value="">{t("hall.table.selectAll")}</option>
{selectionTypeOptions.map((type) => (
<option key={type} value={type}>
{t(`hall.table.selectionTypes.${type}`)}
</option>
))}
</select>
</th>
) : null}
{betProviders.map((provider, providerIndex) => {
const checked = rows.length > 0 && rows.every((row) => row.providerCodes.includes(provider.code));
return (
<th
key={provider.code}
className={cn(providerColClass, providerColumnTone(providerIndex), "px-0.5 py-1 text-center font-black")}
title={provider.name}
>
<span className="block text-sm leading-none">{provider.short_code}</span>
<Checkbox
checked={checked}
disabled={tableDisabled}
onCheckedChange={(next) => toggleProviderColumn(provider.code, next === true)}
aria-label={t("hall.table.selectAllProvider", { provider: provider.name })}
className="mx-auto mt-1 border-slate-500 bg-white data-checked:border-slate-800 data-checked:bg-slate-800"
/>
</th>
);
})}
<th className={cn(rowTotalColClass, "bg-[#eef5ff] px-1 py-1 text-center font-black text-[#17408d]")}>
<span className="block text-xs">{t("hall.table.rowTotal", { defaultValue: "Total" })}</span>
</th>
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<DraftRowItem
key={row.id}
row={row}
index={index}
rowActive={activeRowId === row.id}
tableDisabled={tableDisabled}
numberPlaceholder={numberPlaceholder}
activeCategory={activeCategory}
numberMaxChars={numberMaxChars}
betProviders={betProviders}
playColumns={playColumns}
alertRows={alertRows}
liveSoldOutNumbers={liveSoldOutNumbers}
liveWarningNumbers={liveWarningNumbers}
updateRowNumber={updateRowNumber}
updateRowSelectionType={requestRowSelectionType}
selectionTypeOptions={selectionTypeOptions}
updateAmount={updateAmount}
toggleRowProvider={toggleRowProvider}
setActiveRowId={setActiveRowId}
t={t}
isMobile={isMobile}
indexColClass={indexColClass}
numberColClass={numberColClass}
stickyNumberLeftClass={stickyNumberLeftClass}
selectionTypeColClass={selectionTypeColClass}
showSelectionTypeColumn={showSelectionTypeColumn}
providerColClass={providerColClass}
rowTotalColClass={rowTotalColClass}
currencyCode={currencyCode}
/>
))}
</tbody>
</table>
</div>
<HallBettingTable
rows={rows}
activeRowId={activeRowId}
tableDisabled={tableDisabled}
tableWidthPx={tableWidthPx}
isMobile={isMobile}
indexColClass={indexColClass}
numberColClass={numberColClass}
stickyNumberLeftClass={stickyNumberLeftClass}
numberPlaceholder={numberPlaceholder}
numberMaxChars={numberMaxChars}
activeCategory={activeCategory}
playColumns={playColumns}
amountColClass={amountColClass}
syncAmountColumns={syncAmountColumns}
selectionTypeColClass={selectionTypeColClass}
showSelectionTypeColumn={showSelectionTypeColumn}
selectionTypeOptions={selectionTypeOptions}
betProviders={betProviders}
providerColClass={providerColClass}
rowTotalColClass={rowTotalColClass}
alertRows={alertRows}
liveSoldOutNumbers={liveSoldOutNumbers}
liveWarningNumbers={liveWarningNumbers}
currencyCode={currencyCode}
onToggleSyncAmountColumn={toggleSyncAmountColumn}
onSetAllSelectionTypes={requestAllSelectionTypes}
onToggleProviderColumn={toggleProviderColumn}
onUpdateRowNumber={updateRowNumber}
onUpdateRowSelectionType={requestRowSelectionType}
onUpdateAmount={updateAmount}
onToggleRowProvider={toggleRowProvider}
onSetActiveRowId={setActiveRowId}
t={t}
/>
</div>
@@ -2462,327 +1816,14 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
creditMode={creditMode}
/>
<Dialog
<HallSelectionConfirmDialog
open={pendingSelectionChange !== null}
onOpenChange={(open) => {
if (!open) cancelPendingSelectionChange();
}}
>
<DialogContent className="max-w-[380px] rounded-2xl border border-[#dfe8f6] bg-white p-0 shadow-[0_24px_70px_rgba(15,23,42,0.18)]">
<DialogHeader className="gap-2 border-b border-[#eef2f8] px-5 py-4 text-left">
<DialogTitle className="text-base font-black text-[#0b3f96]">
{t("hall.table.selectionConfirm.title")}
</DialogTitle>
<DialogDescription className="text-sm leading-relaxed text-slate-600">
{pendingSelectionPreview
? pendingSelectionPreview.next === "full_cover"
? t("hall.table.selectionConfirm.fullCoverBody", {
type: t(`hall.table.selectionTypes.${pendingSelectionPreview.next}`),
count: pendingSelectionPreview.comboCount,
amount: formatMinorAsCurrency(pendingSelectionPreview.toMinor || pendingSelectionPreview.fromMinor, currencyCode),
})
: pendingSelectionPreview.fromMinor > 0 &&
pendingSelectionPreview.toMinor > pendingSelectionPreview.fromMinor
? t("hall.table.selectionConfirm.increaseBody", {
type: t(`hall.table.selectionTypes.${pendingSelectionPreview.next}`),
count: pendingSelectionPreview.comboCount,
from: formatMinorAsCurrency(pendingSelectionPreview.fromMinor, currencyCode),
to: formatMinorAsCurrency(pendingSelectionPreview.toMinor, currencyCode),
})
: t("hall.table.selectionConfirm.genericBody", {
type: t(`hall.table.selectionTypes.${pendingSelectionPreview.next}`),
count: pendingSelectionPreview.comboCount,
})
: t("hall.table.selectionConfirm.genericBody", {
type: "",
count: 1,
})}
</DialogDescription>
</DialogHeader>
{pendingSelectionPreview && pendingSelectionPreview.comboCount > 1 ? (
<div className="space-y-1 px-5 py-3 text-xs text-slate-600">
<p className="font-bold text-[#304f86]">
{t("hall.table.selectionConfirm.comboHint", {
count: pendingSelectionPreview.comboCount,
})}
</p>
{pendingSelectionPreview.number ? (
<p className="font-mono text-[11px] text-slate-500">
{t("hall.table.selectionConfirm.numberHint", {
number: pendingSelectionPreview.number,
})}
</p>
) : null}
</div>
) : null}
<DialogFooter className="mx-0 mb-0 gap-2 rounded-b-2xl border-t border-[#eef2f8] bg-white px-5 py-4 sm:justify-end">
<Button
type="button"
variant="outline"
className="rounded-lg"
onClick={cancelPendingSelectionChange}
>
{t("hall.table.selectionConfirm.cancel")}
</Button>
<Button
type="button"
className="rounded-lg bg-[#e5002c] text-white hover:bg-[#d10028]"
onClick={confirmPendingSelectionChange}
>
{t("hall.table.selectionConfirm.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
preview={pendingSelectionPreview}
currencyCode={currencyCode}
onCancel={cancelPendingSelectionChange}
onConfirm={confirmPendingSelectionChange}
t={t}
/>
</>
);
}
const DraftRowItem = memo(function DraftRowItem({
row,
index,
rowActive,
tableDisabled,
numberPlaceholder,
activeCategory,
numberMaxChars,
betProviders,
playColumns,
alertRows,
liveSoldOutNumbers,
liveWarningNumbers,
updateRowNumber,
updateRowSelectionType,
selectionTypeOptions,
updateAmount,
toggleRowProvider,
setActiveRowId,
t,
isMobile,
indexColClass,
numberColClass,
stickyNumberLeftClass,
selectionTypeColClass,
showSelectionTypeColumn,
providerColClass,
rowTotalColClass,
currencyCode,
}: {
row: DraftRow;
index: number;
rowActive: boolean;
tableDisabled: boolean;
numberPlaceholder: string;
activeCategory: PlayHallCategory;
numberMaxChars: number;
betProviders: BetProviderRow[];
playColumns: PlayColumn[];
alertRows: DrawCurrentRiskPoolAlert[];
liveSoldOutNumbers: Set<string>;
liveWarningNumbers: Set<string>;
updateRowNumber: (id: string, value: string) => void;
updateRowSelectionType: (id: string, value: SelectionType) => void;
selectionTypeOptions: SelectionType[];
updateAmount: (rowId: string, playCode: string, value: string) => void;
toggleRowProvider: (rowId: string, code: string) => void;
setActiveRowId: (id: string) => void;
t: HallTranslate;
isMobile: boolean;
indexColClass: string;
numberColClass: string;
stickyNumberLeftClass: string;
selectionTypeColClass: string;
showSelectionTypeColumn: boolean;
providerColClass: string;
rowTotalColClass: string;
currencyCode: string;
}) {
const displayNumber = sanitizeNumber(row.number, activeCategory);
const comboCount = selectionCombinationCount(displayNumber, row.selectionType);
const hasFullCoverAmountError = row.selectionType === "full_cover" && playColumns.some((column) => {
const amount = parseDecimalInputToMinor(row.amounts[column.key] ?? "", currencyCode);
return amount !== null && amount > 0 && !isFullCoverAmountDivisible(amount, comboCount);
});
const rowTotalMinor =
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;
return total + resolveSelectionTotalBet(amount, row.selectionType, comboCount);
}, 0) * row.providerCodes.length;
return (
<tr
className={cn(
"border-b border-[#f0f3f8] last:border-b-0",
rowActive && "bg-[#f5f9ff]/80",
)}
>
<td
className={cn(
"sticky left-0 z-20 align-top px-0.5 text-center font-black text-[#17408d] shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
isMobile ? "py-1.5" : "py-2",
indexColClass,
rowActive ? "bg-[#f5f9ff]" : "bg-white",
)}
>
{index + 1}
</td>
<td
className={cn(
"sticky z-20 align-top px-1 shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
isMobile ? "py-1.5" : "py-2",
stickyNumberLeftClass,
numberColClass,
rowActive ? "bg-[#f5f9ff]" : "bg-white",
)}
>
<Input
value={displayNumber}
disabled={tableDisabled}
inputMode="numeric"
pattern="[0-9Rr]*"
autoComplete="off"
placeholder={numberPlaceholder}
maxLength={numberMaxChars}
onFocus={() => setActiveRowId(row.id)}
onClick={() => setActiveRowId(row.id)}
onChange={(event) => updateRowNumber(row.id, event.target.value)}
className={cn(
"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 ? "h-9 text-sm" : "h-8 text-sm",
"tracking-[0.12em]",
)}
/>
</td>
{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 (
<td
key={`${row.id}-${column.key}`}
className={cn(
"px-0.5 align-top",
isMobile ? "py-1.5" : "py-2",
status === "warning" && "bg-amber-50/70",
status === "sold_out" && "bg-slate-100 text-slate-400",
)}
>
{isInputValidForPlay ? (
<Input
value={amountText}
disabled={cellDisabled}
inputMode="decimal"
placeholder={
status === "sold_out"
? t("hall.table.soldOut")
: "-"
}
onFocus={() => setActiveRowId(row.id)}
onClick={() => setActiveRowId(row.id)}
onChange={(event) => updateAmount(row.id, column.key, event.target.value)}
className={cn(
"w-full rounded-md border-[#e1e8f3] bg-white px-0.5 text-center font-bold tabular-nums shadow-sm focus-visible:ring-[#1d57b7]",
!isMobile ? "h-9 text-sm" : "h-8 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",
)}
/>
) : (
<div className={cn("w-full rounded-md border border-slate-200 bg-slate-100/70", isMobile ? "h-8" : "h-9")} />
)}
{isInputValidForPlay && status === "sold_out" ? (
<p className="mt-0.5 text-center text-[10px] font-bold text-slate-500">
{t("hall.table.soldOut")}
</p>
) : status === "warning" ? (
<p className="mt-0.5 text-center text-[10px] font-bold text-amber-700">
{t("hall.table.warning")}
</p>
) : null}
</td>
);
})}
{showSelectionTypeColumn ? (
<td className={cn(selectionTypeColClass, "align-top px-1 text-center", isMobile ? "py-1.5" : "py-2", rowActive && "bg-[#f5f9ff]")}>
<select
value={row.selectionType}
disabled={tableDisabled}
onChange={(event) => updateRowSelectionType(row.id, event.target.value as SelectionType)}
className={cn("w-full rounded-md border border-[#e1e8f3] bg-white px-1 font-semibold text-[#304f86]", isMobile ? "h-8 text-[10px]" : "h-9 text-xs")}
aria-label={t("hall.table.selectionTypeForRow", { row: index + 1 })}
>
{selectionTypeOptions.map((type) => (
<option key={type} value={type}>
{t(`hall.table.selectionTypes.${type}`)}
</option>
))}
</select>
{comboCount > 1 && displayNumber.length >= 2 ? (
<div className="mt-0.5 flex items-center justify-center gap-0.5 text-[9px] font-bold leading-none text-[#0b56b7]">
<span>{t("hall.table.comboCount", { count: comboCount })}</span>
{row.selectionType === "full_cover" || row.selectionType === "half_play" ? (
<Tooltip.Root>
<Tooltip.Trigger
className="inline-flex size-3 items-center justify-center rounded-full text-[#5378b5] outline-none hover:text-[#0b56b7] focus-visible:ring-1 focus-visible:ring-[#0b56b7]"
aria-label={t("hall.table.selectionTypeRule")}
title={t("hall.table.selectionTypeRule")}
>
<CircleHelp className="size-3" aria-hidden />
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Positioner side="bottom" sideOffset={6}>
<Tooltip.Popup className="z-[70] max-w-52 rounded-md bg-slate-900 px-2 py-1.5 text-left text-[11px] font-medium leading-snug text-white shadow-lg">
{row.selectionType === "full_cover"
? t("hall.table.fullCoverSplitHint", { count: comboCount })
: t("hall.table.halfPlayHint")}
</Tooltip.Popup>
</Tooltip.Positioner>
</Tooltip.Portal>
</Tooltip.Root>
) : null}
</div>
) : null}
{row.selectionType === "full_cover" && comboCount > 1 && hasFullCoverAmountError ? (
<p className="mt-0.5 text-[9px] font-medium leading-tight text-red-600">
{t("hall.table.fullCoverDivisibilityError", { count: comboCount })}
</p>
) : null}
</td>
) : null}
{betProviders.map((provider, providerIndex) => (
<td
key={provider.code}
className={cn(providerColClass, providerColumnTone(providerIndex), "px-0.5 text-center", isMobile ? "py-1.5" : "py-2")}
>
<Checkbox
checked={row.providerCodes.includes(provider.code)}
disabled={tableDisabled}
onCheckedChange={() => toggleRowProvider(row.id, provider.code)}
aria-label={`${provider.name} ${index + 1}`}
className={cn("mx-auto border-slate-500 bg-white data-checked:border-slate-800 data-checked:bg-slate-800", !isMobile && "size-4")}
/>
</td>
))}
<td className={cn(rowTotalColClass, "bg-[#f7fbff] px-1 text-center font-mono font-black tabular-nums text-[#0b3f96]", isMobile ? "py-1.5" : "py-2", rowActive && "bg-[#edf5ff]")}>
{formatMinorAmount(rowTotalMinor)}
</td>
</tr>
);
});