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.
This commit is contained in:
2026-06-01 11:32:18 +08:00
parent 10bee1b857
commit aeaba5eea3
10 changed files with 269 additions and 98 deletions

16
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -0,0 +1,5 @@
import { NotificationsScreen } from "@/features/player/notifications-screen";
export default function NotificationsPage() {
return <NotificationsScreen />;
}

View File

@@ -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 (
<Popover
open={open}
onOpenChange={(next) => {
setOpen(next);
if (next) void refresh();
}}
<Link
href="/notifications"
className={cn(
playerHeaderControl,
"relative size-8 rounded-full text-[#1d57b7] hover:bg-[#f4f7fb]",
className,
)}
aria-label={t("navigation.notifications")}
>
<PopoverTrigger
render={
<button
type="button"
className={cn(
playerHeaderControl,
"relative size-8 rounded-full text-[#1d57b7] hover:bg-[#f4f7fb]",
className,
)}
aria-label={t("navigation.notifications")}
>
<Bell className="size-4" aria-hidden />
{hasPending ? (
<span className="absolute right-1.5 top-1.5 size-1.5 rounded-full bg-[#ff143d]" />
) : null}
</button>
}
/>
<PopoverContent
align="end"
sideOffset={8}
className="w-[min(20rem,calc(100vw-1.5rem))] border-[#dce7f7] p-0 shadow-[0_16px_40px_rgba(15,23,42,0.14)]"
>
<div className="border-b border-[#edf2f9] px-3 py-2.5">
<p className="text-sm font-black text-[#0b3f96]">{t("navigation.notifications")}</p>
</div>
{loading && pending.length === 0 ? (
<p className="px-3 py-6 text-center text-xs text-slate-500">{tp("actions.loading")}</p>
) : null}
{!loading && pending.length === 0 ? (
<p className="px-3 py-6 text-center text-xs text-slate-500">
{tp("notifications.empty")}
</p>
) : null}
{pending.length > 0 ? (
<div className="max-h-[min(50vh,320px)] overflow-y-auto p-2">
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-3">
<p className="text-sm font-black text-amber-700">{tp("wallet.pendingTitle")}</p>
<p className="mt-1 text-xs leading-relaxed text-amber-800/90">
{tp("wallet.pendingDescription")}
</p>
<ul className="mt-2 space-y-2">
{pending.map((p) => (
<li
key={p.transfer_no}
className="flex flex-wrap items-baseline justify-between gap-1 border-b border-dashed border-amber-200/80 py-2 last:border-0"
>
<span className="text-sm text-amber-950">
{logTypeLabel(p.type, tp)}{" "}
{formatMinorAsCurrency(p.amount, p.currency_code)}
</span>
<span className="text-xs font-semibold text-amber-700">
{tp("wallet.pendingStatus")}
</span>
</li>
))}
</ul>
</div>
<Link
href="/wallet/logs"
className="mt-2 block rounded-lg px-2 py-2 text-center text-xs font-bold text-[#0b56b7] hover:bg-[#f8fbff]"
onClick={() => setOpen(false)}
>
{tp("wallet.logs", { defaultValue: "查看流水" })}
</Link>
</div>
) : null}
</PopoverContent>
</Popover>
<Bell className="size-4" aria-hidden />
{hasUnread ? (
<span className="absolute right-1.5 top-1.5 size-1.5 rounded-full bg-[#ff143d]" />
) : null}
</Link>
);
}

View File

