feat: 增加 Jackpot 爆池实时弹层与奖池信息展示

This commit is contained in:
2026-05-18 15:08:29 +08:00
parent 418b446c09
commit 321b56e997
11 changed files with 379 additions and 0 deletions

View File

@@ -22,6 +22,7 @@ import {
} 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 { triggerWalletPollingAfterBet } from "@/hooks/use-wallet-polling"; import { triggerWalletPollingAfterBet } from "@/hooks/use-wallet-polling";
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 { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -63,6 +64,16 @@ type ClosedPlayCleanupData = {
cleanup_lines?: Array<{ client_line_no?: number; play_code?: string }>; 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 CellRiskState = "open" | "warning" | "sold_out";
type QuickFillState = Record<HallCategory, { favorites: string[]; history: string[] }>; 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 alertRows = display?.risk_pool_alerts ?? [];
const jackpot = display?.jackpot;
const currentQuickFill = quickFillState[activeCategory] ?? { favorites: [], history: [] }; const currentQuickFill = quickFillState[activeCategory] ?? { favorites: [], history: [] };
const favorites = currentQuickFill.favorites; const favorites = currentQuickFill.favorites;
const historyNumbers = currentQuickFill.history; 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[] => { const collectEntries = useCallback((): DraftEntry[] => {
if (activeCategory === "JACKPOT") return []; if (activeCategory === "JACKPOT") return [];
const entries: DraftEntry[] = []; const entries: DraftEntry[] = [];
@@ -847,6 +932,29 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
</div> </div>
</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" ? ( {activeCategory === "JACKPOT" ? (
<div className="rounded-xl border border-[#edf1f7] bg-[#f7f9fc] p-7 text-center text-slate-500"> <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"> <div className="mx-auto flex size-14 items-center justify-center rounded-full bg-slate-200 text-slate-600">

View File

@@ -9,7 +9,9 @@ import { LanguageSwitcher } from "@/components/language-switcher";
import { HallBettingGrid } from "@/features/hall/hall-betting-grid"; import { HallBettingGrid } from "@/features/hall/hall-betting-grid";
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 { useHallDrawLive } from "@/features/hall/use-hall-draw-live"; 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。 * 下注大厅:钱包条 §4 + 当期期号 §4.2(封盘置灰 / 倒计时错误色 / WS+轮询);玩法目录 §12.3;下注表格 §13.3。
@@ -18,6 +20,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 { burstEvent, clearBurstEvent } = useJackpotBurstLive(tp);
return ( return (
<div className="mx-auto w-full max-w-[480px]"> <div className="mx-auto w-full max-w-[480px]">
@@ -62,6 +65,7 @@ export function HallScreen() {
<HallBettingGrid drawLive={drawLive} /> <HallBettingGrid drawLive={drawLive} />
</section> </section>
<JackpotBurstOverlay event={burstEvent} onClose={clearBurstEvent} />
</div> </div>
); );
} }

View 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>
);
}

View 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),
};
}

View File

@@ -16,6 +16,7 @@ export function JackpotResultsStrip({
}: JackpotResultsStripProps) { }: JackpotResultsStripProps) {
const { t } = useTranslation("player"); const { t } = useTranslation("player");
const [minor, setMinor] = useState<number | null>(null); const [minor, setMinor] = useState<number | null>(null);
const [gap, setGap] = useState<number | null>(null);
const [enabled, setEnabled] = useState(false); const [enabled, setEnabled] = useState(false);
useEffect(() => { useEffect(() => {
@@ -26,11 +27,13 @@ export function JackpotResultsStrip({
if (!cancelled) { if (!cancelled) {
setEnabled(j.enabled); setEnabled(j.enabled);
setMinor(j.current_amount_minor); setMinor(j.current_amount_minor);
setGap(j.draws_since_last_burst);
} }
} catch { } catch {
if (!cancelled) { if (!cancelled) {
setEnabled(false); setEnabled(false);
setMinor(null); setMinor(null);
setGap(null);
} }
} }
})(); })();
@@ -51,6 +54,11 @@ export function JackpotResultsStrip({
<p className="font-mono text-lg font-black tabular-nums text-[#0b3f96]"> <p className="font-mono text-lg font-black tabular-nums text-[#0b3f96]">
{formatMinorAsCurrency(minor, currencyCode.toUpperCase())} {formatMinorAsCurrency(minor, currencyCode.toUpperCase())}
</p> </p>
{gap !== null ? (
<p className="mt-1 text-xs font-semibold text-amber-800">
{t("results.jackpotGap", { count: gap, defaultValue: "距上次爆池 {{count}} 期" })}
</p>
) : null}
</div> </div>
); );
} }

