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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user