@@ -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 (
<PlayerPanel title={t("notifications.title")} backHref="/hall">
<div className="space-y-3">
<div className="flex items-center justify-between rounded-xl border border-[#dce7f7] bg-[#f8fbff] px-3 py-2.5">
<p className="text-sm font-semibold text-[#0b3f96]">
{t("notifications.unreadCount", { count: unreadCount })}
</p>
<Button
type="button"
size="sm"
variant="ghost"
className="h-8 px-2 text-xs font-bold text-[#0b56b7] hover:bg-[#ebf2ff]"
onClick={markAllAsRead}
disabled={pending.length === 0 || unreadCount === 0}
>
<CheckCheck className="mr-1 size-3.5" aria-hidden />
{t("notifications.markAllRead")}
</Button>
</div>
{loading && pending.length === 0 ? (
<div className="rounded-xl border border-[#dce7f7] bg-white px-4 py-8 text-center text-sm text-slate-500">
{t("actions.loading")}
</div>
) : null}
{!loading && pending.length === 0 ? (
<div className="rounded-xl border border-dashed border-[#dce7f7] bg-white px-4 py-10 text-center">
<BellRing className="mx-auto size-5 text-slate-400" aria-hidden />
<p className="mt-2 text-sm text-slate-500">{t("notifications.empty")}</p>
</div>
) : null}
{pending.length > 0 ? (
<ul className="space-y-2">
{pending.map((item) => {
const cardRead = !unreadSet.has(item.transfer_no);
return (
<li
key={item.transfer_no}
className={cn(
"rounded-xl border px-3 py-3 transition-colors",
cardRead
? "border-[#e4eaf4] bg-white"
: "border-amber-200 bg-amber-50/80",
)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-bold text-[#0b3f96]">
{logTypeLabel(item.type, t)}
</p>
<p className="mt-0.5 text-xs text-slate-500">
{formatLocalDateTime(item.created_at)}
</p>
</div>
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-[11px] font-bold",
cardRead
? "bg-slate-100 text-slate-500"
: "bg-amber-100 text-amber-700",
)}
>
{cardRead ? t("notifications.read") : t("notifications.unread")}
</span>
</div>
<p className="mt-2 text-sm text-slate-700">
{formatMinorAsCurrency(item.amount, item.currency_code)}
</p>
<div className="mt-3 flex items-center gap-2">
<Button
type="button"
size="sm"
variant="outline"
className="h-8 rounded-full border-[#dce7f7] px-3 text-xs font-semibold text-[#0b56b7]"
onClick={() => markAsRead(item.transfer_no)}
>
{t("notifications.markRead")}
</Button>
<Link
href="/wallet/logs"
className="inline-flex h-8 items-center rounded-full bg-[#07459f] px-3 text-xs font-semibold text-white hover:bg-[#063b88]"
onClick={() => markAsRead(item.transfer_no)}
>
{t("notifications.open")}
</Link>
</div>
</li>
);
})}
</ul>
) : null}
</div>
</PlayerPanel>
);
}

View File

@@ -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<void>;
markAsRead: (transferNo: string) => void;
markAllAsRead: () => void;
} {
const bearerToken = usePlayerSessionStore((s) => s.bearerToken);
const [pending, setPending] = useState<WalletPendingTransfer[]>([]);
const [readTransferNos, setReadTransferNos] = useState<Set<string>>(() => {
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<void> => {
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<WalletLogsRefreshDetail>).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,
};
}

View File

@@ -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"

View File

@@ -27,7 +27,15 @@
"wallet": "वालेट"
},
"notifications": {
"empty": "कुनै सूचना छैन"
"title": "सूचनाहरू",
"empty": "कुनै सूचना छैन",
"unread": "नपढिएको",
"read": "पढिएको",
"open": "खोल्नुहोस्",
"markRead": "पढिएको चिन्ह लगाउनुहोस्",
"markAllRead": "सबै पढिएको",
"unreadCount_one": "{{count}} नपढिएको",
"unreadCount_other": "{{count}} नपढिएको"
},
"panel": {
"home": "गृह"

View File

@@ -27,7 +27,15 @@
"wallet": "钱包"
},
"notifications": {
"empty": "暂无通知"
"title": "通知",
"empty": "暂无通知",
"unread": "未读",
"read": "已读",
"open": "打开",
"markRead": "标记已读",
"markAllRead": "全部已读",
"unreadCount_one": "未读 {{count}} 条",
"unreadCount_other": "未读 {{count}} 条"
},
"panel": {
"home": "首页"

View File

@@ -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(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "")
.replace(/<script\b[^>]*\/>/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);
}