From aeaba5eea36bf65ac6f0a21579a369ce9cb1cff5 Mon Sep 17 00:00:00 2001 From: kang Date: Mon, 1 Jun 2026 11:32:18 +0800 Subject: [PATCH] feat: enhance notification system and integrate DOMPurify for HTML sanitization - Added `dompurify` for improved HTML sanitization in the play rules. - Updated `PlayerNotificationBell` component to handle unread notifications and improve user interaction. - Enhanced `usePendingWalletReconcile` hook to manage unread notifications and mark them as read. - Added new translations for notification-related texts in English, Nepali, and Chinese to support multilingual users. --- package-lock.json | 16 +++ package.json | 1 + .../(player)/(main)/notifications/page.tsx | 5 + .../layout/player-notification-bell.tsx | 104 +++------------ src/features/player/notifications-screen.tsx | 119 ++++++++++++++++++ src/hooks/use-pending-wallet-reconcile.ts | 56 ++++++++- src/i18n/locales/en/player.json | 10 +- src/i18n/locales/ne/player.json | 10 +- src/i18n/locales/zh/player.json | 10 +- src/lib/play-rules-html.ts | 36 ++++-- 10 files changed, 269 insertions(+), 98 deletions(-) create mode 100644 src/app/(player)/(main)/notifications/page.tsx create mode 100644 src/features/player/notifications-screen.tsx diff --git a/package-lock.json b/package-lock.json index dc308e0..952f187 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "dompurify": "^3.4.7", "i18next": "^26.1.0", "i18next-browser-languagedetector": "^8.2.1", "i18next-http-backend": "^4.0.0", @@ -2582,6 +2583,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://mirrors.cloud.tencent.com/npm/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/validate-npm-package-name": { "version": "4.0.2", "resolved": "https://registry.npmmirror.com/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", @@ -4407,6 +4415,14 @@ "node": ">=0.10.0" } }, + "node_modules/dompurify": { + "version": "3.4.7", + "resolved": "https://mirrors.cloud.tencent.com/npm/dompurify/-/dompurify-3.4.7.tgz", + "integrity": "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dotenv": { "version": "17.4.2", "resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-17.4.2.tgz", diff --git a/package.json b/package.json index fc91bfd..b52be87 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "dompurify": "^3.4.7", "i18next": "^26.1.0", "i18next-browser-languagedetector": "^8.2.1", "i18next-http-backend": "^4.0.0", diff --git a/src/app/(player)/(main)/notifications/page.tsx b/src/app/(player)/(main)/notifications/page.tsx new file mode 100644 index 0000000..a068c7a --- /dev/null +++ b/src/app/(player)/(main)/notifications/page.tsx @@ -0,0 +1,5 @@ +import { NotificationsScreen } from "@/features/player/notifications-screen"; + +export default function NotificationsPage() { + return ; +} diff --git a/src/components/layout/player-notification-bell.tsx b/src/components/layout/player-notification-bell.tsx index 16d165a..52e7472 100644 --- a/src/components/layout/player-notification-bell.tsx +++ b/src/components/layout/player-notification-bell.tsx @@ -2,14 +2,11 @@ import { Bell } from "lucide-react"; import Link from "next/link"; -import { useState, type ReactElement } from "react"; +import { useEffect, type ReactElement } from "react"; import { useTranslation } from "react-i18next"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { logTypeLabel } from "@/features/wallet/wallet-logs-block"; import { usePendingWalletReconcile } from "@/hooks/use-pending-wallet-reconcile"; import { playerHeaderControl } from "@/lib/player-spacing"; -import { formatMinorAsCurrency } from "@/lib/money"; import { cn } from "@/lib/utils"; type PlayerNotificationBellProps = { @@ -19,89 +16,26 @@ type PlayerNotificationBellProps = { /** 顶栏铃铛:待对账划转等提醒(Popover 右上角展开) */ export function PlayerNotificationBell({ className }: PlayerNotificationBellProps): ReactElement { const { t } = useTranslation("common"); - const { t: tp } = useTranslation("player"); - const [open, setOpen] = useState(false); - const { pending, hasPending, loading, refresh } = usePendingWalletReconcile(); + const { hasUnread, refresh } = usePendingWalletReconcile(); + + useEffect(() => { + void refresh(); + }, [refresh]); return ( - { - setOpen(next); - if (next) void refresh(); - }} + - - - {hasPending ? ( - - ) : null} - - } - /> - -
-

{t("navigation.notifications")}

