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

@@ -19,6 +19,26 @@ export function isHallBlockedForBetting(status: string): boolean {
return status !== "open";
}
/** 已到计划开奖时刻、但调度尚未把期号推进到冷静期closed → drawing → cooldown */
export function isHallAwaitingDrawProcessing(
status: string,
drawTimeIso: string | null | undefined,
secondsToDraw: number | null | undefined,
nowMs: number,
): boolean {
if (!["closing", "closed", "drawing", "review"].includes(status)) {
return false;
}
if (secondsToDraw !== null && secondsToDraw !== undefined && secondsToDraw <= 0) {
return true;
}
if (!drawTimeIso) {
return false;
}
const drawMs = Date.parse(drawTimeIso);
return !Number.isNaN(drawMs) && drawMs <= nowMs;
}
/** 对齐界面文档 §4.2 状态文案与 PRD 期号状态 */
export function drawStatusHud(status: string): DrawStatusHud {
switch (status) {

View File

@@ -46,6 +46,57 @@ export function playNeedsDigitSlot(playCode: string): boolean {
return playCode === "digit_big" || playCode === "digit_small";
}
/** 与后端 {@link App\Services\Ticket\NumberNormalizer::normalizeRoll} 一致4 位且至少含一个 R。 */
export function isValidRollNumber(value: string): boolean {
const trimmed = value.trim().toUpperCase();
return (
trimmed.length === 4
&& /^[0-9R]+$/.test(trimmed)
&& trimmed.includes("R")
);
}
export type DraftLineIssueReason = "invalid_number_length" | "roll_requires_r" | "missing_digit_slot";
/**
* 某玩法列已填金额但无法组成合法注单行时返回原因;合法则返回 null。
*/
export function draftLineIssueReason(
playCode: string,
displayNumber: string,
digitSlot?: number,
): DraftLineIssueReason | null {
const normalized = normalizeNumberForPlay(displayNumber, playCode);
const spec = ticketNumberSpec(playCode);
if (normalized.length !== spec.maxChars) {
return "invalid_number_length";
}
if (playCode === "roll" && !isValidRollNumber(normalized)) {
return "roll_requires_r";
}
if (playNeedsDigitSlot(playCode) && digitSlot === undefined) {
return "missing_digit_slot";
}
return null;
}
function normalizeNumberForPlay(number: string, playCode: string): string {
if (playCode.startsWith("pos_2")) return number.slice(-2);
if (playCode.startsWith("pos_3")) return number.slice(-3);
if (
playCode === "head"
|| playCode === "tail"
|| playCode === "odd"
|| playCode === "even"
|| playCode === "digit_big"
|| playCode === "digit_small"
) {
return number.slice(-1);
}
return number.toUpperCase();
}
/** 产品文档iBox/Roll 单注金额mBox 总金额摊分 */
export function ticketAmountHint(
playCode: string,

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",

View File

@@ -1,12 +1,16 @@
"use client";
import { Hourglass, Landmark, TimerReset } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { drawStatusHud, isHallBlockedForBetting, isHallSealedCountdownUi } from "@/features/draw/draw-status-meta";
import {
drawStatusHud,
isHallAwaitingDrawProcessing,
isHallBlockedForBetting,
isHallSealedCountdownUi,
} from "@/features/draw/draw-status-meta";
import type { HallDrawLiveSnapshot } from "@/features/hall/use-hall-draw-live";
import { formatSecondsClock } from "@/lib/format-gmt";
import { formatLotteryInstant } from "@/lib/player-datetime";
@@ -39,47 +43,67 @@ function CurrentTime({ payload }: { payload: DrawCurrentPayload }) {
}
function CloseTime({
serverNowMs,
nowMs,
hud,
payload,
}: {
serverNowMs: number;
nowMs: number;
hud: ReturnType<typeof drawStatusHud>;
payload: DrawCurrentPayload;
}) {
const { t } = useTranslation("player");
const sealedCountdown = isHallSealedCountdownUi(payload.status);
const [elapsedSeconds, setElapsedSeconds] = useState(0);
useEffect(() => {
const intervalId = window.setInterval(() => {
setElapsedSeconds((current) => current + 1);
}, 1000);
return () => window.clearInterval(intervalId);
}, []);
const nowMs = serverNowMs + elapsedSeconds * 1000;
const awaitingDraw = isHallAwaitingDrawProcessing(
payload.status,
payload.draw_time,
payload.seconds_to_draw,
nowMs,
);
let seconds = 0;
let label = t("draw.closesIn");
let showClock = true;
if (hud.countdownKind === "none") {
if (awaitingDraw) {
label = t("draw.drawProcessing");
showClock = false;
} else if (hud.countdownKind === "none") {
label = t(hud.labelKey, { defaultValue: hud.labelKey });
showClock = false;
} else if (hud.countdownKind === "close") {
seconds = Math.max(0, Math.ceil(((payload.close_time ? Date.parse(payload.close_time) : 0) - nowMs) / 1000));
seconds =
payload.seconds_to_close != null
? Math.max(0, payload.seconds_to_close)
: Math.max(
0,
Math.ceil(((payload.close_time ? Date.parse(payload.close_time) : 0) - nowMs) / 1000),
);
} else if (hud.countdownKind === "draw") {
seconds = Math.max(0, Math.ceil(((payload.draw_time ? Date.parse(payload.draw_time) : 0) - nowMs) / 1000));
seconds =
payload.seconds_to_draw != null
? Math.max(0, payload.seconds_to_draw)
: Math.max(
0,
Math.ceil(((payload.draw_time ? Date.parse(payload.draw_time) : 0) - nowMs) / 1000),
);
label = sealedCountdown ? t("draw.drawsIn") : t("draw.closesIn");
} else if (hud.countdownKind === "cooldown") {
seconds = Math.max(0, Math.ceil(((payload.cooling_end_time ? Date.parse(payload.cooling_end_time) : 0) - nowMs) / 1000));
seconds =
payload.seconds_remaining_in_cooldown != null
? Math.max(0, payload.seconds_remaining_in_cooldown)
: Math.max(
0,
Math.ceil(
((payload.cooling_end_time ? Date.parse(payload.cooling_end_time) : 0) - nowMs) / 1000,
),
);
label = t("draw.coolDown");
}
return (
<>
<span className="text-lg font-black tabular-nums text-[#ff143d]">
{hud.countdownKind === "none" ? "--:--" : formatSecondsClock(seconds)}
{showClock ? formatSecondsClock(seconds) : "--:--"}
</span>
<span className="mt-1 text-[11px] text-slate-500">{label}</span>
</>
@@ -87,7 +111,7 @@ function CloseTime({
}
export function HallDrawPanel({ drawLive }: { drawLive: HallDrawLiveSnapshot }) {
const { raw, display, serverNowMs, error, reload } = drawLive;
const { raw, display, error, reload } = drawLive;
const { t } = useTranslation("player");
if (error) {
@@ -152,8 +176,8 @@ export function HallDrawPanel({ drawLive }: { drawLive: HallDrawLiveSnapshot })
</div>
<div className="relative flex min-w-0 flex-col items-center justify-center px-2 py-3 text-center">
<CloseTime
key={`${display.draw_no}-${display.status}-${serverNowMs}`}
serverNowMs={serverNowMs}
key={`${display.draw_no}-${display.status}-${display.seconds_to_draw ?? ""}-${display.seconds_remaining_in_cooldown ?? ""}`}
nowMs={drawLive.nowMs}
hud={hud}
payload={display}
/>

View File

@@ -3,7 +3,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { getDrawCurrent } from "@/api/draw";
import { isHallSealedCountdownUi } from "@/features/draw/draw-status-meta";
import { isHallAwaitingDrawProcessing, isHallSealedCountdownUi } from "@/features/draw/draw-status-meta";
import { getLotteryEcho } from "@/lib/lottery-echo";
import { useNetworkConnectionStore } from "@/stores/network-connection-store";
import type { DrawCurrentPayload, DrawCurrentResponse } from "@/types/api/draw-current";
@@ -13,6 +13,8 @@ export type HallDrawLiveSnapshot = {
raw: DrawCurrentPayload | null | undefined;
display: DrawCurrentPayload | null | undefined;
serverNowMs: number;
/** 与 display 漂移推演一致的本地「当前」毫秒时间戳 */
nowMs: number;
error: string | null;
reload: () => Promise<void>;
isBettable: boolean;
@@ -266,6 +268,13 @@ export function useHallDrawLive(): HallDrawLiveSnapshot {
((display.seconds_remaining_in_cooldown ?? 1) === 0 ||
(coolingEndMs !== null && !Number.isNaN(coolingEndMs) && coolingEndMs <= nowMs));
const awaitingDraw = isHallAwaitingDrawProcessing(
display.status,
display.draw_time,
display.seconds_to_draw,
nowMs,
);
const sealedDone =
isHallSealedCountdownUi(display.status) && (display.seconds_to_draw ?? 1) === 0;
@@ -274,11 +283,13 @@ export function useHallDrawLive(): HallDrawLiveSnapshot {
const trigger = coolingDone
? `${display.draw_no}:cooldown-end`
: sealedDone
? `${display.draw_no}:sealed-end`
: closeDone
? `${display.draw_no}:close-end`
: null;
: awaitingDraw
? `${display.draw_no}:awaiting-draw`
: sealedDone
? `${display.draw_no}:sealed-end`
: closeDone
? `${display.draw_no}:close-end`
: null;
if (trigger && zeroRefreshKeyRef.current !== trigger) {
zeroRefreshKeyRef.current = trigger;
@@ -292,13 +303,34 @@ export function useHallDrawLive(): HallDrawLiveSnapshot {
}
}, [display?.draw_no, display?.status]);
// WebSocket 已连接时的兜底轮询tick 最多 1 分钟延迟时的保险)
const needsFastDrawPoll =
display != null
&& isHallAwaitingDrawProcessing(
display.status,
display.draw_time,
display.seconds_to_draw,
nowMs,
);
// 封盘/待开奖/开奖中:调度推进状态前每 3 秒拉一次,避免 0:00 卡几十秒
useEffect(() => {
if (!needsFastDrawPoll) {
return;
}
const intervalId = window.setInterval(() => {
void load();
}, 45_000);
}, 3000);
return () => window.clearInterval(intervalId);
}, [load]);
}, [needsFastDrawPoll, load]);
return { raw, display, serverNowMs, error, reload: load, isBettable };
// WebSocket 已连接时的兜底轮询tick 延迟时的保险)
useEffect(() => {
const intervalMs = needsFastDrawPoll ? 15_000 : 45_000;
const intervalId = window.setInterval(() => {
void load();
}, intervalMs);
return () => window.clearInterval(intervalId);
}, [load, needsFastDrawPoll]);
return { raw, display, serverNowMs, nowMs, error, reload: load, isBettable };
}

View File

@@ -7,9 +7,15 @@ export function ticketStatusDisplay(
t?: (key: string, options?: { defaultValue?: string; status?: string }) => string,
): { label: string; dotClass: string; ring?: boolean } {
const total = winMinor + jackpotMinor;
if (status === "success" || status === "pending_draw") {
if (
status === "pending_draw" ||
status === "placed" ||
status === "partial_failed" ||
status === "pending_confirm" ||
status === "partial_pending_confirm"
) {
return {
label: t?.(status === "pending_draw" ? "ticketStatus.pending_draw" : "ticketStatus.success") ?? status,
label: t?.("ticketStatus.pending_draw") ?? status,
dotClass: "bg-sky-500",
};
}

View File

@@ -30,7 +30,7 @@ import { cn } from "@/lib/utils";
import type { TicketItemListRow } from "@/types/api/ticket-items";
const ORDERS_PAGE_SIZE = 20;
const STATUS_OPTIONS = ["pending_draw", "success", "settled_win", "settled_lose", "failed"] as const;
const STATUS_OPTIONS = ["pending_draw", "pending_payout", "settled_win", "settled_lose", "failed"] as const;
function parseYmd(value: string): Date | undefined {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);

View File

@@ -75,6 +75,7 @@
"currentTime": "Current Time",
"closesIn": "Closes In",
"drawsIn": "Draws In",
"drawProcessing": "Drawing…",
"coolDown": "Cool Down",
"loadFailedRefresh": "Failed to load. Pull down to refresh.",
"issueNo": "Issue No.",
@@ -103,6 +104,11 @@
"notBettable": "This issue is closed or cannot accept bets.",
"catalogNotReady": "Play configuration is still loading.",
"emptyLines": "Please enter at least one valid number and stake amount.",
"lineIssue": {
"invalid_number_length": "Row {{row}} «{{play}}»: invalid number length. Check the number field.",
"roll_requires_r": "Row {{row}} «{{play}}»: use 4 characters with at least one R (e.g. 12R4, RR34). R marks rolling digits.",
"missing_digit_slot": "Row {{row}} «{{play}}»: missing digit slot. Refresh and try again."
},
"previewFailed": "Preview failed",
"closedSubmit": "Closed. Cannot submit.",
"changedBeforeSubmit": "Your draft changed before submission. Close the preview and try again.",
@@ -177,6 +183,10 @@
"submitBet": "Submit Bet",
"insufficientBalance": "Insufficient balance",
"amountPlaceholder": "Amount",
"digitShort": {
"big": "B",
"small": "S"
},
"filledPlayCount": "{{count}} plays filled",
"tapToFill": "Tap to enter amounts",
"rowActual": "Actual",

View File

@@ -75,6 +75,7 @@
"currentTime": "हालको समय",
"closesIn": "बन्द हुन बाँकी",
"drawsIn": "ड्र हुन बाँकी",
"drawProcessing": "ड्रअ प्रक्रियामा",
"coolDown": "कुल डाउन",
"loadFailedRefresh": "लोड असफल भयो। तल तानेर रिफ्रेस गर्नुहोस्।",
"issueNo": "इश्यू नं.",
@@ -103,6 +104,11 @@
"notBettable": "यो इश्यू बन्द छ वा बेट स्वीकार गर्न सक्दैन।",
"catalogNotReady": "प्ले कन्फिगरेसन अझै लोड हुँदैछ।",
"emptyLines": "कृपया कम्तीमा एक मान्य नम्बर र रकम प्रविष्ट गर्नुहोस्।",
"lineIssue": {
"invalid_number_length": "पङ्क्ति {{row}} «{{play}}»: नम्बरको लम्बाइ मिलेन। नम्बर क्षेत्र जाँच गर्नुहोस्।",
"roll_requires_r": "पङ्क्ति {{row}} «{{play}}»: 4 अक्षर र कम्तीमा एक R चाहिन्छ (जस्तै 12R4, RR34)। R = घुम्ने अंक।",
"missing_digit_slot": "पङ्क्ति {{row}} «{{play}}»: अंक स्थान छुट्यो। रिफ्रेस गरी पुनः प्रयास गर्नुहोस्।"
},
"previewFailed": "पूर्वावलोकन असफल",
"closedSubmit": "बन्द भयो। पेश गर्न सकिँदैन।",
"changedBeforeSubmit": "पेश गर्नु अघि ड्राफ्ट परिवर्तन भयो। पूर्वावलोकन बन्द गरी फेरि प्रयास गर्नुहोस्।",
@@ -177,6 +183,10 @@
"submitBet": "बेट पेश गर्नुहोस्",
"insufficientBalance": "ब्यालेन्स अपुग",
"amountPlaceholder": "रकम",
"digitShort": {
"big": "ठू",
"small": "सा"
},
"filledPlayCount": "{{count}} प्ले भरियो",
"tapToFill": "रकम लेख्न ट्याप गर्नुहोस्",
"rowActual": "वास्तविक",

View File

@@ -75,6 +75,7 @@
"currentTime": "当前时间",
"closesIn": "距封盘",
"drawsIn": "距开奖",
"drawProcessing": "开奖处理中",
"coolDown": "冷静期",
"loadFailedRefresh": "加载失败,请下拉刷新",
"issueNo": "期号",
@@ -103,6 +104,11 @@
"notBettable": "当前已封盘或不可下注。",
"catalogNotReady": "玩法配置尚未加载完成。",
"emptyLines": "请至少填写一组有效号码和下注金额。",
"lineIssue": {
"invalid_number_length": "第 {{row}} 行「{{play}}」号码位数不正确,请检查号码列。",
"roll_requires_r": "第 {{row}} 行「{{play}}」须为 4 位且含 R如 12R4、RR34R 表示滚动位。",
"missing_digit_slot": "第 {{row}} 行「{{play}}」缺少位数,请刷新后重试。"
},
"previewFailed": "预览失败",
"closedSubmit": "已封盘,无法提交。",
"changedBeforeSubmit": "提交前数据已变化,请关闭预览后重试。",
@@ -177,6 +183,10 @@
"submitBet": "提交下注",
"insufficientBalance": "余额不足",
"amountPlaceholder": "金额",
"digitShort": {
"big": "大",
"small": "小"
},
"filledPlayCount": "已填写 {{count}} 个玩法",
"tapToFill": "点击填写玩法金额",
"rowActual": "实扣",