From 321b56e997359b5ebd7d45c034c8308bc60e6f46 Mon Sep 17 00:00:00 2001 From: kang Date: Mon, 18 May 2026 15:08:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20Jackpot=20?= =?UTF-8?q?=E7=88=86=E6=B1=A0=E5=AE=9E=E6=97=B6=E5=BC=B9=E5=B1=82=E4=B8=8E?= =?UTF-8?q?=E5=A5=96=E6=B1=A0=E4=BF=A1=E6=81=AF=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/hall/hall-betting-grid.tsx | 108 +++++++++++++++++ src/features/hall/hall-screen.tsx | 4 + src/features/hall/jackpot-burst-overlay.tsx | 110 ++++++++++++++++++ src/features/hall/use-jackpot-burst-live.ts | 76 ++++++++++++ .../results/jackpot-results-strip.tsx | 8 ++ src/i18n/locales/en/player.json | 18 +++ src/i18n/locales/ne/player.json | 18 +++ src/i18n/locales/zh/player.json | 18 +++ src/types/api/draw-current.ts | 8 ++ src/types/api/draw-results.ts | 8 ++ src/types/api/jackpot.ts | 3 + 11 files changed, 379 insertions(+) create mode 100644 src/features/hall/jackpot-burst-overlay.tsx create mode 100644 src/features/hall/use-jackpot-burst-live.ts diff --git a/src/features/hall/hall-betting-grid.tsx b/src/features/hall/hall-betting-grid.tsx index 7238e3c..3979903 100644 --- a/src/features/hall/hall-betting-grid.tsx +++ b/src/features/hall/hall-betting-grid.tsx @@ -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; @@ -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 } + {jackpot?.enabled ? ( +
+
+
+

+ {t("results.jackpotLabel", { defaultValue: "Jackpot" })} +

+

+ {formatMinorAsCurrency(jackpot.current_amount_minor, jackpot.currency_code)} +

+
+ {jackpot.draws_since_last_burst !== null ? ( +

+ {t("results.jackpotGap", { + count: jackpot.draws_since_last_burst, + defaultValue: "距上次爆池 {{count}} 期", + })} +

+ ) : null} +
+
+ ) : null} + {activeCategory === "JACKPOT" ? (
diff --git a/src/features/hall/hall-screen.tsx b/src/features/hall/hall-screen.tsx index db453fb..beec26d 100644 --- a/src/features/hall/hall-screen.tsx +++ b/src/features/hall/hall-screen.tsx @@ -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 (
@@ -62,6 +65,7 @@ export function HallScreen() { +
); } diff --git a/src/features/hall/jackpot-burst-overlay.tsx b/src/features/hall/jackpot-burst-overlay.tsx new file mode 100644 index 0000000..5bbf09a --- /dev/null +++ b/src/features/hall/jackpot-burst-overlay.tsx @@ -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["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 ( +
+
+
+
+
+ +
+ + +
+ +
+ +

+ {t("hall.jackpotBurst.title", { defaultValue: "Jackpot 爆池" })} +

+

+ {t("hall.jackpotBurst.subtitle", { + defaultValue: "期号 {{drawNo}} 触发奖池派发", + drawNo: event.draw_no, + })} +

+ +
+
+ {event.first_prize_number || "----"} +
+

+ {t("hall.jackpotBurst.number", { defaultValue: "头奖号码" })} +

+
+ +
+
+ + {t("hall.jackpotBurst.amount", { defaultValue: "爆池金额" })} + + {amount} +
+
+ + {t("hall.jackpotBurst.winners", { defaultValue: "中奖人数" })} + + {event.winner_count} +
+
+ + {t("hall.jackpotBurst.triggerLabel", { defaultValue: "触发方式" })} + + {triggerLabel(event.trigger_type, t)} +
+
+
+
+ ); +} diff --git a/src/features/hall/use-jackpot-burst-live.ts b/src/features/hall/use-jackpot-burst-live.ts new file mode 100644 index 0000000..b889c76 --- /dev/null +++ b/src/features/hall/use-jackpot-burst-live.ts @@ -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(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), + }; +} diff --git a/src/features/results/jackpot-results-strip.tsx b/src/features/results/jackpot-results-strip.tsx index dc16316..b90af0c 100644 --- a/src/features/results/jackpot-results-strip.tsx +++ b/src/features/results/jackpot-results-strip.tsx @@ -16,6 +16,7 @@ export function JackpotResultsStrip({ }: JackpotResultsStripProps) { const { t } = useTranslation("player"); const [minor, setMinor] = useState(null); + const [gap, setGap] = useState(null); const [enabled, setEnabled] = useState(false); useEffect(() => { @@ -26,11 +27,13 @@ export function JackpotResultsStrip({ if (!cancelled) { setEnabled(j.enabled); setMinor(j.current_amount_minor); + setGap(j.draws_since_last_burst); } } catch { if (!cancelled) { setEnabled(false); setMinor(null); + setGap(null); } } })(); @@ -51,6 +54,11 @@ export function JackpotResultsStrip({

{formatMinorAsCurrency(minor, currencyCode.toUpperCase())}

+ {gap !== null ? ( +

+ {t("results.jackpotGap", { count: gap, defaultValue: "距上次爆池 {{count}} 期" })} +

+ ) : null}
); } diff --git a/src/i18n/locales/en/player.json b/src/i18n/locales/en/player.json index 492eabb..d54c80f 100644 --- a/src/i18n/locales/en/player.json +++ b/src/i18n/locales/en/player.json @@ -102,6 +102,23 @@ "changedBeforeSubmit": "Your draft changed before submission. Close the preview and try again.", "placeFailed": "Submission failed", "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": { "title": "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).", "viewMyWinning": "View my winning status", "jackpotLabel": "Jackpot", + "jackpotGap": "{{count}} draws since last burst", "tier": { "first": "First prize", "second": "Second prize", diff --git a/src/i18n/locales/ne/player.json b/src/i18n/locales/ne/player.json index 33624c6..63dafe1 100644 --- a/src/i18n/locales/ne/player.json +++ b/src/i18n/locales/ne/player.json @@ -102,6 +102,23 @@ "changedBeforeSubmit": "पेश गर्नु अघि ड्राफ्ट परिवर्तन भयो। पूर्वावलोकन बन्द गरी फेरि प्रयास गर्नुहोस्।", "placeFailed": "पेश गर्न असफल", "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": { "title": "बन्द", "subtitle": "यो इश्यू बन्द भएको छ।", @@ -351,6 +368,7 @@ "hitHint": "तपाईं जित्नुभयो भने, तपाईंका टिकटसँग मिलेका नम्बरहरू सुनौलो रंगमा देखिन्छन् (लगइन आवश्यक)।", "viewMyWinning": "मेरो जित स्थिति हेर्नुहोस्", "jackpotLabel": "Jackpot", + "jackpotGap": "पछिल्लो burst देखि {{count}} draw", "tier": { "first": "पहिलो पुरस्कार", "second": "दोस्रो पुरस्कार", diff --git a/src/i18n/locales/zh/player.json b/src/i18n/locales/zh/player.json index bac3ae1..c2b58c8 100644 --- a/src/i18n/locales/zh/player.json +++ b/src/i18n/locales/zh/player.json @@ -102,6 +102,23 @@ "changedBeforeSubmit": "提交前数据已变化,请关闭预览后重试。", "placeFailed": "提交失败", "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": { "title": "已封盘", "subtitle": "当前期已停止接收注单。", @@ -362,6 +379,7 @@ "hitHint": "如果您中奖,与注单匹配的号码将以金色高亮显示(需登录)。", "viewMyWinning": "查看我的中奖情况", "jackpotLabel": "Jackpot", + "jackpotGap": "距上次爆池 {{count}} 期", "tier": { "first": "头奖", "second": "二奖", diff --git a/src/types/api/draw-current.ts b/src/types/api/draw-current.ts index 724e932..a039754 100644 --- a/src/types/api/draw-current.ts +++ b/src/types/api/draw-current.ts @@ -31,6 +31,14 @@ export type DrawCurrentPayload = { seconds_to_draw: number; cooling_end_time: string | 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[]; result_items?: DrawCurrentResultItem[]; result_version?: number; diff --git a/src/types/api/draw-results.ts b/src/types/api/draw-results.ts index 6329ef6..79b4680 100644 --- a/src/types/api/draw-results.ts +++ b/src/types/api/draw-results.ts @@ -15,6 +15,14 @@ export type DrawResultListItem = { draw_time_iso?: string | null; result_version: number; 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; result_items: Array<{ prize_type: string; diff --git a/src/types/api/jackpot.ts b/src/types/api/jackpot.ts index 0ecec31..e5cbb06 100644 --- a/src/types/api/jackpot.ts +++ b/src/types/api/jackpot.ts @@ -2,4 +2,7 @@ export type JackpotSummaryData = { 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; };