feat: 增加 Jackpot 爆池实时弹层与奖池信息展示
This commit is contained in:
@@ -22,6 +22,7 @@ import {
|
||||
} from "@/features/hall/hall-bet-rules";
|
||||
import type { HallDrawLiveSnapshot } from "@/features/hall/use-hall-draw-live";
|
||||
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 { cn } from "@/lib/utils";
|
||||
@@ -63,6 +64,16 @@ type ClosedPlayCleanupData = {
|
||||
cleanup_lines?: Array<{ client_line_no?: number; play_code?: string }>;
|
||||
};
|
||||
|
||||
type PlayToggleWsEvent = {
|
||||
play_code?: string;
|
||||
enabled?: boolean;
|
||||
action?: string;
|
||||
};
|
||||
|
||||
type OddsUpdateWsEvent = {
|
||||
message?: string;
|
||||
};
|
||||
|
||||
type CellRiskState = "open" | "warning" | "sold_out";
|
||||
type QuickFillState = Record<HallCategory, { favorites: string[]; history: string[] }>;
|
||||
|
||||
@@ -482,6 +493,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
);
|
||||
|
||||
const alertRows = display?.risk_pool_alerts ?? [];
|
||||
const jackpot = display?.jackpot;
|
||||
const currentQuickFill = quickFillState[activeCategory] ?? { favorites: [], history: [] };
|
||||
const favorites = currentQuickFill.favorites;
|
||||
const historyNumbers = currentQuickFill.history;
|
||||
@@ -577,6 +589,79 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
});
|
||||
};
|
||||
|
||||
const clearAmountsForPlay = useCallback((playCode: string): boolean => {
|
||||
let removed = false;
|
||||
setRows((current) =>
|
||||
current.map((row) => {
|
||||
const nextAmounts = { ...row.amounts };
|
||||
let changed = false;
|
||||
Object.keys(nextAmounts).forEach((amountKey) => {
|
||||
const keyPlayCode = amountKey.split("@")[0];
|
||||
if (keyPlayCode !== playCode || !nextAmounts[amountKey]) return;
|
||||
nextAmounts[amountKey] = "";
|
||||
changed = true;
|
||||
removed = true;
|
||||
});
|
||||
return changed ? { ...row, amounts: nextAmounts } : row;
|
||||
}),
|
||||
);
|
||||
|
||||
return removed;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const echo = getLotteryEcho();
|
||||
if (!echo) return;
|
||||
|
||||
const channel = echo.channel("lottery-hall");
|
||||
|
||||
const onPlayToggle = (evt: PlayToggleWsEvent) => {
|
||||
void loadCatalog();
|
||||
if (evt.enabled === false && typeof evt.play_code === "string") {
|
||||
const removed = clearAmountsForPlay(evt.play_code);
|
||||
setPreviewOpen(false);
|
||||
setPreviewData(null);
|
||||
toast.warning(
|
||||
removed
|
||||
? t("hall.playConfig.playClosedDraftCleared", {
|
||||
playCode: evt.play_code,
|
||||
defaultValue: "{{playCode}} 已关闭,相关草稿金额已清除。",
|
||||
})
|
||||
: t("hall.playConfig.playClosed", {
|
||||
playCode: evt.play_code,
|
||||
defaultValue: "{{playCode}} 已关闭。",
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
toast.message(
|
||||
t("hall.playConfig.updated", {
|
||||
defaultValue: "玩法配置已更新,已刷新下注表格。",
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onOddsUpdate = (evt: OddsUpdateWsEvent) => {
|
||||
void loadCatalog();
|
||||
setPreviewOpen(false);
|
||||
setPreviewData(null);
|
||||
toast.message(
|
||||
evt.message ??
|
||||
t("hall.playConfig.oddsUpdated", {
|
||||
defaultValue: "赔率已更新,请重新预览注单。",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
channel.listen(".play.toggle", onPlayToggle);
|
||||
channel.listen(".odds.update", onOddsUpdate);
|
||||
|
||||
return () => {
|
||||
channel.stopListening(".play.toggle");
|
||||
channel.stopListening(".odds.update");
|
||||
};
|
||||
}, [clearAmountsForPlay, loadCatalog, t]);
|
||||
|
||||
const collectEntries = useCallback((): DraftEntry[] => {
|
||||
if (activeCategory === "JACKPOT") return [];
|
||||
const entries: DraftEntry[] = [];
|
||||
@@ -847,6 +932,29 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{jackpot?.enabled ? (
|
||||
<div className="rounded-xl border border-amber-200 bg-gradient-to-r from-amber-50 via-white to-[#f8fbff] px-4 py-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-[11px] font-black uppercase tracking-normal text-amber-700">
|
||||
{t("results.jackpotLabel", { defaultValue: "Jackpot" })}
|
||||
</p>
|
||||
<p className="font-mono text-xl font-black tabular-nums text-[#07459f]">
|
||||
{formatMinorAsCurrency(jackpot.current_amount_minor, jackpot.currency_code)}
|
||||
</p>
|
||||
</div>
|
||||
{jackpot.draws_since_last_burst !== null ? (
|
||||
<p className="rounded-full bg-amber-100 px-3 py-1 text-xs font-bold text-amber-800">
|
||||
{t("results.jackpotGap", {
|
||||
count: jackpot.draws_since_last_burst,
|
||||
defaultValue: "距上次爆池 {{count}} 期",
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activeCategory === "JACKPOT" ? (
|
||||
<div className="rounded-xl border border-[#edf1f7] bg-[#f7f9fc] p-7 text-center text-slate-500">
|
||||
<div className="mx-auto flex size-14 items-center justify-center rounded-full bg-slate-200 text-slate-600">
|
||||
|
||||
@@ -9,7 +9,9 @@ import { LanguageSwitcher } from "@/components/language-switcher";
|
||||
import { HallBettingGrid } from "@/features/hall/hall-betting-grid";
|
||||
import { HallDrawPanel } from "@/features/hall/hall-draw-panel";
|
||||
import { HallWalletStrip } from "@/features/hall/hall-wallet-strip";
|
||||
import { JackpotBurstOverlay } from "@/features/hall/jackpot-burst-overlay";
|
||||
import { useHallDrawLive } from "@/features/hall/use-hall-draw-live";
|
||||
import { useJackpotBurstLive } from "@/features/hall/use-jackpot-burst-live";
|
||||
|
||||
/**
|
||||
* 下注大厅:钱包条 §4 + 当期期号 §4.2(封盘置灰 / 倒计时错误色 / WS+轮询);玩法目录 §12.3;下注表格 §13.3。
|
||||
@@ -18,6 +20,7 @@ export function HallScreen() {
|
||||
const { t } = useTranslation("common");
|
||||
const { t: tp } = useTranslation("player");
|
||||
const drawLive = useHallDrawLive();
|
||||
const { burstEvent, clearBurstEvent } = useJackpotBurstLive(tp);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[480px]">
|
||||
@@ -62,6 +65,7 @@ export function HallScreen() {
|
||||
|
||||
<HallBettingGrid drawLive={drawLive} />
|
||||
</section>
|
||||
<JackpotBurstOverlay event={burstEvent} onClose={clearBurstEvent} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
110
src/features/hall/jackpot-burst-overlay.tsx
Normal file
110
src/features/hall/jackpot-burst-overlay.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { X, Zap } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatMinorAsCurrency } from "@/lib/money";
|
||||
|
||||
export type JackpotBurstEvent = {
|
||||
draw_id: number;
|
||||
draw_no: string;
|
||||
first_prize_number: string;
|
||||
currency_code: string;
|
||||
total_payout_amount: number;
|
||||
winner_count: number;
|
||||
trigger_type: string;
|
||||
pool_amount_after: number;
|
||||
emitted_at_ms?: number;
|
||||
};
|
||||
|
||||
type JackpotBurstOverlayProps = {
|
||||
event: JackpotBurstEvent | null;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function triggerLabel(triggerType: string, t: ReturnType<typeof useTranslation>["t"]) {
|
||||
return t(`hall.jackpotBurst.trigger.${triggerType}`, {
|
||||
defaultValue: triggerType,
|
||||
});
|
||||
}
|
||||
|
||||
export function JackpotBurstOverlay({ event, onClose }: JackpotBurstOverlayProps) {
|
||||
const { t } = useTranslation("player");
|
||||
|
||||
if (!event) return null;
|
||||
|
||||
const currency = event.currency_code.toUpperCase();
|
||||
const amount = formatMinorAsCurrency(event.total_payout_amount, currency);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[90] flex items-center justify-center bg-[#08111f]/95 px-4 py-6 text-white"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("hall.jackpotBurst.title", { defaultValue: "Jackpot 爆池" })}
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<div className="absolute left-0 top-1/4 h-px w-full animate-[pulse_1.8s_ease-in-out_infinite] bg-[#f5c542]" />
|
||||
<div className="absolute bottom-1/3 left-0 h-px w-full animate-[pulse_2.4s_ease-in-out_infinite] bg-[#2dd4bf]" />
|
||||
</div>
|
||||
|
||||
<div className="relative flex w-full max-w-[420px] flex-col items-center rounded-lg border border-[#f5c542]/50 bg-[#101b2d] px-5 py-6 text-center shadow-2xl shadow-black/40">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-2 top-2 text-white hover:bg-white/10 hover:text-white"
|
||||
onClick={onClose}
|
||||
aria-label={t("hall.jackpotBurst.close", { defaultValue: "关闭" })}
|
||||
>
|
||||
<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]">
|
||||
<Zap className="size-7" aria-hidden />
|
||||
</div>
|
||||
|
||||
<p className="text-xs font-bold uppercase tracking-[0.18em] text-[#f5c542]">
|
||||
{t("hall.jackpotBurst.title", { defaultValue: "Jackpot 爆池" })}
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-semibold text-slate-200">
|
||||
{t("hall.jackpotBurst.subtitle", {
|
||||
defaultValue: "期号 {{drawNo}} 触发奖池派发",
|
||||
drawNo: event.draw_no,
|
||||
})}
|
||||
</p>
|
||||
|
||||
<div className="my-5 w-full rounded-lg border border-white/10 bg-white/[0.06] px-4 py-4">
|
||||
<div className="text-[2.35rem] font-black leading-none text-[#f5c542] sm:text-[2.8rem]">
|
||||
{event.first_prize_number || "----"}
|
||||
</div>
|
||||
<p className="mt-2 text-xs font-semibold text-slate-300">
|
||||
{t("hall.jackpotBurst.number", { defaultValue: "头奖号码" })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-2 text-left text-sm">
|
||||
<div className="flex items-center justify-between gap-4 rounded-md bg-white/[0.05] px-3 py-2">
|
||||
<span className="text-slate-300">
|
||||
{t("hall.jackpotBurst.amount", { defaultValue: "爆池金额" })}
|
||||
</span>
|
||||
<span className="text-right font-black text-[#f5c542]">{amount}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 rounded-md bg-white/[0.05] px-3 py-2">
|
||||
<span className="text-slate-300">
|
||||
{t("hall.jackpotBurst.winners", { defaultValue: "中奖人数" })}
|
||||
</span>
|
||||
<span className="font-bold">{event.winner_count}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 rounded-md bg-white/[0.05] px-3 py-2">
|
||||
<span className="text-slate-300">
|
||||
{t("hall.jackpotBurst.triggerLabel", { defaultValue: "触发方式" })}
|
||||
</span>
|
||||
<span className="font-bold">{triggerLabel(event.trigger_type, t)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
src/features/hall/use-jackpot-burst-live.ts
Normal file
76
src/features/hall/use-jackpot-burst-live.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
import { getLotteryEcho } from "@/lib/lottery-echo";
|
||||
import { formatMinorAsCurrency } from "@/lib/money";
|
||||
import type { JackpotBurstEvent } from "@/features/hall/jackpot-burst-overlay";
|
||||
|
||||
function notifyBrowser(event: JackpotBurstEvent, t: TFunction<"player">): void {
|
||||
if (typeof window === "undefined" || !("Notification" in window)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currency = event.currency_code.toUpperCase();
|
||||
const amount = formatMinorAsCurrency(event.total_payout_amount, currency);
|
||||
const title = t("hall.jackpotBurst.notificationTitle", {
|
||||
defaultValue: "Jackpot 爆池",
|
||||
});
|
||||
const body = t("hall.jackpotBurst.notificationBody", {
|
||||
defaultValue: "期号 {{drawNo}} 派发 {{amount}}",
|
||||
drawNo: event.draw_no,
|
||||
amount,
|
||||
});
|
||||
|
||||
const push = () => {
|
||||
try {
|
||||
new Notification(title, { body, tag: `jackpot-burst-${event.draw_id}` });
|
||||
} catch {
|
||||
// 浏览器通知失败不影响页面内爆池动画。
|
||||
}
|
||||
};
|
||||
|
||||
if (Notification.permission === "granted") {
|
||||
push();
|
||||
return;
|
||||
}
|
||||
|
||||
if (Notification.permission === "default") {
|
||||
void Notification.requestPermission().then((permission) => {
|
||||
if (permission === "granted") {
|
||||
push();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function useJackpotBurstLive(t: TFunction<"player">) {
|
||||
const [event, setEvent] = useState<JackpotBurstEvent | null>(null);
|
||||
|
||||
const handleBurst = useCallback(
|
||||
(payload: JackpotBurstEvent) => {
|
||||
setEvent(payload);
|
||||
notifyBrowser(payload, t);
|
||||
window.dispatchEvent(new Event("lottery-wallet-refresh"));
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const echo = getLotteryEcho();
|
||||
if (!echo) return;
|
||||
|
||||
const channel = echo.channel("lottery-hall");
|
||||
channel.listen(".jackpot.burst", handleBurst);
|
||||
|
||||
return () => {
|
||||
channel.stopListening(".jackpot.burst");
|
||||
};
|
||||
}, [handleBurst]);
|
||||
|
||||
return {
|
||||
burstEvent: event,
|
||||
clearBurstEvent: () => setEvent(null),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user