feat(hall-betting-grid): 增加对投注行的优化和重构,提升代码可读性和维护性
Some checks failed
lotteryfront CI / build (push) Has been cancelled

This commit is contained in:
2026-07-08 14:18:18 +08:00
parent a86e8a2c67
commit 45376473f3

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { CirclePlus, Lock, Ticket, Trash2, Star } from "lucide-react"; import { CirclePlus, Lock, Ticket, Trash2, Star } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, memo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -12,6 +12,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { isHallSealedCountdownUi } from "@/features/draw/draw-status-meta"; import { isHallSealedCountdownUi } from "@/features/draw/draw-status-meta";
import { useIsMobile } from "@/hooks/use-mobile";
import { HallBetPreviewDialog } from "@/features/hall/hall-bet-preview-dialog"; import { HallBetPreviewDialog } from "@/features/hall/hall-bet-preview-dialog";
import { HallBetResultDialog } from "@/features/hall/hall-bet-result-dialog"; import { HallBetResultDialog } from "@/features/hall/hall-bet-result-dialog";
import { mapTicketBetError } from "@/features/hall/hall-bet-errors"; import { mapTicketBetError } from "@/features/hall/hall-bet-errors";
@@ -41,7 +42,7 @@ import type { PlayEffectivePayload, PlayEffectivePlayRow } from "@/types/api/pla
import type { TicketLineInput, TicketPlaceData, TicketPreviewData } from "@/types/api/ticket"; import type { TicketLineInput, TicketPlaceData, TicketPreviewData } from "@/types/api/ticket";
import type { DrawCurrentRiskPoolAlert } from "@/types/api/draw-current"; import type { DrawCurrentRiskPoolAlert } from "@/types/api/draw-current";
const MAX_ROWS = 20; const MAX_ROWS = 50;
type HallCategory = "D2" | "D3" | "D4" | "JACKPOT"; type HallCategory = "D2" | "D3" | "D4" | "JACKPOT";
@@ -74,6 +75,12 @@ type PlayColumn = {
digitSlot?: number; digitSlot?: number;
}; };
function playCategory(playCode: string): Exclude<HallCategory, "JACKPOT"> {
if (playCode.startsWith("pos_3")) return "D3";
if (playCode.startsWith("pos_2")) return "D2";
return "D4";
}
type ClosedPlayCleanupData = { type ClosedPlayCleanupData = {
cleanup_hint?: string; cleanup_hint?: string;
cleanup_lines?: Array<{ client_line_no?: number; play_code?: string }>; cleanup_lines?: Array<{ client_line_no?: number; play_code?: string }>;
@@ -135,29 +142,6 @@ const D4_PLAY_ORDER = [
"digit_small", "digit_small",
] as const; ] as const;
type D4PlayGroupId =
| "big_small"
| "position"
| "combo"
| "roll_straight"
| "head_tail_odd_even"
| "digit_big_small";
const D4_PLAY_GROUPS: { id: D4PlayGroupId; labelKey: string; playCodes: readonly string[] }[] = [
{ id: "big_small", labelKey: "hall.d4Group.big_small", playCodes: ["big", "small"] },
{ id: "position", labelKey: "hall.d4Group.position", playCodes: ["pos_4a", "pos_4b", "pos_4c", "pos_4d", "pos_4e"] },
{ id: "combo", labelKey: "hall.d4Group.combo", playCodes: ["box", "ibox", "mbox"] },
{ id: "roll_straight", labelKey: "hall.d4Group.roll_straight", playCodes: ["roll", "straight"] },
{ id: "head_tail_odd_even", labelKey: "hall.d4Group.head_tail_odd_even", playCodes: ["head", "tail", "odd", "even"] },
{ id: "digit_big_small", labelKey: "hall.d4Group.digit_big_small", playCodes: ["digit_big", "digit_small"] },
];
const categoryPlayOrders: Record<Exclude<HallCategory, "JACKPOT">, readonly string[]> = {
D2: D2_PLAY_ORDER,
D3: D3_PLAY_ORDER,
D4: D4_PLAY_ORDER,
};
function newDraftRow(): DraftRow { function newDraftRow(): DraftRow {
const id = const id =
typeof crypto !== "undefined" && crypto.randomUUID typeof crypto !== "undefined" && crypto.randomUUID
@@ -222,24 +206,8 @@ function playColumnsForCategory(
}); });
} }
function inferCategory(row: PlayEffectivePlayRow): Exclude<HallCategory, "JACKPOT"> { function sanitizeNumber(raw: string): string {
if (row.play_code.startsWith("pos_2")) return "D2";
if (row.play_code.startsWith("pos_3")) return "D3";
return "D4";
}
function categoryDigits(category: HallCategory): number {
if (category === "D2") return 2;
if (category === "D3") return 3;
if (category === "D4") return 4;
return 0;
}
function sanitizeNumber(raw: string, category: HallCategory): string {
if (category === "D4") {
return raw.replace(/[^0-9Rr]/g, "").toUpperCase().slice(0, 4); return raw.replace(/[^0-9Rr]/g, "").toUpperCase().slice(0, 4);
}
return raw.replace(/\D/g, "").slice(0, categoryDigits(category));
} }
function sanitizeAmount(raw: string): string { function sanitizeAmount(raw: string): string {
@@ -269,7 +237,6 @@ function normalizeNumberForPlay(number: string, playCode: string): string {
} }
function lineForPlay( function lineForPlay(
category: Exclude<HallCategory, "JACKPOT">,
play: PlayEffectivePlayRow, play: PlayEffectivePlayRow,
displayNumber: string, displayNumber: string,
amountMinor: number, amountMinor: number,
@@ -291,7 +258,7 @@ function lineForPlay(
}; };
if (playNeedsDimension(play.play_code)) { if (playNeedsDimension(play.play_code)) {
line.dimension = category; line.dimension = playCategory(play.play_code);
} }
if (playNeedsDigitSlot(play.play_code)) { if (playNeedsDigitSlot(play.play_code)) {
if (digitSlot === undefined) return null; if (digitSlot === undefined) return null;
@@ -461,10 +428,9 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
const { t } = useTranslation("player"); const { t } = useTranslation("player");
const { activeCurrency: currencyParam } = useActivePlayerCurrency(); const { activeCurrency: currencyParam } = useActivePlayerCurrency();
const creditMode = isCreditFundingPlayer(usePlayerSessionStore((state) => state.profile)); const creditMode = isCreditFundingPlayer(usePlayerSessionStore((state) => state.profile));
const isMobile = useIsMobile();
const [activeCategory, setActiveCategory] = useState<HallCategory>("D2"); const [rows, setRows] = useState<DraftRow[]>(() => Array.from({ length: 20 }, newDraftRow));
const [d4PlayGroup, setD4PlayGroup] = useState<D4PlayGroupId>("big_small");
const [rows, setRows] = useState<DraftRow[]>(() => [newDraftRow()]);
const [activeRowId, setActiveRowId] = useState<string | null>(null); const [activeRowId, setActiveRowId] = useState<string | null>(null);
const [catalogState, setCatalogState] = useState< const [catalogState, setCatalogState] = useState<
| { kind: "loading" } | { kind: "loading" }
@@ -478,6 +444,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
const [placeLoading, setPlaceLoading] = useState(false); const [placeLoading, setPlaceLoading] = useState(false);
const [resultOpen, setResultOpen] = useState(false); const [resultOpen, setResultOpen] = useState(false);
const [resultData, setResultData] = useState<TicketPlaceData | null>(null); const [resultData, setResultData] = useState<TicketPlaceData | null>(null);
const [activeCategory, setActiveCategory] = useState<Exclude<HallCategory, "JACKPOT">>("D4");
const [quickFillState, setQuickFillState] = useState<QuickFillState>(() => loadQuickFillState()); const [quickFillState, setQuickFillState] = useState<QuickFillState>(() => loadQuickFillState());
const [riskStateDrawNo, setRiskStateDrawNo] = useState<string | null>(display?.draw_no ?? null); const [riskStateDrawNo, setRiskStateDrawNo] = useState<string | null>(display?.draw_no ?? null);
const [liveSoldOutNumbers, setLiveSoldOutNumbers] = useState<Set<string>>(() => new Set()); const [liveSoldOutNumbers, setLiveSoldOutNumbers] = useState<Set<string>>(() => new Set());
@@ -552,55 +519,54 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
const openPlays = useMemo(() => { const openPlays = useMemo(() => {
if (catalogState.kind !== "ok") return []; if (catalogState.kind !== "ok") return [];
const order = categoryPlayOrders[activeCategory === "JACKPOT" ? "D4" : activeCategory]; const order = [...D2_PLAY_ORDER, ...D3_PLAY_ORDER, ...D4_PLAY_ORDER];
return sortByPlayOrder( return sortByPlayOrder(
catalogState.data.plays catalogState.data.plays
.filter(isPlayOpenForPlayer) .filter(isPlayOpenForPlayer)
.filter((p) => order.includes(p.play_code)), .filter((p) => order.includes(p.play_code)),
order, order,
); );
}, [activeCategory, catalogState]); }, [catalogState]);
const activeCategoryPlays = useMemo(
() => openPlays.filter((play) => playCategory(play.play_code) === activeCategory),
[activeCategory, openPlays],
);
const currencyCode = const currencyCode =
catalogState.kind === "ok" ? catalogState.data.currency_code : currencyParam; catalogState.kind === "ok" ? catalogState.data.currency_code : currencyParam;
const categoryPlays = useMemo(() => {
if (catalogState.kind !== "ok") return [];
if (activeCategory === "JACKPOT") return [];
const order = categoryPlayOrders[activeCategory];
return sortByPlayOrder(
openPlays.filter((p) => inferCategory(p) === activeCategory || activeCategory === "D4"),
order,
);
}, [activeCategory, catalogState, openPlays]);
const allPlayColumns = useMemo(() => { const allPlayColumns = useMemo(() => {
if (activeCategory === "JACKPOT") return []; return openPlays.flatMap((play) => {
return playColumnsForCategory(categoryPlays, activeCategory); const category = playCategory(play.play_code);
}, [activeCategory, categoryPlays]); if (!playNeedsDigitSlot(play.play_code)) {
return [{ key: amountKeyForPlay(play.play_code), play }];
}
return digitSlotOptions(category).map((digitSlot) => ({
key: amountKeyForPlay(play.play_code, digitSlot),
play,
digitSlot,
}));
});
}, [openPlays]);
const playColumns = useMemo(() => { const playColumns = useMemo(() => {
if (activeCategory !== "D4") return allPlayColumns; return playColumnsForCategory(activeCategoryPlays, activeCategory);
const group = D4_PLAY_GROUPS.find((g) => g.id === d4PlayGroup); }, [activeCategory, activeCategoryPlays]);
if (!group) return allPlayColumns;
const allowedCodes = new Set(group.playCodes);
return allPlayColumns.filter((col) => allowedCodes.has(col.play.play_code));
}, [activeCategory, allPlayColumns, d4PlayGroup]);
const compactTable = playColumns.length > 6; const amountColClass = !isMobile
const amountColClass = compactTable ? "w-[3.9rem] min-w-[3.9rem]"
? "w-[3.75rem] min-w-[3.75rem] max-w-[3.75rem]" : "w-[4rem] min-w-[4rem]";
: "w-[4.5rem] min-w-[4.5rem] max-w-[4.5rem]";
const tableMinWidthPx = useMemo(() => { const tableMinWidthPx = useMemo(() => {
const indexCol = 40; const indexCol = 34;
const numberCol = activeCategory === "D4" ? 112 : activeCategory === "D3" ? 88 : 72; const numberCol = 88;
const amountCol = compactTable ? 60 : 72; const amountCol = !isMobile ? 62 : 64;
const deleteCol = 36; const deleteCol = 32;
return indexCol + numberCol + playColumns.length * amountCol + deleteCol; return indexCol + numberCol + playColumns.length * amountCol + deleteCol;
}, [activeCategory, compactTable, playColumns.length]); }, [playColumns.length, isMobile]);
const showWideTableHint = activeCategory === "D4" && allPlayColumns.length > 8 && playColumns.length > 8; const showWideTableHint = isMobile && playColumns.length > 8;
const activeRow = useMemo( const activeRow = useMemo(
() => rows.find((row) => row.id === activeRowId) ?? rows[0] ?? null, () => rows.find((row) => row.id === activeRowId) ?? rows[0] ?? null,
@@ -622,46 +588,19 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
const historyNumbers = currentQuickFill.history; const historyNumbers = currentQuickFill.history;
const tableDisabled = !isBettable || catalogState.kind !== "ok"; const tableDisabled = !isBettable || catalogState.kind !== "ok";
const sealedBetUi = Boolean(display && isHallSealedCountdownUi(display.status)); const sealedBetUi = Boolean(display && isHallSealedCountdownUi(display.status));
const defaultNumberPlaceholder = const numberPlaceholder =
activeCategory === "D2" ? "00" : activeCategory === "D3" ? "000" : "0000"; activeCategory === "D2" ? "00" : activeCategory === "D3" ? "000" : "0000";
const numberPlaceholder = useMemo(() => {
if (activeCategory !== "D4") return defaultNumberPlaceholder;
const targetRow = activeRow ?? rows[0];
if (!targetRow) return defaultNumberPlaceholder;
const hasRollStake = allPlayColumns.some((column) => {
if (column.play.play_code !== "roll") return false;
const amount = parseDecimalInputToMinor(targetRow.amounts[column.key] ?? "", currencyCode);
return amount !== null && amount > 0;
});
return hasRollStake ? t("hall.numberInput.rollPlaceholder") : defaultNumberPlaceholder;
}, [
activeCategory,
activeRow,
currencyCode,
defaultNumberPlaceholder,
allPlayColumns,
rows,
t,
]);
const availableD4Groups = useMemo(() => { const updateRowNumber = useCallback((id: string, value: string) => {
if (activeCategory !== "D4") return [];
return D4_PLAY_GROUPS.filter((group) => {
const allowedCodes = new Set(group.playCodes);
return allPlayColumns.some((col) => allowedCodes.has(col.play.play_code));
});
}, [activeCategory, allPlayColumns]);
const updateRowNumber = (id: string, value: string) => {
setRows((current) => setRows((current) =>
current.map((row) => current.map((row) =>
row.id === id ? { ...row, number: sanitizeNumber(value, activeCategory) } : row, row.id === id ? { ...row, number: sanitizeNumber(value) } : row,
), ),
); );
setActiveRowId(id); setActiveRowId(id);
}; }, []);
const updateAmount = (rowId: string, playCode: string, value: string) => { const updateAmount = useCallback((rowId: string, playCode: string, value: string) => {
setRows((current) => setRows((current) =>
current.map((row) => current.map((row) =>
row.id === rowId row.id === rowId
@@ -670,25 +609,27 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
), ),
); );
setActiveRowId(rowId); setActiveRowId(rowId);
}; }, []);
const addRow = () => { const addRow = useCallback(() => {
setRows((current) => { setRows((current) => {
if (current.length >= MAX_ROWS) return current; if (current.length >= MAX_ROWS) return current;
const row = newDraftRow(); const row = newDraftRow();
setActiveRowId(row.id); setActiveRowId(row.id);
return [...current, row]; return [...current, row];
}); });
}; }, []);
const removeRow = (id: string) => { const removeRow = useCallback((id: string) => {
setRows((current) => { setRows((current) => {
if (current.length <= 1) return current; if (current.length <= 1) return current;
const next = current.filter((row) => row.id !== id); return current.filter((row) => row.id !== id);
setActiveRowId((prev) => (prev === id ? next[0]?.id ?? null : prev));
return next;
}); });
}; setActiveRowId((prev) => {
if (prev === id) return null;
return prev;
});
}, []);
const clearAllRows = () => { const clearAllRows = () => {
if (tableDisabled) return; if (tableDisabled) return;
@@ -844,7 +785,6 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
}, [clearAmountsForPlay, clearPlaceTraceId, drawNo, hasAmountsForPlay, reloadDraw, t]); }, [clearAmountsForPlay, clearPlaceTraceId, drawNo, hasAmountsForPlay, reloadDraw, t]);
const collectDraftLineIssues = useCallback((): DraftLineIssue[] => { const collectDraftLineIssues = useCallback((): DraftLineIssue[] => {
if (activeCategory === "JACKPOT") return [];
const issues: DraftLineIssue[] = []; const issues: DraftLineIssue[] = [];
rows.forEach((row, rowIndex) => { rows.forEach((row, rowIndex) => {
allPlayColumns.forEach((column) => { allPlayColumns.forEach((column) => {
@@ -861,7 +801,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
}); });
}); });
return issues; return issues;
}, [activeCategory, currencyCode, allPlayColumns, rows]); }, [allPlayColumns, currencyCode, rows]);
const formatDraftLineIssue = useCallback( const formatDraftLineIssue = useCallback(
(issue: DraftLineIssue): string => { (issue: DraftLineIssue): string => {
@@ -872,13 +812,12 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
); );
const collectEntries = useCallback((): DraftEntry[] => { const collectEntries = useCallback((): DraftEntry[] => {
if (activeCategory === "JACKPOT") return [];
const entries: DraftEntry[] = []; const entries: DraftEntry[] = [];
rows.forEach((row, rowIndex) => { rows.forEach((row, rowIndex) => {
allPlayColumns.forEach((column) => { allPlayColumns.forEach((column) => {
const amount = parseDecimalInputToMinor(row.amounts[column.key] ?? "", currencyCode); const amount = parseDecimalInputToMinor(row.amounts[column.key] ?? "", currencyCode);
if (amount === null || amount <= 0) return; if (amount === null || amount <= 0) return;
const line = lineForPlay(activeCategory, column.play, row.number, amount, column.digitSlot); const line = lineForPlay(column.play, row.number, amount, column.digitSlot);
if (!line) return; if (!line) return;
entries.push({ entries.push({
rowId: row.id, rowId: row.id,
@@ -893,7 +832,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
}); });
}); });
return entries; return entries;
}, [activeCategory, currencyCode, allPlayColumns, rows]); }, [allPlayColumns, currencyCode, rows]);
const draftEntries = collectEntries(); const draftEntries = collectEntries();
const draftSummary = useMemo(() => { const draftSummary = useMemo(() => {
@@ -1161,60 +1100,6 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
return ( return (
<> <>
<section className="space-y-3" aria-label={t("hall.aria")}> <section className="space-y-3" aria-label={t("hall.aria")}>
<div className="rounded-xl border border-[#e8eef7] bg-white p-1 shadow-[0_6px_18px_rgba(30,64,175,0.06)]">
<div className="grid grid-cols-3 gap-1">
{categoryTabs.map((tab) => {
const active = activeCategory === tab.value;
return (
<button
key={tab.value}
type="button"
onClick={() => {
setActiveCategory(tab.value);
setRows((current) =>
current.map((row) => ({
...row,
number: sanitizeNumber(row.number, tab.value),
})),
);
}}
className={cn(
"relative flex h-10 min-w-0 items-center justify-center rounded-lg text-sm font-semibold transition-colors",
active
? "bg-[#07459f] text-white shadow-[inset_0_-2px_0_rgba(255,255,255,0.28)]"
: "text-[#4b5563] hover:bg-[#f4f7fb]",
tab.value === "JACKPOT" && active && "bg-[#7b8492]",
)}
>
<span className="truncate">{tab.label}</span>
</button>
);
})}
</div>
</div>
{activeCategory === "D4" && availableD4Groups.length > 1 ? (
<div className="flex gap-1 overflow-x-auto overscroll-x-contain pb-0.5">
{availableD4Groups.map((group) => {
const active = d4PlayGroup === group.id;
return (
<button
key={group.id}
type="button"
onClick={() => setD4PlayGroup(group.id)}
className={cn(
"shrink-0 rounded-full px-3 py-1.5 text-xs font-bold transition-colors",
active
? "bg-[#07459f] text-white"
: "border border-[#dce7f7] bg-white text-[#32518d] hover:bg-[#f4f7fb]",
)}
>
{t(group.labelKey)}
</button>
);
})}
</div>
) : null}
{jackpot?.enabled ? ( {jackpot?.enabled ? (
<div className="rounded-xl border border-amber-200 bg-gradient-to-r from-amber-50 via-white to-[#f8fbff] px-3 py-2.5"> <div className="rounded-xl border border-amber-200 bg-gradient-to-r from-amber-50 via-white to-[#f8fbff] px-3 py-2.5">
@@ -1369,7 +1254,38 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
</div> </div>
</div> </div>
{categoryPlays.length === 0 ? ( <div className="flex items-end gap-2 overflow-x-auto pb-1">
{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 min-w-[6rem] items-center justify-center rounded-t-xl border px-4 py-3 text-sm font-bold transition-colors",
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]",
!hasPlays && "cursor-not-allowed opacity-40",
)}
aria-pressed={active}
>
<span className="mr-2 text-[13px]" aria-hidden>
</span>
{tab.label}
</button>
);
})}
</div>
{activeCategoryPlays.length === 0 ? (
<div <div
className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2.5 text-center text-xs text-amber-950" className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2.5 text-center text-xs text-amber-950"
role="status" role="status"
@@ -1389,6 +1305,55 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
showWideTableHint && "player-table-scroll-wrap", showWideTableHint && "player-table-scroll-wrap",
)} )}
> >
<div 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">
<th className="border-r border-[#4a7aeb] px-2 py-1.5 font-bold">
{t("hall.table.total", { defaultValue: "共" })}
</th>
{allPlayColumns.map((column) => (
<th key={column.key} className="border-r border-[#4a7aeb] px-1 py-1.5 font-bold last:border-0">
{playColumnHeaderLabel(
column.play,
playCategory(column.play.play_code),
column.digitSlot,
t,
)}
</th>
))}
</tr>
</thead>
<tbody className="bg-white text-[#17408d]">
<tr>
<td className="border-r border-[#edf2f8] px-2 py-1.5 font-bold text-[#e5002c]">
{formatMinorAsCurrency(submitActualMinor, currencyCode)}
</td>
{allPlayColumns.map((column) => {
const colTotalMinor = rows.reduce((sum, row) => {
const amtStr = row.amounts[column.key];
if (!amtStr) return sum;
if (
row.number.trim().length === 0 ||
draftLineIssueReason(column.play.play_code, row.number, column.digitSlot) !== null
) {
return sum;
}
const minorAmt = parseDecimalInputToMinor(amtStr, currencyCode);
return sum + (minorAmt ?? 0);
}, 0);
return (
<td key={`val-${column.key}`} className="border-r border-[#edf2f8] px-1 py-1.5 font-bold last:border-0">
{formatMinorAsCurrency(colTotalMinor, currencyCode)}
</td>
);
})}
</tr>
</tbody>
</table>
</div>
<div className="player-table-scroll overflow-x-auto overscroll-x-contain"> <div className="player-table-scroll overflow-x-auto overscroll-x-contain">
<table <table
className="w-full border-collapse text-[11px]" className="w-full border-collapse text-[11px]"
@@ -1396,159 +1361,63 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
> >
<thead> <thead>
<tr className="border-b border-[#edf2f8] bg-[#f8fafd] text-[#58709d]"> <tr className="border-b border-[#edf2f8] bg-[#f8fafd] text-[#58709d]">
<th className="sticky left-0 z-30 w-10 min-w-10 bg-[#f8fafd] px-1 py-2 text-center font-bold shadow-[2px_0_6px_rgba(15,23,42,0.04)]"> <th className="sticky left-0 z-30 w-[2.15rem] min-w-[2.15rem] bg-[#f8fafd] px-0.5 py-1.5 text-center font-bold shadow-[2px_0_6px_rgba(15,23,42,0.04)]">
{t("hall.table.no", { defaultValue: "No." })} {t("hall.table.no", { defaultValue: "No." })}
</th> </th>
<th <th
className={cn( className={cn(
"sticky left-10 z-30 bg-[#f8fafd] px-1.5 py-2 text-center font-bold shadow-[2px_0_6px_rgba(15,23,42,0.04)]", "sticky left-[2.15rem] z-30 bg-[#f8fafd] px-1 py-1.5 text-center font-bold shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
activeCategory === "D4" "w-[5.5rem] min-w-[5.5rem]",
? "w-28 min-w-28"
: activeCategory === "D3"
? "w-[5.5rem] min-w-[5.5rem]"
: "w-[4.5rem] min-w-[4.5rem]",
)} )}
> >
<span className="block text-xs">{t("hall.table.number", { defaultValue: "Number" })}</span> <span className="block text-xs">{t("hall.table.number", { defaultValue: "Number" })}</span>
<span className="mt-0.5 block font-mono text-[11px] font-medium text-[#9aa8bd]"> <span className="mt-0.5 block font-mono text-[10px] font-medium text-[#9aa8bd]">
{numberPlaceholder} {numberPlaceholder}
</span> </span>
</th> </th>
{playColumns.map((column) => ( {playColumns.map((column) => (
<th <th
key={column.key} key={column.key}
className={cn(amountColClass, "px-0.5 py-2 text-center font-bold")} className={cn(amountColClass, "px-0.5 py-1.5 text-center font-bold")}
> >
<span className="block whitespace-nowrap text-[11px] leading-tight"> <span className="block whitespace-nowrap text-[10px] leading-tight">
{playColumnHeaderLabel( {playColumnHeaderLabel(
column.play, column.play,
activeCategory as Exclude<HallCategory, "JACKPOT">, playCategory(column.play.play_code),
column.digitSlot, column.digitSlot,
t, t,
)} )}
</span> </span>
<span className="mt-0.5 block text-[10px] font-medium text-[#9aa8bd]"> <span className="mt-0.5 block text-[9px] font-medium text-[#9aa8bd]">
{t("hall.table.amountPlaceholder")} {t("hall.table.amountPlaceholder")}
</span> </span>
</th> </th>
))} ))}
<th className="w-9 min-w-9 px-0.5 py-2" aria-label={t("hall.table.delete")} /> <th className="w-8 min-w-8 px-0.5 py-1.5" aria-label={t("hall.table.delete")} />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.map((row, index) => { {rows.map((row, index) => (
const rowKey = row.id; <DraftRowItem
const rowActive = activeRowId === row.id; key={row.id}
return ( row={row}
<tr index={index}
key={rowKey} rowActive={activeRowId === row.id}
className={cn( tableDisabled={tableDisabled}
"border-b border-[#f0f3f8] last:border-b-0", numberPlaceholder={numberPlaceholder}
rowActive && "bg-[#f5f9ff]/80", playColumns={playColumns}
)} alertRows={alertRows}
> liveSoldOutNumbers={liveSoldOutNumbers}
<td liveWarningNumbers={liveWarningNumbers}
className={cn( updateRowNumber={updateRowNumber}
"sticky left-0 z-20 w-10 min-w-10 px-1 py-2 text-center font-black text-[#17408d] shadow-[2px_0_6px_rgba(15,23,42,0.04)]", updateAmount={updateAmount}
rowActive ? "bg-[#f5f9ff]" : "bg-white", setActiveRowId={setActiveRowId}
)} removeRow={removeRow}
> t={t}
{index + 1} isMobile={isMobile}
</td> removable={rows.length > 1}
<td
className={cn(
"sticky left-10 z-20 px-1.5 py-2 shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
activeCategory === "D4"
? "w-28 min-w-28"
: activeCategory === "D3"
? "w-[5.5rem] min-w-[5.5rem]"
: "w-[4.5rem] min-w-[4.5rem]",
rowActive ? "bg-[#f5f9ff]" : "bg-white",
)}
>
<Input
value={row.number}
disabled={tableDisabled}
inputMode="text"
placeholder={numberPlaceholder}
onFocus={() => setActiveRowId(row.id)}
onClick={() => setActiveRowId(row.id)}
onChange={(event) => updateRowNumber(row.id, event.target.value)}
className={cn(
"h-9 w-full rounded-md border-[#e1e8f3] bg-white px-2 text-center font-mono text-base font-bold tabular-nums text-slate-950 shadow-sm focus-visible:ring-[#1d57b7]",
activeCategory === "D4" && "tracking-[0.2em]",
)}
/> />
</td> ))}
{playColumns.map((column) => {
const { play } = column;
const amountText = row.amounts[column.key] ?? "";
const status = cellRiskState(
play,
row.number,
activeCategory as Exclude<HallCategory, "JACKPOT">,
alertRows,
liveSoldOutNumbers,
liveWarningNumbers,
column.digitSlot,
);
const disabled = tableDisabled || status === "sold_out" || (play.config !== null && !play.config.is_enabled);
const hasAmount = amountText.trim().length > 0;
return (
<td
key={`${rowKey}-${column.key}`}
className={cn(
"px-1 py-2 align-top",
status === "warning" && "bg-amber-50/70",
status === "sold_out" && "bg-slate-100 text-slate-400",
)}
>
<Input
value={amountText}
disabled={disabled}
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(
"h-9 w-full rounded-md border-[#e1e8f3] bg-white px-1 text-center text-base font-bold tabular-nums shadow-sm focus-visible:ring-[#1d57b7]",
hasAmount && "border-[#9bbcff] bg-[#f5f9ff] text-[#0b3f96]",
status === "warning" && "border-amber-200 bg-amber-50 text-amber-800",
status === "sold_out" && "border-slate-200 bg-slate-100 text-slate-400",
)}
/>
{status === "sold_out" ? (
<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>
);
})}
<td className="px-1 py-2 text-center align-middle">
<button
type="button"
disabled={tableDisabled || rows.length <= 1}
onClick={() => removeRow(row.id)}
className="inline-flex size-8 items-center justify-center rounded-full text-[#e5002c] hover:bg-red-50 disabled:text-slate-300 disabled:hover:bg-transparent"
aria-label={t("actions.deleteRow", { row: index + 1 })}
>
<Trash2 className="size-3.5" aria-hidden />
</button>
</td>
</tr>
);
})}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -1638,3 +1507,152 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
</> </>
); );
} }
const DraftRowItem = memo(function DraftRowItem({
row,
index,
rowActive,
tableDisabled,
numberPlaceholder,
playColumns,
alertRows,
liveSoldOutNumbers,
liveWarningNumbers,
updateRowNumber,
updateAmount,
setActiveRowId,
removeRow,
t,
isMobile,
removable
}: {
row: DraftRow;
index: number;
rowActive: boolean;
tableDisabled: boolean;
numberPlaceholder: string;
playColumns: PlayColumn[];
alertRows: DrawCurrentRiskPoolAlert[];
liveSoldOutNumbers: Set<string>;
liveWarningNumbers: Set<string>;
updateRowNumber: (id: string, value: string) => void;
updateAmount: (rowId: string, playCode: string, value: string) => void;
setActiveRowId: (id: string) => void;
removeRow: (id: string) => void;
t: HallTranslate;
isMobile: boolean;
removable: boolean;
}) {
return (
<tr
className={cn(
"border-b border-[#f0f3f8] last:border-b-0",
rowActive && "bg-[#f5f9ff]/80",
)}
>
<td
className={cn(
"sticky left-0 z-20 w-[2.15rem] min-w-[2.15rem] px-0.5 py-1.5 text-center font-black text-[#17408d] shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
rowActive ? "bg-[#f5f9ff]" : "bg-white",
)}
>
{index + 1}
</td>
<td
className={cn(
"sticky left-[2.15rem] z-20 px-1 py-1.5 shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
"w-[5.5rem] min-w-[5.5rem]",
rowActive ? "bg-[#f5f9ff]" : "bg-white",
)}
>
<Input
value={row.number}
disabled={tableDisabled}
inputMode="text"
placeholder={numberPlaceholder}
onFocus={() => setActiveRowId(row.id)}
onClick={() => setActiveRowId(row.id)}
onChange={(event) => updateRowNumber(row.id, event.target.value)}
className={cn(
"h-8 w-full rounded-md border-[#e1e8f3] bg-white px-1 text-center font-mono font-bold tabular-nums text-slate-950 shadow-sm focus-visible:ring-[#1d57b7]",
!isMobile ? "text-[13px]" : "text-sm",
"tracking-[0.12em]",
)}
/>
</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 py-1.5 align-top",
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(
"h-8 w-full rounded-md border-[#e1e8f3] bg-white px-0.5 text-center font-bold tabular-nums shadow-sm focus-visible:ring-[#1d57b7]",
!isMobile ? "text-[13px]" : "text-sm",
hasAmount && "border-[#9bbcff] bg-[#f5f9ff] text-[#0b3f96]",
status === "warning" && "border-amber-200 bg-amber-50 text-amber-800",
status === "sold_out" && "border-slate-200 bg-slate-100 text-slate-400",
)}
/>
) : (
<div className="h-8 w-full rounded-md border border-slate-200 bg-slate-100/70" />
)}
{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>
);
})}
<td className="px-0.5 py-1.5 text-center align-middle">
<button
type="button"
disabled={tableDisabled || !removable}
onClick={() => removeRow(row.id)}
className="inline-flex size-7 items-center justify-center rounded-full text-[#e5002c] hover:bg-red-50 disabled:text-slate-300 disabled:hover:bg-transparent"
aria-label={t("actions.deleteRow", { row: index + 1 })}
>
<Trash2 className="size-3.5" aria-hidden />
</button>
</td>
</tr>
);
});