feat: add jackpot animations and enhance currency handling across components

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

View File

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