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:
@@ -155,4 +155,125 @@
|
|||||||
[data-sonner-toast][data-styled="true"] [data-icon] svg {
|
[data-sonner-toast][data-styled="true"] [data-icon] svg {
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Jackpot 爆池全屏动画 */
|
||||||
|
@keyframes jackpot-backdrop-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes jackpot-card-in {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.72) translateY(24px);
|
||||||
|
}
|
||||||
|
55% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1.04) translateY(-4px);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes jackpot-flash {
|
||||||
|
0% {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes jackpot-shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: 200% center;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -200% center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes jackpot-lightning {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
filter: drop-shadow(0 0 6px rgba(245, 197, 66, 0.5));
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
filter: drop-shadow(0 0 18px rgba(255, 220, 100, 0.95));
|
||||||
|
transform: scale(1.12);
|
||||||
|
}
|
||||||
|
55% {
|
||||||
|
filter: drop-shadow(0 0 8px rgba(245, 197, 66, 0.6));
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes jackpot-ring-pulse {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.85);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1.35);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes jackpot-particle {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(0) scale(0.4);
|
||||||
|
}
|
||||||
|
15% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-120px) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes jackpot-digit-pop {
|
||||||
|
0% {
|
||||||
|
transform: scale(1.35);
|
||||||
|
filter: brightness(1.8);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
filter: brightness(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes jackpot-amount-glow {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
text-shadow: 0 0 8px rgba(201, 162, 39, 0.25);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
text-shadow: 0 0 16px rgba(201, 162, 39, 0.55);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes jackpot-amount-row-glow {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 rgba(245, 197, 66, 0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 20px rgba(245, 197, 66, 0.22);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes jackpot-border-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
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 { Bell, ChevronLeft } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { CurrencySwitcher } from "@/components/currency-switcher";
|
||||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
import {
|
import {
|
||||||
playerHeaderControl,
|
playerHeaderControl,
|
||||||
@@ -70,6 +71,15 @@ export function PlayerPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex min-w-0 items-center justify-end gap-1">
|
<div className="flex min-w-0 items-center justify-end gap-1">
|
||||||
|
<CurrencySwitcher
|
||||||
|
variant="minimal"
|
||||||
|
menuAlign="end"
|
||||||
|
showLabel
|
||||||
|
className={cn(
|
||||||
|
playerHeaderControl,
|
||||||
|
"rounded-full border border-[#e4eaf4] bg-[#f8fafc] [&_button]:h-8 [&_button]:gap-1 [&_button]:px-2 [&_button]:py-0 [&_button]:text-xs [&_button]:font-bold [&_button]:text-[#0b3f96]",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<LanguageSwitcher
|
<LanguageSwitcher
|
||||||
variant="minimal"
|
variant="minimal"
|
||||||
menuAlign="end"
|
menuAlign="end"
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ export function HallBetResultDialog({
|
|||||||
}: HallBetResultDialogProps) {
|
}: HallBetResultDialogProps) {
|
||||||
const { t } = useTranslation("player");
|
const { t } = useTranslation("player");
|
||||||
|
|
||||||
const successItems = data?.items.filter((item) => (item.status ?? "success") === "success") ?? [];
|
// 成功注项状态为 pending_draw(待开奖),不是 success
|
||||||
|
const successItems = data?.items.filter((item) => item.status !== "failed") ?? [];
|
||||||
const failedItems = data?.items.filter((item) => item.status === "failed") ?? [];
|
const failedItems = data?.items.filter((item) => item.status === "failed") ?? [];
|
||||||
const totalSuccess = data?.summary.success_count ?? successItems.length;
|
const totalSuccess = data?.summary.success_count ?? successItems.length;
|
||||||
const totalFailure = data?.summary.failure_count ?? failedItems.length;
|
const totalFailure = data?.summary.failure_count ?? failedItems.length;
|
||||||
|
|||||||
@@ -21,14 +21,13 @@ import {
|
|||||||
ticketNumberSpec,
|
ticketNumberSpec,
|
||||||
} from "@/features/hall/hall-bet-rules";
|
} from "@/features/hall/hall-bet-rules";
|
||||||
import type { HallDrawLiveSnapshot } from "@/features/hall/use-hall-draw-live";
|
import type { HallDrawLiveSnapshot } from "@/features/hall/use-hall-draw-live";
|
||||||
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||||
import { triggerWalletPollingAfterBet } from "@/hooks/use-wallet-polling";
|
import { triggerWalletPollingAfterBet } from "@/hooks/use-wallet-polling";
|
||||||
import { getLotteryEcho } from "@/lib/lottery-echo";
|
import { getLotteryEcho } from "@/lib/lottery-echo";
|
||||||
import { getLotteryRequestLocale } from "@/lib/lottery-locale";
|
import { getLotteryRequestLocale } from "@/lib/lottery-locale";
|
||||||
import { formatMinorAsCurrency, parseDecimalInputToMinor } from "@/lib/money";
|
import { formatMinorAsCurrency, parseDecimalInputToMinor } from "@/lib/money";
|
||||||
import { resolvePlayerCurrency } from "@/lib/player-currency";
|
import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
import type { PlayEffectivePayload, PlayEffectivePlayRow } from "@/types/api/play-effective";
|
import type { PlayEffectivePayload, PlayEffectivePlayRow } from "@/types/api/play-effective";
|
||||||
import type { TicketLineInput, TicketPlaceData, TicketPreviewData } from "@/types/api/ticket";
|
import type { TicketLineInput, TicketPlaceData, TicketPreviewData } from "@/types/api/ticket";
|
||||||
@@ -77,6 +76,12 @@ type OddsUpdateWsEvent = {
|
|||||||
message?: string;
|
message?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RiskSoldOutWsEvent = {
|
||||||
|
draw_id?: number;
|
||||||
|
draw_no?: string;
|
||||||
|
normalized_number?: string;
|
||||||
|
};
|
||||||
|
|
||||||
type CellRiskState = "open" | "warning" | "sold_out";
|
type CellRiskState = "open" | "warning" | "sold_out";
|
||||||
type QuickFillState = Record<HallCategory, { favorites: string[]; history: string[] }>;
|
type QuickFillState = Record<HallCategory, { favorites: string[]; history: string[] }>;
|
||||||
|
|
||||||
@@ -128,10 +133,7 @@ function isPlayOpenForPlayer(row: PlayEffectivePlayRow): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pickDisplayName(row: PlayEffectivePlayRow): string {
|
function pickDisplayName(row: PlayEffectivePlayRow): string {
|
||||||
const loc = getLotteryRequestLocale();
|
return row.display_name?.trim() || row.play_code;
|
||||||
if (loc === "zh") return row.display_name_zh ?? row.display_name_en ?? row.play_code;
|
|
||||||
if (loc === "ne") return row.display_name_ne ?? row.display_name_en ?? row.play_code;
|
|
||||||
return row.display_name_en ?? row.display_name_zh ?? row.play_code;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function digitSlotOptions(category: Exclude<HallCategory, "JACKPOT">): number[] {
|
function digitSlotOptions(category: Exclude<HallCategory, "JACKPOT">): number[] {
|
||||||
@@ -367,13 +369,19 @@ function cellRiskState(
|
|||||||
rowNumber: string,
|
rowNumber: string,
|
||||||
category: Exclude<HallCategory, "JACKPOT">,
|
category: Exclude<HallCategory, "JACKPOT">,
|
||||||
alertRows: DrawCurrentRiskPoolAlert[] | undefined,
|
alertRows: DrawCurrentRiskPoolAlert[] | undefined,
|
||||||
|
liveSoldOutNumbers: ReadonlySet<string>,
|
||||||
digitSlot?: number,
|
digitSlot?: number,
|
||||||
): CellRiskState {
|
): CellRiskState {
|
||||||
const alerts = alertRows ?? [];
|
|
||||||
if (alerts.length === 0) return "open";
|
|
||||||
const normalizedRow = rowNumber.trim().toUpperCase();
|
const normalizedRow = rowNumber.trim().toUpperCase();
|
||||||
if (!normalizedRow) return "open";
|
if (!normalizedRow) return "open";
|
||||||
|
|
||||||
|
if (liveSoldOutNumbers.has(normalizedRow)) {
|
||||||
|
return "sold_out";
|
||||||
|
}
|
||||||
|
|
||||||
|
const alerts = alertRows ?? [];
|
||||||
|
if (alerts.length === 0) return "open";
|
||||||
|
|
||||||
for (const alert of alerts) {
|
for (const alert of alerts) {
|
||||||
if (matchesRiskAlert(alert.normalized_number, play.play_code, normalizedRow, category, digitSlot)) {
|
if (matchesRiskAlert(alert.normalized_number, play.play_code, normalizedRow, category, digitSlot)) {
|
||||||
return alert.is_sold_out ? "sold_out" : "warning";
|
return alert.is_sold_out ? "sold_out" : "warning";
|
||||||
@@ -393,7 +401,7 @@ function quickFillKeys(category: HallCategory): { favorites: string; history: st
|
|||||||
export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }) {
|
export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }) {
|
||||||
const { display, isBettable, reload: reloadDraw } = drawLive;
|
const { display, isBettable, reload: reloadDraw } = drawLive;
|
||||||
const { t } = useTranslation("player");
|
const { t } = useTranslation("player");
|
||||||
const profile = usePlayerSessionStore((s) => s.profile);
|
const { activeCurrency: currencyParam } = useActivePlayerCurrency();
|
||||||
|
|
||||||
const [activeCategory, setActiveCategory] = useState<HallCategory>("D2");
|
const [activeCategory, setActiveCategory] = useState<HallCategory>("D2");
|
||||||
const [rows, setRows] = useState<DraftRow[]>(() => [newDraftRow()]);
|
const [rows, setRows] = useState<DraftRow[]>(() => [newDraftRow()]);
|
||||||
@@ -411,16 +419,13 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
const [resultOpen, setResultOpen] = useState(false);
|
const [resultOpen, setResultOpen] = useState(false);
|
||||||
const [resultData, setResultData] = useState<TicketPlaceData | null>(null);
|
const [resultData, setResultData] = useState<TicketPlaceData | null>(null);
|
||||||
const [quickFillState, setQuickFillState] = useState<QuickFillState>(() => loadQuickFillState());
|
const [quickFillState, setQuickFillState] = useState<QuickFillState>(() => loadQuickFillState());
|
||||||
|
const [liveSoldOutNumbers, setLiveSoldOutNumbers] = useState<Set<string>>(() => new Set());
|
||||||
const [debouncedSummary, setDebouncedSummary] = useState({ bet: 0, rebate: 0, actual: 0 });
|
const [debouncedSummary, setDebouncedSummary] = useState({ bet: 0, rebate: 0, actual: 0 });
|
||||||
const holdFavoriteRef = useRef<{ timer: number | null; number: string | null; longPress: boolean }>({
|
const holdFavoriteRef = useRef<{ timer: number | null; number: string | null; longPress: boolean }>({
|
||||||
timer: null,
|
timer: null,
|
||||||
number: null,
|
number: null,
|
||||||
longPress: false,
|
longPress: false,
|
||||||
});
|
});
|
||||||
useCurrencyCatalog();
|
|
||||||
|
|
||||||
const currencyParam = useMemo(() => resolvePlayerCurrency(profile), [profile]);
|
|
||||||
|
|
||||||
const loadCatalog = useCallback(async () => {
|
const loadCatalog = useCallback(async () => {
|
||||||
setCatalogState((s) => (s.kind === "ok" ? s : { kind: "loading" }));
|
setCatalogState((s) => (s.kind === "ok" ? s : { kind: "loading" }));
|
||||||
try {
|
try {
|
||||||
@@ -448,6 +453,15 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
});
|
});
|
||||||
}, [loadCatalog, refreshWallet]);
|
}, [loadCatalog, refreshWallet]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onCurrencyChange = () => {
|
||||||
|
void loadCatalog();
|
||||||
|
void refreshWallet();
|
||||||
|
};
|
||||||
|
window.addEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange);
|
||||||
|
return () => window.removeEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange);
|
||||||
|
}, [loadCatalog, refreshWallet]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = window.setInterval(() => {
|
const id = window.setInterval(() => {
|
||||||
void loadCatalog();
|
void loadCatalog();
|
||||||
@@ -490,6 +504,12 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
[activeRowId, rows],
|
[activeRowId, rows],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const drawNo = display?.draw_no ?? null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLiveSoldOutNumbers(new Set());
|
||||||
|
}, [drawNo]);
|
||||||
|
|
||||||
const alertRows = display?.risk_pool_alerts ?? [];
|
const alertRows = display?.risk_pool_alerts ?? [];
|
||||||
const jackpot = display?.jackpot;
|
const jackpot = display?.jackpot;
|
||||||
const currentQuickFill = quickFillState[activeCategory] ?? { favorites: [], history: [] };
|
const currentQuickFill = quickFillState[activeCategory] ?? { favorites: [], history: [] };
|
||||||
@@ -657,14 +677,30 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
toast.message(evt.message ?? t("hall.playConfig.oddsUpdated"));
|
toast.message(evt.message ?? t("hall.playConfig.oddsUpdated"));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onRiskSoldOut = (evt: RiskSoldOutWsEvent) => {
|
||||||
|
const normalized = evt.normalized_number?.trim().toUpperCase();
|
||||||
|
if (!normalized) return;
|
||||||
|
if (drawNo !== null && evt.draw_no !== undefined && evt.draw_no !== drawNo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLiveSoldOutNumbers((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(normalized);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
void reloadDraw();
|
||||||
|
};
|
||||||
|
|
||||||
channel.listen(".play.toggle", onPlayToggle);
|
channel.listen(".play.toggle", onPlayToggle);
|
||||||
channel.listen(".odds.update", onOddsUpdate);
|
channel.listen(".odds.update", onOddsUpdate);
|
||||||
|
channel.listen(".risk.sold_out", onRiskSoldOut);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
channel.stopListening(".play.toggle");
|
channel.stopListening(".play.toggle");
|
||||||
channel.stopListening(".odds.update");
|
channel.stopListening(".odds.update");
|
||||||
|
channel.stopListening(".risk.sold_out");
|
||||||
};
|
};
|
||||||
}, [clearAmountsForPlay, loadCatalog, t]);
|
}, [clearAmountsForPlay, drawNo, loadCatalog, reloadDraw, t]);
|
||||||
|
|
||||||
const collectEntries = useCallback((): DraftEntry[] => {
|
const collectEntries = useCallback((): DraftEntry[] => {
|
||||||
if (activeCategory === "JACKPOT") return [];
|
if (activeCategory === "JACKPOT") return [];
|
||||||
@@ -1163,6 +1199,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
row.number,
|
row.number,
|
||||||
activeCategory as Exclude<HallCategory, "JACKPOT">,
|
activeCategory as Exclude<HallCategory, "JACKPOT">,
|
||||||
alertRows,
|
alertRows,
|
||||||
|
liveSoldOutNumbers,
|
||||||
column.digitSlot,
|
column.digitSlot,
|
||||||
);
|
);
|
||||||
const disabled = tableDisabled || status === "sold_out" || (play.config !== null && !play.config.is_enabled);
|
const disabled = tableDisabled || status === "sold_out" || (play.config !== null && !play.config.is_enabled);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { getPlayEffective } from "@/api/play";
|
import { getPlayEffective } from "@/api/play";
|
||||||
@@ -21,10 +21,10 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
|
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||||
import { getLotteryRequestLocale } from "@/lib/lottery-locale";
|
import { getLotteryRequestLocale } from "@/lib/lottery-locale";
|
||||||
import { resolvePlayerCurrency } from "@/lib/player-currency";
|
import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
import type {
|
import type {
|
||||||
PlayEffectivePayload,
|
PlayEffectivePayload,
|
||||||
@@ -34,14 +34,7 @@ import type {
|
|||||||
const DEFAULT_POLL_MS = 120_000;
|
const DEFAULT_POLL_MS = 120_000;
|
||||||
|
|
||||||
function pickDisplayName(row: PlayEffectivePlayRow): string {
|
function pickDisplayName(row: PlayEffectivePlayRow): string {
|
||||||
const loc = getLotteryRequestLocale();
|
return row.display_name?.trim() || row.play_code;
|
||||||
if (loc === "zh") {
|
|
||||||
return row.display_name_zh ?? row.display_name_en ?? row.play_code;
|
|
||||||
}
|
|
||||||
if (loc === "ne") {
|
|
||||||
return row.display_name_ne ?? row.display_name_en ?? row.play_code;
|
|
||||||
}
|
|
||||||
return row.display_name_en ?? row.display_name_zh ?? row.play_code;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickRuleText(row: PlayEffectivePlayRow): string | null {
|
function pickRuleText(row: PlayEffectivePlayRow): string | null {
|
||||||
@@ -79,10 +72,9 @@ function formatMoneyAmount(n: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function HallPlayCatalogPanel() {
|
export function HallPlayCatalogPanel() {
|
||||||
const profile = usePlayerSessionStore((s) => s.profile);
|
|
||||||
const { t } = useTranslation("player");
|
const { t } = useTranslation("player");
|
||||||
|
const { activeCurrency: currencyParam } = useActivePlayerCurrency();
|
||||||
const [state, setState] = useState<LoadState>({ kind: "loading" });
|
const [state, setState] = useState<LoadState>({ kind: "loading" });
|
||||||
const currencyParam = useMemo(() => resolvePlayerCurrency(profile), [profile]);
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setState((s) => (s.kind === "ok" ? s : { kind: "loading" }));
|
setState((s) => (s.kind === "ok" ? s : { kind: "loading" }));
|
||||||
@@ -119,6 +111,12 @@ export function HallPlayCatalogPanel() {
|
|||||||
return () => window.clearInterval(id);
|
return () => window.clearInterval(id);
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onCurrencyChange = () => void load();
|
||||||
|
window.addEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange);
|
||||||
|
return () => window.removeEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
const body = (() => {
|
const body = (() => {
|
||||||
if (state.kind === "loading") {
|
if (state.kind === "loading") {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import Image from "next/image";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { CurrencySwitcher } from "@/components/currency-switcher";
|
||||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
import { HallBettingGrid } from "@/features/hall/hall-betting-grid";
|
import { HallBettingGrid } from "@/features/hall/hall-betting-grid";
|
||||||
|
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||||
import { HallDrawPanel } from "@/features/hall/hall-draw-panel";
|
import { HallDrawPanel } from "@/features/hall/hall-draw-panel";
|
||||||
import { HallWalletStrip } from "@/features/hall/hall-wallet-strip";
|
import { HallWalletStrip } from "@/features/hall/hall-wallet-strip";
|
||||||
import { JackpotBurstOverlay } from "@/features/hall/jackpot-burst-overlay";
|
import { JackpotBurstOverlay } from "@/features/hall/jackpot-burst-overlay";
|
||||||
@@ -22,6 +24,7 @@ export function HallScreen() {
|
|||||||
const { t } = useTranslation("common");
|
const { t } = useTranslation("common");
|
||||||
const { t: tp } = useTranslation("player");
|
const { t: tp } = useTranslation("player");
|
||||||
const drawLive = useHallDrawLive();
|
const drawLive = useHallDrawLive();
|
||||||
|
const { activeCurrency } = useActivePlayerCurrency();
|
||||||
const { burstEvent, clearBurstEvent } = useJackpotBurstLive(tp);
|
const { burstEvent, clearBurstEvent } = useJackpotBurstLive(tp);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -39,6 +42,15 @@ export function HallScreen() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
<CurrencySwitcher
|
||||||
|
variant="minimal"
|
||||||
|
menuAlign="end"
|
||||||
|
showLabel
|
||||||
|
className={cn(
|
||||||
|
playerHeaderControl,
|
||||||
|
"rounded-full border border-[#e4eaf4] bg-[#f8fafc] [&_button]:h-8 [&_button]:gap-1 [&_button]:px-2 [&_button]:py-0 [&_button]:text-xs [&_button]:font-bold [&_button]:text-[#0b3f96]",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<LanguageSwitcher
|
<LanguageSwitcher
|
||||||
variant="minimal"
|
variant="minimal"
|
||||||
menuAlign="end"
|
menuAlign="end"
|
||||||
@@ -75,7 +87,7 @@ export function HallScreen() {
|
|||||||
|
|
||||||
<HallWalletStrip />
|
<HallWalletStrip />
|
||||||
|
|
||||||
<HallBettingGrid drawLive={drawLive} />
|
<HallBettingGrid key={activeCurrency} drawLive={drawLive} />
|
||||||
</section>
|
</section>
|
||||||
<JackpotBurstOverlay event={burstEvent} onClose={clearBurstEvent} />
|
<JackpotBurstOverlay event={burstEvent} onClose={clearBurstEvent} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { Wallet } from "lucide-react";
|
import { Wallet } from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { getWalletBalance } from "@/api/wallet";
|
import { getWalletBalance } from "@/api/wallet";
|
||||||
@@ -11,30 +11,27 @@ import {
|
|||||||
TransferInDialog,
|
TransferInDialog,
|
||||||
TransferOutDialog,
|
TransferOutDialog,
|
||||||
} from "@/features/wallet/wallet-transfer-dialogs";
|
} from "@/features/wallet/wallet-transfer-dialogs";
|
||||||
|
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||||
import { formatMinorAsCurrency } from "@/lib/money";
|
import { formatMinorAsCurrency } from "@/lib/money";
|
||||||
import { resolvePlayerCurrency } from "@/lib/player-currency";
|
import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useNetworkConnectionStore } from "@/stores/network-connection-store";
|
import { useNetworkConnectionStore } from "@/stores/network-connection-store";
|
||||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
|
||||||
import type { WalletBalanceData } from "@/types/api/wallet-balance";
|
import type { WalletBalanceData } from "@/types/api/wallet-balance";
|
||||||
|
|
||||||
export function HallWalletStrip() {
|
export function HallWalletStrip() {
|
||||||
const profile = usePlayerSessionStore((s) => s.profile);
|
|
||||||
const mode = useNetworkConnectionStore((s) => s.mode);
|
const mode = useNetworkConnectionStore((s) => s.mode);
|
||||||
const { t } = useTranslation("player");
|
const { t } = useTranslation("player");
|
||||||
|
const { activeCurrency } = useActivePlayerCurrency();
|
||||||
const [balance, setBalance] = useState<WalletBalanceData | null>(null);
|
const [balance, setBalance] = useState<WalletBalanceData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const degradedWalletPollRef = useRef<number | null>(null);
|
const degradedWalletPollRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const currency = useMemo(
|
const currency = activeCurrency;
|
||||||
() => resolvePlayerCurrency(profile, balance),
|
|
||||||
[balance, profile],
|
|
||||||
);
|
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
const b = await getWalletBalance();
|
const b = await getWalletBalance({ currency: activeCurrency });
|
||||||
setBalance(b);
|
setBalance(b);
|
||||||
}, []);
|
}, [activeCurrency]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -54,7 +51,11 @@ export function HallWalletStrip() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onRefresh = () => void refresh();
|
const onRefresh = () => void refresh();
|
||||||
window.addEventListener("lottery-wallet-refresh", onRefresh);
|
window.addEventListener("lottery-wallet-refresh", onRefresh);
|
||||||
return () => window.removeEventListener("lottery-wallet-refresh", onRefresh);
|
window.addEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onRefresh);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("lottery-wallet-refresh", onRefresh);
|
||||||
|
window.removeEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onRefresh);
|
||||||
|
};
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { X, Zap } from "lucide-react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Sparkles, X, Zap } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { getDrawResultByNo } from "@/api/draw";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { formatMinorAsCurrency } from "@/lib/money";
|
import { formatMinorAsCurrency } from "@/lib/money";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export type JackpotBurstEvent = {
|
export type JackpotBurstEvent = {
|
||||||
draw_id: number;
|
draw_id: number;
|
||||||
@@ -23,87 +26,312 @@ type JackpotBurstOverlayProps = {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function triggerLabel(triggerType: string, t: ReturnType<typeof useTranslation>["t"]) {
|
const PARTICLE_SEEDS = [
|
||||||
return t(`hall.jackpotBurst.trigger.${triggerType}`, {
|
{ left: "8%", delay: "0s", duration: "2.2s", size: 4 },
|
||||||
defaultValue: triggerType,
|
{ left: "22%", delay: "0.4s", duration: "2.6s", size: 3 },
|
||||||
});
|
{ left: "38%", delay: "0.1s", duration: "2.4s", size: 5 },
|
||||||
|
{ left: "55%", delay: "0.7s", duration: "2.8s", size: 3 },
|
||||||
|
{ left: "72%", delay: "0.2s", duration: "2.3s", size: 4 },
|
||||||
|
{ left: "88%", delay: "0.5s", duration: "2.5s", size: 3 },
|
||||||
|
{ left: "15%", delay: "0.9s", duration: "2.7s", size: 3 },
|
||||||
|
{ left: "65%", delay: "0.35s", duration: "2.1s", size: 4 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const ROLL_STAGGER_MS = 380;
|
||||||
|
const ROLL_BASE_MS = 900;
|
||||||
|
const ROLL_TICK_MS = 55;
|
||||||
|
|
||||||
|
function isMissingPrizeNumber(raw: string): boolean {
|
||||||
|
const cleaned = raw.replace(/\s/g, "");
|
||||||
|
return !cleaned || /^-+$/.test(cleaned);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePrizeDigits(raw: string): string[] {
|
||||||
|
const cleaned = raw.replace(/\s/g, "");
|
||||||
|
if (isMissingPrizeNumber(raw)) {
|
||||||
|
return ["—", "—", "—", "—"];
|
||||||
|
}
|
||||||
|
const padded = cleaned.padStart(4, "0").slice(-4);
|
||||||
|
return padded.split("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function useRollingDigits(finalNumber: string) {
|
||||||
|
const targets = useMemo(() => normalizePrizeDigits(finalNumber), [finalNumber]);
|
||||||
|
const [display, setDisplay] = useState<string[]>(() => ["?", "?", "?", "?"]);
|
||||||
|
const [allRevealed, setAllRevealed] = useState(false);
|
||||||
|
const [poppedIndex, setPoppedIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const intervals: ReturnType<typeof setInterval>[] = [];
|
||||||
|
const timeouts: ReturnType<typeof setTimeout>[] = [];
|
||||||
|
|
||||||
|
targets.forEach((target, index) => {
|
||||||
|
const tick = setInterval(() => {
|
||||||
|
setDisplay((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
if (target === "—") {
|
||||||
|
next[index] = "—";
|
||||||
|
} else {
|
||||||
|
next[index] = String(Math.floor(Math.random() * 10));
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, ROLL_TICK_MS);
|
||||||
|
intervals.push(tick);
|
||||||
|
|
||||||
|
const stopAt = ROLL_BASE_MS + index * ROLL_STAGGER_MS;
|
||||||
|
const stop = setTimeout(() => {
|
||||||
|
clearInterval(tick);
|
||||||
|
setDisplay((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
next[index] = target;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setPoppedIndex(index);
|
||||||
|
const clearPop = setTimeout(() => setPoppedIndex(null), 320);
|
||||||
|
timeouts.push(clearPop);
|
||||||
|
|
||||||
|
if (index === targets.length - 1) {
|
||||||
|
const revealDone = setTimeout(() => setAllRevealed(true), 200);
|
||||||
|
timeouts.push(revealDone);
|
||||||
|
}
|
||||||
|
}, stopAt);
|
||||||
|
timeouts.push(stop);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
intervals.forEach(clearInterval);
|
||||||
|
timeouts.forEach(clearTimeout);
|
||||||
|
};
|
||||||
|
}, [targets]);
|
||||||
|
|
||||||
|
return { display, allRevealed, poppedIndex };
|
||||||
|
}
|
||||||
|
|
||||||
|
function BurstParticles() {
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden>
|
||||||
|
{PARTICLE_SEEDS.map((p, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="absolute bottom-[18%] rounded-full bg-[#e8b84a] opacity-80"
|
||||||
|
style={{
|
||||||
|
left: p.left,
|
||||||
|
width: p.size,
|
||||||
|
height: p.size,
|
||||||
|
animation: `jackpot-particle ${p.duration} ease-out ${p.delay} infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RollingDigit({
|
||||||
|
char,
|
||||||
|
popping,
|
||||||
|
}: {
|
||||||
|
char: string;
|
||||||
|
popping: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex min-w-[2.1rem] items-center justify-center rounded-lg border border-[#e8c96a] bg-gradient-to-b from-[#fff9e8] to-[#fff3d4] px-1 py-1 font-mono text-[2rem] font-black leading-none text-[#b8860b] shadow-[inset_0_1px_0_rgba(255,255,255,0.9),0_2px_8px_rgba(201,162,39,0.12)] sm:min-w-[2.5rem] sm:text-[2.35rem]",
|
||||||
|
popping && "animate-[jackpot-digit-pop_0.32s_ease-out]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{char}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function JackpotBurstOverlay({ event, onClose }: JackpotBurstOverlayProps) {
|
export function JackpotBurstOverlay({ event, onClose }: JackpotBurstOverlayProps) {
|
||||||
const { t } = useTranslation("player");
|
|
||||||
|
|
||||||
if (!event) return null;
|
if (!event) return null;
|
||||||
|
return (
|
||||||
|
<JackpotBurstOverlayContent
|
||||||
|
key={`${event.draw_id}-${event.emitted_at_ms ?? 0}`}
|
||||||
|
event={event}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useResolvedFirstPrizeNumber(event: JackpotBurstEvent) {
|
||||||
|
const fromEvent = event.first_prize_number;
|
||||||
|
const eventHasNumber = !isMissingPrizeNumber(fromEvent);
|
||||||
|
const [fetchedNumber, setFetchedNumber] = useState<string | null>(null);
|
||||||
|
const [fetchDone, setFetchDone] = useState(eventHasNumber);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (eventHasNumber) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
void getDrawResultByNo(event.draw_no)
|
||||||
|
.then((detail) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const first = detail.results?.["1st"]?.trim() ?? "";
|
||||||
|
if (!isMissingPrizeNumber(first)) {
|
||||||
|
setFetchedNumber(first);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setFetchDone(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [event.draw_no, eventHasNumber]);
|
||||||
|
|
||||||
|
const number = eventHasNumber ? fromEvent : (fetchedNumber ?? fromEvent);
|
||||||
|
const pending = !eventHasNumber && (!fetchDone || isMissingPrizeNumber(number));
|
||||||
|
|
||||||
|
return { number, pending };
|
||||||
|
}
|
||||||
|
|
||||||
|
function JackpotBurstOverlayContent({
|
||||||
|
event,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
event: JackpotBurstEvent;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("player");
|
||||||
|
const { number: prizeNumber, pending: prizePending } = useResolvedFirstPrizeNumber(event);
|
||||||
|
const { display, allRevealed, poppedIndex } = useRollingDigits(prizeNumber);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
},
|
||||||
|
[onClose],
|
||||||
|
);
|
||||||
|
|
||||||
const currency = event.currency_code.toUpperCase();
|
const currency = event.currency_code.toUpperCase();
|
||||||
const amount = formatMinorAsCurrency(event.total_payout_amount, currency);
|
const amount = formatMinorAsCurrency(event.total_payout_amount, currency);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[90] flex items-center justify-center bg-[#08111f]/95 px-4 py-6 text-white"
|
className="fixed inset-0 z-[90] flex items-center justify-center bg-white/88 px-4 py-6 text-slate-900 backdrop-blur-[2px] animate-[jackpot-backdrop-in_0.35s_ease-out]"
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
"radial-gradient(ellipse 75% 55% at 50% 38%, rgba(245,197,66,0.14) 0%, transparent 60%)",
|
||||||
|
}}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={t("hall.jackpotBurst.title")}
|
aria-label={t("hall.jackpotBurst.title")}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
>
|
>
|
||||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
<div
|
||||||
<div className="absolute left-0 top-1/4 h-px w-full animate-[pulse_1.8s_ease-in-out_infinite] bg-[#f5c542]" />
|
className="pointer-events-none absolute inset-0 animate-[jackpot-flash_0.55s_ease-out_forwards] bg-[radial-gradient(circle_at_50%_42%,rgba(255,236,180,0.55)_0%,transparent_58%)]"
|
||||||
<div className="absolute bottom-1/3 left-0 h-px w-full animate-[pulse_2.4s_ease-in-out_infinite] bg-[#2dd4bf]" />
|
aria-hidden
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<div className="relative flex w-full max-w-[420px] flex-col items-center rounded-lg border border-[#f5c542]/50 bg-[#101b2d] px-5 py-6 text-center shadow-2xl shadow-black/40">
|
<BurstParticles />
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="absolute right-2 top-2 text-white hover:bg-white/10 hover:text-white"
|
|
||||||
onClick={onClose}
|
|
||||||
aria-label={t("hall.jackpotBurst.close")}
|
|
||||||
>
|
|
||||||
<X className="size-4" aria-hidden />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="mb-4 flex size-14 items-center justify-center rounded-full border border-[#f5c542] bg-[#f5c542]/15 text-[#f5c542]">
|
<div
|
||||||
<Zap className="size-7" aria-hidden />
|
className="pointer-events-none absolute left-1/2 top-[28%] size-64 -translate-x-1/2 rounded-full border border-[#e8c96a]/35 animate-[jackpot-ring-pulse_1.8s_ease-out_infinite]"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute left-1/2 top-[28%] size-64 -translate-x-1/2 rounded-full border border-[#e8c96a]/20 animate-[jackpot-ring-pulse_1.8s_ease-out_0.6s_infinite]"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative w-full max-w-[420px] animate-[jackpot-card-in_0.65s_cubic-bezier(0.22,1,0.36,1)]">
|
||||||
|
<div className="absolute -inset-[1px] overflow-hidden rounded-2xl">
|
||||||
|
<div
|
||||||
|
className="absolute inset-[-50%] bg-[conic-gradient(from_0deg,#e8c96a,#f5d88a,#c9a227,#e8c96a)] opacity-90 animate-[jackpot-border-spin_4s_linear_infinite]"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs font-bold uppercase tracking-[0.18em] text-[#f5c542]">
|
<div className="relative flex flex-col items-center rounded-2xl border border-[#f0e4c4] bg-white px-5 py-6 text-center shadow-[0_20px_50px_rgba(15,23,42,0.12),0_0_0_1px_rgba(245,197,66,0.08)]">
|
||||||
{t("hall.jackpotBurst.title")}
|
<Button
|
||||||
</p>
|
type="button"
|
||||||
<p className="mt-2 text-sm font-semibold text-slate-200">
|
variant="ghost"
|
||||||
{t("hall.jackpotBurst.subtitle", {
|
size="icon"
|
||||||
drawNo: event.draw_no,
|
className="absolute right-2 top-2 z-10 text-slate-500 hover:bg-slate-100 hover:text-slate-800"
|
||||||
})}
|
onClick={onClose}
|
||||||
</p>
|
aria-label={t("hall.jackpotBurst.close")}
|
||||||
|
>
|
||||||
|
<X className="size-4" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
|
||||||
<div className="my-5 w-full rounded-lg border border-white/10 bg-white/[0.06] px-4 py-4">
|
<div className="relative mb-3">
|
||||||
<div className="text-[2.35rem] font-black leading-none text-[#f5c542] sm:text-[2.8rem]">
|
<div
|
||||||
{event.first_prize_number || "----"}
|
className="absolute inset-0 rounded-full bg-[#f5c542]/25 blur-xl"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div className="relative flex size-16 items-center justify-center rounded-full border-2 border-[#e8c96a] bg-gradient-to-br from-[#fff9e8] to-[#fff3d4] shadow-[0_4px_20px_rgba(201,162,39,0.22)]">
|
||||||
|
<Zap
|
||||||
|
className="size-8 text-[#c9a227] animate-[jackpot-lightning_1.4s_ease-in-out_infinite]"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Sparkles
|
||||||
|
className="absolute -right-1 -top-1 size-4 text-[#d4a82a]"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-xs font-semibold text-slate-300">
|
|
||||||
{t("hall.jackpotBurst.number")}
|
<h2 className="bg-gradient-to-r from-[#9a7b1a] via-[#c9a227] to-[#d4a82a] bg-[length:200%_auto] bg-clip-text text-lg font-black uppercase tracking-[0.14em] text-transparent animate-[jackpot-shimmer_2.8s_linear_infinite]">
|
||||||
|
{t("hall.jackpotBurst.title")}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 max-w-[320px] text-sm font-medium text-slate-600">
|
||||||
|
{t("hall.jackpotBurst.subtitle", { drawNo: event.draw_no })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full space-y-2 text-left text-sm">
|
<div className="relative my-5 w-full overflow-hidden rounded-xl border border-[#f0e4c4] bg-[#faf8f3] px-3 py-5 shadow-[inset_0_1px_0_rgba(255,255,255,0.9)]">
|
||||||
<div className="flex items-center justify-between gap-3 rounded-md bg-white/[0.05] px-3 py-2">
|
<div
|
||||||
<span className="text-slate-300">
|
className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-[#e8c96a]/70 to-transparent"
|
||||||
{t("hall.jackpotBurst.amount")}
|
aria-hidden
|
||||||
</span>
|
/>
|
||||||
<span className="text-right font-black text-[#f5c542]">{amount}</span>
|
<div className="flex items-center justify-center gap-2 sm:gap-2.5">
|
||||||
|
{display.map((char, i) => (
|
||||||
|
<RollingDigit key={i} char={char} popping={poppedIndex === i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
|
{t("hall.jackpotBurst.number")}
|
||||||
|
</p>
|
||||||
|
{prizePending && isMissingPrizeNumber(prizeNumber) ? (
|
||||||
|
<p className="mt-1 text-xs text-amber-700/90">
|
||||||
|
{t("hall.jackpotBurst.numberPending")}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-3 rounded-md bg-white/[0.05] px-3 py-2">
|
|
||||||
<span className="text-slate-300">
|
<div className="w-full space-y-2 text-left text-sm">
|
||||||
{t("hall.jackpotBurst.winners")}
|
<div
|
||||||
</span>
|
className={cn(
|
||||||
<span className="font-bold">{event.winner_count}</span>
|
"flex items-center justify-between gap-3 rounded-lg border border-[#f0e4c4] bg-gradient-to-r from-[#fff9e8] to-[#fffcf5] px-3 py-2.5",
|
||||||
</div>
|
allRevealed &&
|
||||||
<div className="flex items-center justify-between gap-3 rounded-md bg-white/[0.05] px-3 py-2">
|
"animate-[jackpot-amount-row-glow_2s_ease-in-out_infinite]",
|
||||||
<span className="text-slate-300">
|
)}
|
||||||
{t("hall.jackpotBurst.triggerLabel")}
|
>
|
||||||
</span>
|
<span className="text-slate-600">{t("hall.jackpotBurst.amount")}</span>
|
||||||
<span className="font-bold">{triggerLabel(event.trigger_type, t)}</span>
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-right text-base font-black text-[#b8860b]",
|
||||||
|
allRevealed && "animate-[jackpot-amount-glow_2s_ease-in-out_infinite]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{amount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-3 rounded-lg border border-[#e8edf4] bg-[#f8fafc] px-3 py-2.5">
|
||||||
|
<span className="text-slate-500">{t("hall.jackpotBurst.winners")}</span>
|
||||||
|
<span className="font-bold tabular-nums text-slate-900">
|
||||||
|
{event.winner_count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { getDrawCurrent } from "@/api/draw";
|
import { getDrawCurrent } from "@/api/draw";
|
||||||
|
import { isHallSealedCountdownUi } from "@/features/draw/draw-status-meta";
|
||||||
import { getLotteryEcho } from "@/lib/lottery-echo";
|
import { getLotteryEcho } from "@/lib/lottery-echo";
|
||||||
import { useNetworkConnectionStore } from "@/stores/network-connection-store";
|
import { useNetworkConnectionStore } from "@/stores/network-connection-store";
|
||||||
import type { DrawCurrentPayload, DrawCurrentResponse } from "@/types/api/draw-current";
|
import type { DrawCurrentPayload, DrawCurrentResponse } from "@/types/api/draw-current";
|
||||||
@@ -105,6 +106,15 @@ export function useHallDrawLive(): HallDrawLiveSnapshot {
|
|||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
|
// 爆池等场景:刷新大厅快照(含奖池余额)
|
||||||
|
useEffect(() => {
|
||||||
|
const onHallRefresh = () => {
|
||||||
|
void load();
|
||||||
|
};
|
||||||
|
window.addEventListener("lottery-hall-refresh", onHallRefresh);
|
||||||
|
return () => window.removeEventListener("lottery-hall-refresh", onHallRefresh);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
// 本地倒计时计时器(用于 UI 更新)
|
// 本地倒计时计时器(用于 UI 更新)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const bump = () => setNowMs(Date.now());
|
const bump = () => setNowMs(Date.now());
|
||||||
@@ -242,5 +252,53 @@ export function useHallDrawLive(): HallDrawLiveSnapshot {
|
|||||||
|
|
||||||
const isBettable = display != null && display.status === "open";
|
const isBettable = display != null && display.status === "open";
|
||||||
|
|
||||||
|
const zeroRefreshKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
// 本地倒计时归零时主动拉取(冷静期结束、封盘切下一期等;避免只靠手动刷新)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!display) return;
|
||||||
|
|
||||||
|
const coolingEndMs = display.cooling_end_time
|
||||||
|
? Date.parse(display.cooling_end_time)
|
||||||
|
: null;
|
||||||
|
const coolingDone =
|
||||||
|
display.status === "cooldown" &&
|
||||||
|
((display.seconds_remaining_in_cooldown ?? 1) === 0 ||
|
||||||
|
(coolingEndMs !== null && !Number.isNaN(coolingEndMs) && coolingEndMs <= nowMs));
|
||||||
|
|
||||||
|
const sealedDone =
|
||||||
|
isHallSealedCountdownUi(display.status) && (display.seconds_to_draw ?? 1) === 0;
|
||||||
|
|
||||||
|
const closeDone =
|
||||||
|
display.status === "open" && (display.seconds_to_close ?? 1) === 0;
|
||||||
|
|
||||||
|
const trigger = coolingDone
|
||||||
|
? `${display.draw_no}:cooldown-end`
|
||||||
|
: sealedDone
|
||||||
|
? `${display.draw_no}:sealed-end`
|
||||||
|
: closeDone
|
||||||
|
? `${display.draw_no}:close-end`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (trigger && zeroRefreshKeyRef.current !== trigger) {
|
||||||
|
zeroRefreshKeyRef.current = trigger;
|
||||||
|
void load();
|
||||||
|
}
|
||||||
|
}, [display, nowMs, load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (display?.draw_no) {
|
||||||
|
zeroRefreshKeyRef.current = null;
|
||||||
|
}
|
||||||
|
}, [display?.draw_no, display?.status]);
|
||||||
|
|
||||||
|
// WebSocket 已连接时的兜底轮询(tick 最多 1 分钟延迟时的保险)
|
||||||
|
useEffect(() => {
|
||||||
|
const intervalId = window.setInterval(() => {
|
||||||
|
void load();
|
||||||
|
}, 45_000);
|
||||||
|
return () => window.clearInterval(intervalId);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
return { raw, display, serverNowMs, error, reload: load, isBettable };
|
return { raw, display, serverNowMs, error, reload: load, isBettable };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export function useJackpotBurstLive(t: TFunction<"player">) {
|
|||||||
setEvent(payload);
|
setEvent(payload);
|
||||||
notifyBrowser(payload, t);
|
notifyBrowser(payload, t);
|
||||||
window.dispatchEvent(new Event("lottery-wallet-refresh"));
|
window.dispatchEvent(new Event("lottery-wallet-refresh"));
|
||||||
|
window.dispatchEvent(new Event("lottery-hall-refresh"));
|
||||||
},
|
},
|
||||||
[t],
|
[t],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,8 +7,11 @@ export function ticketStatusDisplay(
|
|||||||
t?: (key: string, options?: { defaultValue?: string; status?: string }) => string,
|
t?: (key: string, options?: { defaultValue?: string; status?: string }) => string,
|
||||||
): { label: string; dotClass: string; ring?: boolean } {
|
): { label: string; dotClass: string; ring?: boolean } {
|
||||||
const total = winMinor + jackpotMinor;
|
const total = winMinor + jackpotMinor;
|
||||||
if (status === "success") {
|
if (status === "success" || status === "pending_draw") {
|
||||||
return { label: t?.("ticketStatus.success") ?? status, dotClass: "bg-sky-500" };
|
return {
|
||||||
|
label: t?.(status === "pending_draw" ? "ticketStatus.pending_draw" : "ticketStatus.success") ?? status,
|
||||||
|
dotClass: "bg-sky-500",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (status === "pending_payout") {
|
if (status === "pending_payout") {
|
||||||
return { label: t?.("ticketStatus.pending_payout") ?? status, dotClass: "bg-amber-500" };
|
return { label: t?.("ticketStatus.pending_payout") ?? status, dotClass: "bg-amber-500" };
|
||||||
|
|||||||
@@ -21,10 +21,9 @@ import { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-st
|
|||||||
import { formatLotteryInstant } from "@/lib/player-datetime";
|
import { formatLotteryInstant } from "@/lib/player-datetime";
|
||||||
import { formatMinorAsCurrency } from "@/lib/money";
|
import { formatMinorAsCurrency } from "@/lib/money";
|
||||||
import { norm4d } from "@/lib/norm-4d";
|
import { norm4d } from "@/lib/norm-4d";
|
||||||
import { resolvePlayerCurrency } from "@/lib/player-currency";
|
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||||
import { playLabel } from "@/lib/play-labels";
|
import { playLabel } from "@/lib/play-labels";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
|
||||||
import type { TicketItemDetailPayload } from "@/types/api/ticket-items";
|
import type { TicketItemDetailPayload } from "@/types/api/ticket-items";
|
||||||
|
|
||||||
type OddsSnapRow = { prize_scope?: string; odds_value?: number };
|
type OddsSnapRow = { prize_scope?: string; odds_value?: number };
|
||||||
@@ -69,7 +68,7 @@ type TicketItemDetailWithExtras = TicketItemDetailPayload & {
|
|||||||
/** 界面文档 §4.8 注单详情 */
|
/** 界面文档 §4.8 注单详情 */
|
||||||
export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
||||||
const { t } = useTranslation("player");
|
const { t } = useTranslation("player");
|
||||||
const profile = usePlayerSessionStore((state) => state.profile);
|
const { activeCurrency } = useActivePlayerCurrency();
|
||||||
useCurrencyCatalog();
|
useCurrencyCatalog();
|
||||||
const [data, setData] = useState<TicketItemDetailPayload | null>(null);
|
const [data, setData] = useState<TicketItemDetailPayload | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -132,7 +131,7 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cur = data.currency_code ?? resolvePlayerCurrency(profile);
|
const cur = data.currency_code ?? activeCurrency;
|
||||||
const st = ticketStatusDisplay(data.status, data.win_amount, data.jackpot_win_amount, t);
|
const st = ticketStatusDisplay(data.status, data.win_amount, data.jackpot_win_amount, t);
|
||||||
const totalWin = data.win_amount + data.jackpot_win_amount;
|
const totalWin = data.win_amount + data.jackpot_win_amount;
|
||||||
const pub = data.published_draw_results;
|
const pub = data.published_draw_results;
|
||||||
|
|||||||
@@ -19,14 +19,13 @@ import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
|||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { formatMinorAsCurrency } from "@/lib/money";
|
import { formatMinorAsCurrency } from "@/lib/money";
|
||||||
import { formatLotteryInstant } from "@/lib/player-datetime";
|
import { formatLotteryInstant } from "@/lib/player-datetime";
|
||||||
import { resolvePlayerCurrency } from "@/lib/player-currency";
|
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||||
import { playLabel } from "@/lib/play-labels";
|
import { playLabel } from "@/lib/play-labels";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
|
||||||
import type { TicketItemListRow } from "@/types/api/ticket-items";
|
import type { TicketItemListRow } from "@/types/api/ticket-items";
|
||||||
|
|
||||||
const ORDERS_PAGE_SIZE = 20;
|
const ORDERS_PAGE_SIZE = 20;
|
||||||
const STATUS_OPTIONS = ["success", "settled_win", "settled_lose", "failed"] as const;
|
const STATUS_OPTIONS = ["pending_draw", "success", "settled_win", "settled_lose", "failed"] as const;
|
||||||
|
|
||||||
function parseYmd(value: string): Date | undefined {
|
function parseYmd(value: string): Date | undefined {
|
||||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
||||||
@@ -45,7 +44,7 @@ function formatYmd(value: Date): string {
|
|||||||
export function TicketOrdersListScreen() {
|
export function TicketOrdersListScreen() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { t } = useTranslation("player");
|
const { t } = useTranslation("player");
|
||||||
const profile = usePlayerSessionStore((state) => state.profile);
|
const { activeCurrency } = useActivePlayerCurrency();
|
||||||
useCurrencyCatalog();
|
useCurrencyCatalog();
|
||||||
const drawNoFilter = useMemo(() => (searchParams.get("draw_no") ?? "").trim(), [searchParams]);
|
const drawNoFilter = useMemo(() => (searchParams.get("draw_no") ?? "").trim(), [searchParams]);
|
||||||
const statusFilter = useMemo(
|
const statusFilter = useMemo(
|
||||||
@@ -356,7 +355,7 @@ export function TicketOrdersListScreen() {
|
|||||||
<>
|
<>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{items.map((row) => {
|
{items.map((row) => {
|
||||||
const cur = row.currency_code ?? resolvePlayerCurrency(profile);
|
const cur = row.currency_code ?? activeCurrency;
|
||||||
const st = ticketStatusDisplay(row.status, row.win_amount, row.jackpot_win_amount, t);
|
const st = ticketStatusDisplay(row.status, row.win_amount, row.jackpot_win_amount, t);
|
||||||
const totalWin = row.win_amount + row.jackpot_win_amount;
|
const totalWin = row.win_amount + row.jackpot_win_amount;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export function HydratePlayerAuth(): null {
|
|||||||
const setCurrencies = usePlayerSessionStore((state) => state.setCurrencies);
|
const setCurrencies = usePlayerSessionStore((state) => state.setCurrencies);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
usePlayerSessionStore.getState().reconcileSelectedCurrency();
|
||||||
const token = restoreBearerToken();
|
const token = restoreBearerToken();
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { UserRound } from "lucide-react";
|
import { UserRound } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||||
|
|
||||||
@@ -11,6 +12,7 @@ import { usePlayerSessionStore } from "@/stores/player-session-store";
|
|||||||
*/
|
*/
|
||||||
export function PlayerSessionBar({ className }: { className?: string }) {
|
export function PlayerSessionBar({ className }: { className?: string }) {
|
||||||
const profile = usePlayerSessionStore((s) => s.profile);
|
const profile = usePlayerSessionStore((s) => s.profile);
|
||||||
|
const { activeCurrency } = useActivePlayerCurrency();
|
||||||
const { t } = useTranslation("player");
|
const { t } = useTranslation("player");
|
||||||
|
|
||||||
const label =
|
const label =
|
||||||
@@ -32,10 +34,10 @@ export function PlayerSessionBar({ className }: { className?: string }) {
|
|||||||
<p className="truncate text-xs font-medium text-foreground">
|
<p className="truncate text-xs font-medium text-foreground">
|
||||||
{label ?? "…"}
|
{label ?? "…"}
|
||||||
</p>
|
</p>
|
||||||
{profile?.default_currency ? (
|
{activeCurrency ? (
|
||||||
<p className="truncate text-[10px] text-muted-foreground">
|
<p className="truncate text-[10px] text-muted-foreground">
|
||||||
{profile.default_currency.toUpperCase()}
|
{activeCurrency}
|
||||||
{profile.locale ? (
|
{profile?.locale ? (
|
||||||
<span className="text-muted-foreground/80">
|
<span className="text-muted-foreground/80">
|
||||||
{" "}
|
{" "}
|
||||||
· {profile.locale}
|
· {profile.locale}
|
||||||
|
|||||||
@@ -21,8 +21,7 @@ import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
|||||||
import { formatMinorAsCurrency } from "@/lib/money";
|
import { formatMinorAsCurrency } from "@/lib/money";
|
||||||
import { formatLotteryInstant } from "@/lib/player-datetime";
|
import { formatLotteryInstant } from "@/lib/player-datetime";
|
||||||
import { playLabel } from "@/lib/play-labels";
|
import { playLabel } from "@/lib/play-labels";
|
||||||
import { resolvePlayerCurrency } from "@/lib/player-currency";
|
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
|
||||||
import type { DrawResultListItem } from "@/types/api/draw-results";
|
import type { DrawResultListItem } from "@/types/api/draw-results";
|
||||||
import type { TicketDrawMyMatchPayload, TicketItemListRow } from "@/types/api/ticket-items";
|
import type { TicketDrawMyMatchPayload, TicketItemListRow } from "@/types/api/ticket-items";
|
||||||
|
|
||||||
@@ -201,11 +200,11 @@ function WinningResultDialog({
|
|||||||
onCheckAnother: () => void;
|
onCheckAnother: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("player");
|
const { t } = useTranslation("player");
|
||||||
const profile = usePlayerSessionStore((state) => state.profile);
|
|
||||||
const totalWin = (data?.match.total_win_minor ?? 0) + (data?.match.total_jackpot_win_minor ?? 0);
|
const totalWin = (data?.match.total_win_minor ?? 0) + (data?.match.total_jackpot_win_minor ?? 0);
|
||||||
const isWon = totalWin > 0 || (data?.match.winning_ticket_count ?? 0) > 0;
|
const isWon = totalWin > 0 || (data?.match.winning_ticket_count ?? 0) > 0;
|
||||||
const firstTicket = useMemo(() => data?.tickets[0] ?? null, [data]);
|
const firstTicket = useMemo(() => data?.tickets[0] ?? null, [data]);
|
||||||
const currency = firstTicket?.currency_code ?? resolvePlayerCurrency(profile);
|
const { activeCurrency } = useActivePlayerCurrency();
|
||||||
|
const currency = firstTicket?.currency_code ?? activeCurrency;
|
||||||
|
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -23,9 +23,8 @@ import { getPlayerBearerTokenPayload } from "@/lib/lottery-auth";
|
|||||||
import { formatLotteryInstant } from "@/lib/player-datetime";
|
import { formatLotteryInstant } from "@/lib/player-datetime";
|
||||||
import { formatMinorAsCurrency } from "@/lib/money";
|
import { formatMinorAsCurrency } from "@/lib/money";
|
||||||
import { norm4d } from "@/lib/norm-4d";
|
import { norm4d } from "@/lib/norm-4d";
|
||||||
import { resolvePlayerCurrency } from "@/lib/player-currency";
|
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
|
||||||
import type { DrawResultDetailPayload } from "@/types/api/draw-results";
|
import type { DrawResultDetailPayload } from "@/types/api/draw-results";
|
||||||
|
|
||||||
type DrawResultDetailScreenProps = {
|
type DrawResultDetailScreenProps = {
|
||||||
@@ -35,7 +34,7 @@ type DrawResultDetailScreenProps = {
|
|||||||
/** §4.6 开奖结果详情:23 分区 + [< >] 切换 + 本人命中高亮 + Jackpot */
|
/** §4.6 开奖结果详情:23 分区 + [< >] 切换 + 本人命中高亮 + Jackpot */
|
||||||
export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps) {
|
export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps) {
|
||||||
const { t } = useTranslation("player");
|
const { t } = useTranslation("player");
|
||||||
const profile = usePlayerSessionStore((state) => state.profile);
|
const { activeCurrency } = useActivePlayerCurrency();
|
||||||
useCurrencyCatalog();
|
useCurrencyCatalog();
|
||||||
const [data, setData] = useState<DrawResultDetailPayload | null>(null);
|
const [data, setData] = useState<DrawResultDetailPayload | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -136,7 +135,7 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const currency = data.jackpot?.currency_code ?? resolvePlayerCurrency(profile);
|
const currency = data.jackpot?.currency_code ?? activeCurrency;
|
||||||
const showMyPayout =
|
const showMyPayout =
|
||||||
myTotals &&
|
myTotals &&
|
||||||
myTotals.hasBets &&
|
myTotals.hasBets &&
|
||||||
|
|||||||
@@ -22,8 +22,7 @@ import { JackpotResultsStrip } from "@/features/results/jackpot-results-strip";
|
|||||||
import { TwentyThreeResultsGrid } from "@/features/results/twenty-three-results-grid";
|
import { TwentyThreeResultsGrid } from "@/features/results/twenty-three-results-grid";
|
||||||
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
||||||
import { formatLotteryInstant } from "@/lib/player-datetime";
|
import { formatLotteryInstant } from "@/lib/player-datetime";
|
||||||
import { resolvePlayerCurrency } from "@/lib/player-currency";
|
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
|
||||||
import type { DrawResultListItem } from "@/types/api/draw-results";
|
import type { DrawResultListItem } from "@/types/api/draw-results";
|
||||||
|
|
||||||
const RESULTS_PAGE_SIZE = 10;
|
const RESULTS_PAGE_SIZE = 10;
|
||||||
@@ -35,7 +34,6 @@ const MONTH_OPTIONS = Array.from({ length: 12 }, (_, value) => ({
|
|||||||
|
|
||||||
export function DrawResultsListScreen() {
|
export function DrawResultsListScreen() {
|
||||||
const { t } = useTranslation("player");
|
const { t } = useTranslation("player");
|
||||||
const profile = usePlayerSessionStore((state) => state.profile);
|
|
||||||
useCurrencyCatalog();
|
useCurrencyCatalog();
|
||||||
const [items, setItems] = useState<DrawResultListItem[] | null>(null);
|
const [items, setItems] = useState<DrawResultListItem[] | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -51,7 +49,8 @@ export function DrawResultsListScreen() {
|
|||||||
const businessDate = /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : undefined;
|
const businessDate = /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : undefined;
|
||||||
const quickYears = useMemo(() => buildYearOptions(calendarMonth), [calendarMonth]);
|
const quickYears = useMemo(() => buildYearOptions(calendarMonth), [calendarMonth]);
|
||||||
const featured = items?.[0] ?? null;
|
const featured = items?.[0] ?? null;
|
||||||
const jackpotCurrency = featured?.jackpot?.currency_code ?? resolvePlayerCurrency(profile);
|
const { activeCurrency } = useActivePlayerCurrency();
|
||||||
|
const jackpotCurrency = featured?.jackpot?.currency_code ?? activeCurrency;
|
||||||
|
|
||||||
const fetchList = useCallback(async (targetPage = 1, append = false) => {
|
const fetchList = useCallback(async (targetPage = 1, append = false) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|||||||
@@ -1,31 +1,26 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
import { getWalletBalance } from "@/api/wallet";
|
import { getWalletBalance } from "@/api/wallet";
|
||||||
import { TransferInPage } from "@/features/wallet/wallet-transfer-forms";
|
import { TransferInPage } from "@/features/wallet/wallet-transfer-forms";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { resolvePlayerCurrency } from "@/lib/player-currency";
|
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
|
||||||
import type { WalletBalanceData } from "@/types/api/wallet-balance";
|
import type { WalletBalanceData } from "@/types/api/wallet-balance";
|
||||||
|
|
||||||
/** 独立路由 `/wallet/transfer-in` */
|
/** 独立路由 `/wallet/transfer-in` */
|
||||||
export function TransferInScreen() {
|
export function TransferInScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const profile = usePlayerSessionStore((s) => s.profile);
|
const { activeCurrency: currency } = useActivePlayerCurrency();
|
||||||
const [balance, setBalance] = useState<WalletBalanceData | null>(null);
|
const [balance, setBalance] = useState<WalletBalanceData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const currency = useMemo(
|
|
||||||
() => resolvePlayerCurrency(profile, balance),
|
|
||||||
[balance, profile],
|
|
||||||
);
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
const b = await getWalletBalance();
|
const b = await getWalletBalance({ currency });
|
||||||
setBalance(b);
|
setBalance(b);
|
||||||
}, []);
|
}, [currency]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let c = false;
|
let c = false;
|
||||||
@@ -41,6 +36,12 @@ export function TransferInScreen() {
|
|||||||
};
|
};
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onCurrencyChange = () => void load();
|
||||||
|
window.addEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange);
|
||||||
|
return () => window.removeEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
const onSuccess = useCallback(async () => {
|
const onSuccess = useCallback(async () => {
|
||||||
await load();
|
await load();
|
||||||
router.push("/wallet");
|
router.push("/wallet");
|
||||||
|
|||||||
@@ -1,31 +1,26 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
import { getWalletBalance } from "@/api/wallet";
|
import { getWalletBalance } from "@/api/wallet";
|
||||||
import { TransferOutPage } from "@/features/wallet/wallet-transfer-forms";
|
import { TransferOutPage } from "@/features/wallet/wallet-transfer-forms";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { resolvePlayerCurrency } from "@/lib/player-currency";
|
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
|
||||||
import type { WalletBalanceData } from "@/types/api/wallet-balance";
|
import type { WalletBalanceData } from "@/types/api/wallet-balance";
|
||||||
|
|
||||||
/** 独立路由 `/wallet/transfer-out` */
|
/** 独立路由 `/wallet/transfer-out` */
|
||||||
export function TransferOutScreen() {
|
export function TransferOutScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const profile = usePlayerSessionStore((s) => s.profile);
|
const { activeCurrency: currency } = useActivePlayerCurrency();
|
||||||
const [balance, setBalance] = useState<WalletBalanceData | null>(null);
|
const [balance, setBalance] = useState<WalletBalanceData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const currency = useMemo(
|
|
||||||
() => resolvePlayerCurrency(profile, balance),
|
|
||||||
[balance, profile],
|
|
||||||
);
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
const b = await getWalletBalance();
|
const b = await getWalletBalance({ currency });
|
||||||
setBalance(b);
|
setBalance(b);
|
||||||
}, []);
|
}, [currency]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let c = false;
|
let c = false;
|
||||||
@@ -41,6 +36,12 @@ export function TransferOutScreen() {
|
|||||||
};
|
};
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onCurrencyChange = () => void load();
|
||||||
|
window.addEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange);
|
||||||
|
return () => window.removeEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
const onSuccess = useCallback(async () => {
|
const onSuccess = useCallback(async () => {
|
||||||
await load();
|
await load();
|
||||||
router.push("/wallet");
|
router.push("/wallet");
|
||||||
|
|||||||
@@ -7,15 +7,15 @@ import { getWalletLogs } from "@/api/wallet";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||||
import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block";
|
import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block";
|
||||||
import { resolvePlayerCurrency } from "@/lib/player-currency";
|
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||||
|
import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
|
||||||
import { formatWalletClientError } from "@/lib/wallet-api-error";
|
import { formatWalletClientError } from "@/lib/wallet-api-error";
|
||||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
|
||||||
import { getWalletLogsLastPage, type WalletLogsData } from "@/types/api/wallet-logs";
|
import { getWalletLogsLastPage, type WalletLogsData } from "@/types/api/wallet-logs";
|
||||||
|
|
||||||
const WALLET_LOGS_PAGE_SIZE = 10;
|
const WALLET_LOGS_PAGE_SIZE = 10;
|
||||||
|
|
||||||
export function WalletLogsScreen() {
|
export function WalletLogsScreen() {
|
||||||
const profile = usePlayerSessionStore((s) => s.profile);
|
const { activeCurrency: currency } = useActivePlayerCurrency();
|
||||||
const { t } = useTranslation("player");
|
const { t } = useTranslation("player");
|
||||||
const [logs, setLogs] = useState<WalletLogsData | null>(null);
|
const [logs, setLogs] = useState<WalletLogsData | null>(null);
|
||||||
const [filter, setFilter] = useState("");
|
const [filter, setFilter] = useState("");
|
||||||
@@ -25,10 +25,19 @@ export function WalletLogsScreen() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const currency = useMemo(
|
const logsForCurrency = useMemo(() => {
|
||||||
() => resolvePlayerCurrency(profile),
|
if (!logs) return null;
|
||||||
[profile],
|
const code = currency.toUpperCase();
|
||||||
);
|
return {
|
||||||
|
...logs,
|
||||||
|
items: logs.items.filter(
|
||||||
|
(item) => (item.currency_code || code).toUpperCase() === code,
|
||||||
|
),
|
||||||
|
pending_reconcile: logs.pending_reconcile.filter(
|
||||||
|
(item) => item.currency_code.toUpperCase() === code,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}, [currency, logs]);
|
||||||
|
|
||||||
const fetchPassRef = useRef(true);
|
const fetchPassRef = useRef(true);
|
||||||
|
|
||||||
@@ -69,6 +78,12 @@ export function WalletLogsScreen() {
|
|||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
void load(1, false);
|
void load(1, false);
|
||||||
});
|
});
|
||||||
|
}, [currency, load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onCurrencyChange = () => void load(1, false);
|
||||||
|
window.addEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange);
|
||||||
|
return () => window.removeEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange);
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
const hasMore = logs ? logs.page < getWalletLogsLastPage(logs) : false;
|
const hasMore = logs ? logs.page < getWalletLogsLastPage(logs) : false;
|
||||||
@@ -116,7 +131,7 @@ export function WalletLogsScreen() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<WalletLogsBlock
|
<WalletLogsBlock
|
||||||
logs={logs}
|
logs={logsForCurrency}
|
||||||
logsLoading={loading || logsLoading}
|
logsLoading={loading || logsLoading}
|
||||||
loadingMore={loadingMore}
|
loadingMore={loadingMore}
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
|
|||||||
@@ -14,17 +14,17 @@ import {
|
|||||||
TransferOutDialog,
|
TransferOutDialog,
|
||||||
} from "@/features/wallet/wallet-transfer-dialogs";
|
} from "@/features/wallet/wallet-transfer-dialogs";
|
||||||
import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block";
|
import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block";
|
||||||
|
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||||
import { formatMinorAsCurrency } from "@/lib/money";
|
import { formatMinorAsCurrency } from "@/lib/money";
|
||||||
import { resolvePlayerCurrency } from "@/lib/player-currency";
|
import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
|
||||||
import { formatWalletClientError } from "@/lib/wallet-api-error";
|
import { formatWalletClientError } from "@/lib/wallet-api-error";
|
||||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
|
||||||
import type { WalletBalanceData } from "@/types/api/wallet-balance";
|
import type { WalletBalanceData } from "@/types/api/wallet-balance";
|
||||||
import { getWalletLogsLastPage, type WalletLogsData } from "@/types/api/wallet-logs";
|
import { getWalletLogsLastPage, type WalletLogsData } from "@/types/api/wallet-logs";
|
||||||
|
|
||||||
const WALLET_LOGS_PAGE_SIZE = 10;
|
const WALLET_LOGS_PAGE_SIZE = 10;
|
||||||
|
|
||||||
export function WalletScreen() {
|
export function WalletScreen() {
|
||||||
const profile = usePlayerSessionStore((s) => s.profile);
|
const { activeCurrency: currency } = useActivePlayerCurrency();
|
||||||
const { t } = useTranslation("player");
|
const { t } = useTranslation("player");
|
||||||
const [balance, setBalance] = useState<WalletBalanceData | null>(null);
|
const [balance, setBalance] = useState<WalletBalanceData | null>(null);
|
||||||
const [logs, setLogs] = useState<WalletLogsData | null>(null);
|
const [logs, setLogs] = useState<WalletLogsData | null>(null);
|
||||||
@@ -35,11 +35,6 @@ export function WalletScreen() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const currency = useMemo(
|
|
||||||
() => resolvePlayerCurrency(profile, balance),
|
|
||||||
[balance, profile],
|
|
||||||
);
|
|
||||||
|
|
||||||
const fetchPassRef = useRef(true);
|
const fetchPassRef = useRef(true);
|
||||||
|
|
||||||
const loadLogs = useCallback(async (targetPage = 1, append = false) => {
|
const loadLogs = useCallback(async (targetPage = 1, append = false) => {
|
||||||
@@ -68,7 +63,7 @@ export function WalletScreen() {
|
|||||||
setLogsLoading(true);
|
setLogsLoading(true);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const b = await getWalletBalance();
|
const b = await getWalletBalance({ currency });
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setBalance(b);
|
setBalance(b);
|
||||||
const nextLogs = await loadLogs(1, false);
|
const nextLogs = await loadLogs(1, false);
|
||||||
@@ -89,13 +84,13 @@ export function WalletScreen() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [loadLogs, t]);
|
}, [currency, loadLogs, t]);
|
||||||
|
|
||||||
const refreshAll = useCallback(async () => {
|
const refreshAll = useCallback(async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
setLogsLoading(true);
|
setLogsLoading(true);
|
||||||
try {
|
try {
|
||||||
const b = await getWalletBalance();
|
const b = await getWalletBalance({ currency });
|
||||||
setBalance(b);
|
setBalance(b);
|
||||||
await loadLogs(1, false);
|
await loadLogs(1, false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -104,7 +99,27 @@ export function WalletScreen() {
|
|||||||
setLogsLoading(false);
|
setLogsLoading(false);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [loadLogs, t]);
|
}, [currency, loadLogs, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onCurrencyChange = () => void refreshAll();
|
||||||
|
window.addEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange);
|
||||||
|
return () => window.removeEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange);
|
||||||
|
}, [refreshAll]);
|
||||||
|
|
||||||
|
const logsForCurrency = useMemo(() => {
|
||||||
|
if (!logs) return null;
|
||||||
|
const code = currency.toUpperCase();
|
||||||
|
return {
|
||||||
|
...logs,
|
||||||
|
items: logs.items.filter(
|
||||||
|
(item) => (item.currency_code || code).toUpperCase() === code,
|
||||||
|
),
|
||||||
|
pending_reconcile: logs.pending_reconcile.filter(
|
||||||
|
(item) => item.currency_code.toUpperCase() === code,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}, [currency, logs]);
|
||||||
|
|
||||||
const hasMore = logs ? logs.page < getWalletLogsLastPage(logs) : false;
|
const hasMore = logs ? logs.page < getWalletLogsLastPage(logs) : false;
|
||||||
|
|
||||||
@@ -207,7 +222,7 @@ export function WalletScreen() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<WalletLogsBlock
|
<WalletLogsBlock
|
||||||
logs={logs}
|
logs={logsForCurrency}
|
||||||
logsLoading={loading || logsLoading}
|
logsLoading={loading || logsLoading}
|
||||||
loadingMore={loadingMore}
|
loadingMore={loadingMore}
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
|
|||||||
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 { useCallback, useEffect, useRef } from "react";
|
||||||
|
|
||||||
import { getWalletBalance } from "@/api/wallet";
|
import { getWalletBalance } from "@/api/wallet";
|
||||||
|
import { getActivePlayerCurrencyFromStore } from "@/lib/player-currency";
|
||||||
import { useNetworkConnectionStore } from "@/stores/network-connection-store";
|
import { useNetworkConnectionStore } from "@/stores/network-connection-store";
|
||||||
|
|
||||||
const POLLING_INTERVAL_MS = 30_000; // 30秒轮询间隔
|
const POLLING_INTERVAL_MS = 30_000; // 30秒轮询间隔
|
||||||
@@ -46,7 +47,7 @@ export function useWalletPolling(): UseWalletPollingReturn {
|
|||||||
// 刷新钱包余额
|
// 刷新钱包余额
|
||||||
const refreshWallet = useCallback(async () => {
|
const refreshWallet = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await getWalletBalance();
|
await getWalletBalance({ currency: getActivePlayerCurrencyFromStore() });
|
||||||
// 触发全局刷新事件,让所有监听组件更新
|
// 触发全局刷新事件,让所有监听组件更新
|
||||||
window.dispatchEvent(new Event("lottery-wallet-refresh"));
|
window.dispatchEvent(new Event("lottery-wallet-refresh"));
|
||||||
} catch {
|
} catch {
|
||||||
@@ -144,7 +145,7 @@ export function triggerWalletPollingAfterBet(): void {
|
|||||||
// 如果是降级模式,立即刷新并启动限时轮询
|
// 如果是降级模式,立即刷新并启动限时轮询
|
||||||
if (store.mode === "polling" || store.mode === "offline") {
|
if (store.mode === "polling" || store.mode === "offline") {
|
||||||
// 立即刷新一次
|
// 立即刷新一次
|
||||||
void getWalletBalance().then(() => {
|
void getWalletBalance({ currency: getActivePlayerCurrencyFromStore() }).then(() => {
|
||||||
window.dispatchEvent(new Event("lottery-wallet-refresh"));
|
window.dispatchEvent(new Event("lottery-wallet-refresh"));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -161,7 +162,7 @@ export function triggerWalletPollingAfterBet(): void {
|
|||||||
store.setWalletPollingIntervalId(null);
|
store.setWalletPollingIntervalId(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void getWalletBalance().then(() => {
|
void getWalletBalance({ currency: getActivePlayerCurrencyFromStore() }).then(() => {
|
||||||
window.dispatchEvent(new Event("lottery-wallet-refresh"));
|
window.dispatchEvent(new Event("lottery-wallet-refresh"));
|
||||||
});
|
});
|
||||||
}, POLLING_INTERVAL_MS);
|
}, POLLING_INTERVAL_MS);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef } from "react";
|
|||||||
|
|
||||||
import { getDrawCurrent } from "@/api/draw";
|
import { getDrawCurrent } from "@/api/draw";
|
||||||
import { getWalletBalance } from "@/api/wallet";
|
import { getWalletBalance } from "@/api/wallet";
|
||||||
|
import { getActivePlayerCurrencyFromStore } from "@/lib/player-currency";
|
||||||
import { getLotteryEcho } from "@/lib/lottery-echo";
|
import { getLotteryEcho } from "@/lib/lottery-echo";
|
||||||
import {
|
import {
|
||||||
useNetworkConnectionStore,
|
useNetworkConnectionStore,
|
||||||
@@ -72,7 +73,7 @@ export function useWebSocketManager(): UseWebSocketManagerReturn {
|
|||||||
// 刷新钱包余额
|
// 刷新钱包余额
|
||||||
const refreshWallet = useCallback(async () => {
|
const refreshWallet = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await getWalletBalance();
|
await getWalletBalance({ currency: getActivePlayerCurrencyFromStore() });
|
||||||
// 触发全局刷新事件,让组件更新
|
// 触发全局刷新事件,让组件更新
|
||||||
window.dispatchEvent(new Event("lottery-wallet-refresh"));
|
window.dispatchEvent(new Event("lottery-wallet-refresh"));
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -15,6 +15,11 @@
|
|||||||
"pending": "Pending",
|
"pending": "Pending",
|
||||||
"failed": "Failed"
|
"failed": "Failed"
|
||||||
},
|
},
|
||||||
|
"currency": {
|
||||||
|
"current": "Currency {{code}}",
|
||||||
|
"switchAria": "Switch currency (current {{code}})",
|
||||||
|
"option": "{{code}} · {{name}}"
|
||||||
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"notifications": "Notifications"
|
"notifications": "Notifications"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -117,6 +117,7 @@
|
|||||||
"title": "Jackpot Burst",
|
"title": "Jackpot Burst",
|
||||||
"subtitle": "Issue {{drawNo}} triggered a pool payout",
|
"subtitle": "Issue {{drawNo}} triggered a pool payout",
|
||||||
"number": "First prize number",
|
"number": "First prize number",
|
||||||
|
"numberPending": "First prize numbers are not published for this issue yet",
|
||||||
"amount": "Burst amount",
|
"amount": "Burst amount",
|
||||||
"winners": "Winners",
|
"winners": "Winners",
|
||||||
"triggerLabel": "Trigger",
|
"triggerLabel": "Trigger",
|
||||||
@@ -548,6 +549,7 @@
|
|||||||
},
|
},
|
||||||
"ticketStatus": {
|
"ticketStatus": {
|
||||||
"success": "Awaiting draw",
|
"success": "Awaiting draw",
|
||||||
|
"pending_draw": "Awaiting draw",
|
||||||
"pending_payout": "Won, pending payout",
|
"pending_payout": "Won, pending payout",
|
||||||
"settled_win": "Paid",
|
"settled_win": "Paid",
|
||||||
"settled_lose": "Not won",
|
"settled_lose": "Not won",
|
||||||
|
|||||||
@@ -15,6 +15,11 @@
|
|||||||
"pending": "बाँकी",
|
"pending": "बाँकी",
|
||||||
"failed": "असफल"
|
"failed": "असफल"
|
||||||
},
|
},
|
||||||
|
"currency": {
|
||||||
|
"current": "मुद्रा {{code}}",
|
||||||
|
"switchAria": "मुद्रा बदल्नुहोस् (हाल {{code}})",
|
||||||
|
"option": "{{code}} · {{name}}"
|
||||||
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"notifications": "सूचनाहरू"
|
"notifications": "सूचनाहरू"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -117,6 +117,7 @@
|
|||||||
"title": "Jackpot Burst",
|
"title": "Jackpot Burst",
|
||||||
"subtitle": "इश्यू {{drawNo}} मा पूल payout ट्रिगर भयो",
|
"subtitle": "इश्यू {{drawNo}} मा पूल payout ट्रिगर भयो",
|
||||||
"number": "पहिलो पुरस्कार नम्बर",
|
"number": "पहिलो पुरस्कार नम्बर",
|
||||||
|
"numberPending": "यो इश्यूको लागि पहिलो पुरस्कार नम्बर अझै प्रकाशित भएको छैन",
|
||||||
"amount": "Burst रकम",
|
"amount": "Burst रकम",
|
||||||
"winners": "विजेता",
|
"winners": "विजेता",
|
||||||
"triggerLabel": "ट्रिगर",
|
"triggerLabel": "ट्रिगर",
|
||||||
@@ -543,6 +544,7 @@
|
|||||||
},
|
},
|
||||||
"ticketStatus": {
|
"ticketStatus": {
|
||||||
"success": "ड्र पर्खँदै",
|
"success": "ड्र पर्खँदै",
|
||||||
|
"pending_draw": "ड्र पर्खँदै",
|
||||||
"pending_payout": "जितेको, भुक्तानी बाँकी",
|
"pending_payout": "जितेको, भुक्तानी बाँकी",
|
||||||
"settled_win": "भुक्तानी भयो",
|
"settled_win": "भुक्तानी भयो",
|
||||||
"settled_lose": "जितेन",
|
"settled_lose": "जितेन",
|
||||||
|
|||||||
@@ -15,6 +15,11 @@
|
|||||||
"pending": "待处理",
|
"pending": "待处理",
|
||||||
"failed": "失败"
|
"failed": "失败"
|
||||||
},
|
},
|
||||||
|
"currency": {
|
||||||
|
"current": "当前币种 {{code}}",
|
||||||
|
"switchAria": "切换币种(当前 {{code}})",
|
||||||
|
"option": "{{code}} · {{name}}"
|
||||||
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"notifications": "通知"
|
"notifications": "通知"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -117,6 +117,7 @@
|
|||||||
"title": "Jackpot 爆池",
|
"title": "Jackpot 爆池",
|
||||||
"subtitle": "期号 {{drawNo}} 触发奖池派发",
|
"subtitle": "期号 {{drawNo}} 触发奖池派发",
|
||||||
"number": "头奖号码",
|
"number": "头奖号码",
|
||||||
|
"numberPending": "该期尚未公布头奖号码",
|
||||||
"amount": "爆池金额",
|
"amount": "爆池金额",
|
||||||
"winners": "中奖人数",
|
"winners": "中奖人数",
|
||||||
"triggerLabel": "触发方式",
|
"triggerLabel": "触发方式",
|
||||||
@@ -548,6 +549,7 @@
|
|||||||
},
|
},
|
||||||
"ticketStatus": {
|
"ticketStatus": {
|
||||||
"success": "待开奖",
|
"success": "待开奖",
|
||||||
|
"pending_draw": "待开奖",
|
||||||
"pending_payout": "已中奖待派彩",
|
"pending_payout": "已中奖待派彩",
|
||||||
"settled_win": "已派彩",
|
"settled_win": "已派彩",
|
||||||
"settled_lose": "未中奖",
|
"settled_lose": "未中奖",
|
||||||
|
|||||||
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 { PlayerMeData } from "@/types/api/player-me";
|
||||||
import type { WalletBalanceData } from "@/types/api/wallet-balance";
|
import type { WalletBalanceData } from "@/types/api/wallet-balance";
|
||||||
|
|
||||||
@@ -5,8 +7,7 @@ type CurrencyProfile = Pick<PlayerMeData, "default_currency"> | null | undefined
|
|||||||
type CurrencyBalance = Pick<WalletBalanceData, "currency_code"> | null | undefined;
|
type CurrencyBalance = Pick<WalletBalanceData, "currency_code"> | null | undefined;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 玩家端当前业务币种:
|
* 玩家档案/环境变量层面的默认币种(不含用户在大厅的切换选择)。
|
||||||
* 优先钱包接口返回币种,再回退玩家默认币种,环境变量仅作联调兜底。
|
|
||||||
*/
|
*/
|
||||||
export function resolvePlayerCurrency(
|
export function resolvePlayerCurrency(
|
||||||
profile: CurrencyProfile,
|
profile: CurrencyProfile,
|
||||||
@@ -29,3 +30,15 @@ export function resolvePlayerCurrency(
|
|||||||
|
|
||||||
return "NPR";
|
return "NPR";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前业务币种(含玩家在大厅切换的可下注币种)。
|
||||||
|
* 可在非 React 上下文(轮询、WS 刷新)中调用。
|
||||||
|
*/
|
||||||
|
export function getActivePlayerCurrencyFromStore(): string {
|
||||||
|
const { selectedCurrency, profile, currencies } = usePlayerSessionStore.getState();
|
||||||
|
if (selectedCurrency) {
|
||||||
|
return selectedCurrency;
|
||||||
|
}
|
||||||
|
return pickActivePlayerCurrency(profile, currencies, null);
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ import {
|
|||||||
persistPlayerBearerToken,
|
persistPlayerBearerToken,
|
||||||
readPersistedPlayerBearerToken,
|
readPersistedPlayerBearerToken,
|
||||||
} from "@/lib/player-session";
|
} from "@/lib/player-session";
|
||||||
|
import {
|
||||||
|
dispatchPlayerCurrencyChange,
|
||||||
|
persistPlayerSelectedCurrency,
|
||||||
|
readPersistedPlayerSelectedCurrency,
|
||||||
|
} from "@/lib/player-currency-preference";
|
||||||
|
import { pickActivePlayerCurrency } from "@/lib/player-currency-options";
|
||||||
import type { PublicCurrencyRow } from "@/types/api/currency";
|
import type { PublicCurrencyRow } from "@/types/api/currency";
|
||||||
import type { PlayerMeData } from "@/types/api/player-me";
|
import type { PlayerMeData } from "@/types/api/player-me";
|
||||||
|
|
||||||
@@ -23,6 +29,8 @@ type PlayerSessionState = {
|
|||||||
bearerToken: string | null;
|
bearerToken: string | null;
|
||||||
profile: PlayerMeData | null;
|
profile: PlayerMeData | null;
|
||||||
currencies: PublicCurrencyRow[];
|
currencies: PublicCurrencyRow[];
|
||||||
|
/** 玩家在大厅选择的可下注币种(localStorage 持久化) */
|
||||||
|
selectedCurrency: string | null;
|
||||||
phase: PlayerEntryPhase;
|
phase: PlayerEntryPhase;
|
||||||
progress: number;
|
progress: number;
|
||||||
errorMessage: string | null;
|
errorMessage: string | null;
|
||||||
@@ -32,6 +40,9 @@ type PlayerSessionState = {
|
|||||||
clearBearerToken: () => void;
|
clearBearerToken: () => void;
|
||||||
setProfile: (profile: PlayerMeData | null) => void;
|
setProfile: (profile: PlayerMeData | null) => void;
|
||||||
setCurrencies: (currencies: PublicCurrencyRow[]) => void;
|
setCurrencies: (currencies: PublicCurrencyRow[]) => void;
|
||||||
|
/** 根据档案与币种目录校准当前选中币种 */
|
||||||
|
reconcileSelectedCurrency: () => void;
|
||||||
|
setSelectedCurrency: (code: string) => void;
|
||||||
setPhase: (phase: PlayerEntryPhase) => void;
|
setPhase: (phase: PlayerEntryPhase) => void;
|
||||||
setProgress: (progress: number) => void;
|
setProgress: (progress: number) => void;
|
||||||
setErrorMessage: (message: string | null) => void;
|
setErrorMessage: (message: string | null) => void;
|
||||||
@@ -47,10 +58,11 @@ function initialSteps(): PlayerEntryStep[] {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const usePlayerSessionStore = create<PlayerSessionState>((set) => ({
|
export const usePlayerSessionStore = create<PlayerSessionState>((set, get) => ({
|
||||||
bearerToken: null,
|
bearerToken: null,
|
||||||
profile: null,
|
profile: null,
|
||||||
currencies: [],
|
currencies: [],
|
||||||
|
selectedCurrency: null,
|
||||||
phase: "loading",
|
phase: "loading",
|
||||||
progress: 0,
|
progress: 0,
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
@@ -83,11 +95,46 @@ export const usePlayerSessionStore = create<PlayerSessionState>((set) => ({
|
|||||||
clearBearerToken: () => {
|
clearBearerToken: () => {
|
||||||
setPlayerBearerToken(null);
|
setPlayerBearerToken(null);
|
||||||
clearPersistedPlayerBearerToken();
|
clearPersistedPlayerBearerToken();
|
||||||
set({ bearerToken: null, profile: null });
|
set({ bearerToken: null, profile: null, selectedCurrency: null });
|
||||||
},
|
},
|
||||||
|
|
||||||
setProfile: (profile) => set({ profile }),
|
setProfile: (profile) => {
|
||||||
setCurrencies: (currencies) => set({ currencies }),
|
set({ profile });
|
||||||
|
get().reconcileSelectedCurrency();
|
||||||
|
},
|
||||||
|
|
||||||
|
setCurrencies: (currencies) => {
|
||||||
|
set({ currencies });
|
||||||
|
get().reconcileSelectedCurrency();
|
||||||
|
},
|
||||||
|
|
||||||
|
reconcileSelectedCurrency: () => {
|
||||||
|
const { profile, currencies } = get();
|
||||||
|
const next = pickActivePlayerCurrency(
|
||||||
|
profile,
|
||||||
|
currencies,
|
||||||
|
readPersistedPlayerSelectedCurrency(),
|
||||||
|
);
|
||||||
|
const prev = get().selectedCurrency;
|
||||||
|
if (prev === next) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
persistPlayerSelectedCurrency(next);
|
||||||
|
set({ selectedCurrency: next });
|
||||||
|
},
|
||||||
|
|
||||||
|
setSelectedCurrency: (code) => {
|
||||||
|
const normalized = code.trim().toUpperCase();
|
||||||
|
const allowed = get().currencies.some(
|
||||||
|
(row) => row.is_bettable && row.code === normalized,
|
||||||
|
);
|
||||||
|
if (!allowed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
persistPlayerSelectedCurrency(normalized);
|
||||||
|
set({ selectedCurrency: normalized });
|
||||||
|
dispatchPlayerCurrencyChange(normalized);
|
||||||
|
},
|
||||||
setPhase: (phase) => set({ phase }),
|
setPhase: (phase) => set({ phase }),
|
||||||
setProgress: (progress) => set({ progress: Math.max(0, Math.min(100, progress)) }),
|
setProgress: (progress) => set({ progress: Math.max(0, Math.min(100, progress)) }),
|
||||||
setErrorMessage: (errorMessage) => set({ errorMessage }),
|
setErrorMessage: (errorMessage) => set({ errorMessage }),
|
||||||
|
|||||||
@@ -33,9 +33,7 @@ export type PlayEffectivePlayRow = {
|
|||||||
category: string;
|
category: string;
|
||||||
dimension: number | null;
|
dimension: number | null;
|
||||||
bet_mode: string | null;
|
bet_mode: string | null;
|
||||||
display_name_zh: string | null;
|
display_name: string | null;
|
||||||
display_name_en: string | null;
|
|
||||||
display_name_ne: string | null;
|
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
supports_multi_number: boolean;
|
supports_multi_number: boolean;
|
||||||
master_enabled: boolean;
|
master_enabled: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user