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:
@@ -156,3 +156,124 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
151
src/components/currency-switcher.tsx
Normal file
151
src/components/currency-switcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
</div>
|
||||
|
||||
<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
|
||||
variant="minimal"
|
||||
menuAlign="end"
|
||||
|
||||
@@ -33,7 +33,8 @@ export function HallBetResultDialog({
|
||||
}: HallBetResultDialogProps) {
|
||||
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 totalSuccess = data?.summary.success_count ?? successItems.length;
|
||||
const totalFailure = data?.summary.failure_count ?? failedItems.length;
|
||||
|
||||
@@ -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<HallCategory, { favorites: string[]; history: string[] }>;
|
||||
|
||||
@@ -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<HallCategory, "JACKPOT">): number[] {
|
||||
@@ -367,13 +369,19 @@ function cellRiskState(
|
||||
rowNumber: string,
|
||||
category: Exclude<HallCategory, "JACKPOT">,
|
||||
alertRows: DrawCurrentRiskPoolAlert[] | undefined,
|
||||
liveSoldOutNumbers: ReadonlySet<string>,
|
||||
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<HallCategory>("D2");
|
||||
const [rows, setRows] = useState<DraftRow[]>(() => [newDraftRow()]);
|
||||
@@ -411,16 +419,13 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
const [resultOpen, setResultOpen] = useState(false);
|
||||
const [resultData, setResultData] = useState<TicketPlaceData | null>(null);
|
||||
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 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<HallCategory, "JACKPOT">,
|
||||
alertRows,
|
||||
liveSoldOutNumbers,
|
||||
column.digitSlot,
|
||||
);
|
||||
const disabled = tableDisabled || status === "sold_out" || (play.config !== null && !play.config.is_enabled);
|
||||
|
||||
@@ -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<LoadState>({ 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 (
|
||||
|
||||
@@ -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() {
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
variant="minimal"
|
||||
menuAlign="end"
|
||||
@@ -75,7 +87,7 @@ export function HallScreen() {
|
||||
|
||||
<HallWalletStrip />
|
||||
|
||||
<HallBettingGrid drawLive={drawLive} />
|
||||
<HallBettingGrid key={activeCurrency} drawLive={drawLive} />
|
||||
</section>
|
||||
<JackpotBurstOverlay event={burstEvent} onClose={clearBurstEvent} />
|
||||
</div>
|
||||
|
||||
@@ -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<WalletBalanceData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const degradedWalletPollRef = useRef<number | null>(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(() => {
|
||||
|
||||
@@ -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<typeof useTranslation>["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<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) {
|
||||
const { t } = useTranslation("player");
|
||||
|
||||
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 amount = formatMinorAsCurrency(event.total_payout_amount, currency);
|
||||
|
||||
return (
|
||||
<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"
|
||||
aria-modal="true"
|
||||
aria-label={t("hall.jackpotBurst.title")}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<div className="absolute left-0 top-1/4 h-px w-full animate-[pulse_1.8s_ease-in-out_infinite] bg-[#f5c542]" />
|
||||
<div className="absolute bottom-1/3 left-0 h-px w-full animate-[pulse_2.4s_ease-in-out_infinite] bg-[#2dd4bf]" />
|
||||
</div>
|
||||
<div
|
||||
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%)]"
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
<BurstParticles />
|
||||
|
||||
<div className="mb-4 flex size-14 items-center justify-center rounded-full border border-[#f5c542] bg-[#f5c542]/15 text-[#f5c542]">
|
||||
<Zap className="size-7" aria-hidden />
|
||||
<div
|
||||
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>
|
||||
|
||||
<p className="text-xs font-bold uppercase tracking-[0.18em] text-[#f5c542]">
|
||||
{t("hall.jackpotBurst.title")}
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-semibold text-slate-200">
|
||||
{t("hall.jackpotBurst.subtitle", {
|
||||
drawNo: event.draw_no,
|
||||
})}
|
||||
</p>
|
||||
<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)]">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-2 top-2 z-10 text-slate-500 hover:bg-slate-100 hover:text-slate-800"
|
||||
onClick={onClose}
|
||||
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="text-[2.35rem] font-black leading-none text-[#f5c542] sm:text-[2.8rem]">
|
||||
{event.first_prize_number || "----"}
|
||||
<div className="relative mb-3">
|
||||
<div
|
||||
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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-2 text-left text-sm">
|
||||
<div className="flex items-center justify-between gap-3 rounded-md bg-white/[0.05] px-3 py-2">
|
||||
<span className="text-slate-300">
|
||||
{t("hall.jackpotBurst.amount")}
|
||||
</span>
|
||||
<span className="text-right font-black text-[#f5c542]">{amount}</span>
|
||||
<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="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-[#e8c96a]/70 to-transparent"
|
||||
aria-hidden
|
||||
/>
|
||||
<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 className="flex items-center justify-between gap-3 rounded-md bg-white/[0.05] px-3 py-2">
|
||||
<span className="text-slate-300">
|
||||
{t("hall.jackpotBurst.winners")}
|
||||
</span>
|
||||
<span className="font-bold">{event.winner_count}</span>
|
||||
</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">
|
||||
{t("hall.jackpotBurst.triggerLabel")}
|
||||
</span>
|
||||
<span className="font-bold">{triggerLabel(event.trigger_type, t)}</span>
|
||||
|
||||
<div className="w-full space-y-2 text-left text-sm">
|
||||
<div
|
||||
className={cn(
|
||||
"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",
|
||||
allRevealed &&
|
||||
"animate-[jackpot-amount-row-glow_2s_ease-in-out_infinite]",
|
||||
)}
|
||||
>
|
||||
<span className="text-slate-600">{t("hall.jackpotBurst.amount")}</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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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 };
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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<TicketItemDetailPayload | 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 totalWin = data.win_amount + data.jackpot_win_amount;
|
||||
const pub = data.published_draw_results;
|
||||
|
||||
@@ -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() {
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
{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 (
|
||||
|
||||
@@ -18,6 +18,7 @@ export function HydratePlayerAuth(): null {
|
||||
const setCurrencies = usePlayerSessionStore((state) => state.setCurrencies);
|
||||
|
||||
useEffect(() => {
|
||||
usePlayerSessionStore.getState().reconcileSelectedCurrency();
|
||||
const token = restoreBearerToken();
|
||||
void (async () => {
|
||||
try {
|
||||
|
||||
@@ -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 }) {
|
||||
<p className="truncate text-xs font-medium text-foreground">
|
||||
{label ?? "…"}
|
||||
</p>
|
||||
{profile?.default_currency ? (
|
||||
{activeCurrency ? (
|
||||
<p className="truncate text-[10px] text-muted-foreground">
|
||||
{profile.default_currency.toUpperCase()}
|
||||
{profile.locale ? (
|
||||
{activeCurrency}
|
||||
{profile?.locale ? (
|
||||
<span className="text-muted-foreground/80">
|
||||
{" "}
|
||||
· {profile.locale}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<DrawResultDetailPayload | 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 =
|
||||
myTotals &&
|
||||
myTotals.hasBets &&
|
||||
|
||||
@@ -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<DrawResultListItem[] | 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 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);
|
||||
|
||||
@@ -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<WalletBalanceData | null>(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");
|
||||
|
||||
@@ -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<WalletBalanceData | null>(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");
|
||||
|
||||
@@ -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<WalletLogsData | null>(null);
|
||||
const [filter, setFilter] = useState("");
|
||||
@@ -25,10 +25,19 @@ export function WalletLogsScreen() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const loadMoreRef = useRef<HTMLDivElement | null>(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}
|
||||
|
||||
<WalletLogsBlock
|
||||
logs={logs}
|
||||
logs={logsForCurrency}
|
||||
logsLoading={loading || logsLoading}
|
||||
loadingMore={loadingMore}
|
||||
hasMore={hasMore}
|
||||
|
||||
@@ -14,17 +14,17 @@ import {
|
||||
TransferOutDialog,
|
||||
} from "@/features/wallet/wallet-transfer-dialogs";
|
||||
import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block";
|
||||
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 { formatWalletClientError } from "@/lib/wallet-api-error";
|
||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
import type { WalletBalanceData } from "@/types/api/wallet-balance";
|
||||
import { getWalletLogsLastPage, type WalletLogsData } from "@/types/api/wallet-logs";
|
||||
|
||||
const WALLET_LOGS_PAGE_SIZE = 10;
|
||||
|
||||
export function WalletScreen() {
|
||||
const profile = usePlayerSessionStore((s) => s.profile);
|
||||
const { activeCurrency: currency } = useActivePlayerCurrency();
|
||||
const { t } = useTranslation("player");
|
||||
const [balance, setBalance] = useState<WalletBalanceData | null>(null);
|
||||
const [logs, setLogs] = useState<WalletLogsData | null>(null);
|
||||
@@ -35,11 +35,6 @@ export function WalletScreen() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const loadMoreRef = useRef<HTMLDivElement | null>(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() {
|
||||
</div>
|
||||
|
||||
<WalletLogsBlock
|
||||
logs={logs}
|
||||
logs={logsForCurrency}
|
||||
logsLoading={loading || logsLoading}
|
||||
loadingMore={loadingMore}
|
||||
hasMore={hasMore}
|
||||
|
||||
39
src/hooks/use-active-player-currency.ts
Normal file
39
src/hooks/use-active-player-currency.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -15,6 +15,11 @@
|
||||
"pending": "Pending",
|
||||
"failed": "Failed"
|
||||
},
|
||||
"currency": {
|
||||
"current": "Currency {{code}}",
|
||||
"switchAria": "Switch currency (current {{code}})",
|
||||
"option": "{{code}} · {{name}}"
|
||||
},
|
||||
"navigation": {
|
||||
"notifications": "Notifications"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -15,6 +15,11 @@
|
||||
"pending": "बाँकी",
|
||||
"failed": "असफल"
|
||||
},
|
||||
"currency": {
|
||||
"current": "मुद्रा {{code}}",
|
||||
"switchAria": "मुद्रा बदल्नुहोस् (हाल {{code}})",
|
||||
"option": "{{code}} · {{name}}"
|
||||
},
|
||||
"navigation": {
|
||||
"notifications": "सूचनाहरू"
|
||||
},
|
||||
|
||||
@@ -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": "जितेन",
|
||||
|
||||
@@ -15,6 +15,11 @@
|
||||
"pending": "待处理",
|
||||
"failed": "失败"
|
||||
},
|
||||
"currency": {
|
||||
"current": "当前币种 {{code}}",
|
||||
"switchAria": "切换币种(当前 {{code}})",
|
||||
"option": "{{code}} · {{name}}"
|
||||
},
|
||||
"navigation": {
|
||||
"notifications": "通知"
|
||||
},
|
||||
|
||||
@@ -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": "未中奖",
|
||||
|
||||
41
src/lib/player-currency-options.ts
Normal file
41
src/lib/player-currency-options.ts
Normal 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);
|
||||
}
|
||||
38
src/lib/player-currency-preference.ts
Normal file
38
src/lib/player-currency-preference.ts
Normal 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() },
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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<PlayerMeData, "default_currency"> | null | undefined
|
||||
type CurrencyBalance = Pick<WalletBalanceData, "currency_code"> | 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);
|
||||
}
|
||||
|
||||
@@ -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<PlayerSessionState>((set) => ({
|
||||
export const usePlayerSessionStore = create<PlayerSessionState>((set, get) => ({
|
||||
bearerToken: null,
|
||||
profile: null,
|
||||
currencies: [],
|
||||
selectedCurrency: null,
|
||||
phase: "loading",
|
||||
progress: 0,
|
||||
errorMessage: null,
|
||||
@@ -83,11 +95,46 @@ export const usePlayerSessionStore = create<PlayerSessionState>((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 }),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user