feat: add jackpot animations and enhance currency handling across components

- Introduced new CSS animations for jackpot effects to improve visual engagement.
- Integrated CurrencySwitcher into PlayerPanel and HallScreen for better currency management.
- Updated various components to utilize active player currency for consistent display.
- Enhanced event handling for currency changes to ensure real-time updates across the application.
This commit is contained in:
2026-05-25 14:31:38 +08:00
parent 2bf44e4c29
commit 9bd7cc9b9e
37 changed files with 1030 additions and 180 deletions

View File

@@ -155,4 +155,125 @@
[data-sonner-toast][data-styled="true"] [data-icon] svg { [data-sonner-toast][data-styled="true"] [data-icon] svg {
width: 14px; width: 14px;
height: 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);
}
} }

View File

@@ -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 (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border border-[#e4eaf4] bg-[#f8fafc] px-2 text-xs font-bold text-[#0b3f96]",
className,
)}
aria-label={t("currency.current", { code: activeCurrency })}
>
<Banknote className="size-3.5 shrink-0" aria-hidden />
{showLabel ? <span>{activeCurrency}</span> : null}
</span>
);
}
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 (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger
className={cn("inline-flex", className)}
render={
<button
type="button"
className={cn(
"flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm font-medium transition-colors",
styles.button,
)}
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-label={t("currency.switchAria", { code: activeCurrency })}
>
<Banknote className="size-4 shrink-0" aria-hidden />
{showLabel ? <span>{activeCurrency}</span> : null}
<ChevronDown
className={cn("size-4 transition-transform duration-200", isOpen && "rotate-180")}
aria-hidden
/>
</button>
}
/>
<PopoverContent
align={align}
side="bottom"
sideOffset={6}
className={cn(
"z-[200] w-auto min-w-[min(100vw-2rem,200px)] max-w-[min(100vw-2rem,280px)] p-1 text-gray-900",
styles.dropdown,
)}
>
<div className="max-h-[min(280px,50dvh)] overflow-y-auto" role="listbox">
{options.map((option) => (
<button
key={option.code}
type="button"
onClick={() => handleSelect(option.code)}
className={cn(
"flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors",
activeCurrency === option.code ? styles.activeItem : styles.item,
)}
role="option"
aria-selected={activeCurrency === option.code}
>
<div className="flex min-w-0 flex-col leading-tight">
<span className="font-bold">{option.code}</span>
<span className="truncate text-xs opacity-70">{option.name}</span>
</div>
{activeCurrency === option.code ? (
<svg
className="ml-auto size-4 shrink-0 text-red-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden
>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : null}
</button>
))}
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -6,6 +6,7 @@ import type { ReactNode } from "react";
import { Bell, ChevronLeft } from "lucide-react"; import { Bell, ChevronLeft } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CurrencySwitcher } from "@/components/currency-switcher";
import { LanguageSwitcher } from "@/components/language-switcher"; import { LanguageSwitcher } from "@/components/language-switcher";
import { import {
playerHeaderControl, playerHeaderControl,
@@ -70,6 +71,15 @@ export function PlayerPanel({
</div> </div>
<div className="flex min-w-0 items-center justify-end gap-1"> <div className="flex min-w-0 items-center justify-end gap-1">
<CurrencySwitcher
variant="minimal"
menuAlign="end"
showLabel
className={cn(
playerHeaderControl,
"rounded-full border border-[#e4eaf4] bg-[#f8fafc] [&_button]:h-8 [&_button]:gap-1 [&_button]:px-2 [&_button]:py-0 [&_button]:text-xs [&_button]:font-bold [&_button]:text-[#0b3f96]",
)}
/>
<LanguageSwitcher <LanguageSwitcher
variant="minimal" variant="minimal"
menuAlign="end" menuAlign="end"

View File

@@ -33,7 +33,8 @@ export function HallBetResultDialog({
}: HallBetResultDialogProps) { }: HallBetResultDialogProps) {
const { t } = useTranslation("player"); const { t } = useTranslation("player");
const successItems = data?.items.filter((item) => (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 failedItems = data?.items.filter((item) => item.status === "failed") ?? [];
const totalSuccess = data?.summary.success_count ?? successItems.length; const totalSuccess = data?.summary.success_count ?? successItems.length;
const totalFailure = data?.summary.failure_count ?? failedItems.length; const totalFailure = data?.summary.failure_count ?? failedItems.length;

View File

@@ -21,14 +21,13 @@ import {
ticketNumberSpec, ticketNumberSpec,
} from "@/features/hall/hall-bet-rules"; } from "@/features/hall/hall-bet-rules";
import type { HallDrawLiveSnapshot } from "@/features/hall/use-hall-draw-live"; 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 { triggerWalletPollingAfterBet } from "@/hooks/use-wallet-polling";
import { getLotteryEcho } from "@/lib/lottery-echo"; import { getLotteryEcho } from "@/lib/lottery-echo";
import { getLotteryRequestLocale } from "@/lib/lottery-locale"; import { getLotteryRequestLocale } from "@/lib/lottery-locale";
import { formatMinorAsCurrency, parseDecimalInputToMinor } from "@/lib/money"; 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 { cn } from "@/lib/utils";
import { usePlayerSessionStore } from "@/stores/player-session-store";
import { LotteryApiBizError } from "@/types/api/errors"; import { LotteryApiBizError } from "@/types/api/errors";
import type { PlayEffectivePayload, PlayEffectivePlayRow } from "@/types/api/play-effective"; import type { PlayEffectivePayload, PlayEffectivePlayRow } from "@/types/api/play-effective";
import type { TicketLineInput, TicketPlaceData, TicketPreviewData } from "@/types/api/ticket"; import type { TicketLineInput, TicketPlaceData, TicketPreviewData } from "@/types/api/ticket";
@@ -77,6 +76,12 @@ type OddsUpdateWsEvent = {
message?: string; message?: string;
}; };
type RiskSoldOutWsEvent = {
draw_id?: number;
draw_no?: string;
normalized_number?: string;
};
type CellRiskState = "open" | "warning" | "sold_out"; type CellRiskState = "open" | "warning" | "sold_out";
type QuickFillState = Record<HallCategory, { favorites: string[]; history: string[] }>; type QuickFillState = Record<HallCategory, { favorites: string[]; history: string[] }>;
@@ -128,10 +133,7 @@ function isPlayOpenForPlayer(row: PlayEffectivePlayRow): boolean {
} }
function pickDisplayName(row: PlayEffectivePlayRow): string { function pickDisplayName(row: PlayEffectivePlayRow): string {
const loc = getLotteryRequestLocale(); return row.display_name?.trim() || row.play_code;
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;
} }
function digitSlotOptions(category: Exclude<HallCategory, "JACKPOT">): number[] { function digitSlotOptions(category: Exclude<HallCategory, "JACKPOT">): number[] {
@@ -367,13 +369,19 @@ function cellRiskState(
rowNumber: string, rowNumber: string,
category: Exclude<HallCategory, "JACKPOT">, category: Exclude<HallCategory, "JACKPOT">,
alertRows: DrawCurrentRiskPoolAlert[] | undefined, alertRows: DrawCurrentRiskPoolAlert[] | undefined,
liveSoldOutNumbers: ReadonlySet<string>,
digitSlot?: number, digitSlot?: number,
): CellRiskState { ): CellRiskState {
const alerts = alertRows ?? [];
if (alerts.length === 0) return "open";
const normalizedRow = rowNumber.trim().toUpperCase(); const normalizedRow = rowNumber.trim().toUpperCase();
if (!normalizedRow) return "open"; 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) { for (const alert of alerts) {
if (matchesRiskAlert(alert.normalized_number, play.play_code, normalizedRow, category, digitSlot)) { if (matchesRiskAlert(alert.normalized_number, play.play_code, normalizedRow, category, digitSlot)) {
return alert.is_sold_out ? "sold_out" : "warning"; 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 }) { export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }) {
const { display, isBettable, reload: reloadDraw } = drawLive; const { display, isBettable, reload: reloadDraw } = drawLive;
const { t } = useTranslation("player"); const { t } = useTranslation("player");
const profile = usePlayerSessionStore((s) => s.profile); const { activeCurrency: currencyParam } = useActivePlayerCurrency();
const [activeCategory, setActiveCategory] = useState<HallCategory>("D2"); const [activeCategory, setActiveCategory] = useState<HallCategory>("D2");
const [rows, setRows] = useState<DraftRow[]>(() => [newDraftRow()]); const [rows, setRows] = useState<DraftRow[]>(() => [newDraftRow()]);
@@ -411,16 +419,13 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
const [resultOpen, setResultOpen] = useState(false); const [resultOpen, setResultOpen] = useState(false);
const [resultData, setResultData] = useState<TicketPlaceData | null>(null); const [resultData, setResultData] = useState<TicketPlaceData | null>(null);
const [quickFillState, setQuickFillState] = useState<QuickFillState>(() => loadQuickFillState()); const [quickFillState, setQuickFillState] = useState<QuickFillState>(() => loadQuickFillState());
const [liveSoldOutNumbers, setLiveSoldOutNumbers] = useState<Set<string>>(() => new Set());
const [debouncedSummary, setDebouncedSummary] = useState({ bet: 0, rebate: 0, actual: 0 }); const [debouncedSummary, setDebouncedSummary] = useState({ bet: 0, rebate: 0, actual: 0 });
const holdFavoriteRef = useRef<{ timer: number | null; number: string | null; longPress: boolean }>({ const holdFavoriteRef = useRef<{ timer: number | null; number: string | null; longPress: boolean }>({
timer: null, timer: null,
number: null, number: null,
longPress: false, longPress: false,
}); });
useCurrencyCatalog();
const currencyParam = useMemo(() => resolvePlayerCurrency(profile), [profile]);
const loadCatalog = useCallback(async () => { const loadCatalog = useCallback(async () => {
setCatalogState((s) => (s.kind === "ok" ? s : { kind: "loading" })); setCatalogState((s) => (s.kind === "ok" ? s : { kind: "loading" }));
try { try {
@@ -448,6 +453,15 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
}); });
}, [loadCatalog, refreshWallet]); }, [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(() => { useEffect(() => {
const id = window.setInterval(() => { const id = window.setInterval(() => {
void loadCatalog(); void loadCatalog();
@@ -490,6 +504,12 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
[activeRowId, rows], [activeRowId, rows],
); );
const drawNo = display?.draw_no ?? null;
useEffect(() => {
setLiveSoldOutNumbers(new Set());
}, [drawNo]);
const alertRows = display?.risk_pool_alerts ?? []; const alertRows = display?.risk_pool_alerts ?? [];
const jackpot = display?.jackpot; const jackpot = display?.jackpot;
const currentQuickFill = quickFillState[activeCategory] ?? { favorites: [], history: [] }; const currentQuickFill = quickFillState[activeCategory] ?? { favorites: [], history: [] };
@@ -657,14 +677,30 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
toast.message(evt.message ?? t("hall.playConfig.oddsUpdated")); 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(".play.toggle", onPlayToggle);
channel.listen(".odds.update", onOddsUpdate); channel.listen(".odds.update", onOddsUpdate);
channel.listen(".risk.sold_out", onRiskSoldOut);
return () => { return () => {
channel.stopListening(".play.toggle"); channel.stopListening(".play.toggle");
channel.stopListening(".odds.update"); channel.stopListening(".odds.update");
channel.stopListening(".risk.sold_out");
}; };
}, [clearAmountsForPlay, loadCatalog, t]); }, [clearAmountsForPlay, drawNo, loadCatalog, reloadDraw, t]);
const collectEntries = useCallback((): DraftEntry[] => { const collectEntries = useCallback((): DraftEntry[] => {
if (activeCategory === "JACKPOT") return []; if (activeCategory === "JACKPOT") return [];
@@ -1163,6 +1199,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
row.number, row.number,
activeCategory as Exclude<HallCategory, "JACKPOT">, activeCategory as Exclude<HallCategory, "JACKPOT">,
alertRows, alertRows,
liveSoldOutNumbers,
column.digitSlot, column.digitSlot,
); );
const disabled = tableDisabled || status === "sold_out" || (play.config !== null && !play.config.is_enabled); const disabled = tableDisabled || status === "sold_out" || (play.config !== null && !play.config.is_enabled);

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getPlayEffective } from "@/api/play"; import { getPlayEffective } from "@/api/play";
@@ -21,10 +21,10 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
import { getLotteryRequestLocale } from "@/lib/lottery-locale"; 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 { cn } from "@/lib/utils";
import { usePlayerSessionStore } from "@/stores/player-session-store";
import { LotteryApiBizError } from "@/types/api/errors"; import { LotteryApiBizError } from "@/types/api/errors";
import type { import type {
PlayEffectivePayload, PlayEffectivePayload,
@@ -34,14 +34,7 @@ import type {
const DEFAULT_POLL_MS = 120_000; const DEFAULT_POLL_MS = 120_000;
function pickDisplayName(row: PlayEffectivePlayRow): string { function pickDisplayName(row: PlayEffectivePlayRow): string {
const loc = getLotteryRequestLocale(); return row.display_name?.trim() || row.play_code;
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;
} }
function pickRuleText(row: PlayEffectivePlayRow): string | null { function pickRuleText(row: PlayEffectivePlayRow): string | null {
@@ -79,10 +72,9 @@ function formatMoneyAmount(n: number): string {
} }
export function HallPlayCatalogPanel() { export function HallPlayCatalogPanel() {
const profile = usePlayerSessionStore((s) => s.profile);
const { t } = useTranslation("player"); const { t } = useTranslation("player");
const { activeCurrency: currencyParam } = useActivePlayerCurrency();
const [state, setState] = useState<LoadState>({ kind: "loading" }); const [state, setState] = useState<LoadState>({ kind: "loading" });
const currencyParam = useMemo(() => resolvePlayerCurrency(profile), [profile]);
const load = useCallback(async () => { const load = useCallback(async () => {
setState((s) => (s.kind === "ok" ? s : { kind: "loading" })); setState((s) => (s.kind === "ok" ? s : { kind: "loading" }));
@@ -119,6 +111,12 @@ export function HallPlayCatalogPanel() {
return () => window.clearInterval(id); return () => window.clearInterval(id);
}, [load]); }, [load]);
useEffect(() => {
const onCurrencyChange = () => void load();
window.addEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange);
return () => window.removeEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange);
}, [load]);
const body = (() => { const body = (() => {
if (state.kind === "loading") { if (state.kind === "loading") {
return ( return (

View File

@@ -5,8 +5,10 @@ import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CurrencySwitcher } from "@/components/currency-switcher";
import { LanguageSwitcher } from "@/components/language-switcher"; import { LanguageSwitcher } from "@/components/language-switcher";
import { HallBettingGrid } from "@/features/hall/hall-betting-grid"; 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 { HallDrawPanel } from "@/features/hall/hall-draw-panel";
import { HallWalletStrip } from "@/features/hall/hall-wallet-strip"; import { HallWalletStrip } from "@/features/hall/hall-wallet-strip";
import { JackpotBurstOverlay } from "@/features/hall/jackpot-burst-overlay"; import { JackpotBurstOverlay } from "@/features/hall/jackpot-burst-overlay";
@@ -22,6 +24,7 @@ export function HallScreen() {
const { t } = useTranslation("common"); const { t } = useTranslation("common");
const { t: tp } = useTranslation("player"); const { t: tp } = useTranslation("player");
const drawLive = useHallDrawLive(); const drawLive = useHallDrawLive();
const { activeCurrency } = useActivePlayerCurrency();
const { burstEvent, clearBurstEvent } = useJackpotBurstLive(tp); const { burstEvent, clearBurstEvent } = useJackpotBurstLive(tp);
return ( return (
@@ -39,6 +42,15 @@ export function HallScreen() {
/> />
</div> </div>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
<CurrencySwitcher
variant="minimal"
menuAlign="end"
showLabel
className={cn(
playerHeaderControl,
"rounded-full border border-[#e4eaf4] bg-[#f8fafc] [&_button]:h-8 [&_button]:gap-1 [&_button]:px-2 [&_button]:py-0 [&_button]:text-xs [&_button]:font-bold [&_button]:text-[#0b3f96]",
)}
/>
<LanguageSwitcher <LanguageSwitcher
variant="minimal" variant="minimal"
menuAlign="end" menuAlign="end"
@@ -75,7 +87,7 @@ export function HallScreen() {
<HallWalletStrip /> <HallWalletStrip />
<HallBettingGrid drawLive={drawLive} /> <HallBettingGrid key={activeCurrency} drawLive={drawLive} />
</section> </section>
<JackpotBurstOverlay event={burstEvent} onClose={clearBurstEvent} /> <JackpotBurstOverlay event={burstEvent} onClose={clearBurstEvent} />
</div> </div>

View File

@@ -2,7 +2,7 @@
import { Wallet } from "lucide-react"; import { Wallet } from "lucide-react";
import Image from "next/image"; 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 { useTranslation } from "react-i18next";
import { getWalletBalance } from "@/api/wallet"; import { getWalletBalance } from "@/api/wallet";
@@ -11,30 +11,27 @@ import {
TransferInDialog, TransferInDialog,
TransferOutDialog, TransferOutDialog,
} from "@/features/wallet/wallet-transfer-dialogs"; } from "@/features/wallet/wallet-transfer-dialogs";
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
import { formatMinorAsCurrency } from "@/lib/money"; 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 { cn } from "@/lib/utils";
import { useNetworkConnectionStore } from "@/stores/network-connection-store"; import { useNetworkConnectionStore } from "@/stores/network-connection-store";
import { usePlayerSessionStore } from "@/stores/player-session-store";
import type { WalletBalanceData } from "@/types/api/wallet-balance"; import type { WalletBalanceData } from "@/types/api/wallet-balance";
export function HallWalletStrip() { export function HallWalletStrip() {
const profile = usePlayerSessionStore((s) => s.profile);
const mode = useNetworkConnectionStore((s) => s.mode); const mode = useNetworkConnectionStore((s) => s.mode);
const { t } = useTranslation("player"); const { t } = useTranslation("player");
const { activeCurrency } = useActivePlayerCurrency();
const [balance, setBalance] = useState<WalletBalanceData | null>(null); const [balance, setBalance] = useState<WalletBalanceData | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const degradedWalletPollRef = useRef<number | null>(null); const degradedWalletPollRef = useRef<number | null>(null);
const currency = useMemo( const currency = activeCurrency;
() => resolvePlayerCurrency(profile, balance),
[balance, profile],
);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
const b = await getWalletBalance(); const b = await getWalletBalance({ currency: activeCurrency });
setBalance(b); setBalance(b);
}, []); }, [activeCurrency]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -54,7 +51,11 @@ export function HallWalletStrip() {
useEffect(() => { useEffect(() => {
const onRefresh = () => void refresh(); const onRefresh = () => void refresh();
window.addEventListener("lottery-wallet-refresh", onRefresh); 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]); }, [refresh]);
useEffect(() => { useEffect(() => {

View File

@@ -1,10 +1,13 @@
"use client"; "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 { useTranslation } from "react-i18next";
import { getDrawResultByNo } from "@/api/draw";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { formatMinorAsCurrency } from "@/lib/money"; import { formatMinorAsCurrency } from "@/lib/money";
import { cn } from "@/lib/utils";
export type JackpotBurstEvent = { export type JackpotBurstEvent = {
draw_id: number; draw_id: number;
@@ -23,87 +26,312 @@ type JackpotBurstOverlayProps = {
onClose: () => void; onClose: () => void;
}; };
function triggerLabel(triggerType: string, t: ReturnType<typeof useTranslation>["t"]) { const PARTICLE_SEEDS = [
return t(`hall.jackpotBurst.trigger.${triggerType}`, { { left: "8%", delay: "0s", duration: "2.2s", size: 4 },
defaultValue: triggerType, { 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<string[]>(() => ["?", "?", "?", "?"]);
const [allRevealed, setAllRevealed] = useState(false);
const [poppedIndex, setPoppedIndex] = useState<number | null>(null);
useEffect(() => {
const intervals: ReturnType<typeof setInterval>[] = [];
const timeouts: ReturnType<typeof setTimeout>[] = [];
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 (
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden>
{PARTICLE_SEEDS.map((p, i) => (
<span
key={i}
className="absolute bottom-[18%] rounded-full bg-[#e8b84a] opacity-80"
style={{
left: p.left,
width: p.size,
height: p.size,
animation: `jackpot-particle ${p.duration} ease-out ${p.delay} infinite`,
}}
/>
))}
</div>
);
}
function RollingDigit({
char,
popping,
}: {
char: string;
popping: boolean;
}) {
return (
<span
className={cn(
"inline-flex min-w-[2.1rem] items-center justify-center rounded-lg border border-[#e8c96a] bg-gradient-to-b from-[#fff9e8] to-[#fff3d4] px-1 py-1 font-mono text-[2rem] font-black leading-none text-[#b8860b] shadow-[inset_0_1px_0_rgba(255,255,255,0.9),0_2px_8px_rgba(201,162,39,0.12)] sm:min-w-[2.5rem] sm:text-[2.35rem]",
popping && "animate-[jackpot-digit-pop_0.32s_ease-out]",
)}
>
{char}
</span>
);
} }
export function JackpotBurstOverlay({ event, onClose }: JackpotBurstOverlayProps) { export function JackpotBurstOverlay({ event, onClose }: JackpotBurstOverlayProps) {
const { t } = useTranslation("player");
if (!event) return null; if (!event) return null;
return (
<JackpotBurstOverlayContent
key={`${event.draw_id}-${event.emitted_at_ms ?? 0}`}
event={event}
onClose={onClose}
/>
);
}
function useResolvedFirstPrizeNumber(event: JackpotBurstEvent) {
const fromEvent = event.first_prize_number;
const eventHasNumber = !isMissingPrizeNumber(fromEvent);
const [fetchedNumber, setFetchedNumber] = useState<string | null>(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 currency = event.currency_code.toUpperCase();
const amount = formatMinorAsCurrency(event.total_payout_amount, currency); const amount = formatMinorAsCurrency(event.total_payout_amount, currency);
return ( return (
<div <div
className="fixed inset-0 z-[90] flex items-center justify-center bg-[#08111f]/95 px-4 py-6 text-white" className="fixed inset-0 z-[90] flex items-center justify-center bg-white/88 px-4 py-6 text-slate-900 backdrop-blur-[2px] animate-[jackpot-backdrop-in_0.35s_ease-out]"
style={{
backgroundImage:
"radial-gradient(ellipse 75% 55% at 50% 38%, rgba(245,197,66,0.14) 0%, transparent 60%)",
}}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={t("hall.jackpotBurst.title")} aria-label={t("hall.jackpotBurst.title")}
onKeyDown={handleKeyDown}
> >
<div className="pointer-events-none absolute inset-0 overflow-hidden"> <div
<div className="absolute left-0 top-1/4 h-px w-full animate-[pulse_1.8s_ease-in-out_infinite] bg-[#f5c542]" /> className="pointer-events-none absolute inset-0 animate-[jackpot-flash_0.55s_ease-out_forwards] bg-[radial-gradient(circle_at_50%_42%,rgba(255,236,180,0.55)_0%,transparent_58%)]"
<div className="absolute bottom-1/3 left-0 h-px w-full animate-[pulse_2.4s_ease-in-out_infinite] bg-[#2dd4bf]" /> aria-hidden
</div> />
<div className="relative flex w-full max-w-[420px] flex-col items-center rounded-lg border border-[#f5c542]/50 bg-[#101b2d] px-5 py-6 text-center shadow-2xl shadow-black/40"> <BurstParticles />
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-2 top-2 text-white hover:bg-white/10 hover:text-white"
onClick={onClose}
aria-label={t("hall.jackpotBurst.close")}
>
<X className="size-4" aria-hidden />
</Button>
<div className="mb-4 flex size-14 items-center justify-center rounded-full border border-[#f5c542] bg-[#f5c542]/15 text-[#f5c542]"> <div
<Zap className="size-7" aria-hidden /> className="pointer-events-none absolute left-1/2 top-[28%] size-64 -translate-x-1/2 rounded-full border border-[#e8c96a]/35 animate-[jackpot-ring-pulse_1.8s_ease-out_infinite]"
aria-hidden
/>
<div
className="pointer-events-none absolute left-1/2 top-[28%] size-64 -translate-x-1/2 rounded-full border border-[#e8c96a]/20 animate-[jackpot-ring-pulse_1.8s_ease-out_0.6s_infinite]"
aria-hidden
/>
<div className="relative w-full max-w-[420px] animate-[jackpot-card-in_0.65s_cubic-bezier(0.22,1,0.36,1)]">
<div className="absolute -inset-[1px] overflow-hidden rounded-2xl">
<div
className="absolute inset-[-50%] bg-[conic-gradient(from_0deg,#e8c96a,#f5d88a,#c9a227,#e8c96a)] opacity-90 animate-[jackpot-border-spin_4s_linear_infinite]"
aria-hidden
/>
</div> </div>
<p className="text-xs font-bold uppercase tracking-[0.18em] text-[#f5c542]"> <div className="relative flex flex-col items-center rounded-2xl border border-[#f0e4c4] bg-white px-5 py-6 text-center shadow-[0_20px_50px_rgba(15,23,42,0.12),0_0_0_1px_rgba(245,197,66,0.08)]">
{t("hall.jackpotBurst.title")} <Button
</p> type="button"
<p className="mt-2 text-sm font-semibold text-slate-200"> variant="ghost"
{t("hall.jackpotBurst.subtitle", { size="icon"
drawNo: event.draw_no, className="absolute right-2 top-2 z-10 text-slate-500 hover:bg-slate-100 hover:text-slate-800"
})} onClick={onClose}
</p> aria-label={t("hall.jackpotBurst.close")}
>
<X className="size-4" aria-hidden />
</Button>
<div className="my-5 w-full rounded-lg border border-white/10 bg-white/[0.06] px-4 py-4"> <div className="relative mb-3">
<div className="text-[2.35rem] font-black leading-none text-[#f5c542] sm:text-[2.8rem]"> <div
{event.first_prize_number || "----"} className="absolute inset-0 rounded-full bg-[#f5c542]/25 blur-xl"
aria-hidden
/>
<div className="relative flex size-16 items-center justify-center rounded-full border-2 border-[#e8c96a] bg-gradient-to-br from-[#fff9e8] to-[#fff3d4] shadow-[0_4px_20px_rgba(201,162,39,0.22)]">
<Zap
className="size-8 text-[#c9a227] animate-[jackpot-lightning_1.4s_ease-in-out_infinite]"
aria-hidden
/>
</div>
<Sparkles
className="absolute -right-1 -top-1 size-4 text-[#d4a82a]"
aria-hidden
/>
</div> </div>
<p className="mt-2 text-xs font-semibold text-slate-300">
{t("hall.jackpotBurst.number")} <h2 className="bg-gradient-to-r from-[#9a7b1a] via-[#c9a227] to-[#d4a82a] bg-[length:200%_auto] bg-clip-text text-lg font-black uppercase tracking-[0.14em] text-transparent animate-[jackpot-shimmer_2.8s_linear_infinite]">
{t("hall.jackpotBurst.title")}
</h2>
<p className="mt-2 max-w-[320px] text-sm font-medium text-slate-600">
{t("hall.jackpotBurst.subtitle", { drawNo: event.draw_no })}
</p> </p>
</div>
<div className="w-full space-y-2 text-left text-sm"> <div className="relative my-5 w-full overflow-hidden rounded-xl border border-[#f0e4c4] bg-[#faf8f3] px-3 py-5 shadow-[inset_0_1px_0_rgba(255,255,255,0.9)]">
<div className="flex items-center justify-between gap-3 rounded-md bg-white/[0.05] px-3 py-2"> <div
<span className="text-slate-300"> className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-[#e8c96a]/70 to-transparent"
{t("hall.jackpotBurst.amount")} aria-hidden
</span> />
<span className="text-right font-black text-[#f5c542]">{amount}</span> <div className="flex items-center justify-center gap-2 sm:gap-2.5">
{display.map((char, i) => (
<RollingDigit key={i} char={char} popping={poppedIndex === i} />
))}
</div>
<p className="mt-3 text-xs font-semibold uppercase tracking-wider text-slate-500">
{t("hall.jackpotBurst.number")}
</p>
{prizePending && isMissingPrizeNumber(prizeNumber) ? (
<p className="mt-1 text-xs text-amber-700/90">
{t("hall.jackpotBurst.numberPending")}
</p>
) : null}
</div> </div>
<div className="flex items-center justify-between gap-3 rounded-md bg-white/[0.05] px-3 py-2">
<span className="text-slate-300"> <div className="w-full space-y-2 text-left text-sm">
{t("hall.jackpotBurst.winners")} <div
</span> className={cn(
<span className="font-bold">{event.winner_count}</span> "flex items-center justify-between gap-3 rounded-lg border border-[#f0e4c4] bg-gradient-to-r from-[#fff9e8] to-[#fffcf5] px-3 py-2.5",
</div> allRevealed &&
<div className="flex items-center justify-between gap-3 rounded-md bg-white/[0.05] px-3 py-2"> "animate-[jackpot-amount-row-glow_2s_ease-in-out_infinite]",
<span className="text-slate-300"> )}
{t("hall.jackpotBurst.triggerLabel")} >
</span> <span className="text-slate-600">{t("hall.jackpotBurst.amount")}</span>
<span className="font-bold">{triggerLabel(event.trigger_type, t)}</span> <span
className={cn(
"text-right text-base font-black text-[#b8860b]",
allRevealed && "animate-[jackpot-amount-glow_2s_ease-in-out_infinite]",
)}
>
{amount}
</span>
</div>
<div className="flex items-center justify-between gap-3 rounded-lg border border-[#e8edf4] bg-[#f8fafc] px-3 py-2.5">
<span className="text-slate-500">{t("hall.jackpotBurst.winners")}</span>
<span className="font-bold tabular-nums text-slate-900">
{event.winner_count}
</span>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,8 +1,9 @@
"use client"; "use client";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { getDrawCurrent } from "@/api/draw"; import { getDrawCurrent } from "@/api/draw";
import { isHallSealedCountdownUi } from "@/features/draw/draw-status-meta";
import { getLotteryEcho } from "@/lib/lottery-echo"; import { getLotteryEcho } from "@/lib/lottery-echo";
import { useNetworkConnectionStore } from "@/stores/network-connection-store"; import { useNetworkConnectionStore } from "@/stores/network-connection-store";
import type { DrawCurrentPayload, DrawCurrentResponse } from "@/types/api/draw-current"; import type { DrawCurrentPayload, DrawCurrentResponse } from "@/types/api/draw-current";
@@ -105,6 +106,15 @@ export function useHallDrawLive(): HallDrawLiveSnapshot {
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [load]); }, [load]);
// 爆池等场景:刷新大厅快照(含奖池余额)
useEffect(() => {
const onHallRefresh = () => {
void load();
};
window.addEventListener("lottery-hall-refresh", onHallRefresh);
return () => window.removeEventListener("lottery-hall-refresh", onHallRefresh);
}, [load]);
// 本地倒计时计时器(用于 UI 更新) // 本地倒计时计时器(用于 UI 更新)
useEffect(() => { useEffect(() => {
const bump = () => setNowMs(Date.now()); const bump = () => setNowMs(Date.now());
@@ -242,5 +252,53 @@ export function useHallDrawLive(): HallDrawLiveSnapshot {
const isBettable = display != null && display.status === "open"; const isBettable = display != null && display.status === "open";
const zeroRefreshKeyRef = useRef<string | null>(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 }; return { raw, display, serverNowMs, error, reload: load, isBettable };
} }

View File

@@ -50,6 +50,7 @@ export function useJackpotBurstLive(t: TFunction<"player">) {
setEvent(payload); setEvent(payload);
notifyBrowser(payload, t); notifyBrowser(payload, t);
window.dispatchEvent(new Event("lottery-wallet-refresh")); window.dispatchEvent(new Event("lottery-wallet-refresh"));
window.dispatchEvent(new Event("lottery-hall-refresh"));
}, },
[t], [t],
); );

View File

@@ -7,8 +7,11 @@ export function ticketStatusDisplay(
t?: (key: string, options?: { defaultValue?: string; status?: string }) => string, t?: (key: string, options?: { defaultValue?: string; status?: string }) => string,
): { label: string; dotClass: string; ring?: boolean } { ): { label: string; dotClass: string; ring?: boolean } {
const total = winMinor + jackpotMinor; const total = winMinor + jackpotMinor;
if (status === "success") { if (status === "success" || status === "pending_draw") {
return { label: t?.("ticketStatus.success") ?? status, dotClass: "bg-sky-500" }; return {
label: t?.(status === "pending_draw" ? "ticketStatus.pending_draw" : "ticketStatus.success") ?? status,
dotClass: "bg-sky-500",
};
} }
if (status === "pending_payout") { if (status === "pending_payout") {
return { label: t?.("ticketStatus.pending_payout") ?? status, dotClass: "bg-amber-500" }; return { label: t?.("ticketStatus.pending_payout") ?? status, dotClass: "bg-amber-500" };

View File

@@ -21,10 +21,9 @@ import { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-st
import { formatLotteryInstant } from "@/lib/player-datetime"; import { formatLotteryInstant } from "@/lib/player-datetime";
import { formatMinorAsCurrency } from "@/lib/money"; import { formatMinorAsCurrency } from "@/lib/money";
import { norm4d } from "@/lib/norm-4d"; 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 { playLabel } from "@/lib/play-labels";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { usePlayerSessionStore } from "@/stores/player-session-store";
import type { TicketItemDetailPayload } from "@/types/api/ticket-items"; import type { TicketItemDetailPayload } from "@/types/api/ticket-items";
type OddsSnapRow = { prize_scope?: string; odds_value?: number }; type OddsSnapRow = { prize_scope?: string; odds_value?: number };
@@ -69,7 +68,7 @@ type TicketItemDetailWithExtras = TicketItemDetailPayload & {
/** 界面文档 §4.8 注单详情 */ /** 界面文档 §4.8 注单详情 */
export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) { export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
const { t } = useTranslation("player"); const { t } = useTranslation("player");
const profile = usePlayerSessionStore((state) => state.profile); const { activeCurrency } = useActivePlayerCurrency();
useCurrencyCatalog(); useCurrencyCatalog();
const [data, setData] = useState<TicketItemDetailPayload | null>(null); const [data, setData] = useState<TicketItemDetailPayload | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(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 st = ticketStatusDisplay(data.status, data.win_amount, data.jackpot_win_amount, t);
const totalWin = data.win_amount + data.jackpot_win_amount; const totalWin = data.win_amount + data.jackpot_win_amount;
const pub = data.published_draw_results; const pub = data.published_draw_results;

View File

@@ -19,14 +19,13 @@ import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
import { useIsMobile } from "@/hooks/use-mobile"; import { useIsMobile } from "@/hooks/use-mobile";
import { formatMinorAsCurrency } from "@/lib/money"; import { formatMinorAsCurrency } from "@/lib/money";
import { formatLotteryInstant } from "@/lib/player-datetime"; 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 { playLabel } from "@/lib/play-labels";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { usePlayerSessionStore } from "@/stores/player-session-store";
import type { TicketItemListRow } from "@/types/api/ticket-items"; import type { TicketItemListRow } from "@/types/api/ticket-items";
const ORDERS_PAGE_SIZE = 20; 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 { function parseYmd(value: string): Date | undefined {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
@@ -45,7 +44,7 @@ function formatYmd(value: Date): string {
export function TicketOrdersListScreen() { export function TicketOrdersListScreen() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { t } = useTranslation("player"); const { t } = useTranslation("player");
const profile = usePlayerSessionStore((state) => state.profile); const { activeCurrency } = useActivePlayerCurrency();
useCurrencyCatalog(); useCurrencyCatalog();
const drawNoFilter = useMemo(() => (searchParams.get("draw_no") ?? "").trim(), [searchParams]); const drawNoFilter = useMemo(() => (searchParams.get("draw_no") ?? "").trim(), [searchParams]);
const statusFilter = useMemo( const statusFilter = useMemo(
@@ -356,7 +355,7 @@ export function TicketOrdersListScreen() {
<> <>
<div className="space-y-3"> <div className="space-y-3">
{items.map((row) => { {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 st = ticketStatusDisplay(row.status, row.win_amount, row.jackpot_win_amount, t);
const totalWin = row.win_amount + row.jackpot_win_amount; const totalWin = row.win_amount + row.jackpot_win_amount;
return ( return (

View File

@@ -18,6 +18,7 @@ export function HydratePlayerAuth(): null {
const setCurrencies = usePlayerSessionStore((state) => state.setCurrencies); const setCurrencies = usePlayerSessionStore((state) => state.setCurrencies);
useEffect(() => { useEffect(() => {
usePlayerSessionStore.getState().reconcileSelectedCurrency();
const token = restoreBearerToken(); const token = restoreBearerToken();
void (async () => { void (async () => {
try { try {

View File

@@ -3,6 +3,7 @@
import { UserRound } from "lucide-react"; import { UserRound } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { usePlayerSessionStore } from "@/stores/player-session-store"; import { usePlayerSessionStore } from "@/stores/player-session-store";
@@ -11,6 +12,7 @@ import { usePlayerSessionStore } from "@/stores/player-session-store";
*/ */
export function PlayerSessionBar({ className }: { className?: string }) { export function PlayerSessionBar({ className }: { className?: string }) {
const profile = usePlayerSessionStore((s) => s.profile); const profile = usePlayerSessionStore((s) => s.profile);
const { activeCurrency } = useActivePlayerCurrency();
const { t } = useTranslation("player"); const { t } = useTranslation("player");
const label = const label =
@@ -32,10 +34,10 @@ export function PlayerSessionBar({ className }: { className?: string }) {
<p className="truncate text-xs font-medium text-foreground"> <p className="truncate text-xs font-medium text-foreground">
{label ?? "…"} {label ?? "…"}
</p> </p>
{profile?.default_currency ? ( {activeCurrency ? (
<p className="truncate text-[10px] text-muted-foreground"> <p className="truncate text-[10px] text-muted-foreground">
{profile.default_currency.toUpperCase()} {activeCurrency}
{profile.locale ? ( {profile?.locale ? (
<span className="text-muted-foreground/80"> <span className="text-muted-foreground/80">
{" "} {" "}
· {profile.locale} · {profile.locale}

View File

@@ -21,8 +21,7 @@ import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
import { formatMinorAsCurrency } from "@/lib/money"; import { formatMinorAsCurrency } from "@/lib/money";
import { formatLotteryInstant } from "@/lib/player-datetime"; import { formatLotteryInstant } from "@/lib/player-datetime";
import { playLabel } from "@/lib/play-labels"; import { playLabel } from "@/lib/play-labels";
import { resolvePlayerCurrency } from "@/lib/player-currency"; import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
import { usePlayerSessionStore } from "@/stores/player-session-store";
import type { DrawResultListItem } from "@/types/api/draw-results"; import type { DrawResultListItem } from "@/types/api/draw-results";
import type { TicketDrawMyMatchPayload, TicketItemListRow } from "@/types/api/ticket-items"; import type { TicketDrawMyMatchPayload, TicketItemListRow } from "@/types/api/ticket-items";
@@ -201,11 +200,11 @@ function WinningResultDialog({
onCheckAnother: () => void; onCheckAnother: () => void;
}) { }) {
const { t } = useTranslation("player"); 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 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 isWon = totalWin > 0 || (data?.match.winning_ticket_count ?? 0) > 0;
const firstTicket = useMemo(() => data?.tickets[0] ?? null, [data]); 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; if (!data) return null;

View File

@@ -23,9 +23,8 @@ import { getPlayerBearerTokenPayload } from "@/lib/lottery-auth";
import { formatLotteryInstant } from "@/lib/player-datetime"; import { formatLotteryInstant } from "@/lib/player-datetime";
import { formatMinorAsCurrency } from "@/lib/money"; import { formatMinorAsCurrency } from "@/lib/money";
import { norm4d } from "@/lib/norm-4d"; 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 { cn } from "@/lib/utils";
import { usePlayerSessionStore } from "@/stores/player-session-store";
import type { DrawResultDetailPayload } from "@/types/api/draw-results"; import type { DrawResultDetailPayload } from "@/types/api/draw-results";
type DrawResultDetailScreenProps = { type DrawResultDetailScreenProps = {
@@ -35,7 +34,7 @@ type DrawResultDetailScreenProps = {
/** §4.6 开奖结果详情23 分区 + [< >] 切换 + 本人命中高亮 + Jackpot */ /** §4.6 开奖结果详情23 分区 + [< >] 切换 + 本人命中高亮 + Jackpot */
export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps) { export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps) {
const { t } = useTranslation("player"); const { t } = useTranslation("player");
const profile = usePlayerSessionStore((state) => state.profile); const { activeCurrency } = useActivePlayerCurrency();
useCurrencyCatalog(); useCurrencyCatalog();
const [data, setData] = useState<DrawResultDetailPayload | null>(null); const [data, setData] = useState<DrawResultDetailPayload | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(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 = const showMyPayout =
myTotals && myTotals &&
myTotals.hasBets && myTotals.hasBets &&

View File

@@ -22,8 +22,7 @@ import { JackpotResultsStrip } from "@/features/results/jackpot-results-strip";
import { TwentyThreeResultsGrid } from "@/features/results/twenty-three-results-grid"; import { TwentyThreeResultsGrid } from "@/features/results/twenty-three-results-grid";
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog"; import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
import { formatLotteryInstant } from "@/lib/player-datetime"; import { formatLotteryInstant } from "@/lib/player-datetime";
import { resolvePlayerCurrency } from "@/lib/player-currency"; import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
import { usePlayerSessionStore } from "@/stores/player-session-store";
import type { DrawResultListItem } from "@/types/api/draw-results"; import type { DrawResultListItem } from "@/types/api/draw-results";
const RESULTS_PAGE_SIZE = 10; const RESULTS_PAGE_SIZE = 10;
@@ -35,7 +34,6 @@ const MONTH_OPTIONS = Array.from({ length: 12 }, (_, value) => ({
export function DrawResultsListScreen() { export function DrawResultsListScreen() {
const { t } = useTranslation("player"); const { t } = useTranslation("player");
const profile = usePlayerSessionStore((state) => state.profile);
useCurrencyCatalog(); useCurrencyCatalog();
const [items, setItems] = useState<DrawResultListItem[] | null>(null); const [items, setItems] = useState<DrawResultListItem[] | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -51,7 +49,8 @@ export function DrawResultsListScreen() {
const businessDate = /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : undefined; const businessDate = /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : undefined;
const quickYears = useMemo(() => buildYearOptions(calendarMonth), [calendarMonth]); const quickYears = useMemo(() => buildYearOptions(calendarMonth), [calendarMonth]);
const featured = items?.[0] ?? null; 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) => { const fetchList = useCallback(async (targetPage = 1, append = false) => {
setError(null); setError(null);

View File

@@ -1,31 +1,26 @@
"use client"; "use client";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { getWalletBalance } from "@/api/wallet"; import { getWalletBalance } from "@/api/wallet";
import { TransferInPage } from "@/features/wallet/wallet-transfer-forms"; import { TransferInPage } from "@/features/wallet/wallet-transfer-forms";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { resolvePlayerCurrency } from "@/lib/player-currency"; import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
import { usePlayerSessionStore } from "@/stores/player-session-store"; import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
import type { WalletBalanceData } from "@/types/api/wallet-balance"; import type { WalletBalanceData } from "@/types/api/wallet-balance";
/** 独立路由 `/wallet/transfer-in` */ /** 独立路由 `/wallet/transfer-in` */
export function TransferInScreen() { export function TransferInScreen() {
const router = useRouter(); const router = useRouter();
const profile = usePlayerSessionStore((s) => s.profile); const { activeCurrency: currency } = useActivePlayerCurrency();
const [balance, setBalance] = useState<WalletBalanceData | null>(null); const [balance, setBalance] = useState<WalletBalanceData | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const currency = useMemo(
() => resolvePlayerCurrency(profile, balance),
[balance, profile],
);
const load = useCallback(async () => { const load = useCallback(async () => {
const b = await getWalletBalance(); const b = await getWalletBalance({ currency });
setBalance(b); setBalance(b);
}, []); }, [currency]);
useEffect(() => { useEffect(() => {
let c = false; let c = false;
@@ -41,6 +36,12 @@ export function TransferInScreen() {
}; };
}, [load]); }, [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 () => { const onSuccess = useCallback(async () => {
await load(); await load();
router.push("/wallet"); router.push("/wallet");

View File

@@ -1,31 +1,26 @@
"use client"; "use client";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { getWalletBalance } from "@/api/wallet"; import { getWalletBalance } from "@/api/wallet";
import { TransferOutPage } from "@/features/wallet/wallet-transfer-forms"; import { TransferOutPage } from "@/features/wallet/wallet-transfer-forms";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { resolvePlayerCurrency } from "@/lib/player-currency"; import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
import { usePlayerSessionStore } from "@/stores/player-session-store"; import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
import type { WalletBalanceData } from "@/types/api/wallet-balance"; import type { WalletBalanceData } from "@/types/api/wallet-balance";
/** 独立路由 `/wallet/transfer-out` */ /** 独立路由 `/wallet/transfer-out` */
export function TransferOutScreen() { export function TransferOutScreen() {
const router = useRouter(); const router = useRouter();
const profile = usePlayerSessionStore((s) => s.profile); const { activeCurrency: currency } = useActivePlayerCurrency();
const [balance, setBalance] = useState<WalletBalanceData | null>(null); const [balance, setBalance] = useState<WalletBalanceData | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const currency = useMemo(
() => resolvePlayerCurrency(profile, balance),
[balance, profile],
);
const load = useCallback(async () => { const load = useCallback(async () => {
const b = await getWalletBalance(); const b = await getWalletBalance({ currency });
setBalance(b); setBalance(b);
}, []); }, [currency]);
useEffect(() => { useEffect(() => {
let c = false; let c = false;
@@ -41,6 +36,12 @@ export function TransferOutScreen() {
}; };
}, [load]); }, [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 () => { const onSuccess = useCallback(async () => {
await load(); await load();
router.push("/wallet"); router.push("/wallet");

View File

@@ -7,15 +7,15 @@ import { getWalletLogs } from "@/api/wallet";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { PlayerPanel } from "@/components/layout/player-panel"; import { PlayerPanel } from "@/components/layout/player-panel";
import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block"; 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 { formatWalletClientError } from "@/lib/wallet-api-error";
import { usePlayerSessionStore } from "@/stores/player-session-store";
import { getWalletLogsLastPage, type WalletLogsData } from "@/types/api/wallet-logs"; import { getWalletLogsLastPage, type WalletLogsData } from "@/types/api/wallet-logs";
const WALLET_LOGS_PAGE_SIZE = 10; const WALLET_LOGS_PAGE_SIZE = 10;
export function WalletLogsScreen() { export function WalletLogsScreen() {
const profile = usePlayerSessionStore((s) => s.profile); const { activeCurrency: currency } = useActivePlayerCurrency();
const { t } = useTranslation("player"); const { t } = useTranslation("player");
const [logs, setLogs] = useState<WalletLogsData | null>(null); const [logs, setLogs] = useState<WalletLogsData | null>(null);
const [filter, setFilter] = useState(""); const [filter, setFilter] = useState("");
@@ -25,10 +25,19 @@ export function WalletLogsScreen() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const loadMoreRef = useRef<HTMLDivElement | null>(null); const loadMoreRef = useRef<HTMLDivElement | null>(null);
const currency = useMemo( const logsForCurrency = useMemo(() => {
() => resolvePlayerCurrency(profile), if (!logs) return null;
[profile], 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); const fetchPassRef = useRef(true);
@@ -69,6 +78,12 @@ export function WalletLogsScreen() {
queueMicrotask(() => { queueMicrotask(() => {
void load(1, false); 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]); }, [load]);
const hasMore = logs ? logs.page < getWalletLogsLastPage(logs) : false; const hasMore = logs ? logs.page < getWalletLogsLastPage(logs) : false;
@@ -116,7 +131,7 @@ export function WalletLogsScreen() {
) : null} ) : null}
<WalletLogsBlock <WalletLogsBlock
logs={logs} logs={logsForCurrency}
logsLoading={loading || logsLoading} logsLoading={loading || logsLoading}
loadingMore={loadingMore} loadingMore={loadingMore}
hasMore={hasMore} hasMore={hasMore}

View File

@@ -14,17 +14,17 @@ import {
TransferOutDialog, TransferOutDialog,
} from "@/features/wallet/wallet-transfer-dialogs"; } from "@/features/wallet/wallet-transfer-dialogs";
import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block"; import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block";
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
import { formatMinorAsCurrency } from "@/lib/money"; import { formatMinorAsCurrency } from "@/lib/money";
import { resolvePlayerCurrency } from "@/lib/player-currency"; import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
import { formatWalletClientError } from "@/lib/wallet-api-error"; import { formatWalletClientError } from "@/lib/wallet-api-error";
import { usePlayerSessionStore } from "@/stores/player-session-store";
import type { WalletBalanceData } from "@/types/api/wallet-balance"; import type { WalletBalanceData } from "@/types/api/wallet-balance";
import { getWalletLogsLastPage, type WalletLogsData } from "@/types/api/wallet-logs"; import { getWalletLogsLastPage, type WalletLogsData } from "@/types/api/wallet-logs";
const WALLET_LOGS_PAGE_SIZE = 10; const WALLET_LOGS_PAGE_SIZE = 10;
export function WalletScreen() { export function WalletScreen() {
const profile = usePlayerSessionStore((s) => s.profile); const { activeCurrency: currency } = useActivePlayerCurrency();
const { t } = useTranslation("player"); const { t } = useTranslation("player");
const [balance, setBalance] = useState<WalletBalanceData | null>(null); const [balance, setBalance] = useState<WalletBalanceData | null>(null);
const [logs, setLogs] = useState<WalletLogsData | null>(null); const [logs, setLogs] = useState<WalletLogsData | null>(null);
@@ -35,11 +35,6 @@ export function WalletScreen() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const loadMoreRef = useRef<HTMLDivElement | null>(null); const loadMoreRef = useRef<HTMLDivElement | null>(null);
const currency = useMemo(
() => resolvePlayerCurrency(profile, balance),
[balance, profile],
);
const fetchPassRef = useRef(true); const fetchPassRef = useRef(true);
const loadLogs = useCallback(async (targetPage = 1, append = false) => { const loadLogs = useCallback(async (targetPage = 1, append = false) => {
@@ -68,7 +63,7 @@ export function WalletScreen() {
setLogsLoading(true); setLogsLoading(true);
} }
try { try {
const b = await getWalletBalance(); const b = await getWalletBalance({ currency });
if (cancelled) return; if (cancelled) return;
setBalance(b); setBalance(b);
const nextLogs = await loadLogs(1, false); const nextLogs = await loadLogs(1, false);
@@ -89,13 +84,13 @@ export function WalletScreen() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [loadLogs, t]); }, [currency, loadLogs, t]);
const refreshAll = useCallback(async () => { const refreshAll = useCallback(async () => {
setError(null); setError(null);
setLogsLoading(true); setLogsLoading(true);
try { try {
const b = await getWalletBalance(); const b = await getWalletBalance({ currency });
setBalance(b); setBalance(b);
await loadLogs(1, false); await loadLogs(1, false);
} catch (e) { } catch (e) {
@@ -104,7 +99,27 @@ export function WalletScreen() {
setLogsLoading(false); setLogsLoading(false);
setLoading(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; const hasMore = logs ? logs.page < getWalletLogsLastPage(logs) : false;
@@ -207,7 +222,7 @@ export function WalletScreen() {
</div> </div>
<WalletLogsBlock <WalletLogsBlock
logs={logs} logs={logsForCurrency}
logsLoading={loading || logsLoading} logsLoading={loading || logsLoading}
loadingMore={loadingMore} loadingMore={loadingMore}
hasMore={hasMore} hasMore={hasMore}

View File

@@ -0,0 +1,39 @@
"use client";
import { useCallback, useMemo } from "react";
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
import { listBettableCurrencies } from "@/lib/player-currency-options";
import { usePlayerSessionStore } from "@/stores/player-session-store";
export function useActivePlayerCurrency() {
useCurrencyCatalog();
const profile = usePlayerSessionStore((state) => 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,
};
}

View File

@@ -3,6 +3,7 @@
import { useCallback, useEffect, useRef } from "react"; import { useCallback, useEffect, useRef } from "react";
import { getWalletBalance } from "@/api/wallet"; import { getWalletBalance } from "@/api/wallet";
import { getActivePlayerCurrencyFromStore } from "@/lib/player-currency";
import { useNetworkConnectionStore } from "@/stores/network-connection-store"; import { useNetworkConnectionStore } from "@/stores/network-connection-store";
const POLLING_INTERVAL_MS = 30_000; // 30秒轮询间隔 const POLLING_INTERVAL_MS = 30_000; // 30秒轮询间隔
@@ -46,7 +47,7 @@ export function useWalletPolling(): UseWalletPollingReturn {
// 刷新钱包余额 // 刷新钱包余额
const refreshWallet = useCallback(async () => { const refreshWallet = useCallback(async () => {
try { try {
await getWalletBalance(); await getWalletBalance({ currency: getActivePlayerCurrencyFromStore() });
// 触发全局刷新事件,让所有监听组件更新 // 触发全局刷新事件,让所有监听组件更新
window.dispatchEvent(new Event("lottery-wallet-refresh")); window.dispatchEvent(new Event("lottery-wallet-refresh"));
} catch { } catch {
@@ -144,7 +145,7 @@ export function triggerWalletPollingAfterBet(): void {
// 如果是降级模式,立即刷新并启动限时轮询 // 如果是降级模式,立即刷新并启动限时轮询
if (store.mode === "polling" || store.mode === "offline") { if (store.mode === "polling" || store.mode === "offline") {
// 立即刷新一次 // 立即刷新一次
void getWalletBalance().then(() => { void getWalletBalance({ currency: getActivePlayerCurrencyFromStore() }).then(() => {
window.dispatchEvent(new Event("lottery-wallet-refresh")); window.dispatchEvent(new Event("lottery-wallet-refresh"));
}); });
@@ -161,7 +162,7 @@ export function triggerWalletPollingAfterBet(): void {
store.setWalletPollingIntervalId(null); store.setWalletPollingIntervalId(null);
return; return;
} }
void getWalletBalance().then(() => { void getWalletBalance({ currency: getActivePlayerCurrencyFromStore() }).then(() => {
window.dispatchEvent(new Event("lottery-wallet-refresh")); window.dispatchEvent(new Event("lottery-wallet-refresh"));
}); });
}, POLLING_INTERVAL_MS); }, POLLING_INTERVAL_MS);

View File

@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef } from "react";
import { getDrawCurrent } from "@/api/draw"; import { getDrawCurrent } from "@/api/draw";
import { getWalletBalance } from "@/api/wallet"; import { getWalletBalance } from "@/api/wallet";
import { getActivePlayerCurrencyFromStore } from "@/lib/player-currency";
import { getLotteryEcho } from "@/lib/lottery-echo"; import { getLotteryEcho } from "@/lib/lottery-echo";
import { import {
useNetworkConnectionStore, useNetworkConnectionStore,
@@ -72,7 +73,7 @@ export function useWebSocketManager(): UseWebSocketManagerReturn {
// 刷新钱包余额 // 刷新钱包余额
const refreshWallet = useCallback(async () => { const refreshWallet = useCallback(async () => {
try { try {
await getWalletBalance(); await getWalletBalance({ currency: getActivePlayerCurrencyFromStore() });
// 触发全局刷新事件,让组件更新 // 触发全局刷新事件,让组件更新
window.dispatchEvent(new Event("lottery-wallet-refresh")); window.dispatchEvent(new Event("lottery-wallet-refresh"));
} catch { } catch {

View File

@@ -15,6 +15,11 @@
"pending": "Pending", "pending": "Pending",
"failed": "Failed" "failed": "Failed"
}, },
"currency": {
"current": "Currency {{code}}",
"switchAria": "Switch currency (current {{code}})",
"option": "{{code}} · {{name}}"
},
"navigation": { "navigation": {
"notifications": "Notifications" "notifications": "Notifications"
}, },

View File

@@ -117,6 +117,7 @@
"title": "Jackpot Burst", "title": "Jackpot Burst",
"subtitle": "Issue {{drawNo}} triggered a pool payout", "subtitle": "Issue {{drawNo}} triggered a pool payout",
"number": "First prize number", "number": "First prize number",
"numberPending": "First prize numbers are not published for this issue yet",
"amount": "Burst amount", "amount": "Burst amount",
"winners": "Winners", "winners": "Winners",
"triggerLabel": "Trigger", "triggerLabel": "Trigger",
@@ -548,6 +549,7 @@
}, },
"ticketStatus": { "ticketStatus": {
"success": "Awaiting draw", "success": "Awaiting draw",
"pending_draw": "Awaiting draw",
"pending_payout": "Won, pending payout", "pending_payout": "Won, pending payout",
"settled_win": "Paid", "settled_win": "Paid",
"settled_lose": "Not won", "settled_lose": "Not won",

View File

@@ -15,6 +15,11 @@
"pending": "बाँकी", "pending": "बाँकी",
"failed": "असफल" "failed": "असफल"
}, },
"currency": {
"current": "मुद्रा {{code}}",
"switchAria": "मुद्रा बदल्नुहोस् (हाल {{code}})",
"option": "{{code}} · {{name}}"
},
"navigation": { "navigation": {
"notifications": "सूचनाहरू" "notifications": "सूचनाहरू"
}, },

View File

@@ -117,6 +117,7 @@
"title": "Jackpot Burst", "title": "Jackpot Burst",
"subtitle": "इश्यू {{drawNo}} मा पूल payout ट्रिगर भयो", "subtitle": "इश्यू {{drawNo}} मा पूल payout ट्रिगर भयो",
"number": "पहिलो पुरस्कार नम्बर", "number": "पहिलो पुरस्कार नम्बर",
"numberPending": "यो इश्यूको लागि पहिलो पुरस्कार नम्बर अझै प्रकाशित भएको छैन",
"amount": "Burst रकम", "amount": "Burst रकम",
"winners": "विजेता", "winners": "विजेता",
"triggerLabel": "ट्रिगर", "triggerLabel": "ट्रिगर",
@@ -543,6 +544,7 @@
}, },
"ticketStatus": { "ticketStatus": {
"success": "ड्र पर्खँदै", "success": "ड्र पर्खँदै",
"pending_draw": "ड्र पर्खँदै",
"pending_payout": "जितेको, भुक्तानी बाँकी", "pending_payout": "जितेको, भुक्तानी बाँकी",
"settled_win": "भुक्तानी भयो", "settled_win": "भुक्तानी भयो",
"settled_lose": "जितेन", "settled_lose": "जितेन",

View File

@@ -15,6 +15,11 @@
"pending": "待处理", "pending": "待处理",
"failed": "失败" "failed": "失败"
}, },
"currency": {
"current": "当前币种 {{code}}",
"switchAria": "切换币种(当前 {{code}}",
"option": "{{code}} · {{name}}"
},
"navigation": { "navigation": {
"notifications": "通知" "notifications": "通知"
}, },

View File

@@ -117,6 +117,7 @@
"title": "Jackpot 爆池", "title": "Jackpot 爆池",
"subtitle": "期号 {{drawNo}} 触发奖池派发", "subtitle": "期号 {{drawNo}} 触发奖池派发",
"number": "头奖号码", "number": "头奖号码",
"numberPending": "该期尚未公布头奖号码",
"amount": "爆池金额", "amount": "爆池金额",
"winners": "中奖人数", "winners": "中奖人数",
"triggerLabel": "触发方式", "triggerLabel": "触发方式",
@@ -548,6 +549,7 @@
}, },
"ticketStatus": { "ticketStatus": {
"success": "待开奖", "success": "待开奖",
"pending_draw": "待开奖",
"pending_payout": "已中奖待派彩", "pending_payout": "已中奖待派彩",
"settled_win": "已派彩", "settled_win": "已派彩",
"settled_lose": "未中奖", "settled_lose": "未中奖",

View File

@@ -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<PlayerMeData, "default_currency"> | 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);
}

View File

@@ -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() },
}),
);
}

View File

@@ -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 { PlayerMeData } from "@/types/api/player-me";
import type { WalletBalanceData } from "@/types/api/wallet-balance"; import type { WalletBalanceData } from "@/types/api/wallet-balance";
@@ -5,8 +7,7 @@ type CurrencyProfile = Pick<PlayerMeData, "default_currency"> | null | undefined
type CurrencyBalance = Pick<WalletBalanceData, "currency_code"> | null | undefined; type CurrencyBalance = Pick<WalletBalanceData, "currency_code"> | null | undefined;
/** /**
* 玩家端当前业务币种: * 玩家档案/环境变量层面的默认币种(不含用户在大厅的切换选择)。
* 优先钱包接口返回币种,再回退玩家默认币种,环境变量仅作联调兜底。
*/ */
export function resolvePlayerCurrency( export function resolvePlayerCurrency(
profile: CurrencyProfile, profile: CurrencyProfile,
@@ -29,3 +30,15 @@ export function resolvePlayerCurrency(
return "NPR"; return "NPR";
} }
/**
* 当前业务币种(含玩家在大厅切换的可下注币种)。
* 可在非 React 上下文轮询、WS 刷新)中调用。
*/
export function getActivePlayerCurrencyFromStore(): string {
const { selectedCurrency, profile, currencies } = usePlayerSessionStore.getState();
if (selectedCurrency) {
return selectedCurrency;
}
return pickActivePlayerCurrency(profile, currencies, null);
}

View File

@@ -6,6 +6,12 @@ import {
persistPlayerBearerToken, persistPlayerBearerToken,
readPersistedPlayerBearerToken, readPersistedPlayerBearerToken,
} from "@/lib/player-session"; } 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 { PublicCurrencyRow } from "@/types/api/currency";
import type { PlayerMeData } from "@/types/api/player-me"; import type { PlayerMeData } from "@/types/api/player-me";
@@ -23,6 +29,8 @@ type PlayerSessionState = {
bearerToken: string | null; bearerToken: string | null;
profile: PlayerMeData | null; profile: PlayerMeData | null;
currencies: PublicCurrencyRow[]; currencies: PublicCurrencyRow[];
/** 玩家在大厅选择的可下注币种localStorage 持久化) */
selectedCurrency: string | null;
phase: PlayerEntryPhase; phase: PlayerEntryPhase;
progress: number; progress: number;
errorMessage: string | null; errorMessage: string | null;
@@ -32,6 +40,9 @@ type PlayerSessionState = {
clearBearerToken: () => void; clearBearerToken: () => void;
setProfile: (profile: PlayerMeData | null) => void; setProfile: (profile: PlayerMeData | null) => void;
setCurrencies: (currencies: PublicCurrencyRow[]) => void; setCurrencies: (currencies: PublicCurrencyRow[]) => void;
/** 根据档案与币种目录校准当前选中币种 */
reconcileSelectedCurrency: () => void;
setSelectedCurrency: (code: string) => void;
setPhase: (phase: PlayerEntryPhase) => void; setPhase: (phase: PlayerEntryPhase) => void;
setProgress: (progress: number) => void; setProgress: (progress: number) => void;
setErrorMessage: (message: string | null) => void; setErrorMessage: (message: string | null) => void;
@@ -47,10 +58,11 @@ function initialSteps(): PlayerEntryStep[] {
]; ];
} }
export const usePlayerSessionStore = create<PlayerSessionState>((set) => ({ export const usePlayerSessionStore = create<PlayerSessionState>((set, get) => ({
bearerToken: null, bearerToken: null,
profile: null, profile: null,
currencies: [], currencies: [],
selectedCurrency: null,
phase: "loading", phase: "loading",
progress: 0, progress: 0,
errorMessage: null, errorMessage: null,
@@ -83,11 +95,46 @@ export const usePlayerSessionStore = create<PlayerSessionState>((set) => ({
clearBearerToken: () => { clearBearerToken: () => {
setPlayerBearerToken(null); setPlayerBearerToken(null);
clearPersistedPlayerBearerToken(); clearPersistedPlayerBearerToken();
set({ bearerToken: null, profile: null }); set({ bearerToken: null, profile: null, selectedCurrency: null });
}, },
setProfile: (profile) => set({ profile }), setProfile: (profile) => {
setCurrencies: (currencies) => set({ currencies }), 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 }), setPhase: (phase) => set({ phase }),
setProgress: (progress) => set({ progress: Math.max(0, Math.min(100, progress)) }), setProgress: (progress) => set({ progress: Math.max(0, Math.min(100, progress)) }),
setErrorMessage: (errorMessage) => set({ errorMessage }), setErrorMessage: (errorMessage) => set({ errorMessage }),

View File

@@ -33,9 +33,7 @@ export type PlayEffectivePlayRow = {
category: string; category: string;
dimension: number | null; dimension: number | null;
bet_mode: string | null; bet_mode: string | null;
display_name_zh: string | null; display_name: string | null;
display_name_en: string | null;
display_name_ne: string | null;
sort_order: number; sort_order: number;
supports_multi_number: boolean; supports_multi_number: boolean;
master_enabled: boolean; master_enabled: boolean;