View File

@@ -102,6 +102,23 @@
"changedBeforeSubmit": "Your draft changed before submission. Close the preview and try again.", "changedBeforeSubmit": "Your draft changed before submission. Close the preview and try again.",
"placeFailed": "Submission failed", "placeFailed": "Submission failed",
"placeSuccess": "Bet submitted. Order {{orderNo}}, deducted {{amount}}.", "placeSuccess": "Bet submitted. Order {{orderNo}}, deducted {{amount}}.",
"jackpotBurst": {
"title": "Jackpot Burst",
"subtitle": "Issue {{drawNo}} triggered a pool payout",
"number": "First prize number",
"amount": "Burst amount",
"winners": "Winners",
"triggerLabel": "Trigger",
"close": "Close",
"notificationTitle": "Jackpot Burst",
"notificationBody": "Issue {{drawNo}} paid {{amount}}",
"trigger": {
"threshold": "Threshold reached",
"forced_gap": "Forced gap reached",
"play_combo": "Play combo matched",
"manual": "Manual burst"
}
},
"closed": { "closed": {
"title": "Closed", "title": "Closed",
"subtitle": "This issue is now closed.", "subtitle": "This issue is now closed.",
@@ -362,6 +379,7 @@
"hitHint": "If you win, numbers matched by your tickets are highlighted in gold (login required).", "hitHint": "If you win, numbers matched by your tickets are highlighted in gold (login required).",
"viewMyWinning": "View my winning status", "viewMyWinning": "View my winning status",
"jackpotLabel": "Jackpot", "jackpotLabel": "Jackpot",
"jackpotGap": "{{count}} draws since last burst",
"tier": { "tier": {
"first": "First prize", "first": "First prize",
"second": "Second prize", "second": "Second prize",

View File

@@ -102,6 +102,23 @@
"changedBeforeSubmit": "पेश गर्नु अघि ड्राफ्ट परिवर्तन भयो। पूर्वावलोकन बन्द गरी फेरि प्रयास गर्नुहोस्।", "changedBeforeSubmit": "पेश गर्नु अघि ड्राफ्ट परिवर्तन भयो। पूर्वावलोकन बन्द गरी फेरि प्रयास गर्नुहोस्।",
"placeFailed": "पेश गर्न असफल", "placeFailed": "पेश गर्न असफल",
"placeSuccess": "बेट पेश भयो। अर्डर {{orderNo}}, कट्टा {{amount}}।", "placeSuccess": "बेट पेश भयो। अर्डर {{orderNo}}, कट्टा {{amount}}।",
"jackpotBurst": {
"title": "Jackpot Burst",
"subtitle": "इश्यू {{drawNo}} मा पूल payout ट्रिगर भयो",
"number": "पहिलो पुरस्कार नम्बर",
"amount": "Burst रकम",
"winners": "विजेता",
"triggerLabel": "ट्रिगर",
"close": "बन्द",
"notificationTitle": "Jackpot Burst",
"notificationBody": "इश्यू {{drawNo}} ले {{amount}} payout गर्यो",
"trigger": {
"threshold": "थ्रेसहोल्ड पुगेको",
"forced_gap": "Forced gap पुगेको",
"play_combo": "Play combo मिलेको",
"manual": "Manual burst"
}
},
"closed": { "closed": {
"title": "बन्द", "title": "बन्द",
"subtitle": "यो इश्यू बन्द भएको छ।", "subtitle": "यो इश्यू बन्द भएको छ।",
@@ -351,6 +368,7 @@
"hitHint": "तपाईं जित्नुभयो भने, तपाईंका टिकटसँग मिलेका नम्बरहरू सुनौलो रंगमा देखिन्छन् (लगइन आवश्यक)।", "hitHint": "तपाईं जित्नुभयो भने, तपाईंका टिकटसँग मिलेका नम्बरहरू सुनौलो रंगमा देखिन्छन् (लगइन आवश्यक)।",
"viewMyWinning": "मेरो जित स्थिति हेर्नुहोस्", "viewMyWinning": "मेरो जित स्थिति हेर्नुहोस्",
"jackpotLabel": "Jackpot", "jackpotLabel": "Jackpot",
"jackpotGap": "पछिल्लो burst देखि {{count}} draw",
"tier": { "tier": {
"first": "पहिलो पुरस्कार", "first": "पहिलो पुरस्कार",
"second": "दोस्रो पुरस्कार", "second": "दोस्रो पुरस्कार",

View File

@@ -102,6 +102,23 @@
"changedBeforeSubmit": "提交前数据已变化,请关闭预览后重试。", "changedBeforeSubmit": "提交前数据已变化,请关闭预览后重试。",
"placeFailed": "提交失败", "placeFailed": "提交失败",
"placeSuccess": "下注成功,订单号 {{orderNo}},实扣 {{amount}}。", "placeSuccess": "下注成功,订单号 {{orderNo}},实扣 {{amount}}。",
"jackpotBurst": {
"title": "Jackpot 爆池",
"subtitle": "期号 {{drawNo}} 触发奖池派发",
"number": "头奖号码",
"amount": "爆池金额",
"winners": "中奖人数",
"triggerLabel": "触发方式",
"close": "关闭",
"notificationTitle": "Jackpot 爆池",
"notificationBody": "期号 {{drawNo}} 派发 {{amount}}",
"trigger": {
"threshold": "超过爆池阈值",
"forced_gap": "强制爆池期数",
"play_combo": "玩法组合命中",
"manual": "后台手动爆池"
}
},
"closed": { "closed": {
"title": "已封盘", "title": "已封盘",
"subtitle": "当前期已停止接收注单。", "subtitle": "当前期已停止接收注单。",
@@ -362,6 +379,7 @@
"hitHint": "如果您中奖,与注单匹配的号码将以金色高亮显示(需登录)。", "hitHint": "如果您中奖,与注单匹配的号码将以金色高亮显示(需登录)。",
"viewMyWinning": "查看我的中奖情况", "viewMyWinning": "查看我的中奖情况",
"jackpotLabel": "Jackpot", "jackpotLabel": "Jackpot",
"jackpotGap": "距上次爆池 {{count}} 期",
"tier": { "tier": {
"first": "头奖", "first": "头奖",
"second": "二奖", "second": "二奖",

View File

@@ -31,6 +31,14 @@ export type DrawCurrentPayload = {
seconds_to_draw: number; seconds_to_draw: number;
cooling_end_time: string | null; cooling_end_time: string | null;
seconds_remaining_in_cooldown: number | null; seconds_remaining_in_cooldown: number | null;
jackpot?: {
currency_code: string;
enabled: boolean;
current_amount_minor: number;
current_amount_formatted?: string;
draws_since_last_burst: number | null;
last_trigger_draw_id?: number | null;
};
risk_pool_alerts?: DrawCurrentRiskPoolAlert[]; risk_pool_alerts?: DrawCurrentRiskPoolAlert[];
result_items?: DrawCurrentResultItem[]; result_items?: DrawCurrentResultItem[];
result_version?: number; result_version?: number;

View File

@@ -15,6 +15,14 @@ export type DrawResultListItem = {
draw_time_iso?: string | null; draw_time_iso?: string | null;
result_version: number; result_version: number;
result_source: string | null; result_source: string | null;
jackpot?: {
currency_code: string;
enabled: boolean;
current_amount_minor: number;
current_amount_formatted?: string;
draws_since_last_burst: number | null;
last_trigger_draw_id?: number | null;
};
results: DrawResultsNumbers; results: DrawResultsNumbers;
result_items: Array<{ result_items: Array<{
prize_type: string; prize_type: string;

View File

@@ -2,4 +2,7 @@ export type JackpotSummaryData = {
currency_code: string; currency_code: string;
enabled: boolean; enabled: boolean;
current_amount_minor: number; current_amount_minor: number;
current_amount_formatted?: string;
draws_since_last_burst: number | null;
last_trigger_draw_id?: number | null;
}; };