-
- - {loading && pending.length === 0 ? ( -

{tp("actions.loading")}

- ) : null} - - {!loading && pending.length === 0 ? ( -

- {tp("notifications.empty")} -

- ) : null} - - {pending.length > 0 ? ( -
-
-

{tp("wallet.pendingTitle")}

-

- {tp("wallet.pendingDescription")} -

-
    - {pending.map((p) => ( -
  • - - {logTypeLabel(p.type, tp)}{" "} - {formatMinorAsCurrency(p.amount, p.currency_code)} - - - {tp("wallet.pendingStatus")} - -
  • - ))} -
-
- setOpen(false)} - > - {tp("wallet.logs", { defaultValue: "查看流水" })} - -
- ) : null} -
-
+ + {hasUnread ? ( + + ) : null} + ); } diff --git a/src/features/player/notifications-screen.tsx b/src/features/player/notifications-screen.tsx new file mode 100644 index 0000000..7b5fe35 --- /dev/null +++ b/src/features/player/notifications-screen.tsx @@ -0,0 +1,119 @@ +"use client"; + +import Link from "next/link"; +import { BellRing, CheckCheck } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +import { Button } from "@/components/ui/button"; +import { PlayerPanel } from "@/components/layout/player-panel"; +import { logTypeLabel } from "@/features/wallet/wallet-logs-block"; +import { usePendingWalletReconcile } from "@/hooks/use-pending-wallet-reconcile"; +import { formatLocalDateTime } from "@/lib/format-local-datetime"; +import { formatMinorAsCurrency } from "@/lib/money"; +import { cn } from "@/lib/utils"; + +export function NotificationsScreen() { + const { t } = useTranslation("player"); + const { pending, unreadPending, unreadCount, loading, markAsRead, markAllAsRead } = + usePendingWalletReconcile(); + const unreadSet = new Set(unreadPending.map((item) => item.transfer_no)); + + return ( + +
+
+

+ {t("notifications.unreadCount", { count: unreadCount })} +

+ +
+ + {loading && pending.length === 0 ? ( +
+ {t("actions.loading")} +
+ ) : null} + + {!loading && pending.length === 0 ? ( +
+ +

{t("notifications.empty")}

+
+ ) : null} + + {pending.length > 0 ? ( +
    + {pending.map((item) => { + const cardRead = !unreadSet.has(item.transfer_no); + return ( +
  • +
    +
    +

    + {logTypeLabel(item.type, t)} +

    +

    + {formatLocalDateTime(item.created_at)} +

    +
    + + {cardRead ? t("notifications.read") : t("notifications.unread")} + +
    + +

    + {formatMinorAsCurrency(item.amount, item.currency_code)} +

    + +
    + + markAsRead(item.transfer_no)} + > + {t("notifications.open")} + +
    +
  • + ); + })} +
+ ) : null} +
+
+ ); +} diff --git a/src/hooks/use-pending-wallet-reconcile.ts b/src/hooks/use-pending-wallet-reconcile.ts index 6a2016a..09bb726 100644 --- a/src/hooks/use-pending-wallet-reconcile.ts +++ b/src/hooks/use-pending-wallet-reconcile.ts @@ -7,6 +7,7 @@ import { usePlayerSessionStore } from "@/stores/player-session-store"; import type { WalletPendingTransfer } from "@/types/api/wallet-logs"; export const WALLET_LOGS_REFRESH_EVENT = "lottery:wallet-logs-refreshed"; +const WALLET_NOTIFICATION_READ_KEY = "lottery:wallet-notification-read-transfer-nos"; type WalletLogsRefreshDetail = { pending?: WalletPendingTransfer[]; @@ -14,14 +15,42 @@ type WalletLogsRefreshDetail = { export function usePendingWalletReconcile(): { pending: WalletPendingTransfer[]; + unreadPending: WalletPendingTransfer[]; + unreadCount: number; hasPending: boolean; + hasUnread: boolean; loading: boolean; refresh: () => Promise; + markAsRead: (transferNo: string) => void; + markAllAsRead: () => void; } { const bearerToken = usePlayerSessionStore((s) => s.bearerToken); const [pending, setPending] = useState([]); + const [readTransferNos, setReadTransferNos] = useState>(() => { + if (typeof window === "undefined") return new Set(); + try { + const raw = window.localStorage.getItem(WALLET_NOTIFICATION_READ_KEY); + if (!raw) return new Set(); + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return new Set(); + return new Set( + parsed.filter((value): value is string => typeof value === "string" && value.trim() !== ""), + ); + } catch { + return new Set(); + } + }); const [loading, setLoading] = useState(false); + useEffect(() => { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(WALLET_NOTIFICATION_READ_KEY, JSON.stringify(Array.from(readTransferNos))); + } catch { + // ignore storage quota or privacy mode errors + } + }, [readTransferNos]); + const refresh = useCallback(async (): Promise => { if (!bearerToken?.trim()) { setPending([]); @@ -39,7 +68,9 @@ export function usePendingWalletReconcile(): { }, [bearerToken]); useEffect(() => { - void refresh(); + queueMicrotask(() => { + void refresh(); + }); function onWalletLogsRefreshed(event: Event): void { const detail = (event as CustomEvent).detail; @@ -54,11 +85,34 @@ export function usePendingWalletReconcile(): { return () => window.removeEventListener(WALLET_LOGS_REFRESH_EVENT, onWalletLogsRefreshed); }, [refresh]); + const markAsRead = useCallback((transferNo: string): void => { + const normalized = transferNo.trim(); + if (!normalized) return; + setReadTransferNos((prev) => { + if (prev.has(normalized)) return prev; + const next = new Set(prev); + next.add(normalized); + return next; + }); + }, []); + + const markAllAsRead = useCallback((): void => { + if (pending.length === 0) return; + setReadTransferNos(new Set(pending.map((item) => item.transfer_no))); + }, [pending]); + + const unreadPending = pending.filter((item) => !readTransferNos.has(item.transfer_no)); + return { pending, + unreadPending, + unreadCount: unreadPending.length, hasPending: pending.length > 0, + hasUnread: unreadPending.length > 0, loading, refresh, + markAsRead, + markAllAsRead, }; } diff --git a/src/i18n/locales/en/player.json b/src/i18n/locales/en/player.json index a864b64..82d45dc 100644 --- a/src/i18n/locales/en/player.json +++ b/src/i18n/locales/en/player.json @@ -27,7 +27,15 @@ "wallet": "Wallet" }, "notifications": { - "empty": "No notifications" + "title": "Notifications", + "empty": "No notifications", + "unread": "Unread", + "read": "Read", + "open": "Open", + "markRead": "Mark as read", + "markAllRead": "Mark all read", + "unreadCount_one": "{{count}} unread", + "unreadCount_other": "{{count}} unread" }, "panel": { "home": "Home" diff --git a/src/i18n/locales/ne/player.json b/src/i18n/locales/ne/player.json index 67d1c28..bd7afc2 100644 --- a/src/i18n/locales/ne/player.json +++ b/src/i18n/locales/ne/player.json @@ -27,7 +27,15 @@ "wallet": "वालेट" }, "notifications": { - "empty": "कुनै सूचना छैन" + "title": "सूचनाहरू", + "empty": "कुनै सूचना छैन", + "unread": "नपढिएको", + "read": "पढिएको", + "open": "खोल्नुहोस्", + "markRead": "पढिएको चिन्ह लगाउनुहोस्", + "markAllRead": "सबै पढिएको", + "unreadCount_one": "{{count}} नपढिएको", + "unreadCount_other": "{{count}} नपढिएको" }, "panel": { "home": "गृह" diff --git a/src/i18n/locales/zh/player.json b/src/i18n/locales/zh/player.json index dba619f..ee603f1 100644 --- a/src/i18n/locales/zh/player.json +++ b/src/i18n/locales/zh/player.json @@ -27,7 +27,15 @@ "wallet": "钱包" }, "notifications": { - "empty": "暂无通知" + "title": "通知", + "empty": "暂无通知", + "unread": "未读", + "read": "已读", + "open": "打开", + "markRead": "标记已读", + "markAllRead": "全部已读", + "unreadCount_one": "未读 {{count}} 条", + "unreadCount_other": "未读 {{count}} 条" }, "panel": { "home": "首页" diff --git a/src/lib/play-rules-html.ts b/src/lib/play-rules-html.ts index f9facc1..0cd84db 100644 --- a/src/lib/play-rules-html.ts +++ b/src/lib/play-rules-html.ts @@ -1,4 +1,5 @@ import { normalizeLanguage, type AppLanguage } from "@/i18n/language"; +import DOMPurify from "dompurify"; const KEY_LEGACY = "frontend.play_rules_html"; const KEY_ZH = "frontend.play_rules_html_zh"; @@ -7,13 +8,30 @@ const KEY_NE = "frontend.play_rules_html_ne"; type SettingItem = { key: string; value: unknown }; -function removeScriptTags(html: string | null): string | null { +function sanitizeHtml(html: string | null): string | null { if (!html) return html; - // React won't execute scripts injected via `dangerouslySetInnerHTML`. - // Removing them avoids the warning and prevents unintended script injection. - return html - .replace(/]*>[\s\S]*?<\/script>/gi, "") - .replace(/]*\/>/gi, ""); + + return DOMPurify.sanitize(html, { + USE_PROFILES: { html: true }, + FORBID_TAGS: [ + "script", + "style", + "iframe", + "object", + "embed", + "form", + "input", + "button", + "textarea", + "select", + "option", + "link", + "meta", + "base", + ], + FORBID_ATTR: ["style"], + ALLOW_DATA_ATTR: false, + }); } function asNonEmptyString(value: unknown): string | null { @@ -38,10 +56,10 @@ export function resolvePlayRulesHtml( const lang: AppLanguage = normalizeLanguage(language); if (lang === "zh") { - return removeScriptTags(zh ?? legacy); + return sanitizeHtml(zh ?? legacy); } if (lang === "ne") { - return removeScriptTags(ne ?? en ?? zh ?? legacy); + return sanitizeHtml(ne ?? en ?? zh ?? legacy); } - return removeScriptTags(en ?? zh ?? legacy); + return sanitizeHtml(en ?? zh ?? legacy); }