fix(player): harden realtime auth and refactor hall betting
This commit is contained in:
@@ -3,13 +3,13 @@
|
|||||||
import { useEffect, useCallback, type ReactNode } from "react";
|
import { useEffect, useCallback, type ReactNode } from "react";
|
||||||
|
|
||||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||||
import { setPlayerBearerToken } from "@/lib/lottery-auth";
|
|
||||||
import {
|
import {
|
||||||
loadIframeAllowedOrigins,
|
loadIframeAllowedOrigins,
|
||||||
messageToken,
|
messageToken,
|
||||||
resolvePostMessageTargetOrigin,
|
resolvePostMessageTargetOrigin,
|
||||||
resolveTrustedParentMessage,
|
resolveTrustedParentMessage,
|
||||||
} from "@/lib/iframe-origins";
|
} from "@/lib/iframe-origins";
|
||||||
|
import { publishIframeTokenRefresh } from "@/lib/iframe-token-refresh-events";
|
||||||
|
|
||||||
function sanitizeUrlForParent(href: string): string {
|
function sanitizeUrlForParent(href: string): string {
|
||||||
try {
|
try {
|
||||||
@@ -139,19 +139,19 @@ export function IframeBridge({ children }: { children: ReactNode }): ReactNode {
|
|||||||
if (token !== null) {
|
if (token !== null) {
|
||||||
console.log("[IframeBridge] Received initial token");
|
console.log("[IframeBridge] Received initial token");
|
||||||
setBearerToken(token);
|
setBearerToken(token);
|
||||||
setPlayerBearerToken(token);
|
|
||||||
// 勿再 notifyReady(),否则主站会重复 MAIN_INIT_TOKEN 导致消息刷屏
|
// 勿再 notifyReady(),否则主站会重复 MAIN_INIT_TOKEN 导致消息刷屏
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 主站刷新 Token
|
// 主站刷新 Token
|
||||||
case "MAIN_REFRESH_TOKEN": {
|
case "MAIN_REFRESH_TOKEN":
|
||||||
|
case "LOTTERY_TOKEN_REFRESH_RESPONSE": {
|
||||||
const token = messageToken(data);
|
const token = messageToken(data);
|
||||||
if (token !== null) {
|
if (token !== null) {
|
||||||
console.log("[IframeBridge] Received refreshed token");
|
console.log("[IframeBridge] Received refreshed token");
|
||||||
setBearerToken(token);
|
setBearerToken(token);
|
||||||
setPlayerBearerToken(token);
|
publishIframeTokenRefresh(token);
|
||||||
notifyTokenRefreshed();
|
notifyTokenRefreshed();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|||||||
180
src/features/hall/hall-betting-grid-model.ts
Normal file
180
src/features/hall/hall-betting-grid-model.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { draftLineIssueReason } from "@/features/hall/hall-bet-rules";
|
||||||
|
import type { SelectionType } from "@/features/hall/selection-type";
|
||||||
|
import { playLabel } from "@/lib/play-labels";
|
||||||
|
import type { BetProviderRow } from "@/types/api/bet-provider";
|
||||||
|
import type { DrawCurrentRiskPoolAlert } from "@/types/api/draw-current";
|
||||||
|
import type { PlayEffectivePlayRow } from "@/types/api/play-effective";
|
||||||
|
|
||||||
|
export type HallCategory = "D2" | "D3" | "D4" | "JACKPOT";
|
||||||
|
export type PlayHallCategory = Exclude<HallCategory, "JACKPOT">;
|
||||||
|
|
||||||
|
export type DraftRow = {
|
||||||
|
id: string;
|
||||||
|
number: string;
|
||||||
|
amounts: Record<string, string>;
|
||||||
|
providerCodes: string[];
|
||||||
|
selectionType: SelectionType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlayColumn = {
|
||||||
|
key: string;
|
||||||
|
play: PlayEffectivePlayRow;
|
||||||
|
digitSlot?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HallTranslate = (key: string, options?: Record<string, unknown>) => string;
|
||||||
|
export type CellRiskState = "open" | "warning" | "sold_out";
|
||||||
|
|
||||||
|
export 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 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;
|
||||||
|
|
||||||
|
export function providerColumnTone(index: number): string {
|
||||||
|
return PROVIDER_COLUMN_TONES[index % PROVIDER_COLUMN_TONES.length] ?? "bg-slate-100 text-slate-950";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playCategory(playCode: string): PlayHallCategory {
|
||||||
|
if (playCode.startsWith("pos_3")) return "D3";
|
||||||
|
if (playCode.startsWith("pos_2")) return "D2";
|
||||||
|
return "D4";
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function numberMaxCharsForCategory(category: PlayHallCategory): number {
|
||||||
|
return category === "D2" ? 2 : category === "D3" ? 3 : 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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 sortedDigits(value: string): string {
|
||||||
|
return value.split("").sort().join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesRiskAlert(
|
||||||
|
alertNumber: string,
|
||||||
|
playCode: string,
|
||||||
|
rowNumber: string,
|
||||||
|
category: PlayHallCategory,
|
||||||
|
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 digit = alertNumber[slot] ?? "";
|
||||||
|
return playCode === "digit_big"
|
||||||
|
? ["5", "6", "7", "8", "9"].includes(digit)
|
||||||
|
: ["0", "1", "2", "3", "4"].includes(digit);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cellRiskState(
|
||||||
|
play: PlayEffectivePlayRow,
|
||||||
|
rowNumber: string,
|
||||||
|
category: PlayHallCategory,
|
||||||
|
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 ?? [];
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDraftAmountInputValid(
|
||||||
|
row: DraftRow,
|
||||||
|
column: PlayColumn,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
row.number.trim().length > 0 &&
|
||||||
|
draftLineIssueReason(column.play.play_code, row.number, column.digitSlot) === null
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,32 +1,38 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChevronDown, ChevronUp, CircleHelp, Lock, Ticket, Trash2, Star } from "lucide-react";
|
import { Lock, Ticket, Trash2 } from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState, memo } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Tooltip } from "@base-ui/react/tooltip";
|
|
||||||
|
|
||||||
import { getBetProviders } from "@/api/bet-providers";
|
import { getBetProviders } from "@/api/bet-providers";
|
||||||
import { getPlayEffective } from "@/api/play";
|
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 {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
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";
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { HallBetPreviewDialog } from "@/features/hall/hall-bet-preview-dialog";
|
import { HallBetPreviewDialog } from "@/features/hall/hall-bet-preview-dialog";
|
||||||
import { HallBetResultDialog } from "@/features/hall/hall-bet-result-dialog";
|
import { HallBetResultDialog } from "@/features/hall/hall-bet-result-dialog";
|
||||||
import { mapTicketBetError } from "@/features/hall/hall-bet-errors";
|
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 {
|
import {
|
||||||
HallDesktopReviewPanel,
|
HallDesktopReviewPanel,
|
||||||
HallDesktopWorkflowBar,
|
HallDesktopWorkflowBar,
|
||||||
@@ -69,26 +75,14 @@ import { cn } from "@/lib/utils";
|
|||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
import type { PlayEffectivePayload, PlayEffectivePlayRow } from "@/types/api/play-effective";
|
import type { PlayEffectivePayload, PlayEffectivePlayRow } from "@/types/api/play-effective";
|
||||||
import type { TicketLineInput, TicketPlaceData, TicketPreviewData } from "@/types/api/ticket";
|
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";
|
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[]> = {
|
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"],
|
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"],
|
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"],
|
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 =
|
type PendingSelectionChange =
|
||||||
| { mode: "row"; rowId: string; next: SelectionType; prev: SelectionType }
|
| { mode: "row"; rowId: string; next: SelectionType; prev: SelectionType }
|
||||||
| { mode: "all"; next: SelectionType };
|
| { mode: "all"; next: SelectionType };
|
||||||
@@ -110,75 +104,6 @@ type DraftLineIssue = {
|
|||||||
reason: DraftLineIssueReason;
|
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 = {
|
type ClosedPlayCleanupData = {
|
||||||
cleanup_hint?: string;
|
cleanup_hint?: string;
|
||||||
cleanup_lines?: Array<{ client_line_no?: number; play_code?: string }>;
|
cleanup_lines?: Array<{ client_line_no?: number; play_code?: string }>;
|
||||||
@@ -216,26 +141,8 @@ type RiskWarningWsEvent = {
|
|||||||
usage_percent?: number;
|
usage_percent?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
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[] = [
|
|
||||||
{ 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 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" },
|
||||||
@@ -275,7 +182,6 @@ const CATEGORY_ORDER: readonly PlayHallCategory[] = ["D4", "D3", "D2"];
|
|||||||
const SUMMARY_PLAY_ORDER = CATEGORY_ORDER.flatMap(
|
const SUMMARY_PLAY_ORDER = CATEGORY_ORDER.flatMap(
|
||||||
(category) => PLAY_ORDER_BY_CATEGORY[category],
|
(category) => PLAY_ORDER_BY_CATEGORY[category],
|
||||||
);
|
);
|
||||||
const MOBILE_QUICK_AMOUNT_PRESETS = ["10", "50", "100"] as const;
|
|
||||||
const DEFAULT_DRAFT_ROW_COUNT = 20;
|
const DEFAULT_DRAFT_ROW_COUNT = 20;
|
||||||
|
|
||||||
function playOrderForActiveCategory(activeCategory: PlayHallCategory): readonly string[] {
|
function playOrderForActiveCategory(activeCategory: PlayHallCategory): readonly string[] {
|
||||||
@@ -302,37 +208,6 @@ function isPlayOpenForPlayer(row: PlayEffectivePlayRow): boolean {
|
|||||||
return Boolean(row.master_enabled && row.config?.is_enabled);
|
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 {
|
function amountKeyForPlay(playCode: string, digitSlot?: number): string {
|
||||||
return digitSlot === undefined ? playCode : `${playCode}@${digitSlot}`;
|
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 {
|
function sanitizeAmount(raw: string): string {
|
||||||
return raw.replace(/[^\d.]/g, "").replace(/(\..*)\./g, "$1").slice(0, 12);
|
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);
|
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 } {
|
function quickFillKeys(category: HallCategory): { favorites: string; history: string } {
|
||||||
return {
|
return {
|
||||||
favorites: `lottery.hall.quickfill.favorites.${category}`,
|
favorites: `lottery.hall.quickfill.favorites.${category}`,
|
||||||
@@ -615,11 +383,6 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
const [pendingSelectionChange, setPendingSelectionChange] = useState<PendingSelectionChange | null>(
|
const [pendingSelectionChange, setPendingSelectionChange] = useState<PendingSelectionChange | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const holdFavoriteRef = useRef<{ timer: number | null; number: string | null; longPress: boolean }>({
|
|
||||||
timer: null,
|
|
||||||
number: null,
|
|
||||||
longPress: false,
|
|
||||||
});
|
|
||||||
/** 单次预览→确认共用,重试 place 复用,避免重复扣款 */
|
/** 单次预览→确认共用,重试 place 复用,避免重复扣款 */
|
||||||
const placeTraceIdRef = useRef<string | null>(null);
|
const placeTraceIdRef = useRef<string | null>(null);
|
||||||
const previewRequestSeqRef = useRef(0);
|
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)),
|
() => openPlays.filter((play) => TRADITIONAL_PLAY_CODES[activeCategory].includes(play.play_code)),
|
||||||
[activeCategory, openPlays],
|
[activeCategory, openPlays],
|
||||||
);
|
);
|
||||||
|
const availableCategories = useMemo(
|
||||||
|
() => new Set(openPlays.map((play) => playCategory(play.play_code))),
|
||||||
|
[openPlays],
|
||||||
|
);
|
||||||
|
|
||||||
const currencyCode =
|
const currencyCode =
|
||||||
catalogState.kind === "ok" ? catalogState.data.currency_code : currencyParam;
|
catalogState.kind === "ok" ? catalogState.data.currency_code : currencyParam;
|
||||||
@@ -1846,337 +1613,26 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
|
|
||||||
<HallPlaySummaryGrid items={summaryItems} className="lg:hidden" />
|
<HallPlaySummaryGrid items={summaryItems} className="lg:hidden" />
|
||||||
|
|
||||||
<div
|
{isMobile ? (
|
||||||
className={cn(
|
<HallMobileQuickFill
|
||||||
isMobile ? "space-y-3" : "hidden",
|
activeNumber={activeRow?.number ?? ""}
|
||||||
)}
|
favorites={favoriteChips}
|
||||||
>
|
history={historyChips}
|
||||||
<div
|
expanded={quickFillExpanded}
|
||||||
className={cn(
|
tableDisabled={tableDisabled}
|
||||||
"bg-white",
|
activeCategory={activeCategory}
|
||||||
isMobile
|
availableCategories={availableCategories}
|
||||||
? "rounded-lg border border-[#e8eef7] px-3 py-2.5"
|
onExpandedChange={setQuickFillExpanded}
|
||||||
: "min-w-0 flex-1 px-2 py-0.5",
|
onCategoryChange={setActiveCategory}
|
||||||
)}
|
onFillNumber={fillCurrentRow}
|
||||||
>
|
onToggleFavorite={toggleFavoriteNumber}
|
||||||
<div className={cn("flex gap-3", isMobile ? "items-start justify-between" : "items-center justify-between")}>
|
onClearAll={clearAllRows}
|
||||||
<div className={cn("min-w-0", !isMobile && "flex flex-1 items-center gap-4")}>
|
onApplyQuickAmount={applyQuickAmountToActiveRow}
|
||||||
<div className={cn("shrink-0", !isMobile && "min-w-[5.5rem]")}>
|
onCopyPreviousRow={copyPreviousRowToActiveRow}
|
||||||
<p className="text-sm font-bold leading-5 text-slate-950">
|
onClearActiveRowAmounts={clearActiveRowAmounts}
|
||||||
{t("hall.quickFill.title")}
|
t={t}
|
||||||
</p>
|
/>
|
||||||
{isMobile ? (
|
) : null}
|
||||||
<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>
|
|
||||||
|
|
||||||
{activeCategoryPlays.length === 0 ? (
|
{activeCategoryPlays.length === 0 ? (
|
||||||
<div
|
<div
|
||||||
@@ -2202,143 +1658,41 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
showWideTableHint && "player-table-scroll-wrap",
|
showWideTableHint && "player-table-scroll-wrap",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="player-table-scroll overflow-x-auto overscroll-x-contain">
|
<HallBettingTable
|
||||||
<table
|
rows={rows}
|
||||||
className={cn("mx-auto w-max border-collapse", isMobile ? "text-[11px]" : "text-sm")}
|
activeRowId={activeRowId}
|
||||||
style={{ width: tableWidthPx }}
|
tableDisabled={tableDisabled}
|
||||||
>
|
tableWidthPx={tableWidthPx}
|
||||||
<thead>
|
isMobile={isMobile}
|
||||||
<tr className="border-b border-[#edf2f8] bg-[#f8fafd] text-[#58709d]">
|
indexColClass={indexColClass}
|
||||||
<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)}>
|
numberColClass={numberColClass}
|
||||||
{t("hall.table.no", { defaultValue: "No." })}
|
stickyNumberLeftClass={stickyNumberLeftClass}
|
||||||
</th>
|
numberPlaceholder={numberPlaceholder}
|
||||||
<th
|
numberMaxChars={numberMaxChars}
|
||||||
className={cn(
|
activeCategory={activeCategory}
|
||||||
"sticky z-30 bg-[#f8fafd] px-1 text-center font-bold shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
|
playColumns={playColumns}
|
||||||
isMobile ? "py-1.5" : "py-2.5",
|
amountColClass={amountColClass}
|
||||||
stickyNumberLeftClass,
|
syncAmountColumns={syncAmountColumns}
|
||||||
numberColClass,
|
selectionTypeColClass={selectionTypeColClass}
|
||||||
)}
|
showSelectionTypeColumn={showSelectionTypeColumn}
|
||||||
>
|
selectionTypeOptions={selectionTypeOptions}
|
||||||
<span className="block text-xs">{t("hall.table.number", { defaultValue: "Number" })}</span>
|
betProviders={betProviders}
|
||||||
<span className="mt-0.5 block font-mono text-[10px] font-medium text-[#9aa8bd]">
|
providerColClass={providerColClass}
|
||||||
{numberPlaceholder}
|
rowTotalColClass={rowTotalColClass}
|
||||||
</span>
|
alertRows={alertRows}
|
||||||
</th>
|
liveSoldOutNumbers={liveSoldOutNumbers}
|
||||||
{playColumns.map((column) => (
|
liveWarningNumbers={liveWarningNumbers}
|
||||||
<th
|
currencyCode={currencyCode}
|
||||||
key={column.key}
|
onToggleSyncAmountColumn={toggleSyncAmountColumn}
|
||||||
className={cn(amountColClass, "px-0.5 text-center font-bold", isMobile ? "py-1.5" : "py-2.5")}
|
onSetAllSelectionTypes={requestAllSelectionTypes}
|
||||||
>
|
onToggleProviderColumn={toggleProviderColumn}
|
||||||
<span className="block whitespace-nowrap text-[10px] leading-tight">
|
onUpdateRowNumber={updateRowNumber}
|
||||||
{playColumnHeaderLabel(
|
onUpdateRowSelectionType={requestRowSelectionType}
|
||||||
column.play,
|
onUpdateAmount={updateAmount}
|
||||||
playCategory(column.play.play_code),
|
onToggleRowProvider={toggleRowProvider}
|
||||||
column.digitSlot,
|
onSetActiveRowId={setActiveRowId}
|
||||||
t,
|
t={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>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2462,327 +1816,14 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
creditMode={creditMode}
|
creditMode={creditMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Dialog
|
<HallSelectionConfirmDialog
|
||||||
open={pendingSelectionChange !== null}
|
open={pendingSelectionChange !== null}
|
||||||
onOpenChange={(open) => {
|
preview={pendingSelectionPreview}
|
||||||
if (!open) cancelPendingSelectionChange();
|
currencyCode={currencyCode}
|
||||||
}}
|
onCancel={cancelPendingSelectionChange}
|
||||||
>
|
onConfirm={confirmPendingSelectionChange}
|
||||||
<DialogContent className="max-w-[380px] rounded-2xl border border-[#dfe8f6] bg-white p-0 shadow-[0_24px_70px_rgba(15,23,42,0.18)]">
|
t={t}
|
||||||
<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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|||||||
257
src/features/hall/hall-betting-table.tsx
Normal file
257
src/features/hall/hall-betting-table.tsx
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { HallDraftRowItem } from "@/features/hall/hall-draft-row-item";
|
||||||
|
import {
|
||||||
|
playCategory,
|
||||||
|
playColumnHeaderLabel,
|
||||||
|
providerColumnTone,
|
||||||
|
type DraftRow,
|
||||||
|
type HallTranslate,
|
||||||
|
type PlayColumn,
|
||||||
|
type PlayHallCategory,
|
||||||
|
} from "@/features/hall/hall-betting-grid-model";
|
||||||
|
import type { SelectionType } from "@/features/hall/selection-type";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { BetProviderRow } from "@/types/api/bet-provider";
|
||||||
|
import type { DrawCurrentRiskPoolAlert } from "@/types/api/draw-current";
|
||||||
|
|
||||||
|
type HallBettingTableProps = {
|
||||||
|
rows: DraftRow[];
|
||||||
|
activeRowId: string | null;
|
||||||
|
tableDisabled: boolean;
|
||||||
|
tableWidthPx: number;
|
||||||
|
isMobile: boolean;
|
||||||
|
indexColClass: string;
|
||||||
|
numberColClass: string;
|
||||||
|
stickyNumberLeftClass: string;
|
||||||
|
numberPlaceholder: string;
|
||||||
|
numberMaxChars: number;
|
||||||
|
activeCategory: PlayHallCategory;
|
||||||
|
playColumns: PlayColumn[];
|
||||||
|
amountColClass: string;
|
||||||
|
syncAmountColumns: Record<string, boolean>;
|
||||||
|
selectionTypeColClass: string;
|
||||||
|
showSelectionTypeColumn: boolean;
|
||||||
|
selectionTypeOptions: SelectionType[];
|
||||||
|
betProviders: BetProviderRow[];
|
||||||
|
providerColClass: string;
|
||||||
|
rowTotalColClass: string;
|
||||||
|
alertRows: DrawCurrentRiskPoolAlert[];
|
||||||
|
liveSoldOutNumbers: Set<string>;
|
||||||
|
liveWarningNumbers: Set<string>;
|
||||||
|
currencyCode: string;
|
||||||
|
onToggleSyncAmountColumn: (column: PlayColumn, checked: boolean) => void;
|
||||||
|
onSetAllSelectionTypes: (selectionType: SelectionType) => void;
|
||||||
|
onToggleProviderColumn: (code: string, checked: boolean) => void;
|
||||||
|
onUpdateRowNumber: (id: string, value: string) => void;
|
||||||
|
onUpdateRowSelectionType: (id: string, value: SelectionType) => void;
|
||||||
|
onUpdateAmount: (rowId: string, playCode: string, value: string) => void;
|
||||||
|
onToggleRowProvider: (rowId: string, code: string) => void;
|
||||||
|
onSetActiveRowId: (id: string) => void;
|
||||||
|
t: HallTranslate;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function HallBettingTable({
|
||||||
|
rows,
|
||||||
|
activeRowId,
|
||||||
|
tableDisabled,
|
||||||
|
tableWidthPx,
|
||||||
|
isMobile,
|
||||||
|
indexColClass,
|
||||||
|
numberColClass,
|
||||||
|
stickyNumberLeftClass,
|
||||||
|
numberPlaceholder,
|
||||||
|
numberMaxChars,
|
||||||
|
activeCategory,
|
||||||
|
playColumns,
|
||||||
|
amountColClass,
|
||||||
|
syncAmountColumns,
|
||||||
|
selectionTypeColClass,
|
||||||
|
showSelectionTypeColumn,
|
||||||
|
selectionTypeOptions,
|
||||||
|
betProviders,
|
||||||
|
providerColClass,
|
||||||
|
rowTotalColClass,
|
||||||
|
alertRows,
|
||||||
|
liveSoldOutNumbers,
|
||||||
|
liveWarningNumbers,
|
||||||
|
currencyCode,
|
||||||
|
onToggleSyncAmountColumn,
|
||||||
|
onSetAllSelectionTypes,
|
||||||
|
onToggleProviderColumn,
|
||||||
|
onUpdateRowNumber,
|
||||||
|
onUpdateRowSelectionType,
|
||||||
|
onUpdateAmount,
|
||||||
|
onToggleRowProvider,
|
||||||
|
onSetActiveRowId,
|
||||||
|
t,
|
||||||
|
}: HallBettingTableProps) {
|
||||||
|
return (
|
||||||
|
<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) => {
|
||||||
|
const label = playColumnHeaderLabel(
|
||||||
|
column.play,
|
||||||
|
playCategory(column.play.play_code),
|
||||||
|
column.digitSlot,
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<Checkbox
|
||||||
|
checked={syncAmountColumns[column.key] === true}
|
||||||
|
disabled={tableDisabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onToggleSyncAmountColumn(column, checked === true)
|
||||||
|
}
|
||||||
|
aria-label={t("hall.table.syncColumnAmount", { column: label })}
|
||||||
|
title={t("hall.table.syncColumnAmount", { column: label })}
|
||||||
|
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) onSetAllSelectionTypes(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) =>
|
||||||
|
onToggleProviderColumn(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) => (
|
||||||
|
<HallDraftRowItem
|
||||||
|
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={onUpdateRowNumber}
|
||||||
|
updateRowSelectionType={onUpdateRowSelectionType}
|
||||||
|
selectionTypeOptions={selectionTypeOptions}
|
||||||
|
updateAmount={onUpdateAmount}
|
||||||
|
toggleRowProvider={onToggleRowProvider}
|
||||||
|
setActiveRowId={onSetActiveRowId}
|
||||||
|
t={t}
|
||||||
|
isMobile={isMobile}
|
||||||
|
indexColClass={indexColClass}
|
||||||
|
numberColClass={numberColClass}
|
||||||
|
stickyNumberLeftClass={stickyNumberLeftClass}
|
||||||
|
selectionTypeColClass={selectionTypeColClass}
|
||||||
|
showSelectionTypeColumn={showSelectionTypeColumn}
|
||||||
|
providerColClass={providerColClass}
|
||||||
|
rowTotalColClass={rowTotalColClass}
|
||||||
|
currencyCode={currencyCode}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
312
src/features/hall/hall-draft-row-item.tsx
Normal file
312
src/features/hall/hall-draft-row-item.tsx
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
import { Tooltip } from "@base-ui/react/tooltip";
|
||||||
|
import { CircleHelp } from "lucide-react";
|
||||||
|
import { memo } from "react";
|
||||||
|
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { draftLineIssueReason } from "@/features/hall/hall-bet-rules";
|
||||||
|
import {
|
||||||
|
cellRiskState,
|
||||||
|
isDraftAmountInputValid,
|
||||||
|
playCategory,
|
||||||
|
providerColumnTone,
|
||||||
|
sanitizeNumber,
|
||||||
|
type DraftRow,
|
||||||
|
type HallTranslate,
|
||||||
|
type PlayColumn,
|
||||||
|
type PlayHallCategory,
|
||||||
|
} from "@/features/hall/hall-betting-grid-model";
|
||||||
|
import {
|
||||||
|
isFullCoverAmountDivisible,
|
||||||
|
resolveSelectionTotalBet,
|
||||||
|
selectionCombinationCount,
|
||||||
|
type SelectionType,
|
||||||
|
} from "@/features/hall/selection-type";
|
||||||
|
import { formatMinorAmount, parseDecimalInputToMinor } from "@/lib/money";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { BetProviderRow } from "@/types/api/bet-provider";
|
||||||
|
import type { DrawCurrentRiskPoolAlert } from "@/types/api/draw-current";
|
||||||
|
|
||||||
|
type HallDraftRowItemProps = {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HallDraftRowItem = memo(function HallDraftRowItem({
|
||||||
|
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,
|
||||||
|
}: HallDraftRowItemProps) {
|
||||||
|
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 tracking-[0.12em] tabular-nums text-slate-950 shadow-sm focus-visible:ring-[#1d57b7]",
|
||||||
|
isMobile ? "h-8 text-sm" : "h-9 text-sm",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</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 inputValid = isDraftAmountInputValid(row, column);
|
||||||
|
|
||||||
|
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",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{inputValid ? (
|
||||||
|
<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(
|
||||||
|
"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-8 text-sm" : "h-9 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",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{inputValid && 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
280
src/features/hall/hall-mobile-quick-fill.tsx
Normal file
280
src/features/hall/hall-mobile-quick-fill.tsx
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
import { ChevronDown, ChevronUp, Star, Trash2 } from "lucide-react";
|
||||||
|
import { useRef } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import type {
|
||||||
|
HallTranslate,
|
||||||
|
PlayHallCategory,
|
||||||
|
} from "@/features/hall/hall-betting-grid-model";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const QUICK_AMOUNT_PRESETS = ["10", "50", "100"] as const;
|
||||||
|
const CATEGORY_TABS: Array<{ value: PlayHallCategory; label: string }> = [
|
||||||
|
{ value: "D4", label: "4D" },
|
||||||
|
{ value: "D3", label: "3D" },
|
||||||
|
{ value: "D2", label: "2D" },
|
||||||
|
];
|
||||||
|
|
||||||
|
type HallMobileQuickFillProps = {
|
||||||
|
activeNumber: string;
|
||||||
|
favorites: string[];
|
||||||
|
history: string[];
|
||||||
|
expanded: boolean;
|
||||||
|
tableDisabled: boolean;
|
||||||
|
activeCategory: PlayHallCategory;
|
||||||
|
availableCategories: ReadonlySet<PlayHallCategory>;
|
||||||
|
onExpandedChange: (expanded: boolean) => void;
|
||||||
|
onCategoryChange: (category: PlayHallCategory) => void;
|
||||||
|
onFillNumber: (number: string) => void;
|
||||||
|
onToggleFavorite: (number: string) => void;
|
||||||
|
onClearAll: () => void;
|
||||||
|
onApplyQuickAmount: (amount: string) => void;
|
||||||
|
onCopyPreviousRow: () => void;
|
||||||
|
onClearActiveRowAmounts: () => void;
|
||||||
|
t: HallTranslate;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function HallMobileQuickFill({
|
||||||
|
activeNumber,
|
||||||
|
favorites,
|
||||||
|
history,
|
||||||
|
expanded,
|
||||||
|
tableDisabled,
|
||||||
|
activeCategory,
|
||||||
|
availableCategories,
|
||||||
|
onExpandedChange,
|
||||||
|
onCategoryChange,
|
||||||
|
onFillNumber,
|
||||||
|
onToggleFavorite,
|
||||||
|
onClearAll,
|
||||||
|
onApplyQuickAmount,
|
||||||
|
onCopyPreviousRow,
|
||||||
|
onClearActiveRowAmounts,
|
||||||
|
t,
|
||||||
|
}: HallMobileQuickFillProps) {
|
||||||
|
const holdFavoriteRef = useRef<{
|
||||||
|
timer: number | null;
|
||||||
|
longPress: boolean;
|
||||||
|
}>({ timer: null, longPress: false });
|
||||||
|
|
||||||
|
const cancelFavoriteHold = (): void => {
|
||||||
|
const current = holdFavoriteRef.current;
|
||||||
|
if (current.timer !== null) {
|
||||||
|
window.clearTimeout(current.timer);
|
||||||
|
current.timer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeIsFavorite = favorites.includes(activeNumber);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="rounded-lg border border-[#e8eef7] bg-white px-3 py-2.5">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-bold leading-5 text-slate-950">
|
||||||
|
{t("hall.quickFill.title")}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-[11px] leading-5 text-slate-500">
|
||||||
|
{t("hall.mobile.quickFillSummary", {
|
||||||
|
defaultValue: "收藏 {{favorites}} 个,历史 {{history}} 个",
|
||||||
|
favorites: favorites.length,
|
||||||
|
history: history.length,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={
|
||||||
|
expanded
|
||||||
|
? t("hall.mobile.collapseQuickFill", { defaultValue: "收起快捷填单" })
|
||||||
|
: t("hall.mobile.expandQuickFill", { defaultValue: "展开快捷填单" })
|
||||||
|
}
|
||||||
|
aria-pressed={expanded}
|
||||||
|
className="size-8 rounded-full border border-[#dfe6f0] bg-[#f8fafc] text-slate-500 hover:bg-slate-100 hover:text-slate-900"
|
||||||
|
onClick={() => onExpandedChange(!expanded)}
|
||||||
|
>
|
||||||
|
{expanded ? (
|
||||||
|
<ChevronUp className="size-4" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="size-4" aria-hidden />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{activeNumber ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={
|
||||||
|
activeIsFavorite
|
||||||
|
? t("hall.quickFill.unfavorite")
|
||||||
|
: t("hall.quickFill.favorite")
|
||||||
|
}
|
||||||
|
aria-pressed={activeIsFavorite}
|
||||||
|
title={
|
||||||
|
activeIsFavorite
|
||||||
|
? 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]",
|
||||||
|
activeIsFavorite &&
|
||||||
|
"border-[#d81435] bg-[#d81435] text-white hover:bg-[#c51230] hover:text-white",
|
||||||
|
)}
|
||||||
|
onClick={() => onToggleFavorite(activeNumber)}
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
className={cn("size-4", activeIsFavorite && "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={onClearAll}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded ? (
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
<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>
|
||||||
|
{QUICK_AMOUNT_PRESETS.map((preset) => (
|
||||||
|
<button
|
||||||
|
key={preset}
|
||||||
|
type="button"
|
||||||
|
disabled={tableDisabled}
|
||||||
|
onClick={() => onApplyQuickAmount(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 || !activeNumber}
|
||||||
|
onClick={onCopyPreviousRow}
|
||||||
|
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={onClearActiveRowAmounts}
|
||||||
|
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>
|
||||||
|
|
||||||
|
{favorites.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>
|
||||||
|
{favorites.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.longPress = false;
|
||||||
|
cancelFavoriteHold();
|
||||||
|
current.timer = window.setTimeout(() => {
|
||||||
|
current.longPress = true;
|
||||||
|
onToggleFavorite(number);
|
||||||
|
}, 500);
|
||||||
|
}}
|
||||||
|
onPointerUp={() => {
|
||||||
|
const current = holdFavoriteRef.current;
|
||||||
|
cancelFavoriteHold();
|
||||||
|
if (!current.longPress) onFillNumber(number);
|
||||||
|
current.longPress = false;
|
||||||
|
}}
|
||||||
|
onPointerLeave={() => {
|
||||||
|
cancelFavoriteHold();
|
||||||
|
holdFavoriteRef.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>
|
||||||
|
{history.length > 0 ? (
|
||||||
|
history.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={() => onFillNumber(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>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="-mx-1 flex gap-1.5 overflow-x-auto px-1 pb-1">
|
||||||
|
{CATEGORY_TABS.map((tab) => {
|
||||||
|
const enabled = availableCategories.has(tab.value);
|
||||||
|
const active = activeCategory === tab.value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.value}
|
||||||
|
type="button"
|
||||||
|
disabled={!enabled}
|
||||||
|
onClick={() => onCategoryChange(tab.value)}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex min-w-[5.25rem] items-center justify-center rounded-t-xl border border-b-0 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]",
|
||||||
|
!enabled && "cursor-not-allowed opacity-40",
|
||||||
|
)}
|
||||||
|
aria-pressed={active}
|
||||||
|
>
|
||||||
|
<span className="mr-2 text-[13px]" aria-hidden>
|
||||||
|
▦
|
||||||
|
</span>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/features/hall/hall-play-summary-grid.tsx
Normal file
55
src/features/hall/hall-play-summary-grid.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { formatMinorAmount } from "@/lib/money";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type HallSummaryItem = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
totalMinor: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
src/features/hall/hall-selection-confirm-dialog.tsx
Normal file
105
src/features/hall/hall-selection-confirm-dialog.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import type { HallTranslate } from "@/features/hall/hall-betting-grid-model";
|
||||||
|
import type { SelectionType } from "@/features/hall/selection-type";
|
||||||
|
import { formatMinorAsCurrency } from "@/lib/money";
|
||||||
|
|
||||||
|
export type HallSelectionPreview = {
|
||||||
|
next: SelectionType;
|
||||||
|
comboCount: number;
|
||||||
|
number: string;
|
||||||
|
fromMinor: number;
|
||||||
|
toMinor: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HallSelectionConfirmDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
preview: HallSelectionPreview | null;
|
||||||
|
currencyCode: string;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
t: HallTranslate;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function HallSelectionConfirmDialog({
|
||||||
|
open,
|
||||||
|
preview,
|
||||||
|
currencyCode,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
t,
|
||||||
|
}: HallSelectionConfirmDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(nextOpen) => !nextOpen && onCancel()}>
|
||||||
|
<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">
|
||||||
|
{preview
|
||||||
|
? preview.next === "full_cover"
|
||||||
|
? t("hall.table.selectionConfirm.fullCoverBody", {
|
||||||
|
type: t(`hall.table.selectionTypes.${preview.next}`),
|
||||||
|
count: preview.comboCount,
|
||||||
|
amount: formatMinorAsCurrency(
|
||||||
|
preview.toMinor || preview.fromMinor,
|
||||||
|
currencyCode,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
: preview.fromMinor > 0 && preview.toMinor > preview.fromMinor
|
||||||
|
? t("hall.table.selectionConfirm.increaseBody", {
|
||||||
|
type: t(`hall.table.selectionTypes.${preview.next}`),
|
||||||
|
count: preview.comboCount,
|
||||||
|
from: formatMinorAsCurrency(preview.fromMinor, currencyCode),
|
||||||
|
to: formatMinorAsCurrency(preview.toMinor, currencyCode),
|
||||||
|
})
|
||||||
|
: t("hall.table.selectionConfirm.genericBody", {
|
||||||
|
type: t(`hall.table.selectionTypes.${preview.next}`),
|
||||||
|
count: preview.comboCount,
|
||||||
|
})
|
||||||
|
: t("hall.table.selectionConfirm.genericBody", {
|
||||||
|
type: "",
|
||||||
|
count: 1,
|
||||||
|
})}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{preview && preview.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: preview.comboCount,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
{preview.number ? (
|
||||||
|
<p className="font-mono text-[11px] text-slate-500">
|
||||||
|
{t("hall.table.selectionConfirm.numberHint", {
|
||||||
|
number: preview.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={onCancel}>
|
||||||
|
{t("hall.table.selectionConfirm.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="rounded-lg bg-[#e5002c] text-white hover:bg-[#d10028]"
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
{t("hall.table.selectionConfirm.confirm")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -45,7 +45,7 @@ export function usePlayerBalanceWs(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const channelName = `player.${playerId}`;
|
const channelName = `player.${playerId}`;
|
||||||
const channel = echo.channel(channelName);
|
const channel = echo.private(channelName);
|
||||||
|
|
||||||
const onBalanceUpdate = (evt: BalanceUpdateWsEvent): void => {
|
const onBalanceUpdate = (evt: BalanceUpdateWsEvent): void => {
|
||||||
const currency = evt.currency_code?.trim().toUpperCase();
|
const currency = evt.currency_code?.trim().toUpperCase();
|
||||||
@@ -75,6 +75,7 @@ export function usePlayerBalanceWs(): void {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
channel.stopListening(".balance.update");
|
channel.stopListening(".balance.update");
|
||||||
|
echo.leave(channelName);
|
||||||
};
|
};
|
||||||
}, [activeCurrency, bearerToken, playerId, t]);
|
}, [activeCurrency, bearerToken, playerId, t]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,10 @@ import { useCallback, useEffect, useRef } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { parseJwtExp } from "@/lib/jwt-payload";
|
import { parseJwtExp } from "@/lib/jwt-payload";
|
||||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
|
||||||
import { useErrorStore } from "@/stores/error-store";
|
import { useErrorStore } from "@/stores/error-store";
|
||||||
import {
|
import { resolvePostMessageTargetOrigin } from "@/lib/iframe-origins";
|
||||||
loadIframeAllowedOrigins,
|
import { subscribeIframeTokenRefresh } from "@/lib/iframe-token-refresh-events";
|
||||||
messageToken,
|
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||||
resolvePostMessageTargetOrigin,
|
|
||||||
resolveTrustedParentMessage,
|
|
||||||
} from "@/lib/iframe-origins";
|
|
||||||
|
|
||||||
/** Token 过期前警告阈值(毫秒) */
|
/** Token 过期前警告阈值(毫秒) */
|
||||||
const TOKEN_WARNING_THRESHOLD = 60 * 1000; // 1 分钟
|
const TOKEN_WARNING_THRESHOLD = 60 * 1000; // 1 分钟
|
||||||
@@ -38,7 +34,6 @@ export function useTokenRefresh(): {
|
|||||||
isTokenExpiringSoon: () => boolean;
|
isTokenExpiringSoon: () => boolean;
|
||||||
} {
|
} {
|
||||||
const bearerToken = usePlayerSessionStore((state) => state.bearerToken);
|
const bearerToken = usePlayerSessionStore((state) => state.bearerToken);
|
||||||
const setBearerToken = usePlayerSessionStore((state) => state.setBearerToken);
|
|
||||||
const setServerError = useErrorStore((state) => state.setServerError);
|
const setServerError = useErrorStore((state) => state.setServerError);
|
||||||
const clearServerError = useErrorStore((state) => state.clearServerError);
|
const clearServerError = useErrorStore((state) => state.clearServerError);
|
||||||
const { t } = useTranslation("player");
|
const { t } = useTranslation("player");
|
||||||
@@ -110,43 +105,13 @@ export function useTokenRefresh(): {
|
|||||||
}, REFRESH_RESPONSE_TIMEOUT_MS);
|
}, REFRESH_RESPONSE_TIMEOUT_MS);
|
||||||
}, [clearServerError, requestParentRefresh, setServerError, t]);
|
}, [clearServerError, requestParentRefresh, setServerError, t]);
|
||||||
|
|
||||||
/**
|
/** IframeBridge 统一校验父窗口消息;这里只接收已验证的续签结果。 */
|
||||||
* 监听主站 postMessage 发送的新 Token
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return;
|
return subscribeIframeTokenRefresh(() => {
|
||||||
|
pendingRefreshRef.current = 0;
|
||||||
void loadIframeAllowedOrigins();
|
retryCountRef.current = 0;
|
||||||
|
});
|
||||||
const handleMessage = async (event: MessageEvent): Promise<void> => {
|
}, []);
|
||||||
const data = await resolveTrustedParentMessage(event);
|
|
||||||
if (data === null) {
|
|
||||||
console.warn("[TokenRefresh] Ignored untrusted parent message from:", event.origin);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理主站发送的新 Token(兼容 MAIN_REFRESH_TOKEN 与 LOTTERY_TOKEN_REFRESH_RESPONSE)
|
|
||||||
const token = messageToken(data);
|
|
||||||
if (
|
|
||||||
(data.type === "LOTTERY_TOKEN_REFRESH_RESPONSE" || data.type === "MAIN_REFRESH_TOKEN") &&
|
|
||||||
token !== null
|
|
||||||
) {
|
|
||||||
console.log("[TokenRefresh] Received new token from parent");
|
|
||||||
pendingRefreshRef.current = 0;
|
|
||||||
setBearerToken(token);
|
|
||||||
retryCountRef.current = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理主站通知 Token 即将过期
|
|
||||||
if (data.type === "LOTTERY_TOKEN_EXPIRING_WARNING") {
|
|
||||||
console.log("[TokenRefresh] Token expiring warning from parent");
|
|
||||||
// 可以在这里显示提示或自动刷新
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("message", handleMessage);
|
|
||||||
return () => window.removeEventListener("message", handleMessage);
|
|
||||||
}, [setBearerToken]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 自动刷新逻辑
|
* 自动刷新逻辑
|
||||||
|
|||||||
17
src/lib/iframe-token-refresh-events.ts
Normal file
17
src/lib/iframe-token-refresh-events.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
type TokenRefreshListener = (token: string) => void;
|
||||||
|
|
||||||
|
const listeners = new Set<TokenRefreshListener>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* iframe bridge 是唯一的 window.message 消费者;续签 Hook 通过此内部通道接收结果,
|
||||||
|
* 避免多个全局监听器重复校验、重复写入同一 Token。
|
||||||
|
*/
|
||||||
|
export function publishIframeTokenRefresh(token: string): void {
|
||||||
|
listeners.forEach((listener) => listener(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeIframeTokenRefresh(listener: TokenRefreshListener): () => void {
|
||||||
|
listeners.add(listener);
|
||||||
|
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import Echo from "laravel-echo";
|
import Echo from "laravel-echo";
|
||||||
import Pusher from "pusher-js";
|
import Pusher from "pusher-js";
|
||||||
|
|
||||||
|
import { getPlayerBearerTokenPayload } from "@/lib/lottery-auth";
|
||||||
|
|
||||||
/** 需在浏览器挂载 Pusher(Reverb 走 pusher-js 协议) */
|
/** 需在浏览器挂载 Pusher(Reverb 走 pusher-js 协议) */
|
||||||
function ensurePusherOnWindow(): void {
|
function ensurePusherOnWindow(): void {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
@@ -106,6 +108,17 @@ export function getLotteryEcho(): Echo<"reverb"> | null {
|
|||||||
forceTLS,
|
forceTLS,
|
||||||
enabledTransports: forceTLS ? ["ws", "wss"] : ["ws"],
|
enabledTransports: forceTLS ? ["ws", "wss"] : ["ws"],
|
||||||
disableStats: true,
|
disableStats: true,
|
||||||
|
channelAuthorization: {
|
||||||
|
endpoint: "/api/broadcasting/auth",
|
||||||
|
transport: "ajax",
|
||||||
|
headersProvider: () => {
|
||||||
|
const bearerToken = getPlayerBearerTokenPayload();
|
||||||
|
|
||||||
|
return bearerToken
|
||||||
|
? { Authorization: `Bearer ${bearerToken}` }
|
||||||
|
: {};
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user