1833 lines
64 KiB
TypeScript
1833 lines
64 KiB
TypeScript
"use client";
|
|
|
|
import { Lock, Ticket, Trash2 } from "lucide-react";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { toast } from "sonner";
|
|
|
|
import { getBetProviders } from "@/api/bet-providers";
|
|
import { getPlayEffective } from "@/api/play";
|
|
import { getWalletBalance } from "@/api/wallet";
|
|
import { postTicketPlace, postTicketPreview } from "@/api/ticket";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { isHallSealedCountdownUi } from "@/features/draw/draw-status-meta";
|
|
import { useIsMobile } from "@/hooks/use-mobile";
|
|
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 { HallBettingTable } from "@/features/hall/hall-betting-table";
|
|
import { HallMobileQuickFill } from "@/features/hall/hall-mobile-quick-fill";
|
|
import { HallPlaySummaryGrid } from "@/features/hall/hall-play-summary-grid";
|
|
import { HallSelectionConfirmDialog } from "@/features/hall/hall-selection-confirm-dialog";
|
|
import {
|
|
FALLBACK_BET_PROVIDERS,
|
|
cellRiskState,
|
|
digitSlotOptions,
|
|
numberMaxCharsForCategory,
|
|
playCategory,
|
|
playColumnHeaderLabel,
|
|
sanitizeNumber,
|
|
type DraftRow,
|
|
type HallCategory,
|
|
type PlayColumn,
|
|
type PlayHallCategory,
|
|
} from "@/features/hall/hall-betting-grid-model";
|
|
import {
|
|
HallDesktopReviewPanel,
|
|
HallDesktopWorkflowBar,
|
|
type HallDesktopReviewModel,
|
|
} from "@/features/hall/hall-desktop-workspace";
|
|
import {
|
|
draftLineIssueReason,
|
|
playNeedsDigitSlot,
|
|
playNeedsDimension,
|
|
ticketNumberSpec,
|
|
type DraftLineIssueReason,
|
|
} from "@/features/hall/hall-bet-rules";
|
|
import {
|
|
isFullCoverAmountDivisible,
|
|
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 { useAsyncEffect } from "@/hooks/use-async-effect";
|
|
import { triggerWalletPollingAfterBet } from "@/hooks/use-wallet-polling";
|
|
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
|
import { getLotteryEcho } from "@/lib/lottery-echo";
|
|
import {
|
|
formatMinorAmount,
|
|
formatMinorAsCurrency,
|
|
parseDecimalInputToMinor,
|
|
} from "@/lib/money";
|
|
import { playLabel } from "@/lib/play-labels";
|
|
import { playerViewportFixedBarClass } from "@/lib/player-viewport";
|
|
import {
|
|
PLAY_CATALOG_REFRESH_EVENT,
|
|
type PlayCatalogRefreshSource,
|
|
} from "@/lib/play-catalog-events";
|
|
|
|
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
|
|
import { cn } from "@/lib/utils";
|
|
import { LotteryApiBizError } from "@/types/api/errors";
|
|
import type { PlayEffectivePayload, PlayEffectivePlayRow } from "@/types/api/play-effective";
|
|
import type { TicketLineInput, TicketPlaceData, TicketPreviewData } from "@/types/api/ticket";
|
|
import type { BetProviderRow } from "@/types/api/bet-provider";
|
|
|
|
const TRADITIONAL_PLAY_CODES: Record<PlayHallCategory, readonly string[]> = {
|
|
D4: ["big", "small", "pos_4a", "pos_4b", "pos_4c", "pos_4d", "pos_4e", "four_any", "four_top", "four_lower", "five_d", "six_d"],
|
|
D3: ["pos_3a", "pos_3lower", "pos_3b", "pos_3c", "pos_3d", "pos_3e"],
|
|
D2: ["pos_2a", "pos_2b", "pos_2c", "pos_2d", "pos_2e", "pos_2any"],
|
|
};
|
|
|
|
type PendingSelectionChange =
|
|
| { mode: "row"; rowId: string; next: SelectionType; prev: SelectionType }
|
|
| { mode: "all"; next: SelectionType };
|
|
|
|
type DraftEntry = {
|
|
rowId: string;
|
|
rowNo: number;
|
|
amountKey: string;
|
|
play: PlayEffectivePlayRow;
|
|
digitSlot?: number;
|
|
number: string;
|
|
amountMinor: number;
|
|
line: TicketLineInput;
|
|
};
|
|
|
|
type DraftLineIssue = {
|
|
rowNo: number;
|
|
playCode: string;
|
|
reason: DraftLineIssueReason;
|
|
};
|
|
|
|
type ClosedPlayCleanupData = {
|
|
cleanup_hint?: string;
|
|
cleanup_lines?: Array<{ client_line_no?: number; play_code?: string }>;
|
|
};
|
|
|
|
type TicketPreviewSubmissionSnapshot = {
|
|
drawId: string;
|
|
currencyCode: string;
|
|
clientTraceId: string;
|
|
lines: TicketLineInput[];
|
|
expectedConfigVersions: TicketPreviewData["config_versions"];
|
|
};
|
|
|
|
type PlayToggleWsEvent = {
|
|
play_code?: string;
|
|
enabled?: boolean;
|
|
action?: string;
|
|
};
|
|
|
|
type OddsUpdateWsEvent = {
|
|
message?: string;
|
|
};
|
|
|
|
type RiskSoldOutWsEvent = {
|
|
draw_id?: number;
|
|
draw_no?: string;
|
|
normalized_number?: string;
|
|
};
|
|
|
|
type RiskWarningWsEvent = {
|
|
draw_id?: number;
|
|
draw_no?: string;
|
|
normalized_number?: string;
|
|
usage_ratio?: number;
|
|
usage_percent?: number;
|
|
};
|
|
|
|
type QuickFillState = Record<HallCategory, { favorites: string[]; history: string[] }>;
|
|
const DEFAULT_PROVIDER_CODE = "SG";
|
|
|
|
const categoryTabs: { value: PlayHallCategory; label: string }[] = [
|
|
{ value: "D4", label: "4D" },
|
|
{ value: "D3", label: "3D" },
|
|
{ value: "D2", label: "2D" },
|
|
];
|
|
|
|
const D2_PLAY_ORDER = ["pos_2a", "pos_2b", "pos_2c", "pos_2abc"] as const;
|
|
const D3_PLAY_ORDER = ["pos_3a", "pos_3b", "pos_3c", "pos_3abc"] as const;
|
|
const D4_PLAY_ORDER = [
|
|
"big",
|
|
"small",
|
|
"pos_4a",
|
|
"pos_4b",
|
|
"pos_4c",
|
|
"pos_4d",
|
|
"pos_4e",
|
|
"box",
|
|
"ibox",
|
|
"mbox",
|
|
"roll",
|
|
"straight",
|
|
"head",
|
|
"tail",
|
|
"odd",
|
|
"even",
|
|
"digit_big",
|
|
"digit_small",
|
|
] as const;
|
|
/** 按大厅 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 SUMMARY_PLAY_ORDER = CATEGORY_ORDER.flatMap(
|
|
(category) => PLAY_ORDER_BY_CATEGORY[category],
|
|
);
|
|
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);
|
|
}
|
|
|
|
function newDraftRow(): DraftRow {
|
|
const id =
|
|
typeof crypto !== "undefined" && crypto.randomUUID
|
|
? crypto.randomUUID()
|
|
: `row-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
return { id, number: "", amounts: {}, providerCodes: [DEFAULT_PROVIDER_CODE], selectionType: "straight" };
|
|
}
|
|
|
|
function isPlayOpenForPlayer(row: PlayEffectivePlayRow): boolean {
|
|
return Boolean(row.master_enabled && row.config?.is_enabled);
|
|
}
|
|
|
|
function amountKeyForPlay(playCode: string, digitSlot?: number): string {
|
|
return digitSlot === undefined ? playCode : `${playCode}@${digitSlot}`;
|
|
}
|
|
|
|
function playColumnsForCategory(
|
|
plays: PlayEffectivePlayRow[],
|
|
category: PlayHallCategory,
|
|
): PlayColumn[] {
|
|
return plays.flatMap((play) => {
|
|
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,
|
|
}));
|
|
});
|
|
}
|
|
|
|
function sanitizeAmount(raw: string): string {
|
|
return raw.replace(/[^\d.]/g, "").replace(/(\..*)\./g, "$1").slice(0, 12);
|
|
}
|
|
|
|
function parseRebateRate(rate: string | undefined): number {
|
|
const n = Number(rate ?? 0);
|
|
if (!Number.isFinite(n) || n <= 0) return 0;
|
|
return n > 1 ? n / 100 : n;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function lineForPlay(
|
|
play: PlayEffectivePlayRow,
|
|
displayNumber: string,
|
|
amountMinor: number,
|
|
digitSlot?: number,
|
|
selectionType: SelectionType = "straight",
|
|
): 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;
|
|
}
|
|
|
|
const line: TicketLineInput = {
|
|
number,
|
|
play_code: play.play_code,
|
|
amount: amountMinor,
|
|
selection_type: selectionType,
|
|
};
|
|
|
|
if (playNeedsDimension(play.play_code)) {
|
|
line.dimension = playCategory(play.play_code);
|
|
}
|
|
if (playNeedsDigitSlot(play.play_code)) {
|
|
if (digitSlot === undefined) return null;
|
|
line.digit_slot = digitSlot;
|
|
}
|
|
|
|
return line;
|
|
}
|
|
|
|
function sortByPlayOrder(plays: PlayEffectivePlayRow[], order: readonly string[]): PlayEffectivePlayRow[] {
|
|
const orderMap = new Map(order.map((code, idx) => [code, idx]));
|
|
return [...plays].sort((a, b) => {
|
|
const ai = orderMap.get(a.play_code) ?? 999;
|
|
const bi = orderMap.get(b.play_code) ?? 999;
|
|
return ai - bi || a.sort_order - b.sort_order || a.play_code.localeCompare(b.play_code);
|
|
});
|
|
}
|
|
|
|
function loadStringArray(key: string): string[] {
|
|
if (typeof window === "undefined") return [];
|
|
try {
|
|
const raw = window.localStorage.getItem(key);
|
|
if (!raw) return [];
|
|
const parsed = JSON.parse(raw);
|
|
if (!Array.isArray(parsed)) return [];
|
|
return parsed.filter((v): v is string => typeof v === "string");
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function saveStringArray(key: string, values: string[]): void {
|
|
if (typeof window === "undefined") return;
|
|
window.localStorage.setItem(key, JSON.stringify(values));
|
|
}
|
|
|
|
function loadQuickFillState(): QuickFillState {
|
|
return {
|
|
D2: {
|
|
favorites: loadStringArray(quickFillKeys("D2").favorites),
|
|
history: loadStringArray(quickFillKeys("D2").history),
|
|
},
|
|
D3: {
|
|
favorites: loadStringArray(quickFillKeys("D3").favorites),
|
|
history: loadStringArray(quickFillKeys("D3").history),
|
|
},
|
|
D4: {
|
|
favorites: loadStringArray(quickFillKeys("D4").favorites),
|
|
history: loadStringArray(quickFillKeys("D4").history),
|
|
},
|
|
JACKPOT: {
|
|
favorites: [],
|
|
history: [],
|
|
},
|
|
};
|
|
}
|
|
|
|
function appendUnique(values: string[], value: string, limit = 20): string[] {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return values;
|
|
const next = [trimmed, ...values.filter((v) => v !== trimmed)];
|
|
return next.slice(0, limit);
|
|
}
|
|
|
|
function quickFillKeys(category: HallCategory): { favorites: string; history: string } {
|
|
return {
|
|
favorites: `lottery.hall.quickfill.favorites.${category}`,
|
|
history: `lottery.hall.quickfill.history.${category}`,
|
|
};
|
|
}
|
|
|
|
export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }) {
|
|
const { display, isBettable, reload: reloadDraw } = drawLive;
|
|
const { t } = useTranslation("player");
|
|
const { activeCurrency: currencyParam } = useActivePlayerCurrency();
|
|
const creditMode = isCreditFundingPlayer(usePlayerSessionStore((state) => state.profile));
|
|
const isMobile = useIsMobile();
|
|
|
|
const [rows, setRows] = useState<DraftRow[]>(() => newDraftRows());
|
|
const [activeRowId, setActiveRowId] = useState<string | null>(null);
|
|
const [catalogState, setCatalogState] = useState<
|
|
| { kind: "loading" }
|
|
| { kind: "ok"; data: PlayEffectivePayload }
|
|
| { kind: "error"; message: string }
|
|
>({ kind: "loading" });
|
|
const [availableMinor, setAvailableMinor] = useState<number>(0);
|
|
const [previewOpen, setPreviewOpen] = useState(false);
|
|
const [previewData, setPreviewData] = useState<TicketPreviewData | null>(null);
|
|
const [previewLoading, setPreviewLoading] = useState(false);
|
|
const [placeLoading, setPlaceLoading] = useState(false);
|
|
const [resultOpen, setResultOpen] = useState(false);
|
|
const [resultData, setResultData] = useState<TicketPlaceData | null>(null);
|
|
const [activeCategory, setActiveCategory] = useState<PlayHallCategory>("D4");
|
|
const [betProviders, setBetProviders] = useState<BetProviderRow[]>(FALLBACK_BET_PROVIDERS);
|
|
const [syncAmountColumns, setSyncAmountColumns] = useState<Record<string, boolean>>({});
|
|
const [quickFillState, setQuickFillState] = useState<QuickFillState>(() => loadQuickFillState());
|
|
const [quickFillExpanded, setQuickFillExpanded] = useState(false);
|
|
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,
|
|
);
|
|
/** 单次预览→确认共用,重试 place 复用,避免重复扣款 */
|
|
const placeTraceIdRef = useRef<string | null>(null);
|
|
const previewRequestSeqRef = useRef(0);
|
|
const previewSubmissionRef = useRef<TicketPreviewSubmissionSnapshot | null>(null);
|
|
const newPlaceTraceId = (): string =>
|
|
typeof crypto !== "undefined" && crypto.randomUUID
|
|
? crypto.randomUUID()
|
|
: `pl-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
|
|
const clearPlaceTraceId = useCallback(() => {
|
|
placeTraceIdRef.current = null;
|
|
}, []);
|
|
const invalidatePreview = useCallback(() => {
|
|
previewRequestSeqRef.current += 1;
|
|
previewSubmissionRef.current = null;
|
|
setPreviewOpen(false);
|
|
setPreviewData(null);
|
|
clearPlaceTraceId();
|
|
}, [clearPlaceTraceId]);
|
|
const catalogSeqRef = useRef(0);
|
|
const walletSeqRef = useRef(0);
|
|
const selectedCatalogProviderCode =
|
|
betProviders.find((provider) => provider.is_default)?.code ??
|
|
betProviders[0]?.code;
|
|
|
|
const loadCatalog = useCallback(async () => {
|
|
const seq = ++catalogSeqRef.current;
|
|
setCatalogState({ kind: "loading" });
|
|
try {
|
|
const data = await getPlayEffective({
|
|
currency: currencyParam,
|
|
provider_code: selectedCatalogProviderCode,
|
|
});
|
|
if (seq !== catalogSeqRef.current) return;
|
|
setCatalogState({ kind: "ok", data });
|
|
} catch (e) {
|
|
if (seq !== catalogSeqRef.current) return;
|
|
const msg = e instanceof LotteryApiBizError ? e.message : t("hall.loadingError");
|
|
setCatalogState({ kind: "error", message: msg });
|
|
}
|
|
}, [currencyParam, selectedCatalogProviderCode, t]);
|
|
|
|
const refreshWallet = useCallback(async () => {
|
|
const seq = ++walletSeqRef.current;
|
|
try {
|
|
const wallet = await getWalletBalance({ currency: currencyParam });
|
|
if (seq !== walletSeqRef.current) return;
|
|
setAvailableMinor(Number(wallet.available_balance ?? 0));
|
|
} catch {
|
|
// 保留上次可用余额,避免短暂失败导致误报余额不足
|
|
}
|
|
}, [currencyParam]);
|
|
|
|
useAsyncEffect(() => {
|
|
void loadCatalog();
|
|
void refreshWallet();
|
|
}, [loadCatalog, refreshWallet]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
void getBetProviders()
|
|
.then((data) => {
|
|
if (cancelled) return;
|
|
const items = data.items.length > 0 ? data.items : FALLBACK_BET_PROVIDERS;
|
|
setBetProviders(items);
|
|
setRows((current) => {
|
|
const available = new Set(items.map((item) => item.code));
|
|
const fallback = items.find((item) => item.is_default)?.code ?? items[0]?.code;
|
|
return current.map((row) => {
|
|
const selected = row.providerCodes.filter((code) => available.has(code));
|
|
const wasInitialDefault = row.providerCodes.length === 1 && row.providerCodes[0] === DEFAULT_PROVIDER_CODE;
|
|
return selected.length === 0 && wasInitialDefault && fallback
|
|
? { ...row, providerCodes: [fallback] }
|
|
: { ...row, providerCodes: selected };
|
|
});
|
|
});
|
|
})
|
|
.catch(() => {
|
|
if (cancelled) return;
|
|
setBetProviders(FALLBACK_BET_PROVIDERS);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const onCatalogRefresh = (ev: Event) => {
|
|
void loadCatalog();
|
|
const source = (ev as CustomEvent<{ source?: PlayCatalogRefreshSource }>).detail
|
|
?.source;
|
|
if (source !== undefined) {
|
|
invalidatePreview();
|
|
toast.message(t("hall.playConfig.updated"));
|
|
}
|
|
};
|
|
window.addEventListener(PLAY_CATALOG_REFRESH_EVENT, onCatalogRefresh);
|
|
return () => window.removeEventListener(PLAY_CATALOG_REFRESH_EVENT, onCatalogRefresh);
|
|
}, [invalidatePreview, loadCatalog, t]);
|
|
|
|
const openPlays = useMemo(() => {
|
|
if (catalogState.kind !== "ok") return [];
|
|
// 投注表格列以当前 Tab 玩法为先;顶部汇总另用稳定的全玩法顺序。
|
|
const order = playOrderForActiveCategory(activeCategory);
|
|
const orderSet = new Set(order);
|
|
return sortByPlayOrder(
|
|
catalogState.data.plays
|
|
.filter(isPlayOpenForPlayer)
|
|
.filter((p) => orderSet.has(p.play_code)),
|
|
order,
|
|
);
|
|
}, [activeCategory, catalogState]);
|
|
|
|
const activeCategoryPlays = useMemo(
|
|
() => openPlays.filter((play) => TRADITIONAL_PLAY_CODES[activeCategory].includes(play.play_code)),
|
|
[activeCategory, openPlays],
|
|
);
|
|
const availableCategories = useMemo(
|
|
() => new Set(openPlays.map((play) => playCategory(play.play_code))),
|
|
[openPlays],
|
|
);
|
|
|
|
const currencyCode =
|
|
catalogState.kind === "ok" ? catalogState.data.currency_code : currencyParam;
|
|
|
|
const allPlayColumns = useMemo<PlayColumn[]>(() => {
|
|
return openPlays.flatMap((play) => {
|
|
const category = playCategory(play.play_code);
|
|
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 summaryPlayColumns = useMemo<PlayColumn[]>(() => {
|
|
return sortByPlayOrder(openPlays, SUMMARY_PLAY_ORDER).flatMap((play) => {
|
|
const category = playCategory(play.play_code);
|
|
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(() => {
|
|
return playColumnsForCategory(activeCategoryPlays, activeCategory);
|
|
}, [activeCategory, activeCategoryPlays]);
|
|
|
|
const mobileIndexColClass = "w-[1.75rem] min-w-[1.75rem]";
|
|
const mobileNumberColClass = "w-[4.25rem] min-w-[4.25rem]";
|
|
const mobileSelectionTypeColClass = "w-[4rem] min-w-[4rem]";
|
|
const desktopIndexColClass = "w-9 min-w-9";
|
|
const desktopNumberColClass = "w-[5.25rem] min-w-[5.25rem]";
|
|
const desktopSelectionTypeColClass = "w-[4.5rem] min-w-[4.5rem]";
|
|
const indexColClass = isMobile ? mobileIndexColClass : desktopIndexColClass;
|
|
const numberColClass = isMobile ? mobileNumberColClass : desktopNumberColClass;
|
|
const selectionTypeColClass = isMobile ? mobileSelectionTypeColClass : desktopSelectionTypeColClass;
|
|
const showSelectionTypeColumn = activeCategory !== "D2";
|
|
const stickyNumberLeftClass = isMobile ? "left-[1.75rem]" : "left-9";
|
|
const amountColClass = !isMobile
|
|
? "w-14 min-w-14"
|
|
: "w-[4rem] min-w-[4rem]";
|
|
const providerColClass = !isMobile
|
|
? "w-11 min-w-11"
|
|
: "w-[2.7rem] min-w-[2.7rem]";
|
|
const rowTotalColClass = !isMobile
|
|
? "w-20 min-w-20"
|
|
: "w-[5rem] min-w-[5rem]";
|
|
|
|
const tableWidthPx = useMemo(() => {
|
|
const indexCol = !isMobile ? 36 : 28;
|
|
const numberCol = !isMobile ? 84 : 68;
|
|
const selectionTypeCol = !isMobile ? 72 : 64;
|
|
const providerCol = !isMobile ? 44 : 43;
|
|
const rowTotalCol = !isMobile ? 80 : 80;
|
|
const amountCol = !isMobile ? 56 : 64;
|
|
return indexCol + numberCol + (showSelectionTypeColumn ? selectionTypeCol : 0) + betProviders.length * providerCol + rowTotalCol + playColumns.length * amountCol;
|
|
}, [betProviders.length, isMobile, playColumns.length, showSelectionTypeColumn]);
|
|
|
|
const showWideTableHint = isMobile && playColumns.length > 8;
|
|
|
|
const activeRow = useMemo(
|
|
() => rows.find((row) => row.id === activeRowId) ?? rows[0] ?? null,
|
|
[activeRowId, rows],
|
|
);
|
|
|
|
const drawNo = display?.draw_no ?? null;
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
queueMicrotask(() => {
|
|
if (cancelled) return;
|
|
setLiveSoldOutNumbers(new Set());
|
|
setLiveWarningNumbers(new Set());
|
|
invalidatePreview();
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [currencyCode, drawNo, invalidatePreview]);
|
|
|
|
const alertRows = useMemo(
|
|
() => display?.risk_pool_alerts ?? [],
|
|
[display?.risk_pool_alerts],
|
|
);
|
|
const jackpot = display?.jackpot;
|
|
const currentQuickFill = quickFillState[activeCategory] ?? { favorites: [], history: [] };
|
|
const favorites = currentQuickFill.favorites;
|
|
const historyNumbers = currentQuickFill.history;
|
|
const tableDisabled = !isBettable || catalogState.kind !== "ok";
|
|
const sealedBetUi = Boolean(display && isHallSealedCountdownUi(display.status));
|
|
const numberPlaceholder =
|
|
activeCategory === "D2" ? "00" : activeCategory === "D3" ? "000" : "0000";
|
|
const numberMaxChars = numberMaxCharsForCategory(activeCategory);
|
|
|
|
const updateRowNumber = useCallback((id: string, value: string) => {
|
|
setRows((current) =>
|
|
current.map((row) =>
|
|
row.id === id ? { ...row, number: sanitizeNumber(value, activeCategory) } : row,
|
|
),
|
|
);
|
|
setActiveRowId(id);
|
|
}, [activeCategory]);
|
|
|
|
const applyRowSelectionType = useCallback((id: string, selectionType: SelectionType) => {
|
|
setRows((current) => current.map((row) => (row.id === id ? { ...row, selectionType } : row)));
|
|
}, []);
|
|
|
|
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);
|
|
const syncColumn = syncAmountColumns[playCode] === true && column !== undefined;
|
|
|
|
setRows((current) => current.map((row) => {
|
|
if (!syncColumn) {
|
|
return row.id === rowId
|
|
? { ...row, amounts: { ...row.amounts, [playCode]: amount } }
|
|
: row;
|
|
}
|
|
|
|
const validNumber = draftLineIssueReason(column.play.play_code, row.number, column.digitSlot) === null;
|
|
const status = cellRiskState(
|
|
column.play,
|
|
row.number,
|
|
playCategory(column.play.play_code),
|
|
alertRows,
|
|
liveSoldOutNumbers,
|
|
liveWarningNumbers,
|
|
column.digitSlot,
|
|
);
|
|
if (!validNumber || status === "sold_out" || (column.play.config !== null && !column.play.config.is_enabled)) {
|
|
return row;
|
|
}
|
|
|
|
return { ...row, amounts: { ...row.amounts, [playCode]: amount } };
|
|
}));
|
|
setActiveRowId(rowId);
|
|
}, [alertRows, allPlayColumns, liveSoldOutNumbers, liveWarningNumbers, syncAmountColumns]);
|
|
|
|
const toggleSyncAmountColumn = useCallback((column: PlayColumn, checked: boolean) => {
|
|
setSyncAmountColumns((current) => ({
|
|
...current,
|
|
[column.key]: checked,
|
|
}));
|
|
|
|
setRows((current) => {
|
|
if (!checked) {
|
|
return current.map((row) => ({
|
|
...row,
|
|
amounts: { ...row.amounts, [column.key]: "" },
|
|
}));
|
|
}
|
|
|
|
const sourceAmount = current.find((row) => {
|
|
const amount = row.amounts[column.key];
|
|
return Boolean(
|
|
amount &&
|
|
draftLineIssueReason(
|
|
column.play.play_code,
|
|
row.number,
|
|
column.digitSlot,
|
|
) === null,
|
|
);
|
|
})?.amounts[column.key];
|
|
|
|
if (!sourceAmount) return current;
|
|
|
|
return current.map((row) => {
|
|
const validNumber =
|
|
draftLineIssueReason(
|
|
column.play.play_code,
|
|
row.number,
|
|
column.digitSlot,
|
|
) === null;
|
|
const status = cellRiskState(
|
|
column.play,
|
|
row.number,
|
|
playCategory(column.play.play_code),
|
|
alertRows,
|
|
liveSoldOutNumbers,
|
|
liveWarningNumbers,
|
|
column.digitSlot,
|
|
);
|
|
if (
|
|
!validNumber ||
|
|
status === "sold_out" ||
|
|
(column.play.config !== null && !column.play.config.is_enabled)
|
|
) {
|
|
return row;
|
|
}
|
|
|
|
return {
|
|
...row,
|
|
amounts: { ...row.amounts, [column.key]: sourceAmount },
|
|
};
|
|
});
|
|
});
|
|
}, [alertRows, liveSoldOutNumbers, liveWarningNumbers]);
|
|
|
|
const clearAllRows = () => {
|
|
if (tableDisabled) return;
|
|
setRows((current) =>
|
|
current.map((row) => ({
|
|
...row,
|
|
number: "",
|
|
amounts: {},
|
|
})),
|
|
);
|
|
setActiveRowId((current) => current ?? rows[0]?.id ?? null);
|
|
};
|
|
|
|
const clearActiveRowAmounts = useCallback(() => {
|
|
const targetId = activeRowId ?? rows[0]?.id;
|
|
if (!targetId || tableDisabled) return;
|
|
setRows((current) =>
|
|
current.map((row) => {
|
|
if (row.id !== targetId) return row;
|
|
const nextAmounts = { ...row.amounts };
|
|
playColumns.forEach((column) => {
|
|
nextAmounts[column.key] = "";
|
|
});
|
|
return { ...row, amounts: nextAmounts };
|
|
}),
|
|
);
|
|
}, [activeRowId, playColumns, rows, tableDisabled]);
|
|
|
|
const fillCurrentRow = (number: string) => {
|
|
if (tableDisabled) return;
|
|
const targetId = activeRowId ?? rows[0]?.id;
|
|
if (!targetId) return;
|
|
updateRowNumber(targetId, number);
|
|
};
|
|
|
|
const applyQuickAmountToActiveRow = useCallback(
|
|
(amount: string) => {
|
|
const targetId = activeRowId ?? rows[0]?.id;
|
|
if (!targetId || tableDisabled) return;
|
|
setRows((current) =>
|
|
current.map((row) => {
|
|
if (row.id !== targetId) return row;
|
|
if (
|
|
row.number.trim().length === 0 ||
|
|
playColumns.every(
|
|
(column) =>
|
|
draftLineIssueReason(column.play.play_code, row.number, column.digitSlot) !== null,
|
|
)
|
|
) {
|
|
return row;
|
|
}
|
|
|
|
const nextAmounts = { ...row.amounts };
|
|
playColumns.forEach((column) => {
|
|
const isInputValid =
|
|
draftLineIssueReason(column.play.play_code, row.number, column.digitSlot) === null;
|
|
const status = cellRiskState(
|
|
column.play,
|
|
row.number,
|
|
playCategory(column.play.play_code),
|
|
alertRows,
|
|
liveSoldOutNumbers,
|
|
liveWarningNumbers,
|
|
column.digitSlot,
|
|
);
|
|
if (!isInputValid || status === "sold_out") return;
|
|
nextAmounts[column.key] = amount;
|
|
});
|
|
return { ...row, amounts: nextAmounts };
|
|
}),
|
|
);
|
|
},
|
|
[
|
|
activeRowId,
|
|
alertRows,
|
|
liveSoldOutNumbers,
|
|
liveWarningNumbers,
|
|
playColumns,
|
|
rows,
|
|
tableDisabled,
|
|
],
|
|
);
|
|
|
|
const copyPreviousRowToActiveRow = useCallback(() => {
|
|
const targetId = activeRowId ?? rows[0]?.id;
|
|
if (!targetId || tableDisabled) return;
|
|
const targetIndex = rows.findIndex((row) => row.id === targetId);
|
|
if (targetIndex <= 0) return;
|
|
const previousRow = rows[targetIndex - 1];
|
|
if (!previousRow) return;
|
|
|
|
setRows((current) =>
|
|
current.map((row, index) => {
|
|
if (index !== targetIndex) return row;
|
|
const nextAmounts = { ...row.amounts };
|
|
playColumns.forEach((column) => {
|
|
nextAmounts[column.key] = previousRow.amounts[column.key] ?? "";
|
|
});
|
|
return {
|
|
...row,
|
|
number: previousRow.number,
|
|
amounts: nextAmounts,
|
|
providerCodes: previousRow.providerCodes,
|
|
};
|
|
}),
|
|
);
|
|
}, [activeRowId, playColumns, rows, tableDisabled]);
|
|
|
|
const toggleFavoriteNumber = (number: string) => {
|
|
const keys = quickFillKeys(activeCategory);
|
|
setQuickFillState((current) => {
|
|
const currentFavorites = current[activeCategory]?.favorites ?? [];
|
|
const exists = currentFavorites.includes(number);
|
|
const next = exists
|
|
? currentFavorites.filter((n) => n !== number)
|
|
: [number, ...currentFavorites].slice(0, 20);
|
|
saveStringArray(keys.favorites, next);
|
|
return {
|
|
...current,
|
|
[activeCategory]: {
|
|
...(current[activeCategory] ?? { favorites: [], history: [] }),
|
|
favorites: next,
|
|
},
|
|
};
|
|
});
|
|
};
|
|
|
|
const pushHistory = (number: string) => {
|
|
const keys = quickFillKeys(activeCategory);
|
|
setQuickFillState((current) => {
|
|
const currentHistory = current[activeCategory]?.history ?? [];
|
|
const next = appendUnique(currentHistory, number, 20);
|
|
saveStringArray(keys.history, next);
|
|
return {
|
|
...current,
|
|
[activeCategory]: {
|
|
...(current[activeCategory] ?? { favorites: [], history: [] }),
|
|
history: next,
|
|
},
|
|
};
|
|
});
|
|
};
|
|
|
|
const toggleRowProvider = useCallback((rowId: string, code: string) => {
|
|
setRows((current) => current.map((row) => {
|
|
if (row.id !== rowId) return row;
|
|
return row.providerCodes.includes(code)
|
|
? { ...row, providerCodes: row.providerCodes.filter((item) => item !== code) }
|
|
: { ...row, providerCodes: [...row.providerCodes, code] };
|
|
}));
|
|
}, []);
|
|
|
|
const toggleProviderColumn = useCallback((code: string, checked: boolean) => {
|
|
setRows((current) => current.map((row) => {
|
|
const hasCode = row.providerCodes.includes(code);
|
|
if (checked && !hasCode) return { ...row, providerCodes: [...row.providerCodes, code] };
|
|
if (!checked && hasCode) return { ...row, providerCodes: row.providerCodes.filter((item) => item !== code) };
|
|
return row;
|
|
}));
|
|
}, []);
|
|
|
|
const hasAmountsForPlay = useCallback(
|
|
(playCode: string): boolean =>
|
|
rows.some((row) =>
|
|
Object.entries(row.amounts).some(
|
|
([amountKey, amountValue]) =>
|
|
amountKey.split("@")[0] === playCode && Boolean(amountValue?.trim()),
|
|
),
|
|
),
|
|
[rows],
|
|
);
|
|
|
|
const clearAmountsForPlay = useCallback((playCode: string) => {
|
|
setRows((current) =>
|
|
current.map((row) => {
|
|
const nextAmounts = { ...row.amounts };
|
|
let changed = false;
|
|
Object.keys(nextAmounts).forEach((amountKey) => {
|
|
const keyPlayCode = amountKey.split("@")[0];
|
|
if (keyPlayCode !== playCode || !nextAmounts[amountKey]) return;
|
|
nextAmounts[amountKey] = "";
|
|
changed = true;
|
|
});
|
|
return changed ? { ...row, amounts: nextAmounts } : row;
|
|
}),
|
|
);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const echo = getLotteryEcho();
|
|
if (!echo) return;
|
|
|
|
const channel = echo.channel("lottery-hall");
|
|
|
|
const onPlayToggle = (evt: PlayToggleWsEvent) => {
|
|
if (evt.enabled === false && typeof evt.play_code === "string") {
|
|
const removed = hasAmountsForPlay(evt.play_code);
|
|
clearAmountsForPlay(evt.play_code);
|
|
invalidatePreview();
|
|
toast.warning(
|
|
removed
|
|
? t("hall.playConfig.playClosedDraftCleared", {
|
|
playCode: evt.play_code,
|
|
})
|
|
: t("hall.playConfig.playClosed", {
|
|
playCode: evt.play_code,
|
|
}),
|
|
);
|
|
}
|
|
};
|
|
|
|
const onOddsUpdate = (evt: OddsUpdateWsEvent) => {
|
|
invalidatePreview();
|
|
toast.message(evt.message ?? t("hall.playConfig.oddsUpdated"));
|
|
};
|
|
|
|
const onRiskSoldOut = (evt: RiskSoldOutWsEvent) => {
|
|
const normalized = evt.normalized_number?.trim().toUpperCase();
|
|
if (!normalized) return;
|
|
if (drawNo !== null && evt.draw_no !== undefined && evt.draw_no !== drawNo) {
|
|
return;
|
|
}
|
|
setLiveSoldOutNumbers((prev) => {
|
|
const next = new Set(prev);
|
|
next.add(normalized);
|
|
return next;
|
|
});
|
|
setLiveWarningNumbers((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(normalized);
|
|
return next;
|
|
});
|
|
invalidatePreview();
|
|
void reloadDraw();
|
|
};
|
|
|
|
const onRiskWarning = (evt: RiskWarningWsEvent) => {
|
|
const normalized = evt.normalized_number?.trim().toUpperCase();
|
|
if (!normalized) return;
|
|
if (drawNo !== null && evt.draw_no !== undefined && evt.draw_no !== drawNo) {
|
|
return;
|
|
}
|
|
setLiveWarningNumbers((prev) => {
|
|
const next = new Set(prev);
|
|
next.add(normalized);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
channel.listen(".play.toggle", onPlayToggle);
|
|
channel.listen(".odds.update", onOddsUpdate);
|
|
channel.listen(".risk.sold_out", onRiskSoldOut);
|
|
channel.listen(".risk.warning", onRiskWarning);
|
|
|
|
return () => {
|
|
channel.stopListening(".play.toggle");
|
|
channel.stopListening(".odds.update");
|
|
channel.stopListening(".risk.sold_out");
|
|
channel.stopListening(".risk.warning");
|
|
};
|
|
}, [clearAmountsForPlay, drawNo, hasAmountsForPlay, invalidatePreview, reloadDraw, t]);
|
|
|
|
const collectDraftLineIssues = useCallback((): DraftLineIssue[] => {
|
|
const issues: DraftLineIssue[] = [];
|
|
rows.forEach((row, rowIndex) => {
|
|
allPlayColumns.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;
|
|
}
|
|
const comboCount = selectionCombinationCount(row.number, row.selectionType);
|
|
if (
|
|
row.selectionType === "full_cover"
|
|
&& !isFullCoverAmountDivisible(amount, comboCount)
|
|
) {
|
|
issues.push({
|
|
rowNo: rowIndex + 1,
|
|
playCode: column.play.play_code,
|
|
reason: "full_cover_amount_not_divisible",
|
|
});
|
|
}
|
|
});
|
|
});
|
|
return issues;
|
|
}, [allPlayColumns, currencyCode, rows]);
|
|
|
|
const providerMissingRow = useCallback((): number | null => {
|
|
for (const [index, row] of rows.entries()) {
|
|
const hasAmount = Object.values(row.amounts).some((value) => {
|
|
const amount = parseDecimalInputToMinor(value, currencyCode);
|
|
return amount !== null && amount > 0;
|
|
});
|
|
if (hasAmount && row.providerCodes.length === 0) return index + 1;
|
|
}
|
|
return null;
|
|
}, [currencyCode, 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[] => {
|
|
const entries: DraftEntry[] = [];
|
|
rows.forEach((row, rowIndex) => {
|
|
allPlayColumns.forEach((column) => {
|
|
const amount = parseDecimalInputToMinor(row.amounts[column.key] ?? "", currencyCode);
|
|
if (amount === null || amount <= 0) return;
|
|
const line = lineForPlay(column.play, row.number, amount, column.digitSlot, row.selectionType);
|
|
if (!line) return;
|
|
line.provider_codes = row.providerCodes;
|
|
entries.push({
|
|
rowId: row.id,
|
|
rowNo: rowIndex + 1,
|
|
amountKey: column.key,
|
|
play: column.play,
|
|
digitSlot: column.digitSlot,
|
|
number: row.number,
|
|
amountMinor: amount,
|
|
line,
|
|
});
|
|
});
|
|
});
|
|
return entries;
|
|
}, [allPlayColumns, currencyCode, rows]);
|
|
|
|
const draftEntries = collectEntries();
|
|
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(totalBet * rebateRate);
|
|
const providerCount = entry.line.provider_codes?.length ?? 0;
|
|
acc.bet += totalBet * providerCount;
|
|
acc.rebate += 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);
|
|
}, 300);
|
|
return () => window.clearTimeout(id);
|
|
}, [draftSummary]);
|
|
|
|
const buildLines = (): TicketLineInput[] => collectEntries().map((entry) => entry.line);
|
|
|
|
const applyClosedPlayCleanup = (data: unknown): boolean => {
|
|
const payload = data as ClosedPlayCleanupData | null;
|
|
const cleanupLines = Array.isArray(payload?.cleanup_lines) ? payload.cleanup_lines : [];
|
|
if (cleanupLines.length === 0) return false;
|
|
|
|
const entries = collectEntries();
|
|
const cleanupPairs = new Set<string>();
|
|
cleanupLines.forEach((item) => {
|
|
const clientLineNo = Number(item?.client_line_no ?? 0);
|
|
const playCode = String(item?.play_code ?? "");
|
|
if (!Number.isInteger(clientLineNo) || clientLineNo <= 0 || playCode.trim() === "") return;
|
|
const entry = entries[clientLineNo - 1];
|
|
if (!entry || entry.play.play_code !== playCode) return;
|
|
cleanupPairs.add(`${entry.rowId}::${entry.amountKey}`);
|
|
});
|
|
|
|
if (cleanupPairs.size === 0) return false;
|
|
|
|
setRows((current) =>
|
|
current.map((row) => {
|
|
const nextAmounts = { ...row.amounts };
|
|
let changed = false;
|
|
Object.keys(nextAmounts).forEach((amountKey) => {
|
|
if (!cleanupPairs.has(`${row.id}::${amountKey}`)) return;
|
|
nextAmounts[amountKey] = "";
|
|
changed = true;
|
|
});
|
|
return changed ? { ...row, amounts: nextAmounts } : row;
|
|
}),
|
|
);
|
|
|
|
return true;
|
|
};
|
|
|
|
const handlePreview = async () => {
|
|
if (!display) {
|
|
toast.error(t("hall.noDraw"));
|
|
return;
|
|
}
|
|
if (!isBettable) {
|
|
toast.error(t("hall.notBettable"));
|
|
return;
|
|
}
|
|
if (catalogState.kind !== "ok") {
|
|
toast.error(t("hall.catalogNotReady"));
|
|
return;
|
|
}
|
|
|
|
const lineIssues = collectDraftLineIssues();
|
|
const missingProviderAt = providerMissingRow();
|
|
if (missingProviderAt !== null) {
|
|
toast.error(t("hall.providers.rowRequired", { row: missingProviderAt, defaultValue: `第 ${missingProviderAt} 行请选择至少一个开注商` }));
|
|
return;
|
|
}
|
|
if (lineIssues.length > 0) {
|
|
toast.error(formatDraftLineIssue(lineIssues[0]));
|
|
return;
|
|
}
|
|
|
|
const lines = buildLines();
|
|
if (lines.length === 0) {
|
|
toast.error(t("hall.emptyLines"));
|
|
return;
|
|
}
|
|
|
|
if (previewLoading || placeLoading) {
|
|
return;
|
|
}
|
|
|
|
setPreviewLoading(true);
|
|
const requestSeq = ++previewRequestSeqRef.current;
|
|
const traceId = newPlaceTraceId();
|
|
const drawId = display.draw_no;
|
|
const previewCurrencyCode = currencyCode;
|
|
const frozenLines = lines.map((line) => ({
|
|
...line,
|
|
provider_codes: line.provider_codes ? [...line.provider_codes] : undefined,
|
|
}));
|
|
placeTraceIdRef.current = traceId;
|
|
try {
|
|
const data = await postTicketPreview({
|
|
draw_id: drawId,
|
|
currency_code: previewCurrencyCode,
|
|
client_trace_id: traceId,
|
|
lines: frozenLines,
|
|
});
|
|
if (requestSeq !== previewRequestSeqRef.current) return;
|
|
previewSubmissionRef.current = {
|
|
drawId: data.draw.draw_id.trim() || drawId,
|
|
currencyCode: previewCurrencyCode,
|
|
clientTraceId: traceId,
|
|
lines: frozenLines,
|
|
expectedConfigVersions: data.config_versions,
|
|
};
|
|
setPreviewData(data);
|
|
setPreviewOpen(true);
|
|
rows.forEach((row) => {
|
|
if (row.number.trim()) pushHistory(row.number.trim());
|
|
});
|
|
} catch (e) {
|
|
const code = e instanceof LotteryApiBizError ? e.code : 0;
|
|
const msg = e instanceof LotteryApiBizError ? e.message : t("hall.previewFailed");
|
|
if (e instanceof LotteryApiBizError && code === 2002 && applyClosedPlayCleanup(e.data)) {
|
|
const payload = e.data as ClosedPlayCleanupData;
|
|
toast.error(payload.cleanup_hint ?? t("hall.ticketError.2002"));
|
|
return;
|
|
}
|
|
if (e instanceof LotteryApiBizError && code === 2008) {
|
|
invalidatePreview();
|
|
}
|
|
if (e instanceof LotteryApiBizError && (code === 2001 || code === 2006)) {
|
|
void reloadDraw();
|
|
}
|
|
toast.error(mapTicketBetError(code, msg, t));
|
|
} finally {
|
|
setPreviewLoading(false);
|
|
}
|
|
};
|
|
|
|
const handlePlace = async () => {
|
|
if (!previewData) return;
|
|
if (placeLoading) {
|
|
return;
|
|
}
|
|
if (!isBettable) {
|
|
toast.error(t("hall.closedSubmit"));
|
|
return;
|
|
}
|
|
|
|
const snapshot = previewSubmissionRef.current;
|
|
if (snapshot === null) {
|
|
toast.error(t("hall.changedBeforeSubmit"));
|
|
return;
|
|
}
|
|
|
|
if (snapshot.drawId === "") {
|
|
toast.error(t("hall.notBettable"));
|
|
return;
|
|
}
|
|
|
|
setPlaceLoading(true);
|
|
try {
|
|
const data = await postTicketPlace({
|
|
draw_id: snapshot.drawId,
|
|
currency_code: snapshot.currencyCode,
|
|
client_trace_id: snapshot.clientTraceId,
|
|
lines: snapshot.lines,
|
|
expected_config_versions: snapshot.expectedConfigVersions,
|
|
});
|
|
previewSubmissionRef.current = null;
|
|
clearPlaceTraceId();
|
|
setPreviewOpen(false);
|
|
setPreviewData(null);
|
|
setResultData(data);
|
|
setResultOpen(true);
|
|
setRows(newDraftRows());
|
|
setActiveRowId(null);
|
|
triggerWalletPollingAfterBet();
|
|
void refreshWallet();
|
|
void reloadDraw();
|
|
const failureCount = data.summary.failure_count ?? 0;
|
|
const successCount = data.summary.success_count ?? 0;
|
|
if (failureCount > 0 && successCount === 0) {
|
|
toast.error(
|
|
t("hall.placeAllFailed", {
|
|
failed: failureCount,
|
|
}),
|
|
);
|
|
} else if (failureCount > 0) {
|
|
toast.warning(
|
|
t("hall.placePartialFailed", {
|
|
success: successCount,
|
|
failed: failureCount,
|
|
}),
|
|
);
|
|
} else {
|
|
toast.success(
|
|
t("hall.placeSuccess", {
|
|
orderNo: data.order_no,
|
|
amount: formatMinorAsCurrency(data.summary.total_actual_deduct, currencyCode),
|
|
}),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
const code = e instanceof LotteryApiBizError ? e.code : 0;
|
|
const msg = e instanceof LotteryApiBizError ? e.message : t("hall.placeFailed");
|
|
if (e instanceof LotteryApiBizError && code === 2002 && applyClosedPlayCleanup(e.data)) {
|
|
const payload = e.data as ClosedPlayCleanupData;
|
|
toast.error(payload.cleanup_hint ?? t("hall.ticketError.2002"));
|
|
invalidatePreview();
|
|
return;
|
|
}
|
|
if (e instanceof LotteryApiBizError && (code === 2008 || code === 2009)) {
|
|
invalidatePreview();
|
|
}
|
|
if (e instanceof LotteryApiBizError && (code === 2001 || code === 2006)) {
|
|
void reloadDraw();
|
|
}
|
|
toast.error(mapTicketBetError(code, msg, t));
|
|
} finally {
|
|
setPlaceLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
const onRefresh = () => void refreshWallet();
|
|
window.addEventListener("lottery-wallet-refresh", onRefresh);
|
|
return () => window.removeEventListener("lottery-wallet-refresh", onRefresh);
|
|
}, [refreshWallet]);
|
|
|
|
if (catalogState.kind === "loading") {
|
|
return (
|
|
<section
|
|
className={cn("space-y-3", isMobile && "pb-44")}
|
|
aria-label={t("hall.aria")}
|
|
>
|
|
<Skeleton className="h-12 rounded-xl" />
|
|
<Skeleton className="h-72 rounded-xl" />
|
|
<Skeleton className="h-14 rounded-xl" />
|
|
</section>
|
|
);
|
|
}
|
|
|
|
if (catalogState.kind === "error") {
|
|
return (
|
|
<section className="rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
|
<p>{catalogState.message}</p>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
className="mt-3 border-red-200 bg-white text-red-700 hover:bg-red-50"
|
|
onClick={() => void loadCatalog()}
|
|
>
|
|
{t("actions.retry")}
|
|
</Button>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
const submitActualMinor =
|
|
previewData?.summary.total_actual_deduct ?? debouncedSummary.actual;
|
|
const activeDraftRowIds = new Set(draftEntries.map((entry) => entry.rowId));
|
|
const selectedProviderCount = new Set(
|
|
rows
|
|
.filter((row) => activeDraftRowIds.has(row.id))
|
|
.flatMap((row) => row.providerCodes),
|
|
).size;
|
|
const warningRowCount = rows.filter(
|
|
(row) =>
|
|
row.number.trim().length > 0 &&
|
|
playColumns.some(
|
|
(column) =>
|
|
cellRiskState(
|
|
column.play,
|
|
row.number,
|
|
playCategory(column.play.play_code),
|
|
alertRows,
|
|
liveSoldOutNumbers,
|
|
liveWarningNumbers,
|
|
column.digitSlot,
|
|
) === "warning",
|
|
),
|
|
).length;
|
|
const canSubmit =
|
|
!tableDisabled && draftEntries.length > 0 && providerMissingRow() === null && availableMinor >= submitActualMinor;
|
|
const favoriteChips = favorites.slice(0, 10);
|
|
const historyChips = historyNumbers.slice(0, 20);
|
|
const summaryItems = [
|
|
{
|
|
key: "total",
|
|
label: t("hall.table.total", { defaultValue: "共" }),
|
|
totalMinor: submitActualMinor,
|
|
},
|
|
...summaryPlayColumns.map((column) => ({
|
|
key: column.key,
|
|
label: playColumnHeaderLabel(
|
|
column.play,
|
|
playCategory(column.play.play_code),
|
|
column.digitSlot,
|
|
t,
|
|
),
|
|
totalMinor: rows.reduce((sum, row) => {
|
|
const amount = row.amounts[column.key];
|
|
if (
|
|
!amount ||
|
|
row.number.trim().length === 0 ||
|
|
draftLineIssueReason(
|
|
column.play.play_code,
|
|
row.number,
|
|
column.digitSlot,
|
|
) !== null
|
|
) {
|
|
return sum;
|
|
}
|
|
return sum + (parseDecimalInputToMinor(amount, currencyCode) ?? 0);
|
|
}, 0),
|
|
})),
|
|
];
|
|
const desktopReviewModel: HallDesktopReviewModel = {
|
|
lineCount: draftEntries.length,
|
|
providerCount: selectedProviderCount,
|
|
actualAmount: formatMinorAmount(submitActualMinor),
|
|
availableCredit: formatMinorAmount(availableMinor),
|
|
remainingCredit: formatMinorAmount(Math.max(0, availableMinor - submitActualMinor)),
|
|
warningCount: warningRowCount,
|
|
creditSufficient: availableMinor >= submitActualMinor,
|
|
};
|
|
return (
|
|
<>
|
|
<section className="space-y-3" aria-label={t("hall.aria")}>
|
|
|
|
{jackpot?.enabled ? (
|
|
<div className="relative overflow-hidden rounded-xl border border-[#d6b74e]/70 bg-[linear-gradient(115deg,#06183c_0%,#0a3a89_50%,#071f50_100%)] px-3 py-3 text-white shadow-[0_10px_28px_rgba(7,46,112,0.2)] sm:px-4">
|
|
<div className="pointer-events-none absolute -left-10 top-1/2 size-28 -translate-y-1/2 rounded-full bg-[#2d79ff]/25 blur-2xl motion-safe:animate-[jackpot-amount-row-glow_2.8s_ease-in-out_infinite]" />
|
|
<div className="pointer-events-none absolute -right-5 -top-16 size-36 rounded-full bg-[#f4ca54]/20 blur-2xl motion-safe:animate-[jackpot-amount-row-glow_3.2s_ease-in-out_infinite]" />
|
|
<div className="pointer-events-none absolute -inset-y-10 -left-1/3 w-1/3 rotate-12 bg-gradient-to-r from-transparent via-white/20 to-transparent blur-sm motion-safe:animate-[jackpot-strip-sweep_4s_ease-in-out_infinite]" />
|
|
|
|
<div className="relative flex flex-wrap items-center justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<p className="w-fit bg-gradient-to-r from-[#fff1ad] via-white to-[#e2bd45] bg-[length:200%_auto] bg-clip-text text-[11px] font-black uppercase tracking-[0.16em] text-transparent motion-safe:animate-[jackpot-shimmer_3s_linear_infinite]">
|
|
{t("results.jackpotLabel", { defaultValue: "Jackpot" })}
|
|
</p>
|
|
<p className="mt-0.5 truncate font-mono text-xl font-black tabular-nums text-white drop-shadow-[0_0_12px_rgba(245,216,122,0.35)] sm:text-2xl motion-safe:animate-[jackpot-amount-glow_2.4s_ease-in-out_infinite]">
|
|
{formatMinorAmount(jackpot.current_amount_minor)}
|
|
</p>
|
|
</div>
|
|
{jackpot.draws_since_last_burst !== null ? (
|
|
<p className="ml-auto rounded-full border border-[#f4d66f]/35 bg-white/10 px-3 py-1.5 text-xs font-bold text-[#ffe8a0] shadow-[inset_0_1px_0_rgba(255,255,255,0.12)] backdrop-blur-sm">
|
|
{t("results.jackpotGap", {
|
|
count: jackpot.draws_since_last_burst,
|
|
})}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<HallPlaySummaryGrid items={summaryItems} className="hidden lg:grid" />
|
|
|
|
<HallDesktopWorkflowBar
|
|
stepGame={t("hall.desktop.stepGame", { defaultValue: "选择玩法" })}
|
|
stepFill={t("hall.desktop.stepFill", { defaultValue: "填写号码和金额" })}
|
|
stepReview={t("hall.desktop.stepReview", { defaultValue: "核对并提交" })}
|
|
gameControls={(
|
|
<div className="inline-flex max-w-full items-center gap-1 rounded-lg bg-[#f3f6fb] p-1">
|
|
{categoryTabs.map((tab) => {
|
|
const hasPlays = openPlays.some(
|
|
(play) => playCategory(play.play_code) === tab.value,
|
|
);
|
|
const active = activeCategory === tab.value;
|
|
|
|
return (
|
|
<button
|
|
key={`desktop-${tab.value}`}
|
|
type="button"
|
|
disabled={!hasPlays}
|
|
onClick={() => setActiveCategory(tab.value)}
|
|
className={cn(
|
|
"inline-flex min-w-14 items-center justify-center rounded-lg px-2 py-2 text-sm font-bold transition-colors xl:min-w-[4.5rem] xl:px-3.5",
|
|
active
|
|
? "bg-[#2d63e2] text-white shadow-[0_4px_12px_rgba(45,99,226,0.24)]"
|
|
: "text-[#5b7fbf] hover:bg-white hover:text-[#2d63e2]",
|
|
!hasPlays && "cursor-not-allowed opacity-40",
|
|
)}
|
|
aria-pressed={active}
|
|
>
|
|
{tab.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
fillControls={(
|
|
<div className="flex min-h-9 min-w-0 flex-wrap items-center gap-1.5">
|
|
{favoriteChips.length > 0 ? (
|
|
<>
|
|
<span className="mr-0.5 text-xs font-bold text-[#d81435]">
|
|
{t("hall.quickFill.favorites")}
|
|
</span>
|
|
{favoriteChips.slice(0, 5).map((number) => (
|
|
<button
|
|
key={`desktop-fav-${number}`}
|
|
type="button"
|
|
className="inline-flex h-8 items-center rounded-full border border-[#ffd7db] bg-[#fff3f5] px-3 text-xs font-bold text-[#d81435] transition-colors hover:bg-[#ffe9ed]"
|
|
onClick={() => fillCurrentRow(number)}
|
|
>
|
|
{number}
|
|
</button>
|
|
))}
|
|
</>
|
|
) : null}
|
|
<span className="ml-1 mr-0.5 text-xs font-bold text-slate-400">
|
|
{t("hall.quickFill.history")}
|
|
</span>
|
|
{historyChips.length > 0 ? (
|
|
historyChips.slice(0, 6).map((number) => (
|
|
<button
|
|
key={`desktop-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>
|
|
)}
|
|
reviewControls={(
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="h-9 flex-1 justify-center rounded-lg border-[#dfe6f0] bg-white text-sm font-bold text-slate-600"
|
|
onClick={clearAllRows}
|
|
>
|
|
<Trash2 className="size-4" aria-hidden />
|
|
{t("hall.quickFill.clearAll")}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
/>
|
|
|
|
<HallPlaySummaryGrid items={summaryItems} className="lg:hidden" />
|
|
|
|
{isMobile ? (
|
|
<HallMobileQuickFill
|
|
activeNumber={activeRow?.number ?? ""}
|
|
favorites={favoriteChips}
|
|
history={historyChips}
|
|
expanded={quickFillExpanded}
|
|
tableDisabled={tableDisabled}
|
|
activeCategory={activeCategory}
|
|
availableCategories={availableCategories}
|
|
onExpandedChange={setQuickFillExpanded}
|
|
onCategoryChange={setActiveCategory}
|
|
onFillNumber={fillCurrentRow}
|
|
onToggleFavorite={toggleFavoriteNumber}
|
|
onClearAll={clearAllRows}
|
|
onApplyQuickAmount={applyQuickAmountToActiveRow}
|
|
onCopyPreviousRow={copyPreviousRowToActiveRow}
|
|
onClearActiveRowAmounts={clearActiveRowAmounts}
|
|
t={t}
|
|
/>
|
|
) : null}
|
|
|
|
{activeCategoryPlays.length === 0 ? (
|
|
<div
|
|
className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2.5 text-center text-xs text-amber-950"
|
|
role="status"
|
|
>
|
|
{t("hall.table.noPlaysInCategory")}
|
|
</div>
|
|
) : null}
|
|
|
|
<div
|
|
className={cn(
|
|
"min-w-0",
|
|
!isMobile &&
|
|
"flex flex-col items-stretch gap-3 min-[1500px]:flex-row min-[1500px]:items-start",
|
|
)}
|
|
>
|
|
<div
|
|
className={cn(
|
|
"min-w-0 flex-1 overflow-hidden border border-[#e6edf8] bg-white transition-opacity",
|
|
isMobile ? "rounded-lg" : "rounded-xl shadow-[0_8px_24px_rgba(15,23,42,0.05)]",
|
|
tableDisabled && "opacity-55",
|
|
showWideTableHint && "player-table-scroll-wrap",
|
|
)}
|
|
>
|
|
<HallBettingTable
|
|
rows={rows}
|
|
activeRowId={activeRowId}
|
|
tableDisabled={tableDisabled}
|
|
tableWidthPx={tableWidthPx}
|
|
isMobile={isMobile}
|
|
indexColClass={indexColClass}
|
|
numberColClass={numberColClass}
|
|
stickyNumberLeftClass={stickyNumberLeftClass}
|
|
numberPlaceholder={numberPlaceholder}
|
|
numberMaxChars={numberMaxChars}
|
|
activeCategory={activeCategory}
|
|
playColumns={playColumns}
|
|
amountColClass={amountColClass}
|
|
syncAmountColumns={syncAmountColumns}
|
|
selectionTypeColClass={selectionTypeColClass}
|
|
showSelectionTypeColumn={showSelectionTypeColumn}
|
|
selectionTypeOptions={selectionTypeOptions}
|
|
betProviders={betProviders}
|
|
providerColClass={providerColClass}
|
|
rowTotalColClass={rowTotalColClass}
|
|
alertRows={alertRows}
|
|
liveSoldOutNumbers={liveSoldOutNumbers}
|
|
liveWarningNumbers={liveWarningNumbers}
|
|
currencyCode={currencyCode}
|
|
onToggleSyncAmountColumn={toggleSyncAmountColumn}
|
|
onSetAllSelectionTypes={requestAllSelectionTypes}
|
|
onToggleProviderColumn={toggleProviderColumn}
|
|
onUpdateRowNumber={updateRowNumber}
|
|
onUpdateRowSelectionType={requestRowSelectionType}
|
|
onUpdateAmount={updateAmount}
|
|
onToggleRowProvider={toggleRowProvider}
|
|
onSetActiveRowId={setActiveRowId}
|
|
t={t}
|
|
/>
|
|
|
|
</div>
|
|
|
|
{!isMobile ? (
|
|
<HallDesktopReviewPanel
|
|
model={desktopReviewModel}
|
|
disabled={!canSubmit}
|
|
loading={previewLoading}
|
|
isBettable={isBettable}
|
|
onSubmit={() => void handlePreview()}
|
|
labels={{
|
|
title: t("hall.desktop.reviewTitle", { defaultValue: "核对与提交" }),
|
|
lineCount: t("hall.table.lineCount"),
|
|
providerCount: t("hall.table.providerCount"),
|
|
actualTotal: t("hall.table.actualTotal"),
|
|
availableCredit: t("hall.desktop.availableCredit", { defaultValue: "可用信用" }),
|
|
remainingCredit: t("hall.desktop.remainingCredit", { defaultValue: "下注后可用" }),
|
|
noWarnings: t("hall.desktop.noWarnings", { defaultValue: "暂无风险提醒" }),
|
|
warningCount: t("hall.desktop.warningCount", {
|
|
defaultValue: "{{count}} 行接近售罄",
|
|
count: warningRowCount,
|
|
}),
|
|
creditEnough: t("hall.desktop.creditEnough", { defaultValue: "可用信用充足" }),
|
|
creditInsufficient: t("hall.desktop.creditInsufficient", { defaultValue: "可用信用不足" }),
|
|
submit: previewLoading
|
|
? t("hall.table.previewing")
|
|
: !isBettable
|
|
? t("hall.closed.title")
|
|
: availableMinor < submitActualMinor
|
|
? t("hall.table.insufficientBalance")
|
|
: t("hall.table.submitBet"),
|
|
}}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
|
|
{sealedBetUi ? (
|
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-600">
|
|
{t("hall.table.sealedHint")}
|
|
</p>
|
|
) : null}
|
|
|
|
</section>
|
|
|
|
{isMobile ? (
|
|
<div
|
|
className={cn(
|
|
playerViewportFixedBarClass,
|
|
"bottom-[calc(3.25rem+env(safe-area-inset-bottom,0px))] z-40",
|
|
)}
|
|
>
|
|
<div className="mx-auto w-full border-t border-[#dfe7f3] bg-white">
|
|
<div className="flex items-center gap-2 px-3 pt-2 pb-2.5">
|
|
<div className="min-w-0 flex flex-1 items-center justify-between gap-2">
|
|
<p className="shrink-0 text-[11px] font-semibold text-slate-500">
|
|
{t("hall.table.draftTotal")}
|
|
</p>
|
|
<p className="truncate text-[19px] font-black tabular-nums text-[#0b3f96]">
|
|
{formatMinorAsCurrency(debouncedSummary.actual, currencyCode)}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
disabled={!canSubmit || previewLoading}
|
|
onClick={() => void handlePreview()}
|
|
className={cn(
|
|
"h-9 min-w-[7.2rem] shrink-0 gap-1.5 rounded-lg border-0 px-3.5 text-[14px] font-bold text-white",
|
|
!isBettable
|
|
? "bg-slate-500 shadow-none hover:bg-slate-500 disabled:opacity-100"
|
|
: "bg-[#e5002c] shadow-none hover:bg-[#d10028]",
|
|
)}
|
|
>
|
|
{!isBettable ? (
|
|
<Lock className="size-4.5 shrink-0" aria-hidden />
|
|
) : (
|
|
<Ticket className="size-4.5 shrink-0" aria-hidden />
|
|
)}
|
|
{previewLoading
|
|
? t("hall.table.previewing")
|
|
: !isBettable
|
|
? t("hall.closed.title")
|
|
: availableMinor < submitActualMinor
|
|
? t("hall.table.insufficientBalance")
|
|
: t("hall.table.submitBet")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<HallBetPreviewDialog
|
|
open={previewOpen}
|
|
onOpenChange={(open) => {
|
|
setPreviewOpen(open);
|
|
if (!open) {
|
|
setPreviewData(null);
|
|
if (!placeLoading) {
|
|
previewRequestSeqRef.current += 1;
|
|
previewSubmissionRef.current = null;
|
|
clearPlaceTraceId();
|
|
}
|
|
}
|
|
}}
|
|
currencyCode={currencyCode}
|
|
data={previewData}
|
|
placing={placeLoading}
|
|
jackpotEnabled={Boolean(jackpot?.enabled)}
|
|
allowSubmit={isBettable && availableMinor >= submitActualMinor}
|
|
onConfirmPlace={() => void handlePlace()}
|
|
/>
|
|
|
|
<HallBetResultDialog
|
|
open={resultOpen}
|
|
onOpenChange={(open) => {
|
|
setResultOpen(open);
|
|
if (!open) setResultData(null);
|
|
}}
|
|
currencyCode={currencyCode}
|
|
data={resultData}
|
|
jackpotEnabled={Boolean(jackpot?.enabled)}
|
|
creditMode={creditMode}
|
|
/>
|
|
|
|
<HallSelectionConfirmDialog
|
|
open={pendingSelectionChange !== null}
|
|
preview={pendingSelectionPreview}
|
|
currencyCode={currencyCode}
|
|
onCancel={cancelPendingSelectionChange}
|
|
onConfirm={confirmPendingSelectionChange}
|
|
t={t}
|
|
/>
|
|
</>
|
|
);
|
|
}
|