diff --git a/src/app/globals.css b/src/app/globals.css index b441295..e18b72d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -155,4 +155,125 @@ [data-sonner-toast][data-styled="true"] [data-icon] svg { width: 14px; height: 14px; +} + +/* Jackpot 爆池全屏动画 */ +@keyframes jackpot-backdrop-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes jackpot-card-in { + 0% { + opacity: 0; + transform: scale(0.72) translateY(24px); + } + 55% { + opacity: 1; + transform: scale(1.04) translateY(-4px); + } + 100% { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +@keyframes jackpot-flash { + 0% { + opacity: 0.85; + } + 100% { + opacity: 0; + } +} + +@keyframes jackpot-shimmer { + 0% { + background-position: 200% center; + } + 100% { + background-position: -200% center; + } +} + +@keyframes jackpot-lightning { + 0%, + 100% { + filter: drop-shadow(0 0 6px rgba(245, 197, 66, 0.5)); + transform: scale(1); + } + 40% { + filter: drop-shadow(0 0 18px rgba(255, 220, 100, 0.95)); + transform: scale(1.12); + } + 55% { + filter: drop-shadow(0 0 8px rgba(245, 197, 66, 0.6)); + transform: scale(0.96); + } +} + +@keyframes jackpot-ring-pulse { + 0% { + transform: scale(0.85); + opacity: 0.7; + } + 100% { + transform: scale(1.35); + opacity: 0; + } +} + +@keyframes jackpot-particle { + 0% { + opacity: 0; + transform: translateY(0) scale(0.4); + } + 15% { + opacity: 1; + } + 100% { + opacity: 0; + transform: translateY(-120px) scale(1); + } +} + +@keyframes jackpot-digit-pop { + 0% { + transform: scale(1.35); + filter: brightness(1.8); + } + 100% { + transform: scale(1); + filter: brightness(1); + } +} + +@keyframes jackpot-amount-glow { + 0%, + 100% { + text-shadow: 0 0 8px rgba(201, 162, 39, 0.25); + } + 50% { + text-shadow: 0 0 16px rgba(201, 162, 39, 0.55); + } +} + +@keyframes jackpot-amount-row-glow { + 0%, + 100% { + box-shadow: 0 0 0 rgba(245, 197, 66, 0); + } + 50% { + box-shadow: 0 0 20px rgba(245, 197, 66, 0.22); + } +} + +@keyframes jackpot-border-spin { + to { + transform: rotate(360deg); + } } \ No newline at end of file diff --git a/src/components/currency-switcher.tsx b/src/components/currency-switcher.tsx new file mode 100644 index 0000000..16aebee --- /dev/null +++ b/src/components/currency-switcher.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { Banknote, ChevronDown } from "lucide-react"; +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency"; +import { cn } from "@/lib/utils"; + +type CurrencySwitcherProps = { + variant?: "default" | "header" | "minimal"; + menuAlign?: "start" | "end"; + className?: string; + showLabel?: boolean; +}; + +export function CurrencySwitcher({ + variant = "minimal", + menuAlign, + className, + showLabel = true, +}: CurrencySwitcherProps) { + const { t } = useTranslation("common"); + const { activeCurrency, bettableCurrencies, canSwitchCurrency, setActiveCurrency } = + useActivePlayerCurrency(); + const [isOpen, setIsOpen] = useState(false); + + const options = useMemo( + () => + bettableCurrencies.map((row) => ({ + code: row.code, + name: row.name, + label: t("currency.option", { code: row.code, name: row.name }), + })), + [bettableCurrencies, t], + ); + + if (!canSwitchCurrency) { + return ( + + + {showLabel ? {activeCurrency} : null} + + ); + } + + const variantStyles = { + default: { + button: "border border-white/20 bg-white/10 text-white hover:bg-white/20", + dropdown: "border border-gray-200 bg-white shadow-lg", + item: "text-gray-800 hover:bg-gray-100", + activeItem: "bg-red-50 text-red-600", + }, + header: { + button: "text-white/80 hover:bg-white/10 hover:text-white", + dropdown: "border border-white/20 bg-white/95 shadow-xl backdrop-blur-sm", + item: "text-gray-800 hover:bg-white/10", + activeItem: "bg-red-500/10 text-red-600", + }, + minimal: { + button: "text-current hover:bg-black/5", + dropdown: "border border-gray-200 bg-white shadow-lg", + item: "text-gray-800 hover:bg-gray-100", + activeItem: "bg-red-50 text-red-600", + }, + } as const; + + const styles = variantStyles[variant]; + const align = menuAlign ?? (variant === "header" || variant === "default" ? "start" : "end"); + + function handleSelect(code: string): void { + setActiveCurrency(code); + setIsOpen(false); + } + + return ( + + + + {showLabel ? {activeCurrency} : null} + + + } + /> + +
+ {options.map((option) => ( + + ))} +
+
+
+ ); +} diff --git a/src/components/layout/player-panel.tsx b/src/components/layout/player-panel.tsx index 61e657f..f78eb6e 100644 --- a/src/components/layout/player-panel.tsx +++ b/src/components/layout/player-panel.tsx @@ -6,6 +6,7 @@ import type { ReactNode } from "react"; import { Bell, ChevronLeft } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { CurrencySwitcher } from "@/components/currency-switcher"; import { LanguageSwitcher } from "@/components/language-switcher"; import { playerHeaderControl, @@ -70,6 +71,15 @@ export function PlayerPanel({
+ (item.status ?? "success") === "success") ?? []; + // 成功注项状态为 pending_draw(待开奖),不是 success + const successItems = data?.items.filter((item) => item.status !== "failed") ?? []; const failedItems = data?.items.filter((item) => item.status === "failed") ?? []; const totalSuccess = data?.summary.success_count ?? successItems.length; const totalFailure = data?.summary.failure_count ?? failedItems.length; diff --git a/src/features/hall/hall-betting-grid.tsx b/src/features/hall/hall-betting-grid.tsx index ef5e736..6113fd1 100644 --- a/src/features/hall/hall-betting-grid.tsx +++ b/src/features/hall/hall-betting-grid.tsx @@ -21,14 +21,13 @@ import { ticketNumberSpec, } from "@/features/hall/hall-bet-rules"; import type { HallDrawLiveSnapshot } from "@/features/hall/use-hall-draw-live"; -import { useCurrencyCatalog } from "@/hooks/use-currency-catalog"; +import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency"; import { triggerWalletPollingAfterBet } from "@/hooks/use-wallet-polling"; import { getLotteryEcho } from "@/lib/lottery-echo"; import { getLotteryRequestLocale } from "@/lib/lottery-locale"; import { formatMinorAsCurrency, parseDecimalInputToMinor } from "@/lib/money"; -import { resolvePlayerCurrency } from "@/lib/player-currency"; +import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference"; import { cn } from "@/lib/utils"; -import { usePlayerSessionStore } from "@/stores/player-session-store"; import { LotteryApiBizError } from "@/types/api/errors"; import type { PlayEffectivePayload, PlayEffectivePlayRow } from "@/types/api/play-effective"; import type { TicketLineInput, TicketPlaceData, TicketPreviewData } from "@/types/api/ticket"; @@ -77,6 +76,12 @@ type OddsUpdateWsEvent = { message?: string; }; +type RiskSoldOutWsEvent = { + draw_id?: number; + draw_no?: string; + normalized_number?: string; +}; + type CellRiskState = "open" | "warning" | "sold_out"; type QuickFillState = Record; @@ -128,10 +133,7 @@ function isPlayOpenForPlayer(row: PlayEffectivePlayRow): boolean { } function pickDisplayName(row: PlayEffectivePlayRow): string { - const loc = getLotteryRequestLocale(); - if (loc === "zh") return row.display_name_zh ?? row.display_name_en ?? row.play_code; - if (loc === "ne") return row.display_name_ne ?? row.display_name_en ?? row.play_code; - return row.display_name_en ?? row.display_name_zh ?? row.play_code; + return row.display_name?.trim() || row.play_code; } function digitSlotOptions(category: Exclude): number[] { @@ -367,13 +369,19 @@ function cellRiskState( rowNumber: string, category: Exclude, alertRows: DrawCurrentRiskPoolAlert[] | undefined, + liveSoldOutNumbers: ReadonlySet, digitSlot?: number, ): CellRiskState { - const alerts = alertRows ?? []; - if (alerts.length === 0) return "open"; const normalizedRow = rowNumber.trim().toUpperCase(); if (!normalizedRow) return "open"; + if (liveSoldOutNumbers.has(normalizedRow)) { + return "sold_out"; + } + + 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.is_sold_out ? "sold_out" : "warning"; @@ -393,7 +401,7 @@ function quickFillKeys(category: HallCategory): { favorites: string; history: st export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }) { const { display, isBettable, reload: reloadDraw } = drawLive; const { t } = useTranslation("player"); - const profile = usePlayerSessionStore((s) => s.profile); + const { activeCurrency: currencyParam } = useActivePlayerCurrency(); const [activeCategory, setActiveCategory] = useState("D2"); const [rows, setRows] = useState(() => [newDraftRow()]); @@ -411,16 +419,13 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } const [resultOpen, setResultOpen] = useState(false); const [resultData, setResultData] = useState(null); const [quickFillState, setQuickFillState] = useState(() => loadQuickFillState()); + const [liveSoldOutNumbers, setLiveSoldOutNumbers] = useState>(() => new Set()); const [debouncedSummary, setDebouncedSummary] = useState({ bet: 0, rebate: 0, actual: 0 }); const holdFavoriteRef = useRef<{ timer: number | null; number: string | null; longPress: boolean }>({ timer: null, number: null, longPress: false, }); - useCurrencyCatalog(); - - const currencyParam = useMemo(() => resolvePlayerCurrency(profile), [profile]); - const loadCatalog = useCallback(async () => { setCatalogState((s) => (s.kind === "ok" ? s : { kind: "loading" })); try { @@ -448,6 +453,15 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } }); }, [loadCatalog, refreshWallet]); + useEffect(() => { + const onCurrencyChange = () => { + void loadCatalog(); + void refreshWallet(); + }; + window.addEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange); + return () => window.removeEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange); + }, [loadCatalog, refreshWallet]); + useEffect(() => { const id = window.setInterval(() => { void loadCatalog(); @@ -490,6 +504,12 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } [activeRowId, rows], ); + const drawNo = display?.draw_no ?? null; + + useEffect(() => { + setLiveSoldOutNumbers(new Set()); + }, [drawNo]); + const alertRows = display?.risk_pool_alerts ?? []; const jackpot = display?.jackpot; const currentQuickFill = quickFillState[activeCategory] ?? { favorites: [], history: [] }; @@ -657,14 +677,30 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } toast.message(evt.message ?? t("hall.playConfig.oddsUpdated")); }; + const onRiskSoldOut = (evt: RiskSoldOutWsEvent) => { + const normalized = evt.normalized_number?.trim().toUpperCase(); + if (!normalized) return; + if (drawNo !== null && evt.draw_no !== undefined && evt.draw_no !== drawNo) { + return; + } + setLiveSoldOutNumbers((prev) => { + const next = new Set(prev); + next.add(normalized); + return next; + }); + void reloadDraw(); + }; + channel.listen(".play.toggle", onPlayToggle); channel.listen(".odds.update", onOddsUpdate); + channel.listen(".risk.sold_out", onRiskSoldOut); return () => { channel.stopListening(".play.toggle"); channel.stopListening(".odds.update"); + channel.stopListening(".risk.sold_out"); }; - }, [clearAmountsForPlay, loadCatalog, t]); + }, [clearAmountsForPlay, drawNo, loadCatalog, reloadDraw, t]); const collectEntries = useCallback((): DraftEntry[] => { if (activeCategory === "JACKPOT") return []; @@ -1163,6 +1199,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } row.number, activeCategory as Exclude, alertRows, + liveSoldOutNumbers, column.digitSlot, ); const disabled = tableDisabled || status === "sold_out" || (play.config !== null && !play.config.is_enabled); diff --git a/src/features/hall/hall-play-catalog-panel.tsx b/src/features/hall/hall-play-catalog-panel.tsx index f8e8b32..44b62b7 100644 --- a/src/features/hall/hall-play-catalog-panel.tsx +++ b/src/features/hall/hall-play-catalog-panel.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { getPlayEffective } from "@/api/play"; @@ -21,10 +21,10 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency"; import { getLotteryRequestLocale } from "@/lib/lottery-locale"; -import { resolvePlayerCurrency } from "@/lib/player-currency"; +import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference"; import { cn } from "@/lib/utils"; -import { usePlayerSessionStore } from "@/stores/player-session-store"; import { LotteryApiBizError } from "@/types/api/errors"; import type { PlayEffectivePayload, @@ -34,14 +34,7 @@ import type { const DEFAULT_POLL_MS = 120_000; function pickDisplayName(row: PlayEffectivePlayRow): string { - const loc = getLotteryRequestLocale(); - if (loc === "zh") { - return row.display_name_zh ?? row.display_name_en ?? row.play_code; - } - if (loc === "ne") { - return row.display_name_ne ?? row.display_name_en ?? row.play_code; - } - return row.display_name_en ?? row.display_name_zh ?? row.play_code; + return row.display_name?.trim() || row.play_code; } function pickRuleText(row: PlayEffectivePlayRow): string | null { @@ -79,10 +72,9 @@ function formatMoneyAmount(n: number): string { } export function HallPlayCatalogPanel() { - const profile = usePlayerSessionStore((s) => s.profile); const { t } = useTranslation("player"); + const { activeCurrency: currencyParam } = useActivePlayerCurrency(); const [state, setState] = useState({ kind: "loading" }); - const currencyParam = useMemo(() => resolvePlayerCurrency(profile), [profile]); const load = useCallback(async () => { setState((s) => (s.kind === "ok" ? s : { kind: "loading" })); @@ -119,6 +111,12 @@ export function HallPlayCatalogPanel() { return () => window.clearInterval(id); }, [load]); + useEffect(() => { + const onCurrencyChange = () => void load(); + window.addEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange); + return () => window.removeEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange); + }, [load]); + const body = (() => { if (state.kind === "loading") { return ( diff --git a/src/features/hall/hall-screen.tsx b/src/features/hall/hall-screen.tsx index da8ece7..bcc1840 100644 --- a/src/features/hall/hall-screen.tsx +++ b/src/features/hall/hall-screen.tsx @@ -5,8 +5,10 @@ import Image from "next/image"; import Link from "next/link"; import { useTranslation } from "react-i18next"; +import { CurrencySwitcher } from "@/components/currency-switcher"; import { LanguageSwitcher } from "@/components/language-switcher"; import { HallBettingGrid } from "@/features/hall/hall-betting-grid"; +import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency"; import { HallDrawPanel } from "@/features/hall/hall-draw-panel"; import { HallWalletStrip } from "@/features/hall/hall-wallet-strip"; import { JackpotBurstOverlay } from "@/features/hall/jackpot-burst-overlay"; @@ -22,6 +24,7 @@ export function HallScreen() { const { t } = useTranslation("common"); const { t: tp } = useTranslation("player"); const drawLive = useHallDrawLive(); + const { activeCurrency } = useActivePlayerCurrency(); const { burstEvent, clearBurstEvent } = useJackpotBurstLive(tp); return ( @@ -39,6 +42,15 @@ export function HallScreen() { />
+ - +
diff --git a/src/features/hall/hall-wallet-strip.tsx b/src/features/hall/hall-wallet-strip.tsx index 5f195ed..6ab7a5b 100644 --- a/src/features/hall/hall-wallet-strip.tsx +++ b/src/features/hall/hall-wallet-strip.tsx @@ -2,7 +2,7 @@ import { Wallet } from "lucide-react"; import Image from "next/image"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { getWalletBalance } from "@/api/wallet"; @@ -11,30 +11,27 @@ import { TransferInDialog, TransferOutDialog, } from "@/features/wallet/wallet-transfer-dialogs"; +import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency"; import { formatMinorAsCurrency } from "@/lib/money"; -import { resolvePlayerCurrency } from "@/lib/player-currency"; +import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference"; import { cn } from "@/lib/utils"; import { useNetworkConnectionStore } from "@/stores/network-connection-store"; -import { usePlayerSessionStore } from "@/stores/player-session-store"; import type { WalletBalanceData } from "@/types/api/wallet-balance"; export function HallWalletStrip() { - const profile = usePlayerSessionStore((s) => s.profile); const mode = useNetworkConnectionStore((s) => s.mode); const { t } = useTranslation("player"); + const { activeCurrency } = useActivePlayerCurrency(); const [balance, setBalance] = useState(null); const [loading, setLoading] = useState(true); const degradedWalletPollRef = useRef(null); - const currency = useMemo( - () => resolvePlayerCurrency(profile, balance), - [balance, profile], - ); + const currency = activeCurrency; const refresh = useCallback(async () => { - const b = await getWalletBalance(); + const b = await getWalletBalance({ currency: activeCurrency }); setBalance(b); - }, []); + }, [activeCurrency]); useEffect(() => { let cancelled = false; @@ -54,7 +51,11 @@ export function HallWalletStrip() { useEffect(() => { const onRefresh = () => void refresh(); window.addEventListener("lottery-wallet-refresh", onRefresh); - return () => window.removeEventListener("lottery-wallet-refresh", onRefresh); + window.addEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onRefresh); + return () => { + window.removeEventListener("lottery-wallet-refresh", onRefresh); + window.removeEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onRefresh); + }; }, [refresh]); useEffect(() => { diff --git a/src/features/hall/jackpot-burst-overlay.tsx b/src/features/hall/jackpot-burst-overlay.tsx index 2c66ec9..818a87f 100644 --- a/src/features/hall/jackpot-burst-overlay.tsx +++ b/src/features/hall/jackpot-burst-overlay.tsx @@ -1,10 +1,13 @@ "use client"; -import { X, Zap } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Sparkles, X, Zap } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { getDrawResultByNo } from "@/api/draw"; import { Button } from "@/components/ui/button"; import { formatMinorAsCurrency } from "@/lib/money"; +import { cn } from "@/lib/utils"; export type JackpotBurstEvent = { draw_id: number; @@ -23,87 +26,312 @@ type JackpotBurstOverlayProps = { onClose: () => void; }; -function triggerLabel(triggerType: string, t: ReturnType["t"]) { - return t(`hall.jackpotBurst.trigger.${triggerType}`, { - defaultValue: triggerType, - }); +const PARTICLE_SEEDS = [ + { left: "8%", delay: "0s", duration: "2.2s", size: 4 }, + { left: "22%", delay: "0.4s", duration: "2.6s", size: 3 }, + { left: "38%", delay: "0.1s", duration: "2.4s", size: 5 }, + { left: "55%", delay: "0.7s", duration: "2.8s", size: 3 }, + { left: "72%", delay: "0.2s", duration: "2.3s", size: 4 }, + { left: "88%", delay: "0.5s", duration: "2.5s", size: 3 }, + { left: "15%", delay: "0.9s", duration: "2.7s", size: 3 }, + { left: "65%", delay: "0.35s", duration: "2.1s", size: 4 }, +] as const; + +const ROLL_STAGGER_MS = 380; +const ROLL_BASE_MS = 900; +const ROLL_TICK_MS = 55; + +function isMissingPrizeNumber(raw: string): boolean { + const cleaned = raw.replace(/\s/g, ""); + return !cleaned || /^-+$/.test(cleaned); +} + +function normalizePrizeDigits(raw: string): string[] { + const cleaned = raw.replace(/\s/g, ""); + if (isMissingPrizeNumber(raw)) { + return ["—", "—", "—", "—"]; + } + const padded = cleaned.padStart(4, "0").slice(-4); + return padded.split(""); +} + +function useRollingDigits(finalNumber: string) { + const targets = useMemo(() => normalizePrizeDigits(finalNumber), [finalNumber]); + const [display, setDisplay] = useState(() => ["?", "?", "?", "?"]); + const [allRevealed, setAllRevealed] = useState(false); + const [poppedIndex, setPoppedIndex] = useState(null); + + useEffect(() => { + const intervals: ReturnType[] = []; + const timeouts: ReturnType[] = []; + + targets.forEach((target, index) => { + const tick = setInterval(() => { + setDisplay((prev) => { + const next = [...prev]; + if (target === "—") { + next[index] = "—"; + } else { + next[index] = String(Math.floor(Math.random() * 10)); + } + return next; + }); + }, ROLL_TICK_MS); + intervals.push(tick); + + const stopAt = ROLL_BASE_MS + index * ROLL_STAGGER_MS; + const stop = setTimeout(() => { + clearInterval(tick); + setDisplay((prev) => { + const next = [...prev]; + next[index] = target; + return next; + }); + setPoppedIndex(index); + const clearPop = setTimeout(() => setPoppedIndex(null), 320); + timeouts.push(clearPop); + + if (index === targets.length - 1) { + const revealDone = setTimeout(() => setAllRevealed(true), 200); + timeouts.push(revealDone); + } + }, stopAt); + timeouts.push(stop); + }); + + return () => { + intervals.forEach(clearInterval); + timeouts.forEach(clearTimeout); + }; + }, [targets]); + + return { display, allRevealed, poppedIndex }; +} + +function BurstParticles() { + return ( +
+ {PARTICLE_SEEDS.map((p, i) => ( + + ))} +
+ ); +} + +function RollingDigit({ + char, + popping, +}: { + char: string; + popping: boolean; +}) { + return ( + + {char} + + ); } export function JackpotBurstOverlay({ event, onClose }: JackpotBurstOverlayProps) { - const { t } = useTranslation("player"); - if (!event) return null; + return ( + + ); +} + +function useResolvedFirstPrizeNumber(event: JackpotBurstEvent) { + const fromEvent = event.first_prize_number; + const eventHasNumber = !isMissingPrizeNumber(fromEvent); + const [fetchedNumber, setFetchedNumber] = useState(null); + const [fetchDone, setFetchDone] = useState(eventHasNumber); + + useEffect(() => { + if (eventHasNumber) return; + + let cancelled = false; + + void getDrawResultByNo(event.draw_no) + .then((detail) => { + if (cancelled) return; + const first = detail.results?.["1st"]?.trim() ?? ""; + if (!isMissingPrizeNumber(first)) { + setFetchedNumber(first); + } + }) + .finally(() => { + if (!cancelled) setFetchDone(true); + }); + + return () => { + cancelled = true; + }; + }, [event.draw_no, eventHasNumber]); + + const number = eventHasNumber ? fromEvent : (fetchedNumber ?? fromEvent); + const pending = !eventHasNumber && (!fetchDone || isMissingPrizeNumber(number)); + + return { number, pending }; +} + +function JackpotBurstOverlayContent({ + event, + onClose, +}: { + event: JackpotBurstEvent; + onClose: () => void; +}) { + const { t } = useTranslation("player"); + const { number: prizeNumber, pending: prizePending } = useResolvedFirstPrizeNumber(event); + const { display, allRevealed, poppedIndex } = useRollingDigits(prizeNumber); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }, + [onClose], + ); const currency = event.currency_code.toUpperCase(); const amount = formatMinorAsCurrency(event.total_payout_amount, currency); return (
-
-
-
-
+
-
- + -
- +
+
+ +
+
+
-

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

-

- {t("hall.jackpotBurst.subtitle", { - drawNo: event.draw_no, - })} -

+
+ -
-
- {event.first_prize_number || "----"} +
+
+
+ +
+
-

- {t("hall.jackpotBurst.number")} + +

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

+

+ {t("hall.jackpotBurst.subtitle", { drawNo: event.draw_no })}

-
-
-
- - {t("hall.jackpotBurst.amount")} - - {amount} +
+
+
+ {display.map((char, i) => ( + + ))} +
+

+ {t("hall.jackpotBurst.number")} +

+ {prizePending && isMissingPrizeNumber(prizeNumber) ? ( +

+ {t("hall.jackpotBurst.numberPending")} +

+ ) : null}
-
- - {t("hall.jackpotBurst.winners")} - - {event.winner_count} -
-
- - {t("hall.jackpotBurst.triggerLabel")} - - {triggerLabel(event.trigger_type, t)} + +
+
+ {t("hall.jackpotBurst.amount")} + + {amount} + +
+
+ {t("hall.jackpotBurst.winners")} + + {event.winner_count} + +
); } + diff --git a/src/features/hall/use-hall-draw-live.ts b/src/features/hall/use-hall-draw-live.ts index ce67482..4262da5 100644 --- a/src/features/hall/use-hall-draw-live.ts +++ b/src/features/hall/use-hall-draw-live.ts @@ -1,8 +1,9 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { getDrawCurrent } from "@/api/draw"; +import { isHallSealedCountdownUi } from "@/features/draw/draw-status-meta"; import { getLotteryEcho } from "@/lib/lottery-echo"; import { useNetworkConnectionStore } from "@/stores/network-connection-store"; import type { DrawCurrentPayload, DrawCurrentResponse } from "@/types/api/draw-current"; @@ -105,6 +106,15 @@ export function useHallDrawLive(): HallDrawLiveSnapshot { return () => window.clearTimeout(timer); }, [load]); + // 爆池等场景:刷新大厅快照(含奖池余额) + useEffect(() => { + const onHallRefresh = () => { + void load(); + }; + window.addEventListener("lottery-hall-refresh", onHallRefresh); + return () => window.removeEventListener("lottery-hall-refresh", onHallRefresh); + }, [load]); + // 本地倒计时计时器(用于 UI 更新) useEffect(() => { const bump = () => setNowMs(Date.now()); @@ -242,5 +252,53 @@ export function useHallDrawLive(): HallDrawLiveSnapshot { const isBettable = display != null && display.status === "open"; + const zeroRefreshKeyRef = useRef(null); + + // 本地倒计时归零时主动拉取(冷静期结束、封盘切下一期等;避免只靠手动刷新) + useEffect(() => { + if (!display) return; + + const coolingEndMs = display.cooling_end_time + ? Date.parse(display.cooling_end_time) + : null; + const coolingDone = + display.status === "cooldown" && + ((display.seconds_remaining_in_cooldown ?? 1) === 0 || + (coolingEndMs !== null && !Number.isNaN(coolingEndMs) && coolingEndMs <= nowMs)); + + const sealedDone = + isHallSealedCountdownUi(display.status) && (display.seconds_to_draw ?? 1) === 0; + + const closeDone = + display.status === "open" && (display.seconds_to_close ?? 1) === 0; + + const trigger = coolingDone + ? `${display.draw_no}:cooldown-end` + : sealedDone + ? `${display.draw_no}:sealed-end` + : closeDone + ? `${display.draw_no}:close-end` + : null; + + if (trigger && zeroRefreshKeyRef.current !== trigger) { + zeroRefreshKeyRef.current = trigger; + void load(); + } + }, [display, nowMs, load]); + + useEffect(() => { + if (display?.draw_no) { + zeroRefreshKeyRef.current = null; + } + }, [display?.draw_no, display?.status]); + + // WebSocket 已连接时的兜底轮询(tick 最多 1 分钟延迟时的保险) + useEffect(() => { + const intervalId = window.setInterval(() => { + void load(); + }, 45_000); + return () => window.clearInterval(intervalId); + }, [load]); + return { raw, display, serverNowMs, error, reload: load, isBettable }; } diff --git a/src/features/hall/use-jackpot-burst-live.ts b/src/features/hall/use-jackpot-burst-live.ts index 9df9029..3c5809e 100644 --- a/src/features/hall/use-jackpot-burst-live.ts +++ b/src/features/hall/use-jackpot-burst-live.ts @@ -50,6 +50,7 @@ export function useJackpotBurstLive(t: TFunction<"player">) { setEvent(payload); notifyBrowser(payload, t); window.dispatchEvent(new Event("lottery-wallet-refresh")); + window.dispatchEvent(new Event("lottery-hall-refresh")); }, [t], ); diff --git a/src/features/orders/ticket-item-status.tsx b/src/features/orders/ticket-item-status.tsx index e10deed..34c3b57 100644 --- a/src/features/orders/ticket-item-status.tsx +++ b/src/features/orders/ticket-item-status.tsx @@ -7,8 +7,11 @@ export function ticketStatusDisplay( t?: (key: string, options?: { defaultValue?: string; status?: string }) => string, ): { label: string; dotClass: string; ring?: boolean } { const total = winMinor + jackpotMinor; - if (status === "success") { - return { label: t?.("ticketStatus.success") ?? status, dotClass: "bg-sky-500" }; + if (status === "success" || status === "pending_draw") { + return { + label: t?.(status === "pending_draw" ? "ticketStatus.pending_draw" : "ticketStatus.success") ?? status, + dotClass: "bg-sky-500", + }; } if (status === "pending_payout") { return { label: t?.("ticketStatus.pending_payout") ?? status, dotClass: "bg-amber-500" }; diff --git a/src/features/orders/ticket-order-detail-screen.tsx b/src/features/orders/ticket-order-detail-screen.tsx index 74cf0f8..bd70043 100644 --- a/src/features/orders/ticket-order-detail-screen.tsx +++ b/src/features/orders/ticket-order-detail-screen.tsx @@ -21,10 +21,9 @@ import { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-st import { formatLotteryInstant } from "@/lib/player-datetime"; import { formatMinorAsCurrency } from "@/lib/money"; import { norm4d } from "@/lib/norm-4d"; -import { resolvePlayerCurrency } from "@/lib/player-currency"; +import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency"; import { playLabel } from "@/lib/play-labels"; import { cn } from "@/lib/utils"; -import { usePlayerSessionStore } from "@/stores/player-session-store"; import type { TicketItemDetailPayload } from "@/types/api/ticket-items"; type OddsSnapRow = { prize_scope?: string; odds_value?: number }; @@ -69,7 +68,7 @@ type TicketItemDetailWithExtras = TicketItemDetailPayload & { /** 界面文档 §4.8 注单详情 */ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) { const { t } = useTranslation("player"); - const profile = usePlayerSessionStore((state) => state.profile); + const { activeCurrency } = useActivePlayerCurrency(); useCurrencyCatalog(); const [data, setData] = useState(null); const [error, setError] = useState(null); @@ -132,7 +131,7 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) { ); } - const cur = data.currency_code ?? resolvePlayerCurrency(profile); + const cur = data.currency_code ?? activeCurrency; const st = ticketStatusDisplay(data.status, data.win_amount, data.jackpot_win_amount, t); const totalWin = data.win_amount + data.jackpot_win_amount; const pub = data.published_draw_results; diff --git a/src/features/orders/ticket-orders-list-screen.tsx b/src/features/orders/ticket-orders-list-screen.tsx index 3194ae2..3c55b85 100644 --- a/src/features/orders/ticket-orders-list-screen.tsx +++ b/src/features/orders/ticket-orders-list-screen.tsx @@ -19,14 +19,13 @@ import { useCurrencyCatalog } from "@/hooks/use-currency-catalog"; import { useIsMobile } from "@/hooks/use-mobile"; import { formatMinorAsCurrency } from "@/lib/money"; import { formatLotteryInstant } from "@/lib/player-datetime"; -import { resolvePlayerCurrency } from "@/lib/player-currency"; +import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency"; import { playLabel } from "@/lib/play-labels"; import { cn } from "@/lib/utils"; -import { usePlayerSessionStore } from "@/stores/player-session-store"; import type { TicketItemListRow } from "@/types/api/ticket-items"; const ORDERS_PAGE_SIZE = 20; -const STATUS_OPTIONS = ["success", "settled_win", "settled_lose", "failed"] as const; +const STATUS_OPTIONS = ["pending_draw", "success", "settled_win", "settled_lose", "failed"] as const; function parseYmd(value: string): Date | undefined { const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); @@ -45,7 +44,7 @@ function formatYmd(value: Date): string { export function TicketOrdersListScreen() { const searchParams = useSearchParams(); const { t } = useTranslation("player"); - const profile = usePlayerSessionStore((state) => state.profile); + const { activeCurrency } = useActivePlayerCurrency(); useCurrencyCatalog(); const drawNoFilter = useMemo(() => (searchParams.get("draw_no") ?? "").trim(), [searchParams]); const statusFilter = useMemo( @@ -356,7 +355,7 @@ export function TicketOrdersListScreen() { <>
{items.map((row) => { - const cur = row.currency_code ?? resolvePlayerCurrency(profile); + const cur = row.currency_code ?? activeCurrency; const st = ticketStatusDisplay(row.status, row.win_amount, row.jackpot_win_amount, t); const totalWin = row.win_amount + row.jackpot_win_amount; return ( diff --git a/src/features/player/hydrate-player-auth.tsx b/src/features/player/hydrate-player-auth.tsx index 4a05279..cf494c4 100644 --- a/src/features/player/hydrate-player-auth.tsx +++ b/src/features/player/hydrate-player-auth.tsx @@ -18,6 +18,7 @@ export function HydratePlayerAuth(): null { const setCurrencies = usePlayerSessionStore((state) => state.setCurrencies); useEffect(() => { + usePlayerSessionStore.getState().reconcileSelectedCurrency(); const token = restoreBearerToken(); void (async () => { try { diff --git a/src/features/player/player-session-bar.tsx b/src/features/player/player-session-bar.tsx index 38e52df..a04c737 100644 --- a/src/features/player/player-session-bar.tsx +++ b/src/features/player/player-session-bar.tsx @@ -3,6 +3,7 @@ import { UserRound } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency"; import { cn } from "@/lib/utils"; import { usePlayerSessionStore } from "@/stores/player-session-store"; @@ -11,6 +12,7 @@ import { usePlayerSessionStore } from "@/stores/player-session-store"; */ export function PlayerSessionBar({ className }: { className?: string }) { const profile = usePlayerSessionStore((s) => s.profile); + const { activeCurrency } = useActivePlayerCurrency(); const { t } = useTranslation("player"); const label = @@ -32,10 +34,10 @@ export function PlayerSessionBar({ className }: { className?: string }) {

{label ?? "…"}

- {profile?.default_currency ? ( + {activeCurrency ? (

- {profile.default_currency.toUpperCase()} - {profile.locale ? ( + {activeCurrency} + {profile?.locale ? ( {" "} · {profile.locale} diff --git a/src/features/results/check-winning-screen.tsx b/src/features/results/check-winning-screen.tsx index 48a627c..a676b79 100644 --- a/src/features/results/check-winning-screen.tsx +++ b/src/features/results/check-winning-screen.tsx @@ -21,8 +21,7 @@ import { useCurrencyCatalog } from "@/hooks/use-currency-catalog"; import { formatMinorAsCurrency } from "@/lib/money"; import { formatLotteryInstant } from "@/lib/player-datetime"; import { playLabel } from "@/lib/play-labels"; -import { resolvePlayerCurrency } from "@/lib/player-currency"; -import { usePlayerSessionStore } from "@/stores/player-session-store"; +import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency"; import type { DrawResultListItem } from "@/types/api/draw-results"; import type { TicketDrawMyMatchPayload, TicketItemListRow } from "@/types/api/ticket-items"; @@ -201,11 +200,11 @@ function WinningResultDialog({ onCheckAnother: () => void; }) { const { t } = useTranslation("player"); - const profile = usePlayerSessionStore((state) => state.profile); const totalWin = (data?.match.total_win_minor ?? 0) + (data?.match.total_jackpot_win_minor ?? 0); const isWon = totalWin > 0 || (data?.match.winning_ticket_count ?? 0) > 0; const firstTicket = useMemo(() => data?.tickets[0] ?? null, [data]); - const currency = firstTicket?.currency_code ?? resolvePlayerCurrency(profile); + const { activeCurrency } = useActivePlayerCurrency(); + const currency = firstTicket?.currency_code ?? activeCurrency; if (!data) return null; diff --git a/src/features/results/draw-result-detail-screen.tsx b/src/features/results/draw-result-detail-screen.tsx index 363997f..1e6fb9d 100644 --- a/src/features/results/draw-result-detail-screen.tsx +++ b/src/features/results/draw-result-detail-screen.tsx @@ -23,9 +23,8 @@ import { getPlayerBearerTokenPayload } from "@/lib/lottery-auth"; import { formatLotteryInstant } from "@/lib/player-datetime"; import { formatMinorAsCurrency } from "@/lib/money"; import { norm4d } from "@/lib/norm-4d"; -import { resolvePlayerCurrency } from "@/lib/player-currency"; +import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency"; import { cn } from "@/lib/utils"; -import { usePlayerSessionStore } from "@/stores/player-session-store"; import type { DrawResultDetailPayload } from "@/types/api/draw-results"; type DrawResultDetailScreenProps = { @@ -35,7 +34,7 @@ type DrawResultDetailScreenProps = { /** §4.6 开奖结果详情:23 分区 + [< >] 切换 + 本人命中高亮 + Jackpot */ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps) { const { t } = useTranslation("player"); - const profile = usePlayerSessionStore((state) => state.profile); + const { activeCurrency } = useActivePlayerCurrency(); useCurrencyCatalog(); const [data, setData] = useState(null); const [error, setError] = useState(null); @@ -136,7 +135,7 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps) ); } - const currency = data.jackpot?.currency_code ?? resolvePlayerCurrency(profile); + const currency = data.jackpot?.currency_code ?? activeCurrency; const showMyPayout = myTotals && myTotals.hasBets && diff --git a/src/features/results/draw-results-list-screen.tsx b/src/features/results/draw-results-list-screen.tsx index 4331aa9..095ec59 100644 --- a/src/features/results/draw-results-list-screen.tsx +++ b/src/features/results/draw-results-list-screen.tsx @@ -22,8 +22,7 @@ import { JackpotResultsStrip } from "@/features/results/jackpot-results-strip"; import { TwentyThreeResultsGrid } from "@/features/results/twenty-three-results-grid"; import { useCurrencyCatalog } from "@/hooks/use-currency-catalog"; import { formatLotteryInstant } from "@/lib/player-datetime"; -import { resolvePlayerCurrency } from "@/lib/player-currency"; -import { usePlayerSessionStore } from "@/stores/player-session-store"; +import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency"; import type { DrawResultListItem } from "@/types/api/draw-results"; const RESULTS_PAGE_SIZE = 10; @@ -35,7 +34,6 @@ const MONTH_OPTIONS = Array.from({ length: 12 }, (_, value) => ({ export function DrawResultsListScreen() { const { t } = useTranslation("player"); - const profile = usePlayerSessionStore((state) => state.profile); useCurrencyCatalog(); const [items, setItems] = useState(null); const [error, setError] = useState(null); @@ -51,7 +49,8 @@ export function DrawResultsListScreen() { const businessDate = /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : undefined; const quickYears = useMemo(() => buildYearOptions(calendarMonth), [calendarMonth]); const featured = items?.[0] ?? null; - const jackpotCurrency = featured?.jackpot?.currency_code ?? resolvePlayerCurrency(profile); + const { activeCurrency } = useActivePlayerCurrency(); + const jackpotCurrency = featured?.jackpot?.currency_code ?? activeCurrency; const fetchList = useCallback(async (targetPage = 1, append = false) => { setError(null); diff --git a/src/features/wallet/transfer-in-screen.tsx b/src/features/wallet/transfer-in-screen.tsx index b03ad53..a4b25b8 100644 --- a/src/features/wallet/transfer-in-screen.tsx +++ b/src/features/wallet/transfer-in-screen.tsx @@ -1,31 +1,26 @@ "use client"; import { useRouter } from "next/navigation"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { getWalletBalance } from "@/api/wallet"; import { TransferInPage } from "@/features/wallet/wallet-transfer-forms"; import { Skeleton } from "@/components/ui/skeleton"; -import { resolvePlayerCurrency } from "@/lib/player-currency"; -import { usePlayerSessionStore } from "@/stores/player-session-store"; +import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency"; +import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference"; import type { WalletBalanceData } from "@/types/api/wallet-balance"; /** 独立路由 `/wallet/transfer-in` */ export function TransferInScreen() { const router = useRouter(); - const profile = usePlayerSessionStore((s) => s.profile); + const { activeCurrency: currency } = useActivePlayerCurrency(); const [balance, setBalance] = useState(null); const [loading, setLoading] = useState(true); - const currency = useMemo( - () => resolvePlayerCurrency(profile, balance), - [balance, profile], - ); - const load = useCallback(async () => { - const b = await getWalletBalance(); + const b = await getWalletBalance({ currency }); setBalance(b); - }, []); + }, [currency]); useEffect(() => { let c = false; @@ -41,6 +36,12 @@ export function TransferInScreen() { }; }, [load]); + useEffect(() => { + const onCurrencyChange = () => void load(); + window.addEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange); + return () => window.removeEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange); + }, [load]); + const onSuccess = useCallback(async () => { await load(); router.push("/wallet"); diff --git a/src/features/wallet/transfer-out-screen.tsx b/src/features/wallet/transfer-out-screen.tsx index df6d8a7..4f2d804 100644 --- a/src/features/wallet/transfer-out-screen.tsx +++ b/src/features/wallet/transfer-out-screen.tsx @@ -1,31 +1,26 @@ "use client"; import { useRouter } from "next/navigation"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { getWalletBalance } from "@/api/wallet"; import { TransferOutPage } from "@/features/wallet/wallet-transfer-forms"; import { Skeleton } from "@/components/ui/skeleton"; -import { resolvePlayerCurrency } from "@/lib/player-currency"; -import { usePlayerSessionStore } from "@/stores/player-session-store"; +import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency"; +import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference"; import type { WalletBalanceData } from "@/types/api/wallet-balance"; /** 独立路由 `/wallet/transfer-out` */ export function TransferOutScreen() { const router = useRouter(); - const profile = usePlayerSessionStore((s) => s.profile); + const { activeCurrency: currency } = useActivePlayerCurrency(); const [balance, setBalance] = useState(null); const [loading, setLoading] = useState(true); - const currency = useMemo( - () => resolvePlayerCurrency(profile, balance), - [balance, profile], - ); - const load = useCallback(async () => { - const b = await getWalletBalance(); + const b = await getWalletBalance({ currency }); setBalance(b); - }, []); + }, [currency]); useEffect(() => { let c = false; @@ -41,6 +36,12 @@ export function TransferOutScreen() { }; }, [load]); + useEffect(() => { + const onCurrencyChange = () => void load(); + window.addEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange); + return () => window.removeEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange); + }, [load]); + const onSuccess = useCallback(async () => { await load(); router.push("/wallet"); diff --git a/src/features/wallet/wallet-logs-screen.tsx b/src/features/wallet/wallet-logs-screen.tsx index 7cd3d02..99f6d89 100644 --- a/src/features/wallet/wallet-logs-screen.tsx +++ b/src/features/wallet/wallet-logs-screen.tsx @@ -7,15 +7,15 @@ import { getWalletLogs } from "@/api/wallet"; import { Button } from "@/components/ui/button"; import { PlayerPanel } from "@/components/layout/player-panel"; import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block"; -import { resolvePlayerCurrency } from "@/lib/player-currency"; +import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency"; +import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference"; import { formatWalletClientError } from "@/lib/wallet-api-error"; -import { usePlayerSessionStore } from "@/stores/player-session-store"; import { getWalletLogsLastPage, type WalletLogsData } from "@/types/api/wallet-logs"; const WALLET_LOGS_PAGE_SIZE = 10; export function WalletLogsScreen() { - const profile = usePlayerSessionStore((s) => s.profile); + const { activeCurrency: currency } = useActivePlayerCurrency(); const { t } = useTranslation("player"); const [logs, setLogs] = useState(null); const [filter, setFilter] = useState(""); @@ -25,10 +25,19 @@ export function WalletLogsScreen() { const [error, setError] = useState(null); const loadMoreRef = useRef(null); - const currency = useMemo( - () => resolvePlayerCurrency(profile), - [profile], - ); + const logsForCurrency = useMemo(() => { + if (!logs) return null; + const code = currency.toUpperCase(); + return { + ...logs, + items: logs.items.filter( + (item) => (item.currency_code || code).toUpperCase() === code, + ), + pending_reconcile: logs.pending_reconcile.filter( + (item) => item.currency_code.toUpperCase() === code, + ), + }; + }, [currency, logs]); const fetchPassRef = useRef(true); @@ -69,6 +78,12 @@ export function WalletLogsScreen() { queueMicrotask(() => { void load(1, false); }); + }, [currency, load]); + + useEffect(() => { + const onCurrencyChange = () => void load(1, false); + window.addEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange); + return () => window.removeEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange); }, [load]); const hasMore = logs ? logs.page < getWalletLogsLastPage(logs) : false; @@ -116,7 +131,7 @@ export function WalletLogsScreen() { ) : null} s.profile); + const { activeCurrency: currency } = useActivePlayerCurrency(); const { t } = useTranslation("player"); const [balance, setBalance] = useState(null); const [logs, setLogs] = useState(null); @@ -35,11 +35,6 @@ export function WalletScreen() { const [error, setError] = useState(null); const loadMoreRef = useRef(null); - const currency = useMemo( - () => resolvePlayerCurrency(profile, balance), - [balance, profile], - ); - const fetchPassRef = useRef(true); const loadLogs = useCallback(async (targetPage = 1, append = false) => { @@ -68,7 +63,7 @@ export function WalletScreen() { setLogsLoading(true); } try { - const b = await getWalletBalance(); + const b = await getWalletBalance({ currency }); if (cancelled) return; setBalance(b); const nextLogs = await loadLogs(1, false); @@ -89,13 +84,13 @@ export function WalletScreen() { return () => { cancelled = true; }; - }, [loadLogs, t]); + }, [currency, loadLogs, t]); const refreshAll = useCallback(async () => { setError(null); setLogsLoading(true); try { - const b = await getWalletBalance(); + const b = await getWalletBalance({ currency }); setBalance(b); await loadLogs(1, false); } catch (e) { @@ -104,7 +99,27 @@ export function WalletScreen() { setLogsLoading(false); setLoading(false); } - }, [loadLogs, t]); + }, [currency, loadLogs, t]); + + useEffect(() => { + const onCurrencyChange = () => void refreshAll(); + window.addEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange); + return () => window.removeEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange); + }, [refreshAll]); + + const logsForCurrency = useMemo(() => { + if (!logs) return null; + const code = currency.toUpperCase(); + return { + ...logs, + items: logs.items.filter( + (item) => (item.currency_code || code).toUpperCase() === code, + ), + pending_reconcile: logs.pending_reconcile.filter( + (item) => item.currency_code.toUpperCase() === code, + ), + }; + }, [currency, logs]); const hasMore = logs ? logs.page < getWalletLogsLastPage(logs) : false; @@ -207,7 +222,7 @@ export function WalletScreen() {

state.profile); + const currencies = usePlayerSessionStore((state) => state.currencies); + const selectedCurrency = usePlayerSessionStore((state) => state.selectedCurrency); + const setSelectedCurrency = usePlayerSessionStore((state) => state.setSelectedCurrency); + + const bettableCurrencies = useMemo( + () => listBettableCurrencies(currencies), + [currencies], + ); + + const activeCurrency = selectedCurrency ?? profile?.default_currency?.toUpperCase() ?? "NPR"; + + const canSwitchCurrency = bettableCurrencies.length > 1; + + const setActiveCurrency = useCallback( + (code: string) => { + setSelectedCurrency(code); + }, + [setSelectedCurrency], + ); + + return { + activeCurrency, + bettableCurrencies, + canSwitchCurrency, + setActiveCurrency, + }; +} diff --git a/src/hooks/use-wallet-polling.ts b/src/hooks/use-wallet-polling.ts index 5ed2bc9..475e2ac 100644 --- a/src/hooks/use-wallet-polling.ts +++ b/src/hooks/use-wallet-polling.ts @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef } from "react"; import { getWalletBalance } from "@/api/wallet"; +import { getActivePlayerCurrencyFromStore } from "@/lib/player-currency"; import { useNetworkConnectionStore } from "@/stores/network-connection-store"; const POLLING_INTERVAL_MS = 30_000; // 30秒轮询间隔 @@ -46,7 +47,7 @@ export function useWalletPolling(): UseWalletPollingReturn { // 刷新钱包余额 const refreshWallet = useCallback(async () => { try { - await getWalletBalance(); + await getWalletBalance({ currency: getActivePlayerCurrencyFromStore() }); // 触发全局刷新事件,让所有监听组件更新 window.dispatchEvent(new Event("lottery-wallet-refresh")); } catch { @@ -144,7 +145,7 @@ export function triggerWalletPollingAfterBet(): void { // 如果是降级模式,立即刷新并启动限时轮询 if (store.mode === "polling" || store.mode === "offline") { // 立即刷新一次 - void getWalletBalance().then(() => { + void getWalletBalance({ currency: getActivePlayerCurrencyFromStore() }).then(() => { window.dispatchEvent(new Event("lottery-wallet-refresh")); }); @@ -161,7 +162,7 @@ export function triggerWalletPollingAfterBet(): void { store.setWalletPollingIntervalId(null); return; } - void getWalletBalance().then(() => { + void getWalletBalance({ currency: getActivePlayerCurrencyFromStore() }).then(() => { window.dispatchEvent(new Event("lottery-wallet-refresh")); }); }, POLLING_INTERVAL_MS); diff --git a/src/hooks/use-websocket-manager.ts b/src/hooks/use-websocket-manager.ts index d5200f2..0f37dcc 100644 --- a/src/hooks/use-websocket-manager.ts +++ b/src/hooks/use-websocket-manager.ts @@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef } from "react"; import { getDrawCurrent } from "@/api/draw"; import { getWalletBalance } from "@/api/wallet"; +import { getActivePlayerCurrencyFromStore } from "@/lib/player-currency"; import { getLotteryEcho } from "@/lib/lottery-echo"; import { useNetworkConnectionStore, @@ -72,7 +73,7 @@ export function useWebSocketManager(): UseWebSocketManagerReturn { // 刷新钱包余额 const refreshWallet = useCallback(async () => { try { - await getWalletBalance(); + await getWalletBalance({ currency: getActivePlayerCurrencyFromStore() }); // 触发全局刷新事件,让组件更新 window.dispatchEvent(new Event("lottery-wallet-refresh")); } catch { diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 449a5ec..ef36e98 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -15,6 +15,11 @@ "pending": "Pending", "failed": "Failed" }, + "currency": { + "current": "Currency {{code}}", + "switchAria": "Switch currency (current {{code}})", + "option": "{{code}} · {{name}}" + }, "navigation": { "notifications": "Notifications" }, diff --git a/src/i18n/locales/en/player.json b/src/i18n/locales/en/player.json index 274fc6a..7425f27 100644 --- a/src/i18n/locales/en/player.json +++ b/src/i18n/locales/en/player.json @@ -117,6 +117,7 @@ "title": "Jackpot Burst", "subtitle": "Issue {{drawNo}} triggered a pool payout", "number": "First prize number", + "numberPending": "First prize numbers are not published for this issue yet", "amount": "Burst amount", "winners": "Winners", "triggerLabel": "Trigger", @@ -548,6 +549,7 @@ }, "ticketStatus": { "success": "Awaiting draw", + "pending_draw": "Awaiting draw", "pending_payout": "Won, pending payout", "settled_win": "Paid", "settled_lose": "Not won", diff --git a/src/i18n/locales/ne/common.json b/src/i18n/locales/ne/common.json index e9deac9..5d48391 100644 --- a/src/i18n/locales/ne/common.json +++ b/src/i18n/locales/ne/common.json @@ -15,6 +15,11 @@ "pending": "बाँकी", "failed": "असफल" }, + "currency": { + "current": "मुद्रा {{code}}", + "switchAria": "मुद्रा बदल्नुहोस् (हाल {{code}})", + "option": "{{code}} · {{name}}" + }, "navigation": { "notifications": "सूचनाहरू" }, diff --git a/src/i18n/locales/ne/player.json b/src/i18n/locales/ne/player.json index 24af032..4d18d2d 100644 --- a/src/i18n/locales/ne/player.json +++ b/src/i18n/locales/ne/player.json @@ -117,6 +117,7 @@ "title": "Jackpot Burst", "subtitle": "इश्यू {{drawNo}} मा पूल payout ट्रिगर भयो", "number": "पहिलो पुरस्कार नम्बर", + "numberPending": "यो इश्यूको लागि पहिलो पुरस्कार नम्बर अझै प्रकाशित भएको छैन", "amount": "Burst रकम", "winners": "विजेता", "triggerLabel": "ट्रिगर", @@ -543,6 +544,7 @@ }, "ticketStatus": { "success": "ड्र पर्खँदै", + "pending_draw": "ड्र पर्खँदै", "pending_payout": "जितेको, भुक्तानी बाँकी", "settled_win": "भुक्तानी भयो", "settled_lose": "जितेन", diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index 3189bca..299cc38 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -15,6 +15,11 @@ "pending": "待处理", "failed": "失败" }, + "currency": { + "current": "当前币种 {{code}}", + "switchAria": "切换币种(当前 {{code}})", + "option": "{{code}} · {{name}}" + }, "navigation": { "notifications": "通知" }, diff --git a/src/i18n/locales/zh/player.json b/src/i18n/locales/zh/player.json index 006c3d4..a9ca43a 100644 --- a/src/i18n/locales/zh/player.json +++ b/src/i18n/locales/zh/player.json @@ -117,6 +117,7 @@ "title": "Jackpot 爆池", "subtitle": "期号 {{drawNo}} 触发奖池派发", "number": "头奖号码", + "numberPending": "该期尚未公布头奖号码", "amount": "爆池金额", "winners": "中奖人数", "triggerLabel": "触发方式", @@ -548,6 +549,7 @@ }, "ticketStatus": { "success": "待开奖", + "pending_draw": "待开奖", "pending_payout": "已中奖待派彩", "settled_win": "已派彩", "settled_lose": "未中奖", diff --git a/src/lib/player-currency-options.ts b/src/lib/player-currency-options.ts new file mode 100644 index 0000000..d6ec232 --- /dev/null +++ b/src/lib/player-currency-options.ts @@ -0,0 +1,41 @@ +import type { PublicCurrencyRow } from "@/types/api/currency"; +import type { PlayerMeData } from "@/types/api/player-me"; + +import { resolvePlayerCurrency } from "@/lib/player-currency"; + +export function normalizeCurrencyCode(code: string | null | undefined): string | null { + const trimmed = code?.trim(); + return trimmed ? trimmed.toUpperCase() : null; +} + +export function listBettableCurrencies(currencies: PublicCurrencyRow[]): PublicCurrencyRow[] { + return currencies.filter((row) => row.is_bettable); +} + +/** + * 解析玩家当前应使用的业务币种:持久化选择 → 玩家默认 → 首个可下注币种 → NPR 兜底。 + */ +export function pickActivePlayerCurrency( + profile: Pick | null | undefined, + currencies: PublicCurrencyRow[], + persistedCode: string | null, +): string { + const bettable = listBettableCurrencies(currencies); + const bettableCodes = bettable.map((row) => row.code); + + const persisted = normalizeCurrencyCode(persistedCode); + if (persisted && bettableCodes.includes(persisted)) { + return persisted; + } + + const fromProfile = normalizeCurrencyCode(profile?.default_currency); + if (fromProfile && bettableCodes.includes(fromProfile)) { + return fromProfile; + } + + if (bettable.length > 0) { + return bettable[0].code; + } + + return resolvePlayerCurrency(profile); +} diff --git a/src/lib/player-currency-preference.ts b/src/lib/player-currency-preference.ts new file mode 100644 index 0000000..65c8151 --- /dev/null +++ b/src/lib/player-currency-preference.ts @@ -0,0 +1,38 @@ +const STORAGE_KEY = "lottery.player.selected_currency"; + +export function persistPlayerSelectedCurrency(code: string): void { + try { + localStorage.setItem(STORAGE_KEY, code.trim().toUpperCase()); + } catch { + /* ignore quota / private mode */ + } +} + +export function readPersistedPlayerSelectedCurrency(): string | null { + try { + const raw = localStorage.getItem(STORAGE_KEY); + return raw?.trim() ? raw.trim().toUpperCase() : null; + } catch { + return null; + } +} + +export function clearPersistedPlayerSelectedCurrency(): void { + try { + localStorage.removeItem(STORAGE_KEY); + } catch { + /* ignore */ + } +} + +/** 切换币种后通知各业务模块重新拉取玩法/钱包等数据 */ +export const PLAYER_CURRENCY_CHANGE_EVENT = "lottery-currency-change"; + +export function dispatchPlayerCurrencyChange(code: string): void { + if (typeof window === "undefined") return; + window.dispatchEvent( + new CustomEvent(PLAYER_CURRENCY_CHANGE_EVENT, { + detail: { currency: code.trim().toUpperCase() }, + }), + ); +} diff --git a/src/lib/player-currency.ts b/src/lib/player-currency.ts index 12d0e15..f791b02 100644 --- a/src/lib/player-currency.ts +++ b/src/lib/player-currency.ts @@ -1,3 +1,5 @@ +import { pickActivePlayerCurrency } from "@/lib/player-currency-options"; +import { usePlayerSessionStore } from "@/stores/player-session-store"; import type { PlayerMeData } from "@/types/api/player-me"; import type { WalletBalanceData } from "@/types/api/wallet-balance"; @@ -5,8 +7,7 @@ type CurrencyProfile = Pick | null | undefined type CurrencyBalance = Pick | null | undefined; /** - * 玩家端当前业务币种: - * 优先钱包接口返回币种,再回退玩家默认币种,环境变量仅作联调兜底。 + * 玩家档案/环境变量层面的默认币种(不含用户在大厅的切换选择)。 */ export function resolvePlayerCurrency( profile: CurrencyProfile, @@ -29,3 +30,15 @@ export function resolvePlayerCurrency( return "NPR"; } + +/** + * 当前业务币种(含玩家在大厅切换的可下注币种)。 + * 可在非 React 上下文(轮询、WS 刷新)中调用。 + */ +export function getActivePlayerCurrencyFromStore(): string { + const { selectedCurrency, profile, currencies } = usePlayerSessionStore.getState(); + if (selectedCurrency) { + return selectedCurrency; + } + return pickActivePlayerCurrency(profile, currencies, null); +} diff --git a/src/stores/player-session-store.ts b/src/stores/player-session-store.ts index beef9e2..c5253d5 100644 --- a/src/stores/player-session-store.ts +++ b/src/stores/player-session-store.ts @@ -6,6 +6,12 @@ import { persistPlayerBearerToken, readPersistedPlayerBearerToken, } from "@/lib/player-session"; +import { + dispatchPlayerCurrencyChange, + persistPlayerSelectedCurrency, + readPersistedPlayerSelectedCurrency, +} from "@/lib/player-currency-preference"; +import { pickActivePlayerCurrency } from "@/lib/player-currency-options"; import type { PublicCurrencyRow } from "@/types/api/currency"; import type { PlayerMeData } from "@/types/api/player-me"; @@ -23,6 +29,8 @@ type PlayerSessionState = { bearerToken: string | null; profile: PlayerMeData | null; currencies: PublicCurrencyRow[]; + /** 玩家在大厅选择的可下注币种(localStorage 持久化) */ + selectedCurrency: string | null; phase: PlayerEntryPhase; progress: number; errorMessage: string | null; @@ -32,6 +40,9 @@ type PlayerSessionState = { clearBearerToken: () => void; setProfile: (profile: PlayerMeData | null) => void; setCurrencies: (currencies: PublicCurrencyRow[]) => void; + /** 根据档案与币种目录校准当前选中币种 */ + reconcileSelectedCurrency: () => void; + setSelectedCurrency: (code: string) => void; setPhase: (phase: PlayerEntryPhase) => void; setProgress: (progress: number) => void; setErrorMessage: (message: string | null) => void; @@ -47,10 +58,11 @@ function initialSteps(): PlayerEntryStep[] { ]; } -export const usePlayerSessionStore = create((set) => ({ +export const usePlayerSessionStore = create((set, get) => ({ bearerToken: null, profile: null, currencies: [], + selectedCurrency: null, phase: "loading", progress: 0, errorMessage: null, @@ -83,11 +95,46 @@ export const usePlayerSessionStore = create((set) => ({ clearBearerToken: () => { setPlayerBearerToken(null); clearPersistedPlayerBearerToken(); - set({ bearerToken: null, profile: null }); + set({ bearerToken: null, profile: null, selectedCurrency: null }); }, - setProfile: (profile) => set({ profile }), - setCurrencies: (currencies) => set({ currencies }), + setProfile: (profile) => { + set({ profile }); + get().reconcileSelectedCurrency(); + }, + + setCurrencies: (currencies) => { + set({ currencies }); + get().reconcileSelectedCurrency(); + }, + + reconcileSelectedCurrency: () => { + const { profile, currencies } = get(); + const next = pickActivePlayerCurrency( + profile, + currencies, + readPersistedPlayerSelectedCurrency(), + ); + const prev = get().selectedCurrency; + if (prev === next) { + return; + } + persistPlayerSelectedCurrency(next); + set({ selectedCurrency: next }); + }, + + setSelectedCurrency: (code) => { + const normalized = code.trim().toUpperCase(); + const allowed = get().currencies.some( + (row) => row.is_bettable && row.code === normalized, + ); + if (!allowed) { + return; + } + persistPlayerSelectedCurrency(normalized); + set({ selectedCurrency: normalized }); + dispatchPlayerCurrencyChange(normalized); + }, setPhase: (phase) => set({ phase }), setProgress: (progress) => set({ progress: Math.max(0, Math.min(100, progress)) }), setErrorMessage: (errorMessage) => set({ errorMessage }), diff --git a/src/types/api/play-effective.ts b/src/types/api/play-effective.ts index 9a782ac..1d64321 100644 --- a/src/types/api/play-effective.ts +++ b/src/types/api/play-effective.ts @@ -33,9 +33,7 @@ export type PlayEffectivePlayRow = { category: string; dimension: number | null; bet_mode: string | null; - display_name_zh: string | null; - display_name_en: string | null; - display_name_ne: string | null; + display_name: string | null; sort_order: number; supports_multi_number: boolean; master_enabled: boolean;