feat(player): PC 端列表/布局适配与分页

玩家端桌面:全宽居中 Logo、圆角侧栏与主内容面板;开奖结果/注单改 shadcn Table+分页(移动仍无限滚动),注单组默认折叠;信用/钱包筛选与分页;详情页与奖号网格密度优化。
This commit is contained in:
2026-07-15 10:38:49 +08:00
parent c74bd5f701
commit 295cfb97cd
23 changed files with 2989 additions and 1411 deletions

View File

@@ -11,6 +11,14 @@ 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";
@@ -25,6 +33,13 @@ import {
ticketNumberSpec,
type DraftLineIssueReason,
} from "@/features/hall/hall-bet-rules";
import {
isHighCostSelectionType,
resolveSelectionTotalBet,
selectionCombinationCount,
selectionTypesForCategory,
type SelectionType,
} from "@/features/hall/selection-type";
import type { HallDrawLiveSnapshot } from "@/features/hall/use-hall-draw-live";
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
import { triggerWalletPollingAfterBet } from "@/hooks/use-wallet-polling";
@@ -64,9 +79,13 @@ type DraftRow = {
number: string;
amounts: Record<string, string>;
providerCodes: string[];
selectionType: "straight" | "reverse" | "full_cover" | "full_play" | "half_play";
selectionType: SelectionType;
};
type PendingSelectionChange =
| { mode: "row"; rowId: string; next: SelectionType; prev: SelectionType }
| { mode: "all"; next: SelectionType };
type DraftEntry = {
rowId: string;
rowNo: number;
@@ -174,9 +193,24 @@ const D4_PLAY_ORDER = [
"digit_big",
"digit_small",
] as const;
/** 按大厅 Tab 对应的玩法顺序;汇总条默认以当前 Tab 玩法开头 */
const PLAY_ORDER_BY_CATEGORY: Record<PlayHallCategory, readonly string[]> = {
D2: D2_PLAY_ORDER,
D3: D3_PLAY_ORDER,
D4: D4_PLAY_ORDER,
};
const CATEGORY_ORDER: readonly PlayHallCategory[] = ["D4", "D3", "D2"];
const MOBILE_QUICK_AMOUNT_PRESETS = ["10", "50", "100"] as const;
const DEFAULT_DRAFT_ROW_COUNT = 20;
function playOrderForActiveCategory(activeCategory: PlayHallCategory): readonly string[] {
const rest = CATEGORY_ORDER.filter((category) => category !== activeCategory);
return [
...PLAY_ORDER_BY_CATEGORY[activeCategory],
...rest.flatMap((category) => PLAY_ORDER_BY_CATEGORY[category]),
];
}
function newDraftRows(count = DEFAULT_DRAFT_ROW_COUNT): DraftRow[] {
return Array.from({ length: count }, newDraftRow);
}
@@ -290,7 +324,7 @@ function lineForPlay(
displayNumber: string,
amountMinor: number,
digitSlot?: number,
selectionType: DraftRow["selectionType"] = "straight",
selectionType: SelectionType = "straight",
): TicketLineInput | null {
const number = normalizeNumberForPlay(displayNumber, play.play_code);
if (draftLineIssueReason(play.play_code, displayNumber, digitSlot) !== null) {
@@ -504,6 +538,9 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
const [liveSoldOutNumbers, setLiveSoldOutNumbers] = useState<Set<string>>(() => new Set());
const [liveWarningNumbers, setLiveWarningNumbers] = useState<Set<string>>(() => new Set());
const [debouncedSummary, setDebouncedSummary] = useState({ bet: 0, rebate: 0, actual: 0 });
const [pendingSelectionChange, setPendingSelectionChange] = useState<PendingSelectionChange | null>(
null,
);
const holdFavoriteRef = useRef<{ timer: number | null; number: string | null; longPress: boolean }>({
timer: null,
number: null,
@@ -511,6 +548,8 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
});
/** 单次预览→确认共用,重试 place 复用,避免重复扣款 */
const placeTraceIdRef = useRef<string | null>(null);
/** 玩法汇总条:切 Tab 后滚回左侧,保证当前维度玩法从开头可见 */
const playSummaryScrollRef = useRef<HTMLDivElement | null>(null);
const newPlaceTraceId = (): string =>
typeof crypto !== "undefined" && crypto.randomUUID
@@ -607,7 +646,8 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
const openPlays = useMemo(() => {
if (catalogState.kind !== "ok") return [];
const order: readonly string[] = [...D2_PLAY_ORDER, ...D3_PLAY_ORDER, ...D4_PLAY_ORDER];
// 4D / 3D / 2D 汇总条与列顺序以当前 Tab 玩法为先,避免切到 4D 仍从 2A 起显示
const order = playOrderForActiveCategory(activeCategory);
const orderSet = new Set(order);
return sortByPlayOrder(
catalogState.data.plays
@@ -615,7 +655,12 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
.filter((p) => orderSet.has(p.play_code)),
order,
);
}, [catalogState]);
}, [activeCategory, catalogState]);
useEffect(() => {
const el = playSummaryScrollRef.current;
if (el) el.scrollLeft = 0;
}, [activeCategory]);
const activeCategoryPlays = useMemo(
() => openPlays.filter((play) => TRADITIONAL_PLAY_CODES[activeCategory].includes(play.play_code)),
@@ -712,14 +757,57 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
setActiveRowId(id);
}, [activeCategory]);
const updateRowSelectionType = useCallback((id: string, selectionType: DraftRow["selectionType"]) => {
setRows((current) => current.map((row) => row.id === id ? { ...row, selectionType } : row));
const applyRowSelectionType = useCallback((id: string, selectionType: SelectionType) => {
setRows((current) => current.map((row) => (row.id === id ? { ...row, selectionType } : row)));
}, []);
const updateAllSelectionTypes = useCallback((selectionType: DraftRow["selectionType"]) => {
const applyAllSelectionTypes = useCallback((selectionType: SelectionType) => {
setRows((current) => current.map((row) => ({ ...row, selectionType })));
}, []);
const requestRowSelectionType = useCallback(
(id: string, selectionType: SelectionType) => {
const row = rows.find((item) => item.id === id);
if (!row || row.selectionType === selectionType) return;
if (isHighCostSelectionType(selectionType) && !isHighCostSelectionType(row.selectionType)) {
setPendingSelectionChange({
mode: "row",
rowId: id,
next: selectionType,
prev: row.selectionType,
});
return;
}
applyRowSelectionType(id, selectionType);
},
[applyRowSelectionType, rows],
);
const requestAllSelectionTypes = useCallback(
(selectionType: SelectionType) => {
if (isHighCostSelectionType(selectionType)) {
setPendingSelectionChange({ mode: "all", next: selectionType });
return;
}
applyAllSelectionTypes(selectionType);
},
[applyAllSelectionTypes],
);
const confirmPendingSelectionChange = useCallback(() => {
if (!pendingSelectionChange) return;
if (pendingSelectionChange.mode === "row") {
applyRowSelectionType(pendingSelectionChange.rowId, pendingSelectionChange.next);
} else {
applyAllSelectionTypes(pendingSelectionChange.next);
}
setPendingSelectionChange(null);
}, [applyAllSelectionTypes, applyRowSelectionType, pendingSelectionChange]);
const cancelPendingSelectionChange = useCallback(() => {
setPendingSelectionChange(null);
}, []);
const updateAmount = useCallback((rowId: string, playCode: string, value: string) => {
const amount = sanitizeAmount(value);
const column = allPlayColumns.find((item) => item.key === playCode);
@@ -1076,18 +1164,84 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
const draftSummary = useMemo(() => {
return draftEntries.reduce(
(acc, entry) => {
const selectionType = entry.line.selection_type ?? "straight";
const comboCount = selectionCombinationCount(entry.number, selectionType);
const totalBet = resolveSelectionTotalBet(entry.amountMinor, selectionType, comboCount);
const rebateRate = parseRebateRate(entry.play.odds?.rebate_rate);
const rebate = Math.round(entry.amountMinor * rebateRate);
const rebate = Math.round(totalBet * rebateRate);
const providerCount = entry.line.provider_codes?.length ?? 0;
acc.bet += entry.amountMinor * providerCount;
acc.bet += totalBet * providerCount;
acc.rebate += rebate * providerCount;
acc.actual += Math.max(0, entry.amountMinor - rebate) * providerCount;
acc.actual += Math.max(0, totalBet - rebate) * providerCount;
return acc;
},
{ bet: 0, rebate: 0, actual: 0 },
);
}, [draftEntries]);
const selectionTypeOptions = useMemo(
() => selectionTypesForCategory(activeCategory),
[activeCategory],
);
const pendingSelectionPreview = useMemo(() => {
if (!pendingSelectionChange) return null;
const next = pendingSelectionChange.next;
const rowStake = (
row: DraftRow,
selectionType: SelectionType,
mode: "resolved" | "raw",
): number => {
const comboCount = selectionCombinationCount(row.number, selectionType);
const providerCount = Math.max(1, row.providerCodes.length);
const stake = playColumns.reduce((total, column) => {
if (draftLineIssueReason(column.play.play_code, row.number, column.digitSlot) !== null) {
return total;
}
const amount = parseDecimalInputToMinor(row.amounts[column.key] ?? "", currencyCode) ?? 0;
if (amount <= 0) return total;
if (mode === "raw") return total + amount;
return total + resolveSelectionTotalBet(amount, selectionType, comboCount);
}, 0);
return stake * providerCount;
};
if (pendingSelectionChange.mode === "row") {
const row = rows.find((item) => item.id === pendingSelectionChange.rowId);
if (!row) return null;
const comboCount = selectionCombinationCount(row.number, next);
const fromMinor = rowStake(row, pendingSelectionChange.prev, "resolved");
const toMinor =
next === "full_cover" ? rowStake(row, next, "raw") : rowStake(row, next, "resolved");
return {
next,
comboCount,
number: row.number,
fromMinor,
toMinor,
scope: "row" as const,
};
}
let fromMinor = 0;
let toMinor = 0;
let maxCombo = 1;
rows.forEach((row) => {
maxCombo = Math.max(maxCombo, selectionCombinationCount(row.number, next));
fromMinor += rowStake(row, row.selectionType, "resolved");
toMinor += next === "full_cover" ? rowStake(row, next, "raw") : rowStake(row, next, "resolved");
});
return {
next,
comboCount: maxCombo,
number: "",
fromMinor,
toMinor,
scope: "all" as const,
};
}, [currencyCode, pendingSelectionChange, playColumns, rows]);
useEffect(() => {
const id = window.setTimeout(() => {
setDebouncedSummary(draftSummary);
@@ -1377,30 +1531,105 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
<div
className={cn(
"bg-white",
isMobile
? "rounded-lg border border-[#e8eef7] px-3 py-2.5"
: "rounded-xl border border-[#e3ebf7] px-3 py-3 shadow-[0_8px_24px_rgba(15,23,42,0.045)]",
!isMobile &&
"flex items-center gap-2 rounded-xl border border-[#e3ebf7] bg-white px-2 py-2 shadow-[0_6px_18px_rgba(15,23,42,0.04)]",
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-bold leading-5 text-slate-950">
{t("hall.quickFill.title")}
</p>
<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 ? (
<p className="mt-0.5 text-xs leading-5 text-slate-500">
{t("hall.quickFill.description")}
</p>
) : (
<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>
)}
<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 ? (
@@ -1425,167 +1654,176 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
</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>
<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>
<div className={cn("space-y-2", isMobile ? "mt-2" : "mt-3")}>
{isMobile && 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>
{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={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"
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"
>
{t("hall.quickFill.clearRow", { defaultValue: "Clear row amounts" })}
</button>
</div>
</div>
) : null}
{!isMobile || 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>
{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>
) : 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>
</div>
) : null}
<div className={cn("overflow-x-auto pb-1", isMobile ? "-mx-1 flex gap-1.5 px-1" : "flex items-end gap-2")}>
{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"
: "order-first inline-flex w-fit max-w-full shrink-0 items-center gap-1 rounded-lg bg-[#f3f6fb] p-1",
)}
>
{categoryTabs.map((tab) => {
const hasPlays = openPlays.some(
(play) => playCategory(play.play_code) === tab.value,
@@ -1599,18 +1837,22 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
disabled={!hasPlays}
onClick={() => setActiveCategory(tab.value)}
className={cn(
"inline-flex items-center justify-center border font-bold transition-colors",
"inline-flex items-center justify-center font-bold transition-colors",
isMobile
? "min-w-[5.25rem] rounded-t-xl border-b-0 px-4 py-3 text-sm"
: "min-w-[6rem] rounded-t-xl px-4 py-3 text-sm",
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]",
? "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="mr-2 text-[13px]" aria-hidden>
<span className={cn("mr-2 text-[13px]", !isMobile && active && "text-white/90")} aria-hidden>
</span>
{tab.label}
@@ -1618,6 +1860,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
);
})}
</div>
</div>
{activeCategoryPlays.length === 0 ? (
<div
@@ -1637,7 +1880,10 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
showWideTableHint && "player-table-scroll-wrap",
)}
>
<div className="mb-3 overflow-x-auto rounded border border-[#dae3f3] shadow-sm">
<div
ref={playSummaryScrollRef}
className="mb-3 overflow-x-auto rounded border border-[#dae3f3] shadow-sm"
>
<table className="w-full border-collapse text-center text-[11px] tabular-nums">
<thead>
<tr className="bg-[#2d63e2] text-white">
@@ -1686,9 +1932,9 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
</table>
</div>
<div className="player-table-scroll flex overflow-x-auto overscroll-x-contain">
<div className="player-table-scroll overflow-x-auto overscroll-x-contain">
<table
className="w-max border-collapse text-[11px]"
className="mx-auto w-max border-collapse text-[11px]"
style={{ width: tableWidthPx }}
>
<thead>
@@ -1745,8 +1991,8 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
defaultValue=""
disabled={tableDisabled}
onChange={(event) => {
const selectionType = event.target.value as DraftRow["selectionType"] | "";
if (selectionType) updateAllSelectionTypes(selectionType);
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]"
@@ -1754,11 +2000,11 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
title={t("hall.table.setAllTypes")}
>
<option value="">{t("hall.table.selectAll")}</option>
<option value="straight">{t("hall.table.selectionTypes.straight")}</option>
<option value="reverse">{t("hall.table.selectionTypes.reverse")}</option>
<option value="full_cover">{t("hall.table.selectionTypes.full_cover")}</option>
<option value="full_play">{t("hall.table.selectionTypes.full_play")}</option>
{activeCategory === "D4" ? <option value="half_play">{t("hall.table.selectionTypes.half_play")}</option> : null}
{selectionTypeOptions.map((type) => (
<option key={type} value={type}>
{t(`hall.table.selectionTypes.${type}`)}
</option>
))}
</select>
</th>
) : null}
@@ -1803,7 +2049,8 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
liveSoldOutNumbers={liveSoldOutNumbers}
liveWarningNumbers={liveWarningNumbers}
updateRowNumber={updateRowNumber}
updateRowSelectionType={updateRowSelectionType}
updateRowSelectionType={requestRowSelectionType}
selectionTypeOptions={selectionTypeOptions}
updateAmount={updateAmount}
toggleRowProvider={toggleRowProvider}
setActiveRowId={setActiveRowId}
@@ -1826,20 +2073,20 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
</div>
{!isMobile ? (
<aside className="sticky top-3 z-20 w-64 shrink-0 self-start rounded-xl border border-[#e6edf8] bg-[#f8fbff] px-4 py-4 text-[#304f86] shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
<aside className="sticky top-3 z-20 w-52 shrink-0 self-start rounded-xl border border-[#e6edf8] bg-[#f8fbff] px-3.5 py-3.5 text-[#304f86] shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
<p className="text-xs font-black">{t("hall.table.orderSummary", { defaultValue: "Order summary" })}</p>
<dl className="mt-5 space-y-4">
<div className="flex items-center justify-between gap-3">
<dl className="mt-4 space-y-3">
<div className="flex items-center justify-between gap-2">
<dt className="text-xs text-slate-500">{t("hall.table.lineCount", { defaultValue: "Lines" })}</dt>
<dd className="font-mono text-base font-black tabular-nums">{draftEntries.length}</dd>
<dd className="font-mono text-sm font-black tabular-nums">{draftEntries.length}</dd>
</div>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center justify-between gap-2">
<dt className="text-xs text-slate-500">{t("hall.table.providerCount", { defaultValue: "Providers" })}</dt>
<dd className="font-mono text-base font-black tabular-nums">{selectedProviderCount}</dd>
<dd className="font-mono text-sm font-black tabular-nums">{selectedProviderCount}</dd>
</div>
<div className="border-t border-[#dce6f5] pt-4">
<div className="border-t border-[#dce6f5] pt-3">
<dt className="text-xs text-slate-500">{t("hall.table.actualTotal")}</dt>
<dd className="mt-1 text-lg font-black tabular-nums text-[#0b3f96]">
<dd className="mt-1 text-base font-black tabular-nums text-[#0b3f96]">
{formatMinorAsCurrency(submitActualMinor, currencyCode)}
</dd>
</div>
@@ -1849,7 +2096,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
disabled={!canSubmit || previewLoading}
onClick={() => void handlePreview()}
className={cn(
"mt-6 h-11 w-full gap-2 rounded-lg border-0 text-sm font-bold text-white",
"mt-4 h-10 w-full gap-2 rounded-lg border-0 text-sm font-bold text-white",
!isBettable
? "bg-slate-500 shadow-none hover:bg-slate-500 disabled:opacity-100"
: "bg-[#e5002c] shadow-[0_8px_20px_rgba(229,0,44,0.26)] hover:bg-[#d10028]",
@@ -1956,6 +2203,79 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
jackpotEnabled={Boolean(jackpot?.enabled)}
creditMode={creditMode}
/>
<Dialog
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="gap-2 border-t border-[#eef2f8] 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>
</>
);
}
@@ -1975,6 +2295,7 @@ const DraftRowItem = memo(function DraftRowItem({
liveWarningNumbers,
updateRowNumber,
updateRowSelectionType,
selectionTypeOptions,
updateAmount,
toggleRowProvider,
setActiveRowId,
@@ -1983,45 +2304,51 @@ const DraftRowItem = memo(function DraftRowItem({
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>;
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: DraftRow["selectionType"]) => 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 rowTotalMinor = playColumns.reduce((total, column) => {
if (draftLineIssueReason(column.play.play_code, row.number, column.digitSlot) !== null) return total;
return total + (parseDecimalInputToMinor(row.amounts[column.key] ?? "", currencyCode) ?? 0);
}, 0) * row.providerCodes.length;
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 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
@@ -2132,16 +2459,21 @@ const DraftRowItem = memo(function DraftRowItem({
<select
value={row.selectionType}
disabled={tableDisabled}
onChange={(event) => updateRowSelectionType(row.id, event.target.value as DraftRow["selectionType"])}
onChange={(event) => updateRowSelectionType(row.id, event.target.value as SelectionType)}
className="h-8 w-full rounded-md border border-[#e1e8f3] bg-white px-1 text-[10px] font-semibold text-[#304f86]"
aria-label={t("hall.table.selectionTypeForRow", { row: index + 1 })}
>
<option value="straight">{t("hall.table.selectionTypes.straight")}</option>
<option value="reverse">{t("hall.table.selectionTypes.reverse")}</option>
<option value="full_cover">{t("hall.table.selectionTypes.full_cover")}</option>
<option value="full_play">{t("hall.table.selectionTypes.full_play")}</option>
{activeCategory === "D4" ? <option value="half_play">{t("hall.table.selectionTypes.half_play")}</option> : null}
{selectionTypeOptions.map((type) => (
<option key={type} value={type}>
{t(`hall.table.selectionTypes.${type}`)}
</option>
))}
</select>
{comboCount > 1 && displayNumber.length >= 2 ? (
<p className="mt-0.5 text-[9px] font-bold leading-none text-[#0b56b7]">
{t("hall.table.comboCount", { count: comboCount })}
</p>
) : null}
</td>
) : null}
{betProviders.map((provider, providerIndex) => (