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;