feat(hall-betting-grid): 增加对投注行的优化和重构,提升代码可读性和维护性
Some checks failed
lotteryfront CI / build (push) Has been cancelled
Some checks failed
lotteryfront CI / build (push) Has been cancelled
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { CirclePlus, Lock, Ticket, Trash2, Star } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, memo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Button } from "@/components/ui/button";
|
||||
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";
|
||||
@@ -41,7 +42,7 @@ import type { PlayEffectivePayload, PlayEffectivePlayRow } from "@/types/api/pla
|
||||
import type { TicketLineInput, TicketPlaceData, TicketPreviewData } from "@/types/api/ticket";
|
||||
import type { DrawCurrentRiskPoolAlert } from "@/types/api/draw-current";
|
||||
|
||||
const MAX_ROWS = 20;
|
||||
const MAX_ROWS = 50;
|
||||
|
||||
type HallCategory = "D2" | "D3" | "D4" | "JACKPOT";
|
||||
|
||||
@@ -74,6 +75,12 @@ type PlayColumn = {
|
||||
digitSlot?: number;
|
||||
};
|
||||
|
||||
function playCategory(playCode: string): Exclude<HallCategory, "JACKPOT"> {
|
||||
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 }>;
|
||||
@@ -135,29 +142,6 @@ const D4_PLAY_ORDER = [
|
||||
"digit_small",
|
||||
] as const;
|
||||
|
||||
type D4PlayGroupId =
|
||||
| "big_small"
|
||||
| "position"
|
||||
| "combo"
|
||||
| "roll_straight"
|
||||
| "head_tail_odd_even"
|
||||
| "digit_big_small";
|
||||
|
||||
const D4_PLAY_GROUPS: { id: D4PlayGroupId; labelKey: string; playCodes: readonly string[] }[] = [
|
||||
{ id: "big_small", labelKey: "hall.d4Group.big_small", playCodes: ["big", "small"] },
|
||||
{ id: "position", labelKey: "hall.d4Group.position", playCodes: ["pos_4a", "pos_4b", "pos_4c", "pos_4d", "pos_4e"] },
|
||||
{ id: "combo", labelKey: "hall.d4Group.combo", playCodes: ["box", "ibox", "mbox"] },
|
||||
{ id: "roll_straight", labelKey: "hall.d4Group.roll_straight", playCodes: ["roll", "straight"] },
|
||||
{ id: "head_tail_odd_even", labelKey: "hall.d4Group.head_tail_odd_even", playCodes: ["head", "tail", "odd", "even"] },
|
||||
{ id: "digit_big_small", labelKey: "hall.d4Group.digit_big_small", playCodes: ["digit_big", "digit_small"] },
|
||||
];
|
||||
|
||||
const categoryPlayOrders: Record<Exclude<HallCategory, "JACKPOT">, readonly string[]> = {
|
||||
D2: D2_PLAY_ORDER,
|
||||
D3: D3_PLAY_ORDER,
|
||||
D4: D4_PLAY_ORDER,
|
||||
};
|
||||
|
||||
function newDraftRow(): DraftRow {
|
||||
const id =
|
||||
typeof crypto !== "undefined" && crypto.randomUUID
|
||||
@@ -222,24 +206,8 @@ function playColumnsForCategory(
|
||||
});
|
||||
}
|
||||
|
||||
function inferCategory(row: PlayEffectivePlayRow): Exclude<HallCategory, "JACKPOT"> {
|
||||
if (row.play_code.startsWith("pos_2")) return "D2";
|
||||
if (row.play_code.startsWith("pos_3")) return "D3";
|
||||
return "D4";
|
||||
}
|
||||
|
||||
function categoryDigits(category: HallCategory): number {
|
||||
if (category === "D2") return 2;
|
||||
if (category === "D3") return 3;
|
||||
if (category === "D4") return 4;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function sanitizeNumber(raw: string, category: HallCategory): string {
|
||||
if (category === "D4") {
|
||||
return raw.replace(/[^0-9Rr]/g, "").toUpperCase().slice(0, 4);
|
||||
}
|
||||
return raw.replace(/\D/g, "").slice(0, categoryDigits(category));
|
||||
function sanitizeNumber(raw: string): string {
|
||||
return raw.replace(/[^0-9Rr]/g, "").toUpperCase().slice(0, 4);
|
||||
}
|
||||
|
||||
function sanitizeAmount(raw: string): string {
|
||||
@@ -269,7 +237,6 @@ function normalizeNumberForPlay(number: string, playCode: string): string {
|
||||
}
|
||||
|
||||
function lineForPlay(
|
||||
category: Exclude<HallCategory, "JACKPOT">,
|
||||
play: PlayEffectivePlayRow,
|
||||
displayNumber: string,
|
||||
amountMinor: number,
|
||||
@@ -291,7 +258,7 @@ function lineForPlay(
|
||||
};
|
||||
|
||||
if (playNeedsDimension(play.play_code)) {
|
||||
line.dimension = category;
|
||||
line.dimension = playCategory(play.play_code);
|
||||
}
|
||||
if (playNeedsDigitSlot(play.play_code)) {
|
||||
if (digitSlot === undefined) return null;
|
||||
@@ -461,10 +428,9 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
const { t } = useTranslation("player");
|
||||
const { activeCurrency: currencyParam } = useActivePlayerCurrency();
|
||||
const creditMode = isCreditFundingPlayer(usePlayerSessionStore((state) => state.profile));
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const [activeCategory, setActiveCategory] = useState<HallCategory>("D2");
|
||||
const [d4PlayGroup, setD4PlayGroup] = useState<D4PlayGroupId>("big_small");
|
||||
const [rows, setRows] = useState<DraftRow[]>(() => [newDraftRow()]);
|
||||
const [rows, setRows] = useState<DraftRow[]>(() => Array.from({ length: 20 }, newDraftRow));
|
||||
const [activeRowId, setActiveRowId] = useState<string | null>(null);
|
||||
const [catalogState, setCatalogState] = useState<
|
||||
| { kind: "loading" }
|
||||
@@ -478,6 +444,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
const [placeLoading, setPlaceLoading] = useState(false);
|
||||
const [resultOpen, setResultOpen] = useState(false);
|
||||
const [resultData, setResultData] = useState<TicketPlaceData | null>(null);
|
||||
const [activeCategory, setActiveCategory] = useState<Exclude<HallCategory, "JACKPOT">>("D4");
|
||||
const [quickFillState, setQuickFillState] = useState<QuickFillState>(() => loadQuickFillState());
|
||||
const [riskStateDrawNo, setRiskStateDrawNo] = useState<string | null>(display?.draw_no ?? null);
|
||||
const [liveSoldOutNumbers, setLiveSoldOutNumbers] = useState<Set<string>>(() => new Set());
|
||||
@@ -552,55 +519,54 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
|
||||
const openPlays = useMemo(() => {
|
||||
if (catalogState.kind !== "ok") return [];
|
||||
const order = categoryPlayOrders[activeCategory === "JACKPOT" ? "D4" : activeCategory];
|
||||
const order = [...D2_PLAY_ORDER, ...D3_PLAY_ORDER, ...D4_PLAY_ORDER];
|
||||
return sortByPlayOrder(
|
||||
catalogState.data.plays
|
||||
.filter(isPlayOpenForPlayer)
|
||||
.filter((p) => order.includes(p.play_code)),
|
||||
order,
|
||||
);
|
||||
}, [activeCategory, catalogState]);
|
||||
}, [catalogState]);
|
||||
|
||||
const activeCategoryPlays = useMemo(
|
||||
() => openPlays.filter((play) => playCategory(play.play_code) === activeCategory),
|
||||
[activeCategory, openPlays],
|
||||
);
|
||||
|
||||
const currencyCode =
|
||||
catalogState.kind === "ok" ? catalogState.data.currency_code : currencyParam;
|
||||
|
||||
const categoryPlays = useMemo(() => {
|
||||
if (catalogState.kind !== "ok") return [];
|
||||
if (activeCategory === "JACKPOT") return [];
|
||||
const order = categoryPlayOrders[activeCategory];
|
||||
return sortByPlayOrder(
|
||||
openPlays.filter((p) => inferCategory(p) === activeCategory || activeCategory === "D4"),
|
||||
order,
|
||||
);
|
||||
}, [activeCategory, catalogState, openPlays]);
|
||||
|
||||
const allPlayColumns = useMemo(() => {
|
||||
if (activeCategory === "JACKPOT") return [];
|
||||
return playColumnsForCategory(categoryPlays, activeCategory);
|
||||
}, [activeCategory, categoryPlays]);
|
||||
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 playColumns = useMemo(() => {
|
||||
if (activeCategory !== "D4") return allPlayColumns;
|
||||
const group = D4_PLAY_GROUPS.find((g) => g.id === d4PlayGroup);
|
||||
if (!group) return allPlayColumns;
|
||||
const allowedCodes = new Set(group.playCodes);
|
||||
return allPlayColumns.filter((col) => allowedCodes.has(col.play.play_code));
|
||||
}, [activeCategory, allPlayColumns, d4PlayGroup]);
|
||||
return playColumnsForCategory(activeCategoryPlays, activeCategory);
|
||||
}, [activeCategory, activeCategoryPlays]);
|
||||
|
||||
const compactTable = playColumns.length > 6;
|
||||
const amountColClass = compactTable
|
||||
? "w-[3.75rem] min-w-[3.75rem] max-w-[3.75rem]"
|
||||
: "w-[4.5rem] min-w-[4.5rem] max-w-[4.5rem]";
|
||||
const amountColClass = !isMobile
|
||||
? "w-[3.9rem] min-w-[3.9rem]"
|
||||
: "w-[4rem] min-w-[4rem]";
|
||||
|
||||
const tableMinWidthPx = useMemo(() => {
|
||||
const indexCol = 40;
|
||||
const numberCol = activeCategory === "D4" ? 112 : activeCategory === "D3" ? 88 : 72;
|
||||
const amountCol = compactTable ? 60 : 72;
|
||||
const deleteCol = 36;
|
||||
const indexCol = 34;
|
||||
const numberCol = 88;
|
||||
const amountCol = !isMobile ? 62 : 64;
|
||||
const deleteCol = 32;
|
||||
return indexCol + numberCol + playColumns.length * amountCol + deleteCol;
|
||||
}, [activeCategory, compactTable, playColumns.length]);
|
||||
}, [playColumns.length, isMobile]);
|
||||
|
||||
const showWideTableHint = activeCategory === "D4" && allPlayColumns.length > 8 && playColumns.length > 8;
|
||||
const showWideTableHint = isMobile && playColumns.length > 8;
|
||||
|
||||
const activeRow = useMemo(
|
||||
() => rows.find((row) => row.id === activeRowId) ?? rows[0] ?? null,
|
||||
@@ -622,46 +588,19 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
const historyNumbers = currentQuickFill.history;
|
||||
const tableDisabled = !isBettable || catalogState.kind !== "ok";
|
||||
const sealedBetUi = Boolean(display && isHallSealedCountdownUi(display.status));
|
||||
const defaultNumberPlaceholder =
|
||||
const numberPlaceholder =
|
||||
activeCategory === "D2" ? "00" : activeCategory === "D3" ? "000" : "0000";
|
||||
const numberPlaceholder = useMemo(() => {
|
||||
if (activeCategory !== "D4") return defaultNumberPlaceholder;
|
||||
const targetRow = activeRow ?? rows[0];
|
||||
if (!targetRow) return defaultNumberPlaceholder;
|
||||
const hasRollStake = allPlayColumns.some((column) => {
|
||||
if (column.play.play_code !== "roll") return false;
|
||||
const amount = parseDecimalInputToMinor(targetRow.amounts[column.key] ?? "", currencyCode);
|
||||
return amount !== null && amount > 0;
|
||||
});
|
||||
return hasRollStake ? t("hall.numberInput.rollPlaceholder") : defaultNumberPlaceholder;
|
||||
}, [
|
||||
activeCategory,
|
||||
activeRow,
|
||||
currencyCode,
|
||||
defaultNumberPlaceholder,
|
||||
allPlayColumns,
|
||||
rows,
|
||||
t,
|
||||
]);
|
||||
|
||||
const availableD4Groups = useMemo(() => {
|
||||
if (activeCategory !== "D4") return [];
|
||||
return D4_PLAY_GROUPS.filter((group) => {
|
||||
const allowedCodes = new Set(group.playCodes);
|
||||
return allPlayColumns.some((col) => allowedCodes.has(col.play.play_code));
|
||||
});
|
||||
}, [activeCategory, allPlayColumns]);
|
||||
|
||||
const updateRowNumber = (id: string, value: string) => {
|
||||
const updateRowNumber = useCallback((id: string, value: string) => {
|
||||
setRows((current) =>
|
||||
current.map((row) =>
|
||||
row.id === id ? { ...row, number: sanitizeNumber(value, activeCategory) } : row,
|
||||
row.id === id ? { ...row, number: sanitizeNumber(value) } : row,
|
||||
),
|
||||
);
|
||||
setActiveRowId(id);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const updateAmount = (rowId: string, playCode: string, value: string) => {
|
||||
const updateAmount = useCallback((rowId: string, playCode: string, value: string) => {
|
||||
setRows((current) =>
|
||||
current.map((row) =>
|
||||
row.id === rowId
|
||||
@@ -670,25 +609,27 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
),
|
||||
);
|
||||
setActiveRowId(rowId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const addRow = () => {
|
||||
const addRow = useCallback(() => {
|
||||
setRows((current) => {
|
||||
if (current.length >= MAX_ROWS) return current;
|
||||
const row = newDraftRow();
|
||||
setActiveRowId(row.id);
|
||||
return [...current, row];
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
const removeRow = (id: string) => {
|
||||
const removeRow = useCallback((id: string) => {
|
||||
setRows((current) => {
|
||||
if (current.length <= 1) return current;
|
||||
const next = current.filter((row) => row.id !== id);
|
||||
setActiveRowId((prev) => (prev === id ? next[0]?.id ?? null : prev));
|
||||
return next;
|
||||
return current.filter((row) => row.id !== id);
|
||||
});
|
||||
};
|
||||
setActiveRowId((prev) => {
|
||||
if (prev === id) return null;
|
||||
return prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearAllRows = () => {
|
||||
if (tableDisabled) return;
|
||||
@@ -844,7 +785,6 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
}, [clearAmountsForPlay, clearPlaceTraceId, drawNo, hasAmountsForPlay, reloadDraw, t]);
|
||||
|
||||
const collectDraftLineIssues = useCallback((): DraftLineIssue[] => {
|
||||
if (activeCategory === "JACKPOT") return [];
|
||||
const issues: DraftLineIssue[] = [];
|
||||
rows.forEach((row, rowIndex) => {
|
||||
allPlayColumns.forEach((column) => {
|
||||
@@ -861,7 +801,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
});
|
||||
});
|
||||
return issues;
|
||||
}, [activeCategory, currencyCode, allPlayColumns, rows]);
|
||||
}, [allPlayColumns, currencyCode, rows]);
|
||||
|
||||
const formatDraftLineIssue = useCallback(
|
||||
(issue: DraftLineIssue): string => {
|
||||
@@ -872,13 +812,12 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
);
|
||||
|
||||
const collectEntries = useCallback((): DraftEntry[] => {
|
||||
if (activeCategory === "JACKPOT") return [];
|
||||
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(activeCategory, column.play, row.number, amount, column.digitSlot);
|
||||
const line = lineForPlay(column.play, row.number, amount, column.digitSlot);
|
||||
if (!line) return;
|
||||
entries.push({
|
||||
rowId: row.id,
|
||||
@@ -893,7 +832,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
});
|
||||
});
|
||||
return entries;
|
||||
}, [activeCategory, currencyCode, allPlayColumns, rows]);
|
||||
}, [allPlayColumns, currencyCode, rows]);
|
||||
|
||||
const draftEntries = collectEntries();
|
||||
const draftSummary = useMemo(() => {
|
||||
@@ -1161,60 +1100,6 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
return (
|
||||
<>
|
||||
<section className="space-y-3" aria-label={t("hall.aria")}>
|
||||
<div className="rounded-xl border border-[#e8eef7] bg-white p-1 shadow-[0_6px_18px_rgba(30,64,175,0.06)]">
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{categoryTabs.map((tab) => {
|
||||
const active = activeCategory === tab.value;
|
||||
return (
|
||||
<button
|
||||
key={tab.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveCategory(tab.value);
|
||||
setRows((current) =>
|
||||
current.map((row) => ({
|
||||
...row,
|
||||
number: sanitizeNumber(row.number, tab.value),
|
||||
})),
|
||||
);
|
||||
}}
|
||||
className={cn(
|
||||
"relative flex h-10 min-w-0 items-center justify-center rounded-lg text-sm font-semibold transition-colors",
|
||||
active
|
||||
? "bg-[#07459f] text-white shadow-[inset_0_-2px_0_rgba(255,255,255,0.28)]"
|
||||
: "text-[#4b5563] hover:bg-[#f4f7fb]",
|
||||
tab.value === "JACKPOT" && active && "bg-[#7b8492]",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{tab.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeCategory === "D4" && availableD4Groups.length > 1 ? (
|
||||
<div className="flex gap-1 overflow-x-auto overscroll-x-contain pb-0.5">
|
||||
{availableD4Groups.map((group) => {
|
||||
const active = d4PlayGroup === group.id;
|
||||
return (
|
||||
<button
|
||||
key={group.id}
|
||||
type="button"
|
||||
onClick={() => setD4PlayGroup(group.id)}
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-3 py-1.5 text-xs font-bold transition-colors",
|
||||
active
|
||||
? "bg-[#07459f] text-white"
|
||||
: "border border-[#dce7f7] bg-white text-[#32518d] hover:bg-[#f4f7fb]",
|
||||
)}
|
||||
>
|
||||
{t(group.labelKey)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{jackpot?.enabled ? (
|
||||
<div className="rounded-xl border border-amber-200 bg-gradient-to-r from-amber-50 via-white to-[#f8fbff] px-3 py-2.5">
|
||||
@@ -1369,7 +1254,38 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{categoryPlays.length === 0 ? (
|
||||
<div className="flex items-end gap-2 overflow-x-auto pb-1">
|
||||
{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 min-w-[6rem] items-center justify-center rounded-t-xl border px-4 py-3 text-sm font-bold transition-colors",
|
||||
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]",
|
||||
!hasPlays && "cursor-not-allowed opacity-40",
|
||||
)}
|
||||
aria-pressed={active}
|
||||
>
|
||||
<span className="mr-2 text-[13px]" aria-hidden>
|
||||
▦
|
||||
</span>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{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"
|
||||
@@ -1389,6 +1305,55 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
showWideTableHint && "player-table-scroll-wrap",
|
||||
)}
|
||||
>
|
||||
<div className="mb-3 overflow-x-auto rounded border border-[#dae3f3] shadow-sm">
|
||||
<table className="w-full border-collapse text-center text-[11px] tabular-nums">
|
||||
<thead>
|
||||
<tr className="bg-[#2d63e2] text-white">
|
||||
<th className="border-r border-[#4a7aeb] px-2 py-1.5 font-bold">
|
||||
{t("hall.table.total", { defaultValue: "共" })}
|
||||
</th>
|
||||
{allPlayColumns.map((column) => (
|
||||
<th key={column.key} className="border-r border-[#4a7aeb] px-1 py-1.5 font-bold last:border-0">
|
||||
{playColumnHeaderLabel(
|
||||
column.play,
|
||||
playCategory(column.play.play_code),
|
||||
column.digitSlot,
|
||||
t,
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white text-[#17408d]">
|
||||
<tr>
|
||||
<td className="border-r border-[#edf2f8] px-2 py-1.5 font-bold text-[#e5002c]">
|
||||
{formatMinorAsCurrency(submitActualMinor, currencyCode)}
|
||||
</td>
|
||||
{allPlayColumns.map((column) => {
|
||||
const colTotalMinor = rows.reduce((sum, row) => {
|
||||
const amtStr = row.amounts[column.key];
|
||||
if (!amtStr) return sum;
|
||||
if (
|
||||
row.number.trim().length === 0 ||
|
||||
draftLineIssueReason(column.play.play_code, row.number, column.digitSlot) !== null
|
||||
) {
|
||||
return sum;
|
||||
}
|
||||
const minorAmt = parseDecimalInputToMinor(amtStr, currencyCode);
|
||||
return sum + (minorAmt ?? 0);
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<td key={`val-${column.key}`} className="border-r border-[#edf2f8] px-1 py-1.5 font-bold last:border-0">
|
||||
{formatMinorAsCurrency(colTotalMinor, currencyCode)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="player-table-scroll overflow-x-auto overscroll-x-contain">
|
||||
<table
|
||||
className="w-full border-collapse text-[11px]"
|
||||
@@ -1396,159 +1361,63 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
>
|
||||
<thead>
|
||||
<tr className="border-b border-[#edf2f8] bg-[#f8fafd] text-[#58709d]">
|
||||
<th className="sticky left-0 z-30 w-10 min-w-10 bg-[#f8fafd] px-1 py-2 text-center font-bold shadow-[2px_0_6px_rgba(15,23,42,0.04)]">
|
||||
<th className="sticky left-0 z-30 w-[2.15rem] min-w-[2.15rem] bg-[#f8fafd] px-0.5 py-1.5 text-center font-bold shadow-[2px_0_6px_rgba(15,23,42,0.04)]">
|
||||
{t("hall.table.no", { defaultValue: "No." })}
|
||||
</th>
|
||||
<th
|
||||
className={cn(
|
||||
"sticky left-10 z-30 bg-[#f8fafd] px-1.5 py-2 text-center font-bold shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
|
||||
activeCategory === "D4"
|
||||
? "w-28 min-w-28"
|
||||
: activeCategory === "D3"
|
||||
? "w-[5.5rem] min-w-[5.5rem]"
|
||||
: "w-[4.5rem] min-w-[4.5rem]",
|
||||
"sticky left-[2.15rem] z-30 bg-[#f8fafd] px-1 py-1.5 text-center font-bold shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
|
||||
"w-[5.5rem] min-w-[5.5rem]",
|
||||
)}
|
||||
>
|
||||
<span className="block text-xs">{t("hall.table.number", { defaultValue: "Number" })}</span>
|
||||
<span className="mt-0.5 block font-mono text-[11px] font-medium text-[#9aa8bd]">
|
||||
<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 py-2 text-center font-bold")}
|
||||
className={cn(amountColClass, "px-0.5 py-1.5 text-center font-bold")}
|
||||
>
|
||||
<span className="block whitespace-nowrap text-[11px] leading-tight">
|
||||
<span className="block whitespace-nowrap text-[10px] leading-tight">
|
||||
{playColumnHeaderLabel(
|
||||
column.play,
|
||||
activeCategory as Exclude<HallCategory, "JACKPOT">,
|
||||
playCategory(column.play.play_code),
|
||||
column.digitSlot,
|
||||
t,
|
||||
)}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[10px] font-medium text-[#9aa8bd]">
|
||||
<span className="mt-0.5 block text-[9px] font-medium text-[#9aa8bd]">
|
||||
{t("hall.table.amountPlaceholder")}
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
<th className="w-9 min-w-9 px-0.5 py-2" aria-label={t("hall.table.delete")} />
|
||||
<th className="w-8 min-w-8 px-0.5 py-1.5" aria-label={t("hall.table.delete")} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, index) => {
|
||||
const rowKey = row.id;
|
||||
const rowActive = activeRowId === row.id;
|
||||
return (
|
||||
<tr
|
||||
key={rowKey}
|
||||
className={cn(
|
||||
"border-b border-[#f0f3f8] last:border-b-0",
|
||||
rowActive && "bg-[#f5f9ff]/80",
|
||||
)}
|
||||
>
|
||||
<td
|
||||
className={cn(
|
||||
"sticky left-0 z-20 w-10 min-w-10 px-1 py-2 text-center font-black text-[#17408d] shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
|
||||
rowActive ? "bg-[#f5f9ff]" : "bg-white",
|
||||
)}
|
||||
>
|
||||
{index + 1}
|
||||
</td>
|
||||
<td
|
||||
className={cn(
|
||||
"sticky left-10 z-20 px-1.5 py-2 shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
|
||||
activeCategory === "D4"
|
||||
? "w-28 min-w-28"
|
||||
: activeCategory === "D3"
|
||||
? "w-[5.5rem] min-w-[5.5rem]"
|
||||
: "w-[4.5rem] min-w-[4.5rem]",
|
||||
rowActive ? "bg-[#f5f9ff]" : "bg-white",
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
value={row.number}
|
||||
disabled={tableDisabled}
|
||||
inputMode="text"
|
||||
placeholder={numberPlaceholder}
|
||||
onFocus={() => setActiveRowId(row.id)}
|
||||
onClick={() => setActiveRowId(row.id)}
|
||||
onChange={(event) => updateRowNumber(row.id, event.target.value)}
|
||||
className={cn(
|
||||
"h-9 w-full rounded-md border-[#e1e8f3] bg-white px-2 text-center font-mono text-base font-bold tabular-nums text-slate-950 shadow-sm focus-visible:ring-[#1d57b7]",
|
||||
activeCategory === "D4" && "tracking-[0.2em]",
|
||||
)}
|
||||
/>
|
||||
</td>
|
||||
{playColumns.map((column) => {
|
||||
const { play } = column;
|
||||
const amountText = row.amounts[column.key] ?? "";
|
||||
const status = cellRiskState(
|
||||
play,
|
||||
row.number,
|
||||
activeCategory as Exclude<HallCategory, "JACKPOT">,
|
||||
alertRows,
|
||||
liveSoldOutNumbers,
|
||||
liveWarningNumbers,
|
||||
column.digitSlot,
|
||||
);
|
||||
const disabled = tableDisabled || status === "sold_out" || (play.config !== null && !play.config.is_enabled);
|
||||
const hasAmount = amountText.trim().length > 0;
|
||||
|
||||
return (
|
||||
<td
|
||||
key={`${rowKey}-${column.key}`}
|
||||
className={cn(
|
||||
"px-1 py-2 align-top",
|
||||
status === "warning" && "bg-amber-50/70",
|
||||
status === "sold_out" && "bg-slate-100 text-slate-400",
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
value={amountText}
|
||||
disabled={disabled}
|
||||
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(
|
||||
"h-9 w-full rounded-md border-[#e1e8f3] bg-white px-1 text-center text-base font-bold tabular-nums shadow-sm focus-visible:ring-[#1d57b7]",
|
||||
hasAmount && "border-[#9bbcff] bg-[#f5f9ff] text-[#0b3f96]",
|
||||
status === "warning" && "border-amber-200 bg-amber-50 text-amber-800",
|
||||
status === "sold_out" && "border-slate-200 bg-slate-100 text-slate-400",
|
||||
)}
|
||||
/>
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
<td className="px-1 py-2 text-center align-middle">
|
||||
<button
|
||||
type="button"
|
||||
disabled={tableDisabled || rows.length <= 1}
|
||||
onClick={() => removeRow(row.id)}
|
||||
className="inline-flex size-8 items-center justify-center rounded-full text-[#e5002c] hover:bg-red-50 disabled:text-slate-300 disabled:hover:bg-transparent"
|
||||
aria-label={t("actions.deleteRow", { row: index + 1 })}
|
||||
>
|
||||
<Trash2 className="size-3.5" aria-hidden />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{rows.map((row, index) => (
|
||||
<DraftRowItem
|
||||
key={row.id}
|
||||
row={row}
|
||||
index={index}
|
||||
rowActive={activeRowId === row.id}
|
||||
tableDisabled={tableDisabled}
|
||||
numberPlaceholder={numberPlaceholder}
|
||||
playColumns={playColumns}
|
||||
alertRows={alertRows}
|
||||
liveSoldOutNumbers={liveSoldOutNumbers}
|
||||
liveWarningNumbers={liveWarningNumbers}
|
||||
updateRowNumber={updateRowNumber}
|
||||
updateAmount={updateAmount}
|
||||
setActiveRowId={setActiveRowId}
|
||||
removeRow={removeRow}
|
||||
t={t}
|
||||
isMobile={isMobile}
|
||||
removable={rows.length > 1}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -1638,3 +1507,152 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const DraftRowItem = memo(function DraftRowItem({
|
||||
row,
|
||||
index,
|
||||
rowActive,
|
||||
tableDisabled,
|
||||
numberPlaceholder,
|
||||
playColumns,
|
||||
alertRows,
|
||||
liveSoldOutNumbers,
|
||||
liveWarningNumbers,
|
||||
updateRowNumber,
|
||||
updateAmount,
|
||||
setActiveRowId,
|
||||
removeRow,
|
||||
t,
|
||||
isMobile,
|
||||
removable
|
||||
}: {
|
||||
row: DraftRow;
|
||||
index: number;
|
||||
rowActive: boolean;
|
||||
tableDisabled: boolean;
|
||||
numberPlaceholder: string;
|
||||
playColumns: PlayColumn[];
|
||||
alertRows: DrawCurrentRiskPoolAlert[];
|
||||
liveSoldOutNumbers: Set<string>;
|
||||
liveWarningNumbers: Set<string>;
|
||||
updateRowNumber: (id: string, value: string) => void;
|
||||
updateAmount: (rowId: string, playCode: string, value: string) => void;
|
||||
setActiveRowId: (id: string) => void;
|
||||
removeRow: (id: string) => void;
|
||||
t: HallTranslate;
|
||||
isMobile: boolean;
|
||||
removable: boolean;
|
||||
}) {
|
||||
return (
|
||||
<tr
|
||||
className={cn(
|
||||
"border-b border-[#f0f3f8] last:border-b-0",
|
||||
rowActive && "bg-[#f5f9ff]/80",
|
||||
)}
|
||||
>
|
||||
<td
|
||||
className={cn(
|
||||
"sticky left-0 z-20 w-[2.15rem] min-w-[2.15rem] px-0.5 py-1.5 text-center font-black text-[#17408d] shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
|
||||
rowActive ? "bg-[#f5f9ff]" : "bg-white",
|
||||
)}
|
||||
>
|
||||
{index + 1}
|
||||
</td>
|
||||
<td
|
||||
className={cn(
|
||||
"sticky left-[2.15rem] z-20 px-1 py-1.5 shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
|
||||
"w-[5.5rem] min-w-[5.5rem]",
|
||||
rowActive ? "bg-[#f5f9ff]" : "bg-white",
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
value={row.number}
|
||||
disabled={tableDisabled}
|
||||
inputMode="text"
|
||||
placeholder={numberPlaceholder}
|
||||
onFocus={() => setActiveRowId(row.id)}
|
||||
onClick={() => setActiveRowId(row.id)}
|
||||
onChange={(event) => updateRowNumber(row.id, event.target.value)}
|
||||
className={cn(
|
||||
"h-8 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 ? "text-[13px]" : "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 py-1.5 align-top",
|
||||
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(
|
||||
"h-8 w-full rounded-md border-[#e1e8f3] bg-white px-0.5 text-center font-bold tabular-nums shadow-sm focus-visible:ring-[#1d57b7]",
|
||||
!isMobile ? "text-[13px]" : "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="h-8 w-full rounded-md border border-slate-200 bg-slate-100/70" />
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
<td className="px-0.5 py-1.5 text-center align-middle">
|
||||
<button
|
||||
type="button"
|
||||
disabled={tableDisabled || !removable}
|
||||
onClick={() => removeRow(row.id)}
|
||||
className="inline-flex size-7 items-center justify-center rounded-full text-[#e5002c] hover:bg-red-50 disabled:text-slate-300 disabled:hover:bg-transparent"
|
||||
aria-label={t("actions.deleteRow", { row: index + 1 })}
|
||||
>
|
||||
<Trash2 className="size-3.5" aria-hidden />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user