fix(player): harden realtime auth and refactor hall betting
This commit is contained in:
@@ -1,32 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown, ChevronUp, CircleHelp, Lock, Ticket, Trash2, Star } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, memo } from "react";
|
||||
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 { Tooltip } from "@base-ui/react/tooltip";
|
||||
|
||||
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 { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
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,
|
||||
@@ -69,26 +75,14 @@ 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 { DrawCurrentRiskPoolAlert } from "@/types/api/draw-current";
|
||||
import type { BetProviderRow } from "@/types/api/bet-provider";
|
||||
|
||||
type HallCategory = "D2" | "D3" | "D4" | "JACKPOT";
|
||||
type PlayHallCategory = Exclude<HallCategory, "JACKPOT">;
|
||||
|
||||
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 DraftRow = {
|
||||
id: string;
|
||||
number: string;
|
||||
amounts: Record<string, string>;
|
||||
providerCodes: string[];
|
||||
selectionType: SelectionType;
|
||||
};
|
||||
|
||||
type PendingSelectionChange =
|
||||
| { mode: "row"; rowId: string; next: SelectionType; prev: SelectionType }
|
||||
| { mode: "all"; next: SelectionType };
|
||||
@@ -110,75 +104,6 @@ type DraftLineIssue = {
|
||||
reason: DraftLineIssueReason;
|
||||
};
|
||||
|
||||
type PlayColumn = {
|
||||
key: string;
|
||||
play: PlayEffectivePlayRow;
|
||||
digitSlot?: number;
|
||||
};
|
||||
|
||||
type HallSummaryItem = {
|
||||
key: string;
|
||||
label: string;
|
||||
totalMinor: number;
|
||||
};
|
||||
|
||||
function HallPlaySummaryGrid({
|
||||
items,
|
||||
className,
|
||||
}: {
|
||||
items: HallSummaryItem[];
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-6 overflow-hidden rounded-lg border border-[#dfe6f0] bg-[#f8fafc] text-center tabular-nums shadow-sm sm:grid-cols-8 md:grid-cols-10 lg:grid-cols-[repeat(auto-fit,minmax(3.5rem,1fr))]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const hasValue = item.totalMinor > 0;
|
||||
return (
|
||||
<div
|
||||
key={`summary-${item.key}`}
|
||||
className={cn(
|
||||
"min-w-0 border-b border-r transition-colors",
|
||||
hasValue
|
||||
? "border-[#cbdcf7] bg-[#eef5ff]"
|
||||
: "border-slate-200 bg-slate-50",
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
"flex min-h-7 items-center justify-center px-1 text-[10px] font-bold leading-tight transition-colors lg:min-h-8 lg:text-[11px]",
|
||||
hasValue
|
||||
? "bg-[#2d63e2] text-white"
|
||||
: "bg-slate-200 text-slate-500",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"px-1 py-1 text-[11px] font-black transition-colors lg:py-1.5 lg:text-xs",
|
||||
hasValue ? "text-[#0b3f96]" : "text-slate-400",
|
||||
)}
|
||||
>
|
||||
{formatMinorAmount(item.totalMinor)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function playCategory(playCode: string): PlayHallCategory {
|
||||
if (playCode.startsWith("pos_3")) return "D3";
|
||||
if (playCode.startsWith("pos_2")) return "D2";
|
||||
return "D4";
|
||||
}
|
||||
|
||||
type ClosedPlayCleanupData = {
|
||||
cleanup_hint?: string;
|
||||
cleanup_lines?: Array<{ client_line_no?: number; play_code?: string }>;
|
||||
@@ -216,26 +141,8 @@ type RiskWarningWsEvent = {
|
||||
usage_percent?: number;
|
||||
};
|
||||
|
||||
type CellRiskState = "open" | "warning" | "sold_out";
|
||||
type QuickFillState = Record<HallCategory, { favorites: string[]; history: string[] }>;
|
||||
|
||||
const FALLBACK_BET_PROVIDERS: BetProviderRow[] = [
|
||||
{ code: "SG", name: "Singapore", short_code: "S", sort_order: 10, is_default: true },
|
||||
{ code: "MY", name: "Malaysia", short_code: "M", sort_order: 20, is_default: false },
|
||||
{ code: "TH", name: "Thailand", short_code: "T", sort_order: 30, is_default: false },
|
||||
];
|
||||
const DEFAULT_PROVIDER_CODE = "SG";
|
||||
const PROVIDER_COLUMN_TONES = [
|
||||
"bg-[#fff875] text-slate-950",
|
||||
"bg-[#b7d9ff] text-slate-950",
|
||||
"bg-[#ffc7ca] text-slate-950",
|
||||
"bg-[#d9d8ff] text-slate-950",
|
||||
"bg-[#a8f8a4] text-slate-950",
|
||||
] as const;
|
||||
|
||||
function providerColumnTone(index: number): string {
|
||||
return PROVIDER_COLUMN_TONES[index % PROVIDER_COLUMN_TONES.length] ?? "bg-slate-100 text-slate-950";
|
||||
}
|
||||
|
||||
const categoryTabs: { value: PlayHallCategory; label: string }[] = [
|
||||
{ value: "D4", label: "4D" },
|
||||
@@ -275,7 +182,6 @@ const CATEGORY_ORDER: readonly PlayHallCategory[] = ["D4", "D3", "D2"];
|
||||
const SUMMARY_PLAY_ORDER = CATEGORY_ORDER.flatMap(
|
||||
(category) => PLAY_ORDER_BY_CATEGORY[category],
|
||||
);
|
||||
const MOBILE_QUICK_AMOUNT_PRESETS = ["10", "50", "100"] as const;
|
||||
const DEFAULT_DRAFT_ROW_COUNT = 20;
|
||||
|
||||
function playOrderForActiveCategory(activeCategory: PlayHallCategory): readonly string[] {
|
||||
@@ -302,37 +208,6 @@ function isPlayOpenForPlayer(row: PlayEffectivePlayRow): boolean {
|
||||
return Boolean(row.master_enabled && row.config?.is_enabled);
|
||||
}
|
||||
|
||||
type HallTranslate = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
/** 表头用短标签,避免 digit_big + 千/百/十/个 挤成一团。 */
|
||||
function playColumnHeaderLabel(
|
||||
play: PlayEffectivePlayRow,
|
||||
category: PlayHallCategory,
|
||||
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: PlayHallCategory): number[] {
|
||||
if (category === "D2") return [2, 3];
|
||||
if (category === "D3") return [1, 2, 3];
|
||||
return [0, 1, 2, 3];
|
||||
}
|
||||
|
||||
function digitSlotLabel(category: PlayHallCategory, slot: number): string {
|
||||
const labels: Record<PlayHallCategory, Record<number, string>> = {
|
||||
D2: { 2: "十", 3: "个" },
|
||||
D3: { 1: "百", 2: "十", 3: "个" },
|
||||
D4: { 0: "千", 1: "百", 2: "十", 3: "个" },
|
||||
};
|
||||
return labels[category][slot] ?? String(slot + 1);
|
||||
}
|
||||
|
||||
function amountKeyForPlay(playCode: string, digitSlot?: number): string {
|
||||
return digitSlot === undefined ? playCode : `${playCode}@${digitSlot}`;
|
||||
}
|
||||
@@ -354,20 +229,6 @@ function playColumnsForCategory(
|
||||
});
|
||||
}
|
||||
|
||||
function numberMaxCharsForCategory(category: PlayHallCategory): number {
|
||||
return category === "D2" ? 2 : category === "D3" ? 3 : 4;
|
||||
}
|
||||
|
||||
function sanitizeNumber(raw: string, category: PlayHallCategory): string {
|
||||
const normalized =
|
||||
category === "D4"
|
||||
? raw.replace(/[^0-9Rr]/g, "").toUpperCase()
|
||||
: raw.replace(/\D/g, "");
|
||||
const maxChars = numberMaxCharsForCategory(category);
|
||||
|
||||
return category === "D4" ? normalized.slice(0, maxChars) : normalized.slice(-maxChars);
|
||||
}
|
||||
|
||||
function sanitizeAmount(raw: string): string {
|
||||
return raw.replace(/[^\d.]/g, "").replace(/(\..*)\./g, "$1").slice(0, 12);
|
||||
}
|
||||
@@ -483,99 +344,6 @@ function appendUnique(values: string[], value: string, limit = 20): string[] {
|
||||
return next.slice(0, limit);
|
||||
}
|
||||
|
||||
function sortedDigits(value: string): string {
|
||||
return value.split("").sort().join("");
|
||||
}
|
||||
|
||||
function matchesRiskAlert(
|
||||
alertNumber: string,
|
||||
playCode: string,
|
||||
rowNumber: string,
|
||||
category: Exclude<HallCategory, "JACKPOT">,
|
||||
digitSlot?: number,
|
||||
): boolean {
|
||||
const normalizedRow = rowNumber.toUpperCase();
|
||||
|
||||
if (playCode === "big" || playCode === "small" || playCode === "straight") {
|
||||
return alertNumber === normalizedRow.slice(0, 4);
|
||||
}
|
||||
|
||||
if (playCode === "box" || playCode === "ibox" || playCode === "mbox") {
|
||||
return sortedDigits(alertNumber) === sortedDigits(normalizedRow.slice(0, 4));
|
||||
}
|
||||
|
||||
if (playCode === "roll") {
|
||||
const regex = new RegExp(`^${normalizedRow.replace(/R/g, "[0-9]")}$`);
|
||||
return regex.test(alertNumber);
|
||||
}
|
||||
|
||||
if (playCode.startsWith("pos_4")) {
|
||||
return alertNumber === normalizedRow.slice(0, 4);
|
||||
}
|
||||
|
||||
if (playCode.startsWith("pos_3")) {
|
||||
return alertNumber.endsWith(normalizedRow.slice(-3));
|
||||
}
|
||||
|
||||
if (playCode.startsWith("pos_2")) {
|
||||
return alertNumber.endsWith(normalizedRow.slice(-2));
|
||||
}
|
||||
|
||||
if (playCode === "head") {
|
||||
return ["5", "6", "7", "8", "9"].includes(alertNumber[0] ?? "");
|
||||
}
|
||||
if (playCode === "tail") {
|
||||
return ["0", "1", "2", "3", "4"].includes(alertNumber[0] ?? "");
|
||||
}
|
||||
if (playCode === "odd" || playCode === "even") {
|
||||
const last = alertNumber[3] ?? "";
|
||||
return playCode === "odd"
|
||||
? ["1", "3", "5", "7", "9"].includes(last)
|
||||
: ["0", "2", "4", "6", "8"].includes(last);
|
||||
}
|
||||
if (playCode === "digit_big" || playCode === "digit_small") {
|
||||
const slot = digitSlot ?? digitSlotOptions(category).at(-1) ?? 3;
|
||||
const last = alertNumber[slot] ?? "";
|
||||
return playCode === "digit_big"
|
||||
? ["5", "6", "7", "8", "9"].includes(last)
|
||||
: ["0", "1", "2", "3", "4"].includes(last);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function cellRiskState(
|
||||
play: PlayEffectivePlayRow,
|
||||
rowNumber: string,
|
||||
category: Exclude<HallCategory, "JACKPOT">,
|
||||
alertRows: DrawCurrentRiskPoolAlert[] | undefined,
|
||||
liveSoldOutNumbers: ReadonlySet<string>,
|
||||
liveWarningNumbers: ReadonlySet<string>,
|
||||
digitSlot?: number,
|
||||
): CellRiskState {
|
||||
const normalizedRow = rowNumber.trim().toUpperCase();
|
||||
if (!normalizedRow) return "open";
|
||||
|
||||
if (liveSoldOutNumbers.has(normalizedRow)) {
|
||||
return "sold_out";
|
||||
}
|
||||
|
||||
if (liveWarningNumbers.has(normalizedRow)) {
|
||||
return "warning";
|
||||
}
|
||||
|
||||
const alerts = alertRows ?? [];
|
||||
if (alerts.length === 0) return "open";
|
||||
|
||||
for (const alert of alerts) {
|
||||
if (matchesRiskAlert(alert.normalized_number, play.play_code, normalizedRow, category, digitSlot)) {
|
||||
return alert.status === "sold_out" ? "sold_out" : "warning";
|
||||
}
|
||||
}
|
||||
|
||||
return "open";
|
||||
}
|
||||
|
||||
function quickFillKeys(category: HallCategory): { favorites: string; history: string } {
|
||||
return {
|
||||
favorites: `lottery.hall.quickfill.favorites.${category}`,
|
||||
@@ -615,11 +383,6 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
const [pendingSelectionChange, setPendingSelectionChange] = useState<PendingSelectionChange | null>(
|
||||
null,
|
||||
);
|
||||
const holdFavoriteRef = useRef<{ timer: number | null; number: string | null; longPress: boolean }>({
|
||||
timer: null,
|
||||
number: null,
|
||||
longPress: false,
|
||||
});
|
||||
/** 单次预览→确认共用,重试 place 复用,避免重复扣款 */
|
||||
const placeTraceIdRef = useRef<string | null>(null);
|
||||
const previewRequestSeqRef = useRef(0);
|
||||
@@ -739,6 +502,10 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
() => 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;
|
||||
@@ -1846,337 +1613,26 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
|
||||
<HallPlaySummaryGrid items={summaryItems} className="lg:hidden" />
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
isMobile ? "space-y-3" : "hidden",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-white",
|
||||
isMobile
|
||||
? "rounded-lg border border-[#e8eef7] px-3 py-2.5"
|
||||
: "min-w-0 flex-1 px-2 py-0.5",
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex gap-3", isMobile ? "items-start justify-between" : "items-center justify-between")}>
|
||||
<div className={cn("min-w-0", !isMobile && "flex flex-1 items-center gap-4")}>
|
||||
<div className={cn("shrink-0", !isMobile && "min-w-[5.5rem]")}>
|
||||
<p className="text-sm font-bold leading-5 text-slate-950">
|
||||
{t("hall.quickFill.title")}
|
||||
</p>
|
||||
{isMobile ? (
|
||||
<p className="mt-0.5 text-[11px] leading-5 text-slate-500">
|
||||
{t("hall.mobile.quickFillSummary", {
|
||||
defaultValue: "收藏 {{favorites}} 个,历史 {{history}} 个",
|
||||
favorites: favorites.length,
|
||||
history: historyNumbers.length,
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!isMobile ? (
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-4 gap-y-1.5">
|
||||
{favoriteChips.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="mr-0.5 text-[11px] font-semibold text-[#d81435]">
|
||||
{t("hall.quickFill.favorites")}
|
||||
</span>
|
||||
{favoriteChips.map((number) => (
|
||||
<button
|
||||
key={`fav-${number}`}
|
||||
type="button"
|
||||
className="inline-flex h-7 touch-manipulation items-center gap-1 rounded-full border border-[#ffd7db] bg-[#fff3f5] px-2.5 text-xs font-semibold text-[#d81435] transition-colors hover:bg-[#ffe9ed]"
|
||||
onPointerDown={() => {
|
||||
const current = holdFavoriteRef.current;
|
||||
current.number = number;
|
||||
current.longPress = false;
|
||||
if (current.timer) window.clearTimeout(current.timer);
|
||||
current.timer = window.setTimeout(() => {
|
||||
current.longPress = true;
|
||||
toggleFavoriteNumber(number);
|
||||
}, 500);
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
const current = holdFavoriteRef.current;
|
||||
if (current.timer) {
|
||||
window.clearTimeout(current.timer);
|
||||
current.timer = null;
|
||||
}
|
||||
if (!current.longPress) {
|
||||
fillCurrentRow(number);
|
||||
}
|
||||
current.longPress = false;
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
const current = holdFavoriteRef.current;
|
||||
if (current.timer) {
|
||||
window.clearTimeout(current.timer);
|
||||
current.timer = null;
|
||||
}
|
||||
current.longPress = false;
|
||||
}}
|
||||
>
|
||||
{number}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<span className="mr-0.5 text-[11px] font-semibold text-slate-400">
|
||||
{t("hall.quickFill.history")}
|
||||
</span>
|
||||
{historyChips.length > 0 ? (
|
||||
historyChips.map((number) => (
|
||||
<button
|
||||
key={`his-${number}`}
|
||||
type="button"
|
||||
className="inline-flex h-7 min-w-9 items-center justify-center rounded-full border border-[#d7e5f8] bg-[#f8fbff] px-2.5 text-xs font-bold text-[#07459f] transition-colors hover:border-[#b9d0f3] hover:bg-[#eef6ff]"
|
||||
onClick={() => fillCurrentRow(number)}
|
||||
>
|
||||
{number}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<span className="inline-flex h-7 items-center rounded-full border border-dashed border-slate-200 bg-slate-50 px-2.5 text-xs text-slate-400">
|
||||
{t("hall.quickFill.emptyHistory")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{isMobile ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={
|
||||
quickFillExpanded
|
||||
? t("hall.mobile.collapseQuickFill", { defaultValue: "收起快捷填单" })
|
||||
: t("hall.mobile.expandQuickFill", { defaultValue: "展开快捷填单" })
|
||||
}
|
||||
aria-pressed={quickFillExpanded}
|
||||
className="size-8 rounded-full border border-[#dfe6f0] bg-[#f8fafc] text-slate-500 hover:bg-slate-100 hover:text-slate-900"
|
||||
onClick={() => setQuickFillExpanded((current) => !current)}
|
||||
>
|
||||
{quickFillExpanded ? (
|
||||
<ChevronUp className="size-4" aria-hidden />
|
||||
) : (
|
||||
<ChevronDown className="size-4" aria-hidden />
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
{activeRow?.number ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={
|
||||
favorites.includes(activeRow.number)
|
||||
? t("hall.quickFill.unfavorite")
|
||||
: t("hall.quickFill.favorite")
|
||||
}
|
||||
aria-pressed={favorites.includes(activeRow.number)}
|
||||
title={
|
||||
favorites.includes(activeRow.number)
|
||||
? t("hall.quickFill.unfavorite")
|
||||
: t("hall.quickFill.favorite")
|
||||
}
|
||||
className={cn(
|
||||
"size-8 rounded-full border border-[#ffd7db] bg-[#fff7f8] text-[#d81435] hover:bg-[#fff1f3] hover:text-[#b80f2b]",
|
||||
favorites.includes(activeRow.number) &&
|
||||
"border-[#d81435] bg-[#d81435] text-white hover:bg-[#c51230] hover:text-white",
|
||||
)}
|
||||
onClick={() => toggleFavoriteNumber(activeRow.number)}
|
||||
>
|
||||
<Star
|
||||
className={cn(
|
||||
"size-4",
|
||||
favorites.includes(activeRow.number) && "fill-current",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("hall.quickFill.clearAll")}
|
||||
title={t("hall.quickFill.clearAll")}
|
||||
className="size-8 rounded-full border border-[#dfe6f0] bg-[#f8fafc] text-slate-500 hover:bg-slate-100 hover:text-slate-900"
|
||||
onClick={clearAllRows}
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isMobile ? (
|
||||
<div className="mt-2 space-y-2">
|
||||
{quickFillExpanded ? (
|
||||
<div className="rounded-xl border border-[#e7eef8] bg-[#f8fbff] px-3 py-2.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[11px] font-semibold text-[#5b6f95]">
|
||||
{t("hall.quickFill.batchTitle", { defaultValue: "Quick amount" })}
|
||||
</span>
|
||||
{MOBILE_QUICK_AMOUNT_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
disabled={tableDisabled}
|
||||
onClick={() => applyQuickAmountToActiveRow(preset)}
|
||||
className="inline-flex h-8 min-w-12 items-center justify-center rounded-full border border-[#cfe0fb] bg-white px-3 text-xs font-bold text-[#0b4ab3] transition-colors hover:border-[#aac7f5] hover:bg-[#edf5ff] disabled:opacity-40"
|
||||
>
|
||||
{preset}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
disabled={tableDisabled || !activeRow?.number}
|
||||
onClick={copyPreviousRowToActiveRow}
|
||||
className="inline-flex h-8 items-center justify-center rounded-full border border-[#dfe6f0] bg-white px-3 text-xs font-semibold text-slate-600 transition-colors hover:bg-slate-50 disabled:opacity-40"
|
||||
>
|
||||
{t("hall.quickFill.copyPrev", { defaultValue: "Copy prev row" })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={tableDisabled}
|
||||
onClick={clearActiveRowAmounts}
|
||||
className="inline-flex h-8 items-center justify-center rounded-full border border-[#ffe0e5] bg-white px-3 text-xs font-semibold text-[#d81435] transition-colors hover:bg-[#fff4f6] disabled:opacity-40"
|
||||
>
|
||||
{t("hall.quickFill.clearRow", { defaultValue: "Clear row amounts" })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{quickFillExpanded ? (
|
||||
<>
|
||||
{favoriteChips.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="mr-0.5 text-[11px] font-semibold text-[#d81435]">
|
||||
{t("hall.quickFill.favorites")}
|
||||
</span>
|
||||
{favoriteChips.map((number) => (
|
||||
<button
|
||||
key={`fav-${number}`}
|
||||
type="button"
|
||||
className="inline-flex h-8 touch-manipulation items-center gap-1 rounded-full border border-[#ffd7db] bg-[#fff3f5] px-2.5 text-xs font-semibold text-[#d81435] transition-colors hover:bg-[#ffe9ed]"
|
||||
onPointerDown={() => {
|
||||
const current = holdFavoriteRef.current;
|
||||
current.number = number;
|
||||
current.longPress = false;
|
||||
if (current.timer) window.clearTimeout(current.timer);
|
||||
current.timer = window.setTimeout(() => {
|
||||
current.longPress = true;
|
||||
toggleFavoriteNumber(number);
|
||||
}, 500);
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
const current = holdFavoriteRef.current;
|
||||
if (current.timer) {
|
||||
window.clearTimeout(current.timer);
|
||||
current.timer = null;
|
||||
}
|
||||
if (!current.longPress) {
|
||||
fillCurrentRow(number);
|
||||
}
|
||||
current.longPress = false;
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
const current = holdFavoriteRef.current;
|
||||
if (current.timer) {
|
||||
window.clearTimeout(current.timer);
|
||||
current.timer = null;
|
||||
}
|
||||
current.longPress = false;
|
||||
}}
|
||||
>
|
||||
{number}
|
||||
<span className="text-[11px] font-medium opacity-70">
|
||||
{t("hall.quickFill.tapHold")}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="mr-0.5 text-[11px] font-semibold text-slate-400">
|
||||
{t("hall.quickFill.history")}
|
||||
</span>
|
||||
{historyChips.length > 0 ? (
|
||||
historyChips.map((number) => (
|
||||
<button
|
||||
key={`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>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-x-auto",
|
||||
isMobile
|
||||
? "-mx-1 flex gap-1.5 px-1 pb-1"
|
||||
: "hidden",
|
||||
)}
|
||||
>
|
||||
{categoryTabs.map((tab) => {
|
||||
const hasPlays = openPlays.some(
|
||||
(play) => playCategory(play.play_code) === tab.value,
|
||||
);
|
||||
const active = activeCategory === tab.value;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.value}
|
||||
type="button"
|
||||
disabled={!hasPlays}
|
||||
onClick={() => setActiveCategory(tab.value)}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center font-bold transition-colors",
|
||||
isMobile
|
||||
? "min-w-[5.25rem] rounded-t-xl border border-b-0 px-4 py-3 text-sm"
|
||||
: "min-w-[4.5rem] rounded-lg px-3.5 py-2 text-sm",
|
||||
isMobile
|
||||
? active
|
||||
? "border-[#d7e1f3] border-b-white bg-white text-[#21335b] shadow-[0_-1px_0_rgba(255,255,255,0.8)]"
|
||||
: "border-transparent bg-transparent text-[#6d8fd6] hover:text-[#2d63e2]"
|
||||
: active
|
||||
? "bg-[#2d63e2] text-white shadow-[0_4px_12px_rgba(45,99,226,0.28)]"
|
||||
: "bg-transparent text-[#5b7fbf] hover:bg-[#f3f7ff] hover:text-[#2d63e2]",
|
||||
!hasPlays && "cursor-not-allowed opacity-40",
|
||||
)}
|
||||
aria-pressed={active}
|
||||
>
|
||||
<span className={cn("mr-2 text-[13px]", !isMobile && active && "text-white/90")} aria-hidden>
|
||||
▦
|
||||
</span>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{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
|
||||
@@ -2202,143 +1658,41 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
showWideTableHint && "player-table-scroll-wrap",
|
||||
)}
|
||||
>
|
||||
<div className="player-table-scroll overflow-x-auto overscroll-x-contain">
|
||||
<table
|
||||
className={cn("mx-auto w-max border-collapse", isMobile ? "text-[11px]" : "text-sm")}
|
||||
style={{ width: tableWidthPx }}
|
||||
>
|
||||
<thead>
|
||||
<tr className="border-b border-[#edf2f8] bg-[#f8fafd] text-[#58709d]">
|
||||
<th className={cn("sticky left-0 z-30 bg-[#f8fafd] px-0.5 text-center font-bold shadow-[2px_0_6px_rgba(15,23,42,0.04)]", isMobile ? "py-1.5" : "py-2.5", indexColClass)}>
|
||||
{t("hall.table.no", { defaultValue: "No." })}
|
||||
</th>
|
||||
<th
|
||||
className={cn(
|
||||
"sticky z-30 bg-[#f8fafd] px-1 text-center font-bold shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
|
||||
isMobile ? "py-1.5" : "py-2.5",
|
||||
stickyNumberLeftClass,
|
||||
numberColClass,
|
||||
)}
|
||||
>
|
||||
<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={cn(amountColClass, "px-0.5 text-center font-bold", isMobile ? "py-1.5" : "py-2.5")}
|
||||
>
|
||||
<span className="block whitespace-nowrap text-[10px] leading-tight">
|
||||
{playColumnHeaderLabel(
|
||||
column.play,
|
||||
playCategory(column.play.play_code),
|
||||
column.digitSlot,
|
||||
t,
|
||||
)}
|
||||
</span>
|
||||
<Checkbox
|
||||
checked={syncAmountColumns[column.key] === true}
|
||||
disabled={tableDisabled}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleSyncAmountColumn(column, checked === true)
|
||||
}
|
||||
aria-label={t("hall.table.syncColumnAmount", {
|
||||
column: playColumnHeaderLabel(column.play, playCategory(column.play.play_code), column.digitSlot, t),
|
||||
})}
|
||||
title={t("hall.table.syncColumnAmount", {
|
||||
column: playColumnHeaderLabel(column.play, playCategory(column.play.play_code), column.digitSlot, t),
|
||||
})}
|
||||
className={cn("mx-auto mt-1", isMobile ? "size-3.5" : "size-4")}
|
||||
/>
|
||||
</th>
|
||||
))}
|
||||
{showSelectionTypeColumn ? (
|
||||
<th className={cn(selectionTypeColClass, "px-1 py-1 text-center font-bold")}>
|
||||
<span className="block text-xs">{t("hall.table.selectionType", { defaultValue: "Type" })}</span>
|
||||
<select
|
||||
defaultValue=""
|
||||
disabled={tableDisabled}
|
||||
onChange={(event) => {
|
||||
const selectionType = event.target.value as SelectionType | "";
|
||||
if (selectionType) requestAllSelectionTypes(selectionType);
|
||||
event.currentTarget.value = "";
|
||||
}}
|
||||
className="mx-auto mt-1 h-5 w-full rounded border border-[#d7e1f3] bg-white px-0.5 text-[10px] font-semibold text-[#304f86]"
|
||||
aria-label={t("hall.table.selectAllTypes")}
|
||||
title={t("hall.table.setAllTypes")}
|
||||
>
|
||||
<option value="">{t("hall.table.selectAll")}</option>
|
||||
{selectionTypeOptions.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{t(`hall.table.selectionTypes.${type}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</th>
|
||||
) : null}
|
||||
{betProviders.map((provider, providerIndex) => {
|
||||
const checked = rows.length > 0 && rows.every((row) => row.providerCodes.includes(provider.code));
|
||||
return (
|
||||
<th
|
||||
key={provider.code}
|
||||
className={cn(providerColClass, providerColumnTone(providerIndex), "px-0.5 py-1 text-center font-black")}
|
||||
title={provider.name}
|
||||
>
|
||||
<span className="block text-sm leading-none">{provider.short_code}</span>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
disabled={tableDisabled}
|
||||
onCheckedChange={(next) => toggleProviderColumn(provider.code, next === true)}
|
||||
aria-label={t("hall.table.selectAllProvider", { provider: provider.name })}
|
||||
className="mx-auto mt-1 border-slate-500 bg-white data-checked:border-slate-800 data-checked:bg-slate-800"
|
||||
/>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
<th className={cn(rowTotalColClass, "bg-[#eef5ff] px-1 py-1 text-center font-black text-[#17408d]")}>
|
||||
<span className="block text-xs">{t("hall.table.rowTotal", { defaultValue: "Total" })}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, index) => (
|
||||
<DraftRowItem
|
||||
key={row.id}
|
||||
row={row}
|
||||
index={index}
|
||||
rowActive={activeRowId === row.id}
|
||||
tableDisabled={tableDisabled}
|
||||
numberPlaceholder={numberPlaceholder}
|
||||
activeCategory={activeCategory}
|
||||
numberMaxChars={numberMaxChars}
|
||||
betProviders={betProviders}
|
||||
playColumns={playColumns}
|
||||
alertRows={alertRows}
|
||||
liveSoldOutNumbers={liveSoldOutNumbers}
|
||||
liveWarningNumbers={liveWarningNumbers}
|
||||
updateRowNumber={updateRowNumber}
|
||||
updateRowSelectionType={requestRowSelectionType}
|
||||
selectionTypeOptions={selectionTypeOptions}
|
||||
updateAmount={updateAmount}
|
||||
toggleRowProvider={toggleRowProvider}
|
||||
setActiveRowId={setActiveRowId}
|
||||
t={t}
|
||||
isMobile={isMobile}
|
||||
indexColClass={indexColClass}
|
||||
numberColClass={numberColClass}
|
||||
stickyNumberLeftClass={stickyNumberLeftClass}
|
||||
selectionTypeColClass={selectionTypeColClass}
|
||||
showSelectionTypeColumn={showSelectionTypeColumn}
|
||||
providerColClass={providerColClass}
|
||||
rowTotalColClass={rowTotalColClass}
|
||||
currencyCode={currencyCode}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -2462,327 +1816,14 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
creditMode={creditMode}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
<HallSelectionConfirmDialog
|
||||
open={pendingSelectionChange !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) cancelPendingSelectionChange();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-[380px] rounded-2xl border border-[#dfe8f6] bg-white p-0 shadow-[0_24px_70px_rgba(15,23,42,0.18)]">
|
||||
<DialogHeader className="gap-2 border-b border-[#eef2f8] px-5 py-4 text-left">
|
||||
<DialogTitle className="text-base font-black text-[#0b3f96]">
|
||||
{t("hall.table.selectionConfirm.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm leading-relaxed text-slate-600">
|
||||
{pendingSelectionPreview
|
||||
? pendingSelectionPreview.next === "full_cover"
|
||||
? t("hall.table.selectionConfirm.fullCoverBody", {
|
||||
type: t(`hall.table.selectionTypes.${pendingSelectionPreview.next}`),
|
||||
count: pendingSelectionPreview.comboCount,
|
||||
amount: formatMinorAsCurrency(pendingSelectionPreview.toMinor || pendingSelectionPreview.fromMinor, currencyCode),
|
||||
})
|
||||
: pendingSelectionPreview.fromMinor > 0 &&
|
||||
pendingSelectionPreview.toMinor > pendingSelectionPreview.fromMinor
|
||||
? t("hall.table.selectionConfirm.increaseBody", {
|
||||
type: t(`hall.table.selectionTypes.${pendingSelectionPreview.next}`),
|
||||
count: pendingSelectionPreview.comboCount,
|
||||
from: formatMinorAsCurrency(pendingSelectionPreview.fromMinor, currencyCode),
|
||||
to: formatMinorAsCurrency(pendingSelectionPreview.toMinor, currencyCode),
|
||||
})
|
||||
: t("hall.table.selectionConfirm.genericBody", {
|
||||
type: t(`hall.table.selectionTypes.${pendingSelectionPreview.next}`),
|
||||
count: pendingSelectionPreview.comboCount,
|
||||
})
|
||||
: t("hall.table.selectionConfirm.genericBody", {
|
||||
type: "",
|
||||
count: 1,
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{pendingSelectionPreview && pendingSelectionPreview.comboCount > 1 ? (
|
||||
<div className="space-y-1 px-5 py-3 text-xs text-slate-600">
|
||||
<p className="font-bold text-[#304f86]">
|
||||
{t("hall.table.selectionConfirm.comboHint", {
|
||||
count: pendingSelectionPreview.comboCount,
|
||||
})}
|
||||
</p>
|
||||
{pendingSelectionPreview.number ? (
|
||||
<p className="font-mono text-[11px] text-slate-500">
|
||||
{t("hall.table.selectionConfirm.numberHint", {
|
||||
number: pendingSelectionPreview.number,
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<DialogFooter className="mx-0 mb-0 gap-2 rounded-b-2xl border-t border-[#eef2f8] bg-white px-5 py-4 sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="rounded-lg"
|
||||
onClick={cancelPendingSelectionChange}
|
||||
>
|
||||
{t("hall.table.selectionConfirm.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="rounded-lg bg-[#e5002c] text-white hover:bg-[#d10028]"
|
||||
onClick={confirmPendingSelectionChange}
|
||||
>
|
||||
{t("hall.table.selectionConfirm.confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
preview={pendingSelectionPreview}
|
||||
currencyCode={currencyCode}
|
||||
onCancel={cancelPendingSelectionChange}
|
||||
onConfirm={confirmPendingSelectionChange}
|
||||
t={t}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const DraftRowItem = memo(function DraftRowItem({
|
||||
row,
|
||||
index,
|
||||
rowActive,
|
||||
tableDisabled,
|
||||
numberPlaceholder,
|
||||
activeCategory,
|
||||
numberMaxChars,
|
||||
betProviders,
|
||||
playColumns,
|
||||
alertRows,
|
||||
liveSoldOutNumbers,
|
||||
liveWarningNumbers,
|
||||
updateRowNumber,
|
||||
updateRowSelectionType,
|
||||
selectionTypeOptions,
|
||||
updateAmount,
|
||||
toggleRowProvider,
|
||||
setActiveRowId,
|
||||
t,
|
||||
isMobile,
|
||||
indexColClass,
|
||||
numberColClass,
|
||||
stickyNumberLeftClass,
|
||||
selectionTypeColClass,
|
||||
showSelectionTypeColumn,
|
||||
providerColClass,
|
||||
rowTotalColClass,
|
||||
currencyCode,
|
||||
}: {
|
||||
row: DraftRow;
|
||||
index: number;
|
||||
rowActive: boolean;
|
||||
tableDisabled: boolean;
|
||||
numberPlaceholder: string;
|
||||
activeCategory: PlayHallCategory;
|
||||
numberMaxChars: number;
|
||||
betProviders: BetProviderRow[];
|
||||
playColumns: PlayColumn[];
|
||||
alertRows: DrawCurrentRiskPoolAlert[];
|
||||
liveSoldOutNumbers: Set<string>;
|
||||
liveWarningNumbers: Set<string>;
|
||||
updateRowNumber: (id: string, value: string) => void;
|
||||
updateRowSelectionType: (id: string, value: SelectionType) => void;
|
||||
selectionTypeOptions: SelectionType[];
|
||||
updateAmount: (rowId: string, playCode: string, value: string) => void;
|
||||
toggleRowProvider: (rowId: string, code: string) => void;
|
||||
setActiveRowId: (id: string) => void;
|
||||
t: HallTranslate;
|
||||
isMobile: boolean;
|
||||
indexColClass: string;
|
||||
numberColClass: string;
|
||||
stickyNumberLeftClass: string;
|
||||
selectionTypeColClass: string;
|
||||
showSelectionTypeColumn: boolean;
|
||||
providerColClass: string;
|
||||
rowTotalColClass: string;
|
||||
currencyCode: string;
|
||||
}) {
|
||||
const displayNumber = sanitizeNumber(row.number, activeCategory);
|
||||
const comboCount = selectionCombinationCount(displayNumber, row.selectionType);
|
||||
const hasFullCoverAmountError = row.selectionType === "full_cover" && playColumns.some((column) => {
|
||||
const amount = parseDecimalInputToMinor(row.amounts[column.key] ?? "", currencyCode);
|
||||
return amount !== null && amount > 0 && !isFullCoverAmountDivisible(amount, comboCount);
|
||||
});
|
||||
const rowTotalMinor =
|
||||
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;
|
||||
return total + resolveSelectionTotalBet(amount, row.selectionType, comboCount);
|
||||
}, 0) * row.providerCodes.length;
|
||||
|
||||
return (
|
||||
<tr
|
||||
className={cn(
|
||||
"border-b border-[#f0f3f8] last:border-b-0",
|
||||
rowActive && "bg-[#f5f9ff]/80",
|
||||
)}
|
||||
>
|
||||
<td
|
||||
className={cn(
|
||||
"sticky left-0 z-20 align-top px-0.5 text-center font-black text-[#17408d] shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
|
||||
isMobile ? "py-1.5" : "py-2",
|
||||
indexColClass,
|
||||
rowActive ? "bg-[#f5f9ff]" : "bg-white",
|
||||
)}
|
||||
>
|
||||
{index + 1}
|
||||
</td>
|
||||
<td
|
||||
className={cn(
|
||||
"sticky z-20 align-top px-1 shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
|
||||
isMobile ? "py-1.5" : "py-2",
|
||||
stickyNumberLeftClass,
|
||||
numberColClass,
|
||||
rowActive ? "bg-[#f5f9ff]" : "bg-white",
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
value={displayNumber}
|
||||
disabled={tableDisabled}
|
||||
inputMode="numeric"
|
||||
pattern="[0-9Rr]*"
|
||||
autoComplete="off"
|
||||
placeholder={numberPlaceholder}
|
||||
maxLength={numberMaxChars}
|
||||
onFocus={() => setActiveRowId(row.id)}
|
||||
onClick={() => setActiveRowId(row.id)}
|
||||
onChange={(event) => updateRowNumber(row.id, event.target.value)}
|
||||
className={cn(
|
||||
"w-full rounded-md border-[#e1e8f3] bg-white px-1 text-center font-mono font-bold tabular-nums text-slate-950 shadow-sm focus-visible:ring-[#1d57b7]",
|
||||
!isMobile ? "h-9 text-sm" : "h-8 text-sm",
|
||||
"tracking-[0.12em]",
|
||||
)}
|
||||
/>
|
||||
</td>
|
||||
{playColumns.map((column) => {
|
||||
const { play } = column;
|
||||
const amountText = row.amounts[column.key] ?? "";
|
||||
const status = cellRiskState(
|
||||
play,
|
||||
row.number,
|
||||
playCategory(play.play_code),
|
||||
alertRows,
|
||||
liveSoldOutNumbers,
|
||||
liveWarningNumbers,
|
||||
column.digitSlot,
|
||||
);
|
||||
const disabled = tableDisabled || status === "sold_out" || (play.config !== null && !play.config.is_enabled);
|
||||
const hasAmount = amountText.trim().length > 0;
|
||||
const isInputValidForPlay = row.number.trim().length > 0 && draftLineIssueReason(play.play_code, row.number, column.digitSlot) === null;
|
||||
const cellDisabled = disabled || !isInputValidForPlay;
|
||||
|
||||
return (
|
||||
<td
|
||||
key={`${row.id}-${column.key}`}
|
||||
className={cn(
|
||||
"px-0.5 align-top",
|
||||
isMobile ? "py-1.5" : "py-2",
|
||||
status === "warning" && "bg-amber-50/70",
|
||||
status === "sold_out" && "bg-slate-100 text-slate-400",
|
||||
)}
|
||||
>
|
||||
{isInputValidForPlay ? (
|
||||
<Input
|
||||
value={amountText}
|
||||
disabled={cellDisabled}
|
||||
inputMode="decimal"
|
||||
placeholder={
|
||||
status === "sold_out"
|
||||
? t("hall.table.soldOut")
|
||||
: "-"
|
||||
}
|
||||
onFocus={() => setActiveRowId(row.id)}
|
||||
onClick={() => setActiveRowId(row.id)}
|
||||
onChange={(event) => updateAmount(row.id, column.key, event.target.value)}
|
||||
className={cn(
|
||||
"w-full rounded-md border-[#e1e8f3] bg-white px-0.5 text-center font-bold tabular-nums shadow-sm focus-visible:ring-[#1d57b7]",
|
||||
!isMobile ? "h-9 text-sm" : "h-8 text-sm",
|
||||
hasAmount && "border-[#9bbcff] bg-[#f5f9ff] text-[#0b3f96]",
|
||||
status === "warning" && "border-amber-200 bg-amber-50 text-amber-800",
|
||||
status === "sold_out" && "border-slate-200 bg-slate-100 text-slate-400",
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className={cn("w-full rounded-md border border-slate-200 bg-slate-100/70", isMobile ? "h-8" : "h-9")} />
|
||||
)}
|
||||
{isInputValidForPlay && status === "sold_out" ? (
|
||||
<p className="mt-0.5 text-center text-[10px] font-bold text-slate-500">
|
||||
{t("hall.table.soldOut")}
|
||||
</p>
|
||||
) : status === "warning" ? (
|
||||
<p className="mt-0.5 text-center text-[10px] font-bold text-amber-700">
|
||||
{t("hall.table.warning")}
|
||||
</p>
|
||||
) : null}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
{showSelectionTypeColumn ? (
|
||||
<td className={cn(selectionTypeColClass, "align-top px-1 text-center", isMobile ? "py-1.5" : "py-2", rowActive && "bg-[#f5f9ff]")}>
|
||||
<select
|
||||
value={row.selectionType}
|
||||
disabled={tableDisabled}
|
||||
onChange={(event) => updateRowSelectionType(row.id, event.target.value as SelectionType)}
|
||||
className={cn("w-full rounded-md border border-[#e1e8f3] bg-white px-1 font-semibold text-[#304f86]", isMobile ? "h-8 text-[10px]" : "h-9 text-xs")}
|
||||
aria-label={t("hall.table.selectionTypeForRow", { row: index + 1 })}
|
||||
>
|
||||
{selectionTypeOptions.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{t(`hall.table.selectionTypes.${type}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{comboCount > 1 && displayNumber.length >= 2 ? (
|
||||
<div className="mt-0.5 flex items-center justify-center gap-0.5 text-[9px] font-bold leading-none text-[#0b56b7]">
|
||||
<span>{t("hall.table.comboCount", { count: comboCount })}</span>
|
||||
{row.selectionType === "full_cover" || row.selectionType === "half_play" ? (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger
|
||||
className="inline-flex size-3 items-center justify-center rounded-full text-[#5378b5] outline-none hover:text-[#0b56b7] focus-visible:ring-1 focus-visible:ring-[#0b56b7]"
|
||||
aria-label={t("hall.table.selectionTypeRule")}
|
||||
title={t("hall.table.selectionTypeRule")}
|
||||
>
|
||||
<CircleHelp className="size-3" aria-hidden />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Positioner side="bottom" sideOffset={6}>
|
||||
<Tooltip.Popup className="z-[70] max-w-52 rounded-md bg-slate-900 px-2 py-1.5 text-left text-[11px] font-medium leading-snug text-white shadow-lg">
|
||||
{row.selectionType === "full_cover"
|
||||
? t("hall.table.fullCoverSplitHint", { count: comboCount })
|
||||
: t("hall.table.halfPlayHint")}
|
||||
</Tooltip.Popup>
|
||||
</Tooltip.Positioner>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{row.selectionType === "full_cover" && comboCount > 1 && hasFullCoverAmountError ? (
|
||||
<p className="mt-0.5 text-[9px] font-medium leading-tight text-red-600">
|
||||
{t("hall.table.fullCoverDivisibilityError", { count: comboCount })}
|
||||
</p>
|
||||
) : null}
|
||||
</td>
|
||||
) : null}
|
||||
{betProviders.map((provider, providerIndex) => (
|
||||
<td
|
||||
key={provider.code}
|
||||
className={cn(providerColClass, providerColumnTone(providerIndex), "px-0.5 text-center", isMobile ? "py-1.5" : "py-2")}
|
||||
>
|
||||
<Checkbox
|
||||
checked={row.providerCodes.includes(provider.code)}
|
||||
disabled={tableDisabled}
|
||||
onCheckedChange={() => toggleRowProvider(row.id, provider.code)}
|
||||
aria-label={`${provider.name} ${index + 1}`}
|
||||
className={cn("mx-auto border-slate-500 bg-white data-checked:border-slate-800 data-checked:bg-slate-800", !isMobile && "size-4")}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
<td className={cn(rowTotalColClass, "bg-[#f7fbff] px-1 text-center font-mono font-black tabular-nums text-[#0b3f96]", isMobile ? "py-1.5" : "py-2", rowActive && "bg-[#edf5ff]")}>
|
||||
{formatMinorAmount(rowTotalMinor)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user