feat(hall): 支持按行选择开注商与选号种类并优化下注表格
Some checks failed
lotteryfront CI / build (push) Has been cancelled

This commit is contained in:
2026-07-10 17:04:54 +08:00
parent 960b10e02a
commit 99d49f457f
11 changed files with 336 additions and 219 deletions

View File

@@ -16,14 +16,17 @@
const path = require("path"); const path = require("path");
/** 改成服务器上的绝对路径,例如 /www/wwwroot/lottery.yourdomain.com */ /** 改成服务器上的绝对路径,例如 /www/wwwroot/lottery.yourdomain.com */
const APP_CWD = path.resolve(__dirname); const DEPLOY_ROOT = path.resolve(__dirname);
// deploy.sh uploads the standalone bundle to app/. Keep runtime and static assets
// on the same build so Next.js chunk references cannot become mismatched.
const APP_CWD = path.join(DEPLOY_ROOT, "app");
module.exports = { module.exports = {
apps: [ apps: [
{ {
name: "lotteryfront", name: "lotteryfront",
cwd: APP_CWD, cwd: APP_CWD,
script: ".next/standalone/server.js", script: "server.js",
interpreter: "node", interpreter: "node",
exec_mode: "fork", exec_mode: "fork",
instances: 1, instances: 1,
@@ -32,8 +35,8 @@ module.exports = {
max_memory_restart: "1G", max_memory_restart: "1G",
time: true, time: true,
merge_logs: true, merge_logs: true,
out_file: path.join(APP_CWD, "logs/pm2-out.log"), out_file: path.join(DEPLOY_ROOT, "logs/pm2-out.log"),
error_file: path.join(APP_CWD, "logs/pm2-error.log"), error_file: path.join(DEPLOY_ROOT, "logs/pm2-error.log"),
env: { env: {
NODE_ENV: "production", NODE_ENV: "production",
@@ -42,7 +45,7 @@ module.exports = {
}, },
// LOTTERY_API_UPSTREAM、NEXT_PUBLIC_* 写在 .env勿在此硬编码 LOTTERY_API_UPSTREAM // LOTTERY_API_UPSTREAM、NEXT_PUBLIC_* 写在 .env勿在此硬编码 LOTTERY_API_UPSTREAM
env_file: path.join(APP_CWD, ".env"), env_file: path.join(DEPLOY_ROOT, ".env"),
}, },
], ],
}; };

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 152 KiB

View File

@@ -7,8 +7,8 @@ export type TicketNumberSpec = {
mode: "digits" | "roll"; mode: "digits" | "roll";
}; };
const PLAY_3D = new Set(["pos_3a", "pos_3b", "pos_3c", "pos_3abc"]); const PLAY_3D = new Set(["pos_3a", "pos_3b", "pos_3c", "pos_3d", "pos_3e", "pos_3lower"]);
const PLAY_2D = new Set(["pos_2a", "pos_2b", "pos_2c", "pos_2abc"]); const PLAY_2D = new Set(["pos_2a", "pos_2b", "pos_2c", "pos_2d", "pos_2e", "pos_2any"]);
export function ticketNumberSpec(playCode: string): TicketNumberSpec { export function ticketNumberSpec(playCode: string): TicketNumberSpec {
if (playCode === "roll") { if (playCode === "roll") {

View File

@@ -10,6 +10,7 @@ import { getPlayEffective } from "@/api/play";
import { getWalletBalance } from "@/api/wallet"; import { getWalletBalance } from "@/api/wallet";
import { postTicketPlace, postTicketPreview } from "@/api/ticket"; import { postTicketPlace, postTicketPreview } from "@/api/ticket";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { isHallSealedCountdownUi } from "@/features/draw/draw-status-meta"; import { isHallSealedCountdownUi } from "@/features/draw/draw-status-meta";
@@ -29,7 +30,11 @@ import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
import { triggerWalletPollingAfterBet } from "@/hooks/use-wallet-polling"; import { triggerWalletPollingAfterBet } from "@/hooks/use-wallet-polling";
import { usePlayerSessionStore } from "@/stores/player-session-store"; import { usePlayerSessionStore } from "@/stores/player-session-store";
import { getLotteryEcho } from "@/lib/lottery-echo"; import { getLotteryEcho } from "@/lib/lottery-echo";
import { formatMinorAsCurrency, parseDecimalInputToMinor } from "@/lib/money"; import {
formatMinorAmount,
formatMinorAsCurrency,
parseDecimalInputToMinor,
} from "@/lib/money";
import { playLabel } from "@/lib/play-labels"; import { playLabel } from "@/lib/play-labels";
import { playerViewportFixedBarClass } from "@/lib/player-viewport"; import { playerViewportFixedBarClass } from "@/lib/player-viewport";
import { import {
@@ -48,10 +53,18 @@ import type { BetProviderRow } from "@/types/api/bet-provider";
type HallCategory = "D2" | "D3" | "D4" | "JACKPOT"; type HallCategory = "D2" | "D3" | "D4" | "JACKPOT";
type PlayHallCategory = Exclude<HallCategory, "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 = { type DraftRow = {
id: string; id: string;
number: string; number: string;
amounts: Record<string, string>; amounts: Record<string, string>;
providerCodes: string[];
selectionType: "straight" | "reverse" | "full_cover" | "full_play" | "half_play";
}; };
type DraftEntry = { type DraftEntry = {
@@ -116,10 +129,22 @@ type CellRiskState = "open" | "warning" | "sold_out";
type QuickFillState = Record<HallCategory, { favorites: string[]; history: string[] }>; type QuickFillState = Record<HallCategory, { favorites: string[]; history: string[] }>;
const FALLBACK_BET_PROVIDERS: BetProviderRow[] = [ const FALLBACK_BET_PROVIDERS: BetProviderRow[] = [
{ code: "SG", name: "Singapore", sort_order: 10, is_default: true }, { code: "SG", name: "Singapore", short_code: "S", sort_order: 10, is_default: true },
{ code: "MY", name: "Malaysia", sort_order: 20, is_default: false }, { code: "MY", name: "Malaysia", short_code: "M", sort_order: 20, is_default: false },
{ code: "TH", name: "Thailand", sort_order: 30, 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 }[] = [ const categoryTabs: { value: PlayHallCategory; label: string }[] = [
{ value: "D4", label: "4D" }, { value: "D4", label: "4D" },
@@ -161,7 +186,7 @@ function newDraftRow(): DraftRow {
typeof crypto !== "undefined" && crypto.randomUUID typeof crypto !== "undefined" && crypto.randomUUID
? crypto.randomUUID() ? crypto.randomUUID()
: `row-${Date.now()}-${Math.random().toString(36).slice(2)}`; : `row-${Date.now()}-${Math.random().toString(36).slice(2)}`;
return { id, number: "", amounts: {} }; return { id, number: "", amounts: {}, providerCodes: [DEFAULT_PROVIDER_CODE], selectionType: "straight" };
} }
function isPlayOpenForPlayer(row: PlayEffectivePlayRow): boolean { function isPlayOpenForPlayer(row: PlayEffectivePlayRow): boolean {
@@ -265,6 +290,7 @@ function lineForPlay(
displayNumber: string, displayNumber: string,
amountMinor: number, amountMinor: number,
digitSlot?: number, digitSlot?: number,
selectionType: DraftRow["selectionType"] = "straight",
): TicketLineInput | null { ): TicketLineInput | null {
const number = normalizeNumberForPlay(displayNumber, play.play_code); const number = normalizeNumberForPlay(displayNumber, play.play_code);
if (draftLineIssueReason(play.play_code, displayNumber, digitSlot) !== null) { if (draftLineIssueReason(play.play_code, displayNumber, digitSlot) !== null) {
@@ -279,6 +305,7 @@ function lineForPlay(
number, number,
play_code: play.play_code, play_code: play.play_code,
amount: amountMinor, amount: amountMinor,
selection_type: selectionType,
}; };
if (playNeedsDimension(play.play_code)) { if (playNeedsDimension(play.play_code)) {
@@ -470,10 +497,9 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
const [resultData, setResultData] = useState<TicketPlaceData | null>(null); const [resultData, setResultData] = useState<TicketPlaceData | null>(null);
const [activeCategory, setActiveCategory] = useState<PlayHallCategory>("D4"); const [activeCategory, setActiveCategory] = useState<PlayHallCategory>("D4");
const [betProviders, setBetProviders] = useState<BetProviderRow[]>(FALLBACK_BET_PROVIDERS); const [betProviders, setBetProviders] = useState<BetProviderRow[]>(FALLBACK_BET_PROVIDERS);
const [selectedProviderCodes, setSelectedProviderCodes] = useState<string[]>([]); const [syncAmountColumns, setSyncAmountColumns] = useState<Record<string, boolean>>({});
const [quickFillState, setQuickFillState] = useState<QuickFillState>(() => loadQuickFillState()); const [quickFillState, setQuickFillState] = useState<QuickFillState>(() => loadQuickFillState());
const [quickFillExpanded, setQuickFillExpanded] = useState(false); const [quickFillExpanded, setQuickFillExpanded] = useState(false);
const [providersExpanded, setProvidersExpanded] = useState(false);
const [riskStateDrawNo, setRiskStateDrawNo] = useState<string | null>(display?.draw_no ?? null); const [riskStateDrawNo, setRiskStateDrawNo] = useState<string | null>(display?.draw_no ?? null);
const [liveSoldOutNumbers, setLiveSoldOutNumbers] = useState<Set<string>>(() => new Set()); const [liveSoldOutNumbers, setLiveSoldOutNumbers] = useState<Set<string>>(() => new Set());
const [liveWarningNumbers, setLiveWarningNumbers] = useState<Set<string>>(() => new Set()); const [liveWarningNumbers, setLiveWarningNumbers] = useState<Set<string>>(() => new Set());
@@ -497,7 +523,6 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
const catalogSeqRef = useRef(0); const catalogSeqRef = useRef(0);
const walletSeqRef = useRef(0); const walletSeqRef = useRef(0);
const selectedCatalogProviderCode = const selectedCatalogProviderCode =
selectedProviderCodes[0] ??
betProviders.find((provider) => provider.is_default)?.code ?? betProviders.find((provider) => provider.is_default)?.code ??
betProviders[0]?.code; betProviders[0]?.code;
@@ -543,9 +568,16 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
if (cancelled) return; if (cancelled) return;
const items = data.items.length > 0 ? data.items : FALLBACK_BET_PROVIDERS; const items = data.items.length > 0 ? data.items : FALLBACK_BET_PROVIDERS;
setBetProviders(items); setBetProviders(items);
setSelectedProviderCodes((current) => { setRows((current) => {
const available = new Set(items.map((item) => item.code)); const available = new Set(items.map((item) => item.code));
return current.filter((code) => available.has(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(() => { .catch(() => {
@@ -586,7 +618,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
}, [catalogState]); }, [catalogState]);
const activeCategoryPlays = useMemo( const activeCategoryPlays = useMemo(
() => openPlays.filter((play) => playCategory(play.play_code) === activeCategory), () => openPlays.filter((play) => TRADITIONAL_PLAY_CODES[activeCategory].includes(play.play_code)),
[activeCategory, openPlays], [activeCategory, openPlays],
); );
@@ -613,22 +645,34 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
const mobileIndexColClass = "w-[1.75rem] min-w-[1.75rem]"; const mobileIndexColClass = "w-[1.75rem] min-w-[1.75rem]";
const mobileNumberColClass = "w-[4.25rem] min-w-[4.25rem]"; const mobileNumberColClass = "w-[4.25rem] min-w-[4.25rem]";
const mobileSelectionTypeColClass = "w-[4rem] min-w-[4rem]";
const desktopIndexColClass = "w-[2.15rem] min-w-[2.15rem]"; const desktopIndexColClass = "w-[2.15rem] min-w-[2.15rem]";
const desktopNumberColClass = "w-[5.5rem] min-w-[5.5rem]"; const desktopNumberColClass = "w-[5.5rem] min-w-[5.5rem]";
const desktopSelectionTypeColClass = "w-[4.75rem] min-w-[4.75rem]";
const indexColClass = isMobile ? mobileIndexColClass : desktopIndexColClass; const indexColClass = isMobile ? mobileIndexColClass : desktopIndexColClass;
const numberColClass = isMobile ? mobileNumberColClass : desktopNumberColClass; const numberColClass = isMobile ? mobileNumberColClass : desktopNumberColClass;
const selectionTypeColClass = isMobile ? mobileSelectionTypeColClass : desktopSelectionTypeColClass;
const showSelectionTypeColumn = activeCategory !== "D2";
const stickyNumberLeftClass = isMobile ? "left-[1.75rem]" : "left-[2.15rem]"; const stickyNumberLeftClass = isMobile ? "left-[1.75rem]" : "left-[2.15rem]";
const amountColClass = !isMobile const amountColClass = !isMobile
? "w-[3.9rem] min-w-[3.9rem]" ? "w-[3.9rem] min-w-[3.9rem]"
: "w-[4rem] min-w-[4rem]"; : "w-[4rem] min-w-[4rem]";
const providerColClass = !isMobile
? "w-[2.85rem] min-w-[2.85rem]"
: "w-[2.7rem] min-w-[2.7rem]";
const rowTotalColClass = !isMobile
? "w-[5.5rem] min-w-[5.5rem]"
: "w-[5rem] min-w-[5rem]";
const tableMinWidthPx = useMemo(() => { const tableWidthPx = useMemo(() => {
const indexCol = !isMobile ? 34 : 28; const indexCol = !isMobile ? 34 : 28;
const numberCol = !isMobile ? 88 : 68; const numberCol = !isMobile ? 88 : 68;
const selectionTypeCol = !isMobile ? 76 : 64;
const providerCol = !isMobile ? 46 : 43;
const rowTotalCol = !isMobile ? 88 : 80;
const amountCol = !isMobile ? 62 : 64; const amountCol = !isMobile ? 62 : 64;
const deleteCol = 32; return indexCol + numberCol + (showSelectionTypeColumn ? selectionTypeCol : 0) + betProviders.length * providerCol + rowTotalCol + playColumns.length * amountCol;
return indexCol + numberCol + playColumns.length * amountCol + deleteCol; }, [betProviders.length, isMobile, playColumns.length, showSelectionTypeColumn]);
}, [playColumns.length, isMobile]);
const showWideTableHint = isMobile && playColumns.length > 8; const showWideTableHint = isMobile && playColumns.length > 8;
@@ -668,28 +712,45 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
setActiveRowId(id); setActiveRowId(id);
}, [activeCategory]); }, [activeCategory]);
const updateAmount = useCallback((rowId: string, playCode: string, value: string) => { const updateRowSelectionType = useCallback((id: string, selectionType: DraftRow["selectionType"]) => {
setRows((current) => setRows((current) => current.map((row) => row.id === id ? { ...row, selectionType } : row));
current.map((row) =>
row.id === rowId
? { ...row, amounts: { ...row.amounts, [playCode]: sanitizeAmount(value) } }
: row,
),
);
setActiveRowId(rowId);
}, []); }, []);
const removeRow = useCallback((id: string) => { const updateAllSelectionTypes = useCallback((selectionType: DraftRow["selectionType"]) => {
setRows((current) => { setRows((current) => current.map((row) => ({ ...row, selectionType })));
if (current.length <= 1) return current;
return current.filter((row) => row.id !== id);
});
setActiveRowId((prev) => {
if (prev === id) return null;
return prev;
});
}, []); }, []);
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 clearAllRows = () => { const clearAllRows = () => {
if (tableDisabled) return; if (tableDisabled) return;
const next = [newDraftRow()]; const next = [newDraftRow()];
@@ -786,6 +847,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
...row, ...row,
number: previousRow.number, number: previousRow.number,
amounts: nextAmounts, amounts: nextAmounts,
providerCodes: previousRow.providerCodes,
}; };
}), }),
); );
@@ -826,14 +888,23 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
}); });
}; };
const toggleProvider = (code: string) => { const toggleRowProvider = useCallback((rowId: string, code: string) => {
setSelectedProviderCodes((current) => { setRows((current) => current.map((row) => {
if (current.includes(code)) { if (row.id !== rowId) return row;
return current.filter((item) => item !== code); return row.providerCodes.includes(code)
} ? { ...row, providerCodes: row.providerCodes.filter((item) => item !== code) }
return [...current, 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( const hasAmountsForPlay = useCallback(
(playCode: string): boolean => (playCode: string): boolean =>
@@ -958,6 +1029,17 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
return issues; return issues;
}, [allPlayColumns, currencyCode, rows]); }, [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( const formatDraftLineIssue = useCallback(
(issue: DraftLineIssue): string => { (issue: DraftLineIssue): string => {
const label = playLabel(issue.playCode, t); const label = playLabel(issue.playCode, t);
@@ -972,8 +1054,9 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
allPlayColumns.forEach((column) => { allPlayColumns.forEach((column) => {
const amount = parseDecimalInputToMinor(row.amounts[column.key] ?? "", currencyCode); const amount = parseDecimalInputToMinor(row.amounts[column.key] ?? "", currencyCode);
if (amount === null || amount <= 0) return; if (amount === null || amount <= 0) return;
const line = lineForPlay(column.play, row.number, amount, column.digitSlot); const line = lineForPlay(column.play, row.number, amount, column.digitSlot, row.selectionType);
if (!line) return; if (!line) return;
line.provider_codes = row.providerCodes;
entries.push({ entries.push({
rowId: row.id, rowId: row.id,
rowNo: rowIndex + 1, rowNo: rowIndex + 1,
@@ -990,25 +1073,20 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
}, [allPlayColumns, currencyCode, rows]); }, [allPlayColumns, currencyCode, rows]);
const draftEntries = collectEntries(); const draftEntries = collectEntries();
const effectiveProviderCount = selectedProviderCodes.length > 0 ? selectedProviderCodes.length : 1;
const draftSummary = useMemo(() => { const draftSummary = useMemo(() => {
const base = draftEntries.reduce( return draftEntries.reduce(
(acc, entry) => { (acc, entry) => {
const rebateRate = parseRebateRate(entry.play.odds?.rebate_rate); const rebateRate = parseRebateRate(entry.play.odds?.rebate_rate);
const rebate = Math.round(entry.amountMinor * rebateRate); const rebate = Math.round(entry.amountMinor * rebateRate);
acc.bet += entry.amountMinor; const providerCount = entry.line.provider_codes?.length ?? 0;
acc.rebate += rebate; acc.bet += entry.amountMinor * providerCount;
acc.actual += Math.max(0, entry.amountMinor - rebate); acc.rebate += rebate * providerCount;
acc.actual += Math.max(0, entry.amountMinor - rebate) * providerCount;
return acc; return acc;
}, },
{ bet: 0, rebate: 0, actual: 0 }, { bet: 0, rebate: 0, actual: 0 },
); );
return { }, [draftEntries]);
bet: base.bet * effectiveProviderCount,
rebate: base.rebate * effectiveProviderCount,
actual: base.actual * effectiveProviderCount,
};
}, [draftEntries, effectiveProviderCount]);
useEffect(() => { useEffect(() => {
const id = window.setTimeout(() => { const id = window.setTimeout(() => {
@@ -1068,6 +1146,11 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
} }
const lineIssues = collectDraftLineIssues(); const lineIssues = collectDraftLineIssues();
const missingProviderAt = providerMissingRow();
if (missingProviderAt !== null) {
toast.error(t("hall.providers.rowRequired", { row: missingProviderAt, defaultValue: `${missingProviderAt} 行请选择至少一个开注商` }));
return;
}
if (lineIssues.length > 0) { if (lineIssues.length > 0) {
toast.error(formatDraftLineIssue(lineIssues[0])); toast.error(formatDraftLineIssue(lineIssues[0]));
return; return;
@@ -1090,7 +1173,6 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
draw_id: display.draw_no, draw_id: display.draw_no,
currency_code: currencyCode, currency_code: currencyCode,
client_trace_id: placeTraceIdRef.current, client_trace_id: placeTraceIdRef.current,
provider_codes: selectedProviderCodes.length > 0 ? selectedProviderCodes : undefined,
lines, lines,
}); });
setPreviewData(data); setPreviewData(data);
@@ -1131,6 +1213,11 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
} }
const lineIssues = collectDraftLineIssues(); const lineIssues = collectDraftLineIssues();
const missingProviderAt = providerMissingRow();
if (missingProviderAt !== null) {
toast.error(t("hall.providers.rowRequired", { row: missingProviderAt, defaultValue: `${missingProviderAt} 行请选择至少一个开注商` }));
return;
}
if (lineIssues.length > 0) { if (lineIssues.length > 0) {
toast.error(formatDraftLineIssue(lineIssues[0])); toast.error(formatDraftLineIssue(lineIssues[0]));
return; return;
@@ -1158,7 +1245,6 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
draw_id: drawIdForBet, draw_id: drawIdForBet,
currency_code: currencyCode, currency_code: currencyCode,
client_trace_id: traceId, client_trace_id: traceId,
provider_codes: selectedProviderCodes.length > 0 ? selectedProviderCodes : undefined,
lines, lines,
expected_config_versions: previewData.config_versions, expected_config_versions: previewData.config_versions,
}); });
@@ -1258,27 +1344,11 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
const submitActualMinor = const submitActualMinor =
previewData?.summary.total_actual_deduct ?? debouncedSummary.actual; previewData?.summary.total_actual_deduct ?? debouncedSummary.actual;
const selectedProviderCount = new Set(rows.flatMap((row) => row.providerCodes)).size;
const canSubmit = const canSubmit =
!tableDisabled && draftEntries.length > 0 && availableMinor >= submitActualMinor; !tableDisabled && draftEntries.length > 0 && providerMissingRow() === null && availableMinor >= submitActualMinor;
const favoriteChips = favorites.slice(0, 10); const favoriteChips = favorites.slice(0, 10);
const historyChips = historyNumbers.slice(0, 20); const historyChips = historyNumbers.slice(0, 20);
const selectedProviderNames = betProviders
.filter((provider) => selectedProviderCodes.includes(provider.code))
.map((provider) => provider.name);
const implicitDefaultProvider =
betProviders.find((provider) => provider.is_default) ?? betProviders[0] ?? null;
const providerSummaryCount = selectedProviderCodes.length > 0 ? selectedProviderCodes.length : 1;
const providerSummaryText =
selectedProviderCodes.length > 0
? t("hall.providers.summary", {
count: providerSummaryCount,
})
: implicitDefaultProvider
? t("hall.providers.platformDefaultSummary", {
provider: implicitDefaultProvider.name,
})
: t("hall.providers.platformDefaultSummaryNoProvider");
return ( return (
<> <>
<section className="space-y-3" aria-label={t("hall.aria")}> <section className="space-y-3" aria-label={t("hall.aria")}>
@@ -1515,107 +1585,6 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
</div> </div>
</div> </div>
<div
className={cn(
"rounded-lg border border-[#dfe8f6] bg-[#f8fbff]",
isMobile ? "px-3 py-2.5" : "px-2.5 py-2",
)}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-1.5">
<span className="mr-1 text-[11px] font-black text-[#304f86]">
{t("hall.providers.title")}
</span>
{!isMobile ? betProviders.map((provider) => {
const checked = selectedProviderCodes.includes(provider.code);
return (
<button
key={provider.code}
type="button"
disabled={tableDisabled}
aria-pressed={checked}
onClick={() => toggleProvider(provider.code)}
className={cn(
"inline-flex h-8 items-center gap-1 rounded-md border px-2 text-xs font-bold transition-colors disabled:cursor-not-allowed disabled:opacity-50",
checked
? "border-[#2d63e2] bg-[#2d63e2] text-white"
: "border-[#cfdbee] bg-white text-[#304f86] hover:border-[#8fb0eb]",
)}
>
<span
className={cn(
"size-3 rounded-[3px] border",
checked ? "border-white bg-white/25" : "border-[#9aa8bd]",
)}
aria-hidden
/>
{provider.name}
</button>
);
}) : (
<span className="text-xs font-semibold text-[#58709d]">
{selectedProviderNames.length > 0
? selectedProviderNames.join(" / ")
: implicitDefaultProvider
? t("hall.providers.platformDefault", { provider: implicitDefaultProvider.name })
: t("hall.providers.platformDefaultNoProvider")}
</span>
)}
</div>
<div className="flex items-center gap-2">
<p className="text-xs font-semibold text-[#58709d]">{providerSummaryText}</p>
{isMobile ? (
<button
type="button"
disabled={tableDisabled}
aria-pressed={providersExpanded}
onClick={() => setProvidersExpanded((current) => !current)}
className="inline-flex h-7 items-center justify-center rounded-full border border-[#d4dff1] bg-white px-2 text-[#58709d] transition-colors hover:bg-[#f3f7ff] disabled:opacity-40"
>
{providersExpanded ? (
<ChevronUp className="size-3.5" aria-hidden />
) : (
<ChevronDown className="size-3.5" aria-hidden />
)}
</button>
) : null}
</div>
</div>
{isMobile && providersExpanded ? (
<div className="mt-2.5 flex flex-wrap items-center gap-1.5">
{betProviders.map((provider) => {
const checked = selectedProviderCodes.includes(provider.code);
return (
<button
key={provider.code}
type="button"
disabled={tableDisabled}
aria-pressed={checked}
onClick={() => toggleProvider(provider.code)}
className={cn(
"inline-flex h-8 items-center gap-1 rounded-md border px-2 text-xs font-bold transition-colors disabled:cursor-not-allowed disabled:opacity-50",
checked
? "border-[#2d63e2] bg-[#2d63e2] text-white"
: "border-[#cfdbee] bg-white text-[#304f86] hover:border-[#8fb0eb]",
)}
>
<span
className={cn(
"size-3 rounded-[3px] border",
checked ? "border-white bg-white/25" : "border-[#9aa8bd]",
)}
aria-hidden
/>
{provider.name}
</button>
);
})}
</div>
) : null}
</div>
<div className={cn("overflow-x-auto pb-1", isMobile ? "-mx-1 flex gap-1.5 px-1" : "flex items-end gap-2")}> <div className={cn("overflow-x-auto pb-1", isMobile ? "-mx-1 flex gap-1.5 px-1" : "flex items-end gap-2")}>
{categoryTabs.map((tab) => { {categoryTabs.map((tab) => {
const hasPlays = openPlays.some( const hasPlays = openPlays.some(
@@ -1689,7 +1658,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
<tbody className="bg-white text-[#17408d]"> <tbody className="bg-white text-[#17408d]">
<tr> <tr>
<td className="border-r border-[#edf2f8] px-2 py-1.5 font-bold text-[#e5002c]"> <td className="border-r border-[#edf2f8] px-2 py-1.5 font-bold text-[#e5002c]">
{formatMinorAsCurrency(submitActualMinor, currencyCode)} {formatMinorAmount(submitActualMinor)}
</td> </td>
{allPlayColumns.map((column) => { {allPlayColumns.map((column) => {
const colTotalMinor = rows.reduce((sum, row) => { const colTotalMinor = rows.reduce((sum, row) => {
@@ -1707,7 +1676,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
return ( return (
<td key={`val-${column.key}`} className="border-r border-[#edf2f8] px-1 py-1.5 font-bold last:border-0"> <td key={`val-${column.key}`} className="border-r border-[#edf2f8] px-1 py-1.5 font-bold last:border-0">
{formatMinorAsCurrency(colTotalMinor, currencyCode)} {formatMinorAmount(colTotalMinor)}
</td> </td>
); );
})} })}
@@ -1716,10 +1685,10 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
</table> </table>
</div> </div>
<div className="player-table-scroll overflow-x-auto overscroll-x-contain"> <div className="player-table-scroll flex overflow-x-auto overscroll-x-contain">
<table <table
className="w-full border-collapse text-[11px]" className="w-max border-collapse text-[11px]"
style={{ minWidth: tableMinWidthPx }} style={{ width: tableWidthPx }}
> >
<thead> <thead>
<tr className="border-b border-[#edf2f8] bg-[#f8fafd] text-[#58709d]"> <tr className="border-b border-[#edf2f8] bg-[#f8fafd] text-[#58709d]">
@@ -1751,12 +1720,65 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
t, t,
)} )}
</span> </span>
<span className="mt-0.5 block text-[9px] font-medium text-[#9aa8bd]"> <Checkbox
{t("hall.table.amountPlaceholder")} checked={syncAmountColumns[column.key] === true}
</span> disabled={tableDisabled}
onCheckedChange={(checked) => setSyncAmountColumns((current) => ({
...current,
[column.key]: checked === true,
}))}
aria-label={`${playColumnHeaderLabel(column.play, playCategory(column.play.play_code), column.digitSlot, t)} 同步本列金额`}
title="同步本列金额"
className="mx-auto mt-1 size-3.5"
/>
</th> </th>
))} ))}
<th className="w-8 min-w-8 px-0.5 py-1.5" aria-label={t("hall.table.delete")} /> {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 DraftRow["selectionType"] | "";
if (selectionType) updateAllSelectionTypes(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="全选种类"
title="统一设置所有行的种类"
>
<option value=""></option>
<option value="straight"></option>
<option value="reverse"></option>
<option value="full_cover"></option>
<option value="full_play"></option>
{activeCategory === "D4" ? <option value="half_play"></option> : null}
</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={`全选 ${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> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -1770,24 +1792,51 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
numberPlaceholder={numberPlaceholder} numberPlaceholder={numberPlaceholder}
activeCategory={activeCategory} activeCategory={activeCategory}
numberMaxChars={numberMaxChars} numberMaxChars={numberMaxChars}
betProviders={betProviders}
playColumns={playColumns} playColumns={playColumns}
alertRows={alertRows} alertRows={alertRows}
liveSoldOutNumbers={liveSoldOutNumbers} liveSoldOutNumbers={liveSoldOutNumbers}
liveWarningNumbers={liveWarningNumbers} liveWarningNumbers={liveWarningNumbers}
updateRowNumber={updateRowNumber} updateRowNumber={updateRowNumber}
updateRowSelectionType={updateRowSelectionType}
updateAmount={updateAmount} updateAmount={updateAmount}
toggleRowProvider={toggleRowProvider}
setActiveRowId={setActiveRowId} setActiveRowId={setActiveRowId}
removeRow={removeRow}
t={t} t={t}
isMobile={isMobile} isMobile={isMobile}
removable={rows.length > 1}
indexColClass={indexColClass} indexColClass={indexColClass}
numberColClass={numberColClass} numberColClass={numberColClass}
stickyNumberLeftClass={stickyNumberLeftClass} stickyNumberLeftClass={stickyNumberLeftClass}
selectionTypeColClass={selectionTypeColClass}
showSelectionTypeColumn={showSelectionTypeColumn}
providerColClass={providerColClass}
rowTotalColClass={rowTotalColClass}
currencyCode={currencyCode}
/> />
))} ))}
</tbody> </tbody>
</table> </table>
{!isMobile ? (
<aside className="min-w-[12rem] flex-1 border-l border-[#e6edf8] bg-[#f8fbff] px-4 py-4 text-[#304f86]">
<p className="text-xs font-black">{t("hall.table.orderSummary", { defaultValue: "Order summary" })}</p>
<dl className="mt-5 space-y-4">
<div className="flex items-center justify-between gap-3">
<dt className="text-xs text-slate-500">{t("hall.table.lineCount", { defaultValue: "Lines" })}</dt>
<dd className="font-mono text-base font-black tabular-nums">{draftEntries.length}</dd>
</div>
<div className="flex items-center justify-between gap-3">
<dt className="text-xs text-slate-500">{t("hall.table.providerCount", { defaultValue: "Providers" })}</dt>
<dd className="font-mono text-base font-black tabular-nums">{selectedProviderCount}</dd>
</div>
<div className="border-t border-[#dce6f5] pt-4">
<dt className="text-xs text-slate-500">{t("hall.table.actualTotal")}</dt>
<dd className="mt-1 text-lg font-black tabular-nums text-[#0b3f96]">
{formatMinorAsCurrency(submitActualMinor, currencyCode)}
</dd>
</div>
</dl>
</aside>
) : null}
</div> </div>
</div> </div>
@@ -1925,20 +1974,26 @@ const DraftRowItem = memo(function DraftRowItem({
numberPlaceholder, numberPlaceholder,
activeCategory, activeCategory,
numberMaxChars, numberMaxChars,
betProviders,
playColumns, playColumns,
alertRows, alertRows,
liveSoldOutNumbers, liveSoldOutNumbers,
liveWarningNumbers, liveWarningNumbers,
updateRowNumber, updateRowNumber,
updateRowSelectionType,
updateAmount, updateAmount,
toggleRowProvider,
setActiveRowId, setActiveRowId,
removeRow,
t, t,
isMobile, isMobile,
removable,
indexColClass, indexColClass,
numberColClass, numberColClass,
stickyNumberLeftClass, stickyNumberLeftClass,
selectionTypeColClass,
showSelectionTypeColumn,
providerColClass,
rowTotalColClass,
currencyCode,
}: { }: {
row: DraftRow; row: DraftRow;
index: number; index: number;
@@ -1947,22 +2002,32 @@ const DraftRowItem = memo(function DraftRowItem({
numberPlaceholder: string; numberPlaceholder: string;
activeCategory: PlayHallCategory; activeCategory: PlayHallCategory;
numberMaxChars: number; numberMaxChars: number;
betProviders: BetProviderRow[];
playColumns: PlayColumn[]; playColumns: PlayColumn[];
alertRows: DrawCurrentRiskPoolAlert[]; alertRows: DrawCurrentRiskPoolAlert[];
liveSoldOutNumbers: Set<string>; liveSoldOutNumbers: Set<string>;
liveWarningNumbers: Set<string>; liveWarningNumbers: Set<string>;
updateRowNumber: (id: string, value: string) => void; updateRowNumber: (id: string, value: string) => void;
updateRowSelectionType: (id: string, value: DraftRow["selectionType"]) => void;
updateAmount: (rowId: string, playCode: string, value: string) => void; updateAmount: (rowId: string, playCode: string, value: string) => void;
toggleRowProvider: (rowId: string, code: string) => void;
setActiveRowId: (id: string) => void; setActiveRowId: (id: string) => void;
removeRow: (id: string) => void;
t: HallTranslate; t: HallTranslate;
isMobile: boolean; isMobile: boolean;
removable: boolean;
indexColClass: string; indexColClass: string;
numberColClass: string; numberColClass: string;
stickyNumberLeftClass: string; stickyNumberLeftClass: string;
selectionTypeColClass: string;
showSelectionTypeColumn: boolean;
providerColClass: string;
rowTotalColClass: string;
currencyCode: string;
}) { }) {
const displayNumber = sanitizeNumber(row.number, activeCategory); const displayNumber = sanitizeNumber(row.number, activeCategory);
const rowTotalMinor = playColumns.reduce((total, column) => {
if (draftLineIssueReason(column.play.play_code, row.number, column.digitSlot) !== null) return total;
return total + (parseDecimalInputToMinor(row.amounts[column.key] ?? "", currencyCode) ?? 0);
}, 0) * row.providerCodes.length;
return ( return (
<tr <tr
@@ -2068,16 +2133,39 @@ const DraftRowItem = memo(function DraftRowItem({
</td> </td>
); );
})} })}
<td className="px-0.5 py-1.5 text-center align-middle"> {showSelectionTypeColumn ? (
<button <td className={cn(selectionTypeColClass, "px-1 py-1.5 text-center", rowActive && "bg-[#f5f9ff]")}>
type="button" <select
disabled={tableDisabled || !removable} value={row.selectionType}
onClick={() => removeRow(row.id)} disabled={tableDisabled}
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" onChange={(event) => updateRowSelectionType(row.id, event.target.value as DraftRow["selectionType"])}
aria-label={t("actions.deleteRow", { row: index + 1 })} className="h-8 w-full rounded-md border border-[#e1e8f3] bg-white px-1 text-[10px] font-semibold text-[#304f86]"
aria-label={`${index + 1} 行种类`}
> >
<Trash2 className="size-3.5" aria-hidden /> <option value="straight"></option>
</button> <option value="reverse"></option>
<option value="full_cover"></option>
<option value="full_play"></option>
{activeCategory === "D4" ? <option value="half_play"></option> : null}
</select>
</td>
) : null}
{betProviders.map((provider, providerIndex) => (
<td
key={provider.code}
className={cn(providerColClass, providerColumnTone(providerIndex), "px-0.5 py-1.5 text-center")}
>
<Checkbox
checked={row.providerCodes.includes(provider.code)}
disabled={tableDisabled}
onCheckedChange={() => toggleRowProvider(row.id, provider.code)}
aria-label={`${provider.name} ${index + 1}`}
className="mx-auto border-slate-500 bg-white data-checked:border-slate-800 data-checked:bg-slate-800"
/>
</td>
))}
<td className={cn(rowTotalColClass, "bg-[#f7fbff] px-1 py-1.5 text-center font-mono font-black tabular-nums text-[#0b3f96]", rowActive && "bg-[#edf5ff]")}>
{formatMinorAmount(rowTotalMinor)}
</td> </td>
</tr> </tr>
); );

View File

@@ -9,19 +9,14 @@ import { PLAYER_DESKTOP_BREAKPOINT_PX } from "@/lib/player-viewport";
* 桌面端启用侧栏 + 顶栏布局(见 UI 设计稿)。 * 桌面端启用侧栏 + 顶栏布局(见 UI 设计稿)。
*/ */
export function useIsMobile(): boolean { export function useIsMobile(): boolean {
const [isMobile, setIsMobile] = useState(() => { // The server cannot know the viewport. Keep the first browser render aligned
if (typeof window === "undefined") return true; // with SSR, then read matchMedia after hydration to avoid className mismatches.
return window.matchMedia( const [isMobile, setIsMobile] = useState(true);
`(max-width: ${PLAYER_DESKTOP_BREAKPOINT_PX - 1}px)`,
).matches;
});
useEffect(() => { useEffect(() => {
const query = `(max-width: ${PLAYER_DESKTOP_BREAKPOINT_PX - 1}px)`; const query = `(max-width: ${PLAYER_DESKTOP_BREAKPOINT_PX - 1}px)`;
const mq = window.matchMedia(query); const mq = window.matchMedia(query);
const sync = () => setIsMobile(mq.matches); const sync = () => setIsMobile(mq.matches);
// sync() is still needed for the rare case where the lazy initializer
// was correct but the viewport changed between SSR and hydration.
sync(); sync();
mq.addEventListener("change", sync); mq.addEventListener("change", sync);
return () => mq.removeEventListener("change", sync); return () => mq.removeEventListener("change", sync);

View File

@@ -233,6 +233,8 @@
"table": { "table": {
"no": "No.", "no": "No.",
"number": "Number", "number": "Number",
"selectionType": "Type",
"rowTotal": "Row Total",
"stake": "Stake Amount", "stake": "Stake Amount",
"rebate": "Commission / Rebate", "rebate": "Commission / Rebate",
"actual": "Actual Deduction", "actual": "Actual Deduction",
@@ -242,6 +244,9 @@
"totalBet": "Total", "totalBet": "Total",
"totalRebate": "Rebate", "totalRebate": "Rebate",
"actualTotal": "Estimated Deduction", "actualTotal": "Estimated Deduction",
"orderSummary": "Order Summary",
"lineCount": "Lines",
"providerCount": "Providers",
"sealedHint": "Closed: this table is locked. Please wait for the next issue.", "sealedHint": "Closed: this table is locked. Please wait for the next issue.",
"previewing": "Previewing...", "previewing": "Previewing...",
"submitBet": "Submit Bet", "submitBet": "Submit Bet",

View File

@@ -232,6 +232,8 @@
"table": { "table": {
"no": "序号", "no": "序号",
"number": "号码", "number": "号码",
"selectionType": "种类",
"rowTotal": "本行合计",
"stake": "下注金额", "stake": "下注金额",
"rebate": "佣金 / 回水", "rebate": "佣金 / 回水",
"actual": "实扣金额", "actual": "实扣金额",
@@ -241,6 +243,9 @@
"totalBet": "合计", "totalBet": "合计",
"totalRebate": "回水", "totalRebate": "回水",
"actualTotal": "预计扣款", "actualTotal": "预计扣款",
"orderSummary": "本单概览",
"lineCount": "注项",
"providerCount": "开注商",
"sealedHint": "已封盘:当前表格不可编辑,请等待下一期。", "sealedHint": "已封盘:当前表格不可编辑,请等待下一期。",
"previewing": "预览中...", "previewing": "预览中...",
"submitBet": "提交下注", "submitBet": "提交下注",

View File

@@ -52,13 +52,12 @@ function formatMinorWithDisplaySettings(
return `${negative ? "-" : ""}${integerPartFormatted}${decimalSeparator}${fractionPadded}`; return `${negative ? "-" : ""}${integerPartFormatted}${decimalSeparator}${fractionPadded}`;
} }
export function formatMinorAsCurrency( export function formatMinorAmount(
minor: number | string, minor: number | string,
currencyCode: string,
decimalPlaces?: number, decimalPlaces?: number,
): string { ): string {
const n = typeof minor === "string" ? Number(minor) : minor; const n = typeof minor === "string" ? Number(minor) : minor;
if (!Number.isFinite(n)) return `${currencyCode}`; if (!Number.isFinite(n)) return "—";
const format = getCurrencyDisplayFormat(); const format = getCurrencyDisplayFormat();
const resolvedDisplayDecimals = const resolvedDisplayDecimals =
@@ -68,10 +67,18 @@ export function formatMinorAsCurrency(
? Math.min(12, Math.trunc(decimalPlaces)) ? Math.min(12, Math.trunc(decimalPlaces))
: format.displayDecimals; : format.displayDecimals;
const amount = formatMinorWithDisplaySettings(n, { return formatMinorWithDisplaySettings(n, {
...format, ...format,
displayDecimals: resolvedDisplayDecimals, displayDecimals: resolvedDisplayDecimals,
}); });
}
export function formatMinorAsCurrency(
minor: number | string,
currencyCode: string,
decimalPlaces?: number,
): string {
const amount = formatMinorAmount(minor, decimalPlaces);
return `${currencyCode} ${amount}`; return `${currencyCode} ${amount}`;
} }

View File

@@ -15,6 +15,17 @@ const LABELS: Record<string, string> = {
pos_2b: "2B", pos_2b: "2B",
pos_2c: "2C", pos_2c: "2C",
pos_2abc: "2ABC", pos_2abc: "2ABC",
four_any: "4N",
four_top: "A",
four_lower: "C",
pos_3d: "3D",
pos_3e: "3E",
pos_3lower: "C",
pos_2d: "2D",
pos_2e: "2E",
pos_2any: "2N",
five_d: "5D",
six_d: "6D",
straight: "Straight", straight: "Straight",
box: "Box", box: "Box",
ibox: "iBox", ibox: "iBox",

View File

@@ -1,6 +1,7 @@
export type BetProviderRow = { export type BetProviderRow = {
code: string; code: string;
name: string; name: string;
short_code: string;
sort_order: number; sort_order: number;
is_default: boolean; is_default: boolean;
}; };

View File

@@ -3,8 +3,10 @@ export type TicketLineInput = {
number: string; number: string;
play_code: string; play_code: string;
amount: number; amount: number;
provider_codes?: string[];
digit_slot?: number; digit_slot?: number;
dimension?: "D2" | "D3" | "D4"; dimension?: "D2" | "D3" | "D4";
selection_type?: "straight" | "reverse" | "full_cover" | "full_play" | "half_play";
}; };
export type TicketConfigVersions = { export type TicketConfigVersions = {