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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user