diff --git a/src/components/iframe-bridge.tsx b/src/components/iframe-bridge.tsx index a0fc1bb..ba4b787 100644 --- a/src/components/iframe-bridge.tsx +++ b/src/components/iframe-bridge.tsx @@ -3,13 +3,13 @@ import { useEffect, useCallback, type ReactNode } from "react"; import { usePlayerSessionStore } from "@/stores/player-session-store"; -import { setPlayerBearerToken } from "@/lib/lottery-auth"; import { loadIframeAllowedOrigins, messageToken, resolvePostMessageTargetOrigin, resolveTrustedParentMessage, } from "@/lib/iframe-origins"; +import { publishIframeTokenRefresh } from "@/lib/iframe-token-refresh-events"; function sanitizeUrlForParent(href: string): string { try { @@ -139,19 +139,19 @@ export function IframeBridge({ children }: { children: ReactNode }): ReactNode { if (token !== null) { console.log("[IframeBridge] Received initial token"); setBearerToken(token); - setPlayerBearerToken(token); // 勿再 notifyReady(),否则主站会重复 MAIN_INIT_TOKEN 导致消息刷屏 } break; } // 主站刷新 Token - case "MAIN_REFRESH_TOKEN": { + case "MAIN_REFRESH_TOKEN": + case "LOTTERY_TOKEN_REFRESH_RESPONSE": { const token = messageToken(data); if (token !== null) { console.log("[IframeBridge] Received refreshed token"); setBearerToken(token); - setPlayerBearerToken(token); + publishIframeTokenRefresh(token); notifyTokenRefreshed(); } break; diff --git a/src/features/hall/hall-betting-grid-model.ts b/src/features/hall/hall-betting-grid-model.ts new file mode 100644 index 0000000..88306a5 --- /dev/null +++ b/src/features/hall/hall-betting-grid-model.ts @@ -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; + +export type DraftRow = { + id: string; + number: string; + amounts: Record; + providerCodes: string[]; + selectionType: SelectionType; +}; + +export type PlayColumn = { + key: string; + play: PlayEffectivePlayRow; + digitSlot?: number; +}; + +export type HallTranslate = (key: string, options?: Record) => 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> = { + 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, + liveWarningNumbers: ReadonlySet, + 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 + ); +} diff --git a/src/features/hall/hall-betting-grid.tsx b/src/features/hall/hall-betting-grid.tsx index 0997b44..6488f29 100644 --- a/src/features/hall/hall-betting-grid.tsx +++ b/src/features/hall/hall-betting-grid.tsx @@ -1,32 +1,38 @@ "use client"; -import { ChevronDown, ChevronUp, CircleHelp, Lock, Ticket, Trash2, Star } from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState, memo } from "react"; +import { Lock, Ticket, Trash2 } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; -import { Tooltip } from "@base-ui/react/tooltip"; import { getBetProviders } from "@/api/bet-providers"; import { getPlayEffective } from "@/api/play"; import { getWalletBalance } from "@/api/wallet"; import { postTicketPlace, postTicketPreview } from "@/api/ticket"; import { Button } from "@/components/ui/button"; -import { Checkbox } from "@/components/ui/checkbox"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; import { Skeleton } from "@/components/ui/skeleton"; import { isHallSealedCountdownUi } from "@/features/draw/draw-status-meta"; import { useIsMobile } from "@/hooks/use-mobile"; import { HallBetPreviewDialog } from "@/features/hall/hall-bet-preview-dialog"; import { HallBetResultDialog } from "@/features/hall/hall-bet-result-dialog"; import { mapTicketBetError } from "@/features/hall/hall-bet-errors"; +import { HallBettingTable } from "@/features/hall/hall-betting-table"; +import { HallMobileQuickFill } from "@/features/hall/hall-mobile-quick-fill"; +import { HallPlaySummaryGrid } from "@/features/hall/hall-play-summary-grid"; +import { HallSelectionConfirmDialog } from "@/features/hall/hall-selection-confirm-dialog"; +import { + FALLBACK_BET_PROVIDERS, + cellRiskState, + digitSlotOptions, + numberMaxCharsForCategory, + playCategory, + playColumnHeaderLabel, + sanitizeNumber, + type DraftRow, + type HallCategory, + type PlayColumn, + type PlayHallCategory, +} from "@/features/hall/hall-betting-grid-model"; import { HallDesktopReviewPanel, HallDesktopWorkflowBar, @@ -69,26 +75,14 @@ import { cn } from "@/lib/utils"; import { LotteryApiBizError } from "@/types/api/errors"; import type { PlayEffectivePayload, PlayEffectivePlayRow } from "@/types/api/play-effective"; import type { TicketLineInput, TicketPlaceData, TicketPreviewData } from "@/types/api/ticket"; -import type { DrawCurrentRiskPoolAlert } from "@/types/api/draw-current"; import type { BetProviderRow } from "@/types/api/bet-provider"; -type HallCategory = "D2" | "D3" | "D4" | "JACKPOT"; -type PlayHallCategory = Exclude; - const TRADITIONAL_PLAY_CODES: Record = { D4: ["big", "small", "pos_4a", "pos_4b", "pos_4c", "pos_4d", "pos_4e", "four_any", "four_top", "four_lower", "five_d", "six_d"], D3: ["pos_3a", "pos_3lower", "pos_3b", "pos_3c", "pos_3d", "pos_3e"], D2: ["pos_2a", "pos_2b", "pos_2c", "pos_2d", "pos_2e", "pos_2any"], }; -type DraftRow = { - id: string; - number: string; - amounts: Record; - providerCodes: string[]; - selectionType: SelectionType; -}; - type PendingSelectionChange = | { mode: "row"; rowId: string; next: SelectionType; prev: SelectionType } | { mode: "all"; next: SelectionType }; @@ -110,75 +104,6 @@ type DraftLineIssue = { reason: DraftLineIssueReason; }; -type PlayColumn = { - key: string; - play: PlayEffectivePlayRow; - digitSlot?: number; -}; - -type HallSummaryItem = { - key: string; - label: string; - totalMinor: number; -}; - -function HallPlaySummaryGrid({ - items, - className, -}: { - items: HallSummaryItem[]; - className?: string; -}) { - return ( -
- {items.map((item) => { - const hasValue = item.totalMinor > 0; - return ( -
-

- {item.label} -

-

- {formatMinorAmount(item.totalMinor)} -

-
- ); - })} -
- ); -} - -function playCategory(playCode: string): PlayHallCategory { - if (playCode.startsWith("pos_3")) return "D3"; - if (playCode.startsWith("pos_2")) return "D2"; - return "D4"; -} - type ClosedPlayCleanupData = { cleanup_hint?: string; cleanup_lines?: Array<{ client_line_no?: number; play_code?: string }>; @@ -216,26 +141,8 @@ type RiskWarningWsEvent = { usage_percent?: number; }; -type CellRiskState = "open" | "warning" | "sold_out"; type QuickFillState = Record; - -const FALLBACK_BET_PROVIDERS: BetProviderRow[] = [ - { code: "SG", name: "Singapore", short_code: "S", sort_order: 10, is_default: true }, - { code: "MY", name: "Malaysia", short_code: "M", sort_order: 20, is_default: false }, - { code: "TH", name: "Thailand", short_code: "T", sort_order: 30, is_default: false }, -]; const DEFAULT_PROVIDER_CODE = "SG"; -const PROVIDER_COLUMN_TONES = [ - "bg-[#fff875] text-slate-950", - "bg-[#b7d9ff] text-slate-950", - "bg-[#ffc7ca] text-slate-950", - "bg-[#d9d8ff] text-slate-950", - "bg-[#a8f8a4] text-slate-950", -] as const; - -function providerColumnTone(index: number): string { - return PROVIDER_COLUMN_TONES[index % PROVIDER_COLUMN_TONES.length] ?? "bg-slate-100 text-slate-950"; -} const categoryTabs: { value: PlayHallCategory; label: string }[] = [ { value: "D4", label: "4D" }, @@ -275,7 +182,6 @@ const CATEGORY_ORDER: readonly PlayHallCategory[] = ["D4", "D3", "D2"]; const SUMMARY_PLAY_ORDER = CATEGORY_ORDER.flatMap( (category) => PLAY_ORDER_BY_CATEGORY[category], ); -const MOBILE_QUICK_AMOUNT_PRESETS = ["10", "50", "100"] as const; const DEFAULT_DRAFT_ROW_COUNT = 20; function playOrderForActiveCategory(activeCategory: PlayHallCategory): readonly string[] { @@ -302,37 +208,6 @@ function isPlayOpenForPlayer(row: PlayEffectivePlayRow): boolean { return Boolean(row.master_enabled && row.config?.is_enabled); } -type HallTranslate = (key: string, options?: Record) => string; - -/** 表头用短标签,避免 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> = { - D2: { 2: "十", 3: "个" }, - D3: { 1: "百", 2: "十", 3: "个" }, - D4: { 0: "千", 1: "百", 2: "十", 3: "个" }, - }; - return labels[category][slot] ?? String(slot + 1); -} - function amountKeyForPlay(playCode: string, digitSlot?: number): string { return digitSlot === undefined ? playCode : `${playCode}@${digitSlot}`; } @@ -354,20 +229,6 @@ function playColumnsForCategory( }); } -function numberMaxCharsForCategory(category: PlayHallCategory): number { - return category === "D2" ? 2 : category === "D3" ? 3 : 4; -} - -function sanitizeNumber(raw: string, category: PlayHallCategory): string { - const normalized = - category === "D4" - ? raw.replace(/[^0-9Rr]/g, "").toUpperCase() - : raw.replace(/\D/g, ""); - const maxChars = numberMaxCharsForCategory(category); - - return category === "D4" ? normalized.slice(0, maxChars) : normalized.slice(-maxChars); -} - function sanitizeAmount(raw: string): string { return raw.replace(/[^\d.]/g, "").replace(/(\..*)\./g, "$1").slice(0, 12); } @@ -483,99 +344,6 @@ function appendUnique(values: string[], value: string, limit = 20): string[] { return next.slice(0, limit); } -function sortedDigits(value: string): string { - return value.split("").sort().join(""); -} - -function matchesRiskAlert( - alertNumber: string, - playCode: string, - rowNumber: string, - category: Exclude, - 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, - alertRows: DrawCurrentRiskPoolAlert[] | undefined, - liveSoldOutNumbers: ReadonlySet, - liveWarningNumbers: ReadonlySet, - digitSlot?: number, -): CellRiskState { - const normalizedRow = rowNumber.trim().toUpperCase(); - if (!normalizedRow) return "open"; - - if (liveSoldOutNumbers.has(normalizedRow)) { - return "sold_out"; - } - - if (liveWarningNumbers.has(normalizedRow)) { - return "warning"; - } - - const alerts = alertRows ?? []; - if (alerts.length === 0) return "open"; - - for (const alert of alerts) { - if (matchesRiskAlert(alert.normalized_number, play.play_code, normalizedRow, category, digitSlot)) { - return alert.status === "sold_out" ? "sold_out" : "warning"; - } - } - - return "open"; -} - function quickFillKeys(category: HallCategory): { favorites: string; history: string } { return { favorites: `lottery.hall.quickfill.favorites.${category}`, @@ -615,11 +383,6 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } const [pendingSelectionChange, setPendingSelectionChange] = useState( null, ); - const holdFavoriteRef = useRef<{ timer: number | null; number: string | null; longPress: boolean }>({ - timer: null, - number: null, - longPress: false, - }); /** 单次预览→确认共用,重试 place 复用,避免重复扣款 */ const placeTraceIdRef = useRef(null); const previewRequestSeqRef = useRef(0); @@ -739,6 +502,10 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } () => openPlays.filter((play) => TRADITIONAL_PLAY_CODES[activeCategory].includes(play.play_code)), [activeCategory, openPlays], ); + const availableCategories = useMemo( + () => new Set(openPlays.map((play) => playCategory(play.play_code))), + [openPlays], + ); const currencyCode = catalogState.kind === "ok" ? catalogState.data.currency_code : currencyParam; @@ -1846,337 +1613,26 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } -
-
-
-
-
-

- {t("hall.quickFill.title")} -

- {isMobile ? ( -

- {t("hall.mobile.quickFillSummary", { - defaultValue: "收藏 {{favorites}} 个,历史 {{history}} 个", - favorites: favorites.length, - history: historyNumbers.length, - })} -

- ) : null} -
- - {!isMobile ? ( -
- {favoriteChips.length > 0 ? ( -
- - {t("hall.quickFill.favorites")} - - {favoriteChips.map((number) => ( - - ))} -
- ) : null} -
- - {t("hall.quickFill.history")} - - {historyChips.length > 0 ? ( - historyChips.map((number) => ( - - )) - ) : ( - - {t("hall.quickFill.emptyHistory")} - - )} -
-
- ) : null} -
-
- {isMobile ? ( - - ) : null} - {activeRow?.number ? ( - - ) : null} - -
-
- - {isMobile ? ( -
- {quickFillExpanded ? ( -
-
- - {t("hall.quickFill.batchTitle", { defaultValue: "Quick amount" })} - - {MOBILE_QUICK_AMOUNT_PRESETS.map((preset) => ( - - ))} - - -
-
- ) : null} - - {quickFillExpanded ? ( - <> - {favoriteChips.length > 0 ? ( -
- - {t("hall.quickFill.favorites")} - - {favoriteChips.map((number) => ( - - ))} -
- ) : null} - -
- - {t("hall.quickFill.history")} - - {historyChips.length > 0 ? ( - historyChips.map((number) => ( - - )) - ) : ( - - {t("hall.quickFill.emptyHistory")} - - )} -
- - ) : null} -
- ) : null} -
- -
- {categoryTabs.map((tab) => { - const hasPlays = openPlays.some( - (play) => playCategory(play.play_code) === tab.value, - ); - const active = activeCategory === tab.value; - - return ( - - ); - })} -
-
+ {isMobile ? ( + + ) : null} {activeCategoryPlays.length === 0 ? (
-
- - - - - - {playColumns.map((column) => ( - - ))} - {showSelectionTypeColumn ? ( - - ) : null} - {betProviders.map((provider, providerIndex) => { - const checked = rows.length > 0 && rows.every((row) => row.providerCodes.includes(provider.code)); - return ( - - ); - })} - - - - - {rows.map((row, index) => ( - - ))} - -
- {t("hall.table.no", { defaultValue: "No." })} - - {t("hall.table.number", { defaultValue: "Number" })} - - {numberPlaceholder} - - - - {playColumnHeaderLabel( - column.play, - playCategory(column.play.play_code), - column.digitSlot, - t, - )} - - - 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")} - /> - - {t("hall.table.selectionType", { defaultValue: "Type" })} - - - {provider.short_code} - 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" - /> - - {t("hall.table.rowTotal", { defaultValue: "Total" })} -
-
+
@@ -2462,327 +1816,14 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } creditMode={creditMode} /> - { - if (!open) cancelPendingSelectionChange(); - }} - > - - - - {t("hall.table.selectionConfirm.title")} - - - {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, - })} - - - {pendingSelectionPreview && pendingSelectionPreview.comboCount > 1 ? ( -
-

- {t("hall.table.selectionConfirm.comboHint", { - count: pendingSelectionPreview.comboCount, - })} -

- {pendingSelectionPreview.number ? ( -

- {t("hall.table.selectionConfirm.numberHint", { - number: pendingSelectionPreview.number, - })} -

- ) : null} -
- ) : null} - - - - -
-
+ preview={pendingSelectionPreview} + currencyCode={currencyCode} + onCancel={cancelPendingSelectionChange} + onConfirm={confirmPendingSelectionChange} + t={t} + /> ); } - -const DraftRowItem = memo(function DraftRowItem({ - row, - index, - rowActive, - tableDisabled, - numberPlaceholder, - activeCategory, - numberMaxChars, - betProviders, - playColumns, - alertRows, - liveSoldOutNumbers, - liveWarningNumbers, - updateRowNumber, - updateRowSelectionType, - selectionTypeOptions, - updateAmount, - toggleRowProvider, - setActiveRowId, - t, - isMobile, - indexColClass, - numberColClass, - stickyNumberLeftClass, - selectionTypeColClass, - showSelectionTypeColumn, - providerColClass, - rowTotalColClass, - currencyCode, -}: { - row: DraftRow; - index: number; - rowActive: boolean; - tableDisabled: boolean; - numberPlaceholder: string; - activeCategory: PlayHallCategory; - numberMaxChars: number; - betProviders: BetProviderRow[]; - playColumns: PlayColumn[]; - alertRows: DrawCurrentRiskPoolAlert[]; - liveSoldOutNumbers: Set; - liveWarningNumbers: Set; - 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 ( - - - {index + 1} - - - 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]", - )} - /> - - {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 ( - - {isInputValidForPlay ? ( - 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", - )} - /> - ) : ( -
- )} - {isInputValidForPlay && status === "sold_out" ? ( -

- {t("hall.table.soldOut")} -

- ) : status === "warning" ? ( -

- {t("hall.table.warning")} -

- ) : null} - - ); - })} - {showSelectionTypeColumn ? ( - - - {comboCount > 1 && displayNumber.length >= 2 ? ( -
- {t("hall.table.comboCount", { count: comboCount })} - {row.selectionType === "full_cover" || row.selectionType === "half_play" ? ( - - - - - - - - {row.selectionType === "full_cover" - ? t("hall.table.fullCoverSplitHint", { count: comboCount }) - : t("hall.table.halfPlayHint")} - - - - - ) : null} -
- ) : null} - {row.selectionType === "full_cover" && comboCount > 1 && hasFullCoverAmountError ? ( -

- {t("hall.table.fullCoverDivisibilityError", { count: comboCount })} -

- ) : null} - - ) : null} - {betProviders.map((provider, providerIndex) => ( - - 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")} - /> - - ))} - - {formatMinorAmount(rowTotalMinor)} - - - ); -}); diff --git a/src/features/hall/hall-betting-table.tsx b/src/features/hall/hall-betting-table.tsx new file mode 100644 index 0000000..bae1ff9 --- /dev/null +++ b/src/features/hall/hall-betting-table.tsx @@ -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; + selectionTypeColClass: string; + showSelectionTypeColumn: boolean; + selectionTypeOptions: SelectionType[]; + betProviders: BetProviderRow[]; + providerColClass: string; + rowTotalColClass: string; + alertRows: DrawCurrentRiskPoolAlert[]; + liveSoldOutNumbers: Set; + liveWarningNumbers: Set; + 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 ( +
+ + + + + + {playColumns.map((column) => { + const label = playColumnHeaderLabel( + column.play, + playCategory(column.play.play_code), + column.digitSlot, + t, + ); + return ( + + ); + })} + {showSelectionTypeColumn ? ( + + ) : null} + {betProviders.map((provider, providerIndex) => { + const checked = + rows.length > 0 && + rows.every((row) => row.providerCodes.includes(provider.code)); + return ( + + ); + })} + + + + + {rows.map((row, index) => ( + + ))} + +
+ {t("hall.table.no", { defaultValue: "No." })} + + + {t("hall.table.number", { defaultValue: "Number" })} + + + {numberPlaceholder} + + + + {label} + + + 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")} + /> + + + {t("hall.table.selectionType", { defaultValue: "Type" })} + + + + {provider.short_code} + + 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" + /> + + + {t("hall.table.rowTotal", { defaultValue: "Total" })} + +
+
+ ); +} diff --git a/src/features/hall/hall-draft-row-item.tsx b/src/features/hall/hall-draft-row-item.tsx new file mode 100644 index 0000000..34db9d7 --- /dev/null +++ b/src/features/hall/hall-draft-row-item.tsx @@ -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; + liveWarningNumbers: Set; + 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 ( + + + {index + 1} + + + 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", + )} + /> + + {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 ( + + {inputValid ? ( + 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", + )} + /> + ) : ( +
+ )} + {inputValid && status === "sold_out" ? ( +

+ {t("hall.table.soldOut")} +

+ ) : status === "warning" ? ( +

+ {t("hall.table.warning")} +

+ ) : null} + + ); + })} + {showSelectionTypeColumn ? ( + + + {comboCount > 1 && displayNumber.length >= 2 ? ( +
+ {t("hall.table.comboCount", { count: comboCount })} + {row.selectionType === "full_cover" || row.selectionType === "half_play" ? ( + + + + + + + + {row.selectionType === "full_cover" + ? t("hall.table.fullCoverSplitHint", { count: comboCount }) + : t("hall.table.halfPlayHint")} + + + + + ) : null} +
+ ) : null} + {row.selectionType === "full_cover" && + comboCount > 1 && + hasFullCoverAmountError ? ( +

+ {t("hall.table.fullCoverDivisibilityError", { count: comboCount })} +

+ ) : null} + + ) : null} + {betProviders.map((provider, providerIndex) => ( + + 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", + )} + /> + + ))} + + {formatMinorAmount(rowTotalMinor)} + + + ); +}); diff --git a/src/features/hall/hall-mobile-quick-fill.tsx b/src/features/hall/hall-mobile-quick-fill.tsx new file mode 100644 index 0000000..778eb21 --- /dev/null +++ b/src/features/hall/hall-mobile-quick-fill.tsx @@ -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; + 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 ( +
+
+
+
+

+ {t("hall.quickFill.title")} +

+

+ {t("hall.mobile.quickFillSummary", { + defaultValue: "收藏 {{favorites}} 个,历史 {{history}} 个", + favorites: favorites.length, + history: history.length, + })} +

+
+
+ + {activeNumber ? ( + + ) : null} + +
+
+ + {expanded ? ( +
+
+
+ + {t("hall.quickFill.batchTitle", { defaultValue: "Quick amount" })} + + {QUICK_AMOUNT_PRESETS.map((preset) => ( + + ))} + + +
+
+ + {favorites.length > 0 ? ( +
+ + {t("hall.quickFill.favorites")} + + {favorites.map((number) => ( + + ))} +
+ ) : null} + +
+ + {t("hall.quickFill.history")} + + {history.length > 0 ? ( + history.map((number) => ( + + )) + ) : ( + + {t("hall.quickFill.emptyHistory")} + + )} +
+
+ ) : null} +
+ +
+ {CATEGORY_TABS.map((tab) => { + const enabled = availableCategories.has(tab.value); + const active = activeCategory === tab.value; + + return ( + + ); + })} +
+
+ ); +} diff --git a/src/features/hall/hall-play-summary-grid.tsx b/src/features/hall/hall-play-summary-grid.tsx new file mode 100644 index 0000000..ce54c89 --- /dev/null +++ b/src/features/hall/hall-play-summary-grid.tsx @@ -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 ( +
+ {items.map((item) => { + const hasValue = item.totalMinor > 0; + return ( +
+

+ {item.label} +

+

+ {formatMinorAmount(item.totalMinor)} +

+
+ ); + })} +
+ ); +} diff --git a/src/features/hall/hall-selection-confirm-dialog.tsx b/src/features/hall/hall-selection-confirm-dialog.tsx new file mode 100644 index 0000000..1d9fdc3 --- /dev/null +++ b/src/features/hall/hall-selection-confirm-dialog.tsx @@ -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 ( + !nextOpen && onCancel()}> + + + + {t("hall.table.selectionConfirm.title")} + + + {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, + })} + + + {preview && preview.comboCount > 1 ? ( +
+

+ {t("hall.table.selectionConfirm.comboHint", { + count: preview.comboCount, + })} +

+ {preview.number ? ( +

+ {t("hall.table.selectionConfirm.numberHint", { + number: preview.number, + })} +

+ ) : null} +
+ ) : null} + + + + +
+
+ ); +} diff --git a/src/hooks/use-player-balance-ws.ts b/src/hooks/use-player-balance-ws.ts index e1496a1..ee2b0d7 100644 --- a/src/hooks/use-player-balance-ws.ts +++ b/src/hooks/use-player-balance-ws.ts @@ -45,7 +45,7 @@ export function usePlayerBalanceWs(): void { } const channelName = `player.${playerId}`; - const channel = echo.channel(channelName); + const channel = echo.private(channelName); const onBalanceUpdate = (evt: BalanceUpdateWsEvent): void => { const currency = evt.currency_code?.trim().toUpperCase(); @@ -75,6 +75,7 @@ export function usePlayerBalanceWs(): void { return () => { channel.stopListening(".balance.update"); + echo.leave(channelName); }; }, [activeCurrency, bearerToken, playerId, t]); } diff --git a/src/hooks/use-token-refresh.ts b/src/hooks/use-token-refresh.ts index 0b1ee54..0d67e00 100644 --- a/src/hooks/use-token-refresh.ts +++ b/src/hooks/use-token-refresh.ts @@ -2,14 +2,10 @@ import { useCallback, useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; import { parseJwtExp } from "@/lib/jwt-payload"; -import { usePlayerSessionStore } from "@/stores/player-session-store"; import { useErrorStore } from "@/stores/error-store"; -import { - loadIframeAllowedOrigins, - messageToken, - resolvePostMessageTargetOrigin, - resolveTrustedParentMessage, -} from "@/lib/iframe-origins"; +import { resolvePostMessageTargetOrigin } from "@/lib/iframe-origins"; +import { subscribeIframeTokenRefresh } from "@/lib/iframe-token-refresh-events"; +import { usePlayerSessionStore } from "@/stores/player-session-store"; /** Token 过期前警告阈值(毫秒) */ const TOKEN_WARNING_THRESHOLD = 60 * 1000; // 1 分钟 @@ -38,7 +34,6 @@ export function useTokenRefresh(): { isTokenExpiringSoon: () => boolean; } { const bearerToken = usePlayerSessionStore((state) => state.bearerToken); - const setBearerToken = usePlayerSessionStore((state) => state.setBearerToken); const setServerError = useErrorStore((state) => state.setServerError); const clearServerError = useErrorStore((state) => state.clearServerError); const { t } = useTranslation("player"); @@ -110,43 +105,13 @@ export function useTokenRefresh(): { }, REFRESH_RESPONSE_TIMEOUT_MS); }, [clearServerError, requestParentRefresh, setServerError, t]); - /** - * 监听主站 postMessage 发送的新 Token - */ + /** IframeBridge 统一校验父窗口消息;这里只接收已验证的续签结果。 */ useEffect(() => { - if (typeof window === "undefined") return; - - void loadIframeAllowedOrigins(); - - const handleMessage = async (event: MessageEvent): Promise => { - 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]); + return subscribeIframeTokenRefresh(() => { + pendingRefreshRef.current = 0; + retryCountRef.current = 0; + }); + }, []); /** * 自动刷新逻辑 diff --git a/src/lib/iframe-token-refresh-events.ts b/src/lib/iframe-token-refresh-events.ts new file mode 100644 index 0000000..6f032ba --- /dev/null +++ b/src/lib/iframe-token-refresh-events.ts @@ -0,0 +1,17 @@ +type TokenRefreshListener = (token: string) => void; + +const listeners = new Set(); + +/** + * 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); +} diff --git a/src/lib/lottery-echo.ts b/src/lib/lottery-echo.ts index ea1d5ba..1764b08 100644 --- a/src/lib/lottery-echo.ts +++ b/src/lib/lottery-echo.ts @@ -1,6 +1,8 @@ import Echo from "laravel-echo"; import Pusher from "pusher-js"; +import { getPlayerBearerTokenPayload } from "@/lib/lottery-auth"; + /** 需在浏览器挂载 Pusher(Reverb 走 pusher-js 协议) */ function ensurePusherOnWindow(): void { if (typeof window === "undefined") return; @@ -106,6 +108,17 @@ export function getLotteryEcho(): Echo<"reverb"> | null { forceTLS, enabledTransports: forceTLS ? ["ws", "wss"] : ["ws"], disableStats: true, + channelAuthorization: { + endpoint: "/api/broadcasting/auth", + transport: "ajax", + headersProvider: () => { + const bearerToken = getPlayerBearerTokenPayload(); + + return bearerToken + ? { Authorization: `Bearer ${bearerToken}` } + : {}; + }, + }, }); }