feat: enhance draw processing and ticket validation logic

- Added a new function to check if the hall is awaiting draw processing, improving the draw status handling.
- Implemented validation for roll numbers in ticket orders, ensuring compliance with specified formats.
- Enhanced the draft line issue reasoning to provide detailed feedback on invalid ticket entries.
- Updated HallDrawPanel and related components to utilize the new draw processing checks and improve user notifications.
- Added new translations for draw processing and ticket validation messages in multiple languages.
This commit is contained in:
2026-05-25 16:44:00 +08:00
parent 3bcbf7d256
commit 3b83c6627c
10 changed files with 356 additions and 61 deletions

View File

@@ -16,9 +16,11 @@ 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 {
draftLineIssueReason,
playNeedsDigitSlot,
playNeedsDimension,
ticketNumberSpec,
type DraftLineIssueReason,
} from "@/features/hall/hall-bet-rules";
import type { HallDrawLiveSnapshot } from "@/features/hall/use-hall-draw-live";
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
@@ -26,6 +28,7 @@ import { triggerWalletPollingAfterBet } from "@/hooks/use-wallet-polling";
import { getLotteryEcho } from "@/lib/lottery-echo";
import { getLotteryRequestLocale } from "@/lib/lottery-locale";
import { formatMinorAsCurrency, parseDecimalInputToMinor } from "@/lib/money";
import { playLabel } from "@/lib/play-labels";
import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
import { cn } from "@/lib/utils";
import { LotteryApiBizError } from "@/types/api/errors";
@@ -55,6 +58,12 @@ type DraftEntry = {
line: TicketLineInput;
};
type DraftLineIssue = {
rowNo: number;
playCode: string;
reason: DraftLineIssueReason;
};
type PlayColumn = {
key: string;
play: PlayEffectivePlayRow;
@@ -132,8 +141,20 @@ function isPlayOpenForPlayer(row: PlayEffectivePlayRow): boolean {
return Boolean(row.master_enabled && row.config?.is_enabled);
}
function pickDisplayName(row: PlayEffectivePlayRow): string {
return row.display_name?.trim() || row.play_code;
type HallTranslate = (key: string, options?: Record<string, unknown>) => string;
/** 表头用短标签,避免 digit_big + 千/百/十/个 挤成一团。 */
function playColumnHeaderLabel(
play: PlayEffectivePlayRow,
category: Exclude<HallCategory, "JACKPOT">,
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: Exclude<HallCategory, "JACKPOT">): number[] {
@@ -226,6 +247,9 @@ function lineForPlay(
digitSlot?: number,
): TicketLineInput | null {
const number = normalizeNumberForPlay(displayNumber, play.play_code);
if (draftLineIssueReason(play.play_code, displayNumber, digitSlot) !== null) {
return null;
}
const spec = ticketNumberSpec(play.play_code);
if (number.length !== spec.maxChars) {
return null;
@@ -499,6 +523,16 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
return playColumnsForCategory(categoryPlays, activeCategory);
}, [activeCategory, categoryPlays]);
const tableMinWidthPx = useMemo(() => {
const indexCol = 40;
const numberCol = activeCategory === "D4" ? 112 : activeCategory === "D3" ? 88 : 72;
const amountCol = 72;
const deleteCol = 36;
return indexCol + numberCol + playColumns.length * amountCol + deleteCol;
}, [activeCategory, playColumns.length]);
const showWideTableHint = activeCategory === "D4" && playColumns.length > 8;
const activeRow = useMemo(
() => rows.find((row) => row.id === activeRowId) ?? rows[0] ?? null,
[activeRowId, rows],
@@ -535,7 +569,27 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
const tableDisabled = !isBettable || catalogState.kind !== "ok";
const sealedBetUi = Boolean(display && isHallSealedCountdownUi(display.status));
const numberPlaceholder = activeCategory === "D2" ? "00" : activeCategory === "D3" ? "000" : "0000";
const defaultNumberPlaceholder =
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 = playColumns.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,
playColumns,
rows,
t,
]);
const updateRowNumber = (id: string, value: string) => {
setRows((current) =>
@@ -702,6 +756,34 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
};
}, [clearAmountsForPlay, drawNo, loadCatalog, reloadDraw, t]);
const collectDraftLineIssues = useCallback((): DraftLineIssue[] => {
if (activeCategory === "JACKPOT") return [];
const issues: DraftLineIssue[] = [];
rows.forEach((row, rowIndex) => {
playColumns.forEach((column) => {
const amount = parseDecimalInputToMinor(row.amounts[column.key] ?? "", currencyCode);
if (amount === null || amount <= 0) return;
const reason = draftLineIssueReason(column.play.play_code, row.number, column.digitSlot);
if (reason !== null) {
issues.push({
rowNo: rowIndex + 1,
playCode: column.play.play_code,
reason,
});
}
});
});
return issues;
}, [activeCategory, currencyCode, playColumns, rows]);
const formatDraftLineIssue = useCallback(
(issue: DraftLineIssue): string => {
const label = playLabel(issue.playCode, t);
return t(`hall.lineIssue.${issue.reason}`, { row: issue.rowNo, play: label });
},
[t],
);
const collectEntries = useCallback((): DraftEntry[] => {
if (activeCategory === "JACKPOT") return [];
const entries: DraftEntry[] = [];
@@ -798,6 +880,12 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
return;
}
const lineIssues = collectDraftLineIssues();
if (lineIssues.length > 0) {
toast.error(formatDraftLineIssue(lineIssues[0]));
return;
}
const lines = buildLines();
if (lines.length === 0) {
toast.error(t("hall.emptyLines"));
@@ -842,6 +930,12 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
return;
}
const lineIssues = collectDraftLineIssues();
if (lineIssues.length > 0) {
toast.error(formatDraftLineIssue(lineIssues[0]));
return;
}
const lines = buildLines();
if (lines.length === 0) {
toast.error(t("hall.changedBeforeSubmit"));
@@ -1133,53 +1227,88 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
</div>
) : null}
{showWideTableHint ? (
<p className="mb-2 text-xs leading-5 text-[#58709d]">{t("hall.table.scrollHint")}</p>
) : null}
<div
className={cn(
"overflow-hidden rounded-xl border border-[#e6edf8] bg-white shadow-[0_8px_24px_rgba(15,23,42,0.05)] transition-opacity",
tableDisabled && "opacity-55",
)}
>
<div className="overflow-x-auto">
<div className="overflow-x-auto overscroll-x-contain">
<table
className={cn(
"w-full border-collapse text-[11px]",
activeCategory === "D4" ? "min-w-[760px]" : "min-w-[460px]",
)}
className="w-full border-collapse text-[11px]"
style={{ minWidth: tableMinWidthPx }}
>
<thead>
<tr className="border-b border-[#edf2f8] bg-[#f8fafd] text-[#58709d]">
<th className="w-8 px-1.5 py-2 text-center font-bold">
<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)]">
{t("hall.table.no", { defaultValue: "No." })}
</th>
<th className="w-20 px-1.5 py-2 text-center font-bold">
<span className="block">{t("hall.table.number", { defaultValue: "Number" })}</span>
<span className="block text-[9px] font-medium text-[#9aa8bd]">({numberPlaceholder})</span>
<th
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)]",
activeCategory === "D4"
? "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="mt-0.5 block font-mono text-[10px] font-medium text-[#9aa8bd]">
{numberPlaceholder}
</span>
</th>
{playColumns.map((column) => (
<th key={column.key} className="min-w-16 px-1 py-2 text-center font-bold">
<span className="block truncate">
{pickDisplayName(column.play)}
{column.digitSlot !== undefined && activeCategory !== "JACKPOT"
? `-${digitSlotLabel(activeCategory, column.digitSlot)}`
: ""}
<th
key={column.key}
className="w-[4.5rem] min-w-[4.5rem] max-w-[4.5rem] px-0.5 py-2 text-center font-bold"
>
<span className="block whitespace-nowrap text-[10px] leading-tight">
{playColumnHeaderLabel(column.play, activeCategory, column.digitSlot, t)}
</span>
<span className="block text-[9px] font-medium text-[#9aa8bd]">
<span className="mt-0.5 block text-[9px] font-medium text-[#9aa8bd]">
{t("hall.table.amountPlaceholder")}
</span>
</th>
))}
<th className="w-7 px-1 py-2" aria-label={t("hall.table.delete")} />
<th className="w-9 min-w-9 px-0.5 py-2" aria-label={t("hall.table.delete")} />
</tr>
</thead>
<tbody>
{rows.map((row, index) => {
const rowKey = row.id;
const rowActive = activeRowId === row.id;
return (
<tr key={rowKey} className="border-b border-[#f0f3f8] last:border-b-0">
<td className="px-1.5 py-2 text-center font-black text-[#17408d]">
<tr
key={rowKey}
className={cn(
"border-b border-[#f0f3f8] last:border-b-0",
rowActive && "bg-[#f5f9ff]/80",
)}
>
<td
className={cn(
"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)]",
rowActive ? "bg-[#f5f9ff]" : "bg-white",
)}
>
{index + 1}
</td>
<td className="px-1.5 py-2">
<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}
@@ -1188,7 +1317,10 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
onFocus={() => setActiveRowId(row.id)}
onClick={() => setActiveRowId(row.id)}
onChange={(event) => updateRowNumber(row.id, event.target.value)}
className="h-8 rounded-md border-[#e1e8f3] bg-white px-1 text-center font-mono text-sm font-black tracking-[0.1em] text-slate-950 shadow-sm focus-visible:ring-[#1d57b7]"
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) => {
@@ -1227,7 +1359,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
onClick={() => setActiveRowId(row.id)}
onChange={(event) => updateAmount(row.id, column.key, event.target.value)}
className={cn(
"h-8 rounded-md border-[#e1e8f3] bg-white px-1 text-center text-xs font-bold tabular-nums shadow-sm focus-visible:ring-[#1d57b7]",
"h-8 w-full rounded-md border-[#e1e8f3] bg-white px-1 text-center text-xs 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",