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:
16
package-lock.json
generated
16
package-lock.json
generated
@@ -13,6 +13,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"dompurify": "^3.4.7",
|
||||||
"i18next": "^26.1.0",
|
"i18next": "^26.1.0",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"i18next-http-backend": "^4.0.0",
|
"i18next-http-backend": "^4.0.0",
|
||||||
@@ -2582,6 +2583,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/validate-npm-package-name": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmmirror.com/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
|
"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": ">=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": {
|
"node_modules/dotenv": {
|
||||||
"version": "17.4.2",
|
"version": "17.4.2",
|
||||||
"resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-17.4.2.tgz",
|
"resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-17.4.2.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"dompurify": "^3.4.7",
|
||||||
"i18next": "^26.1.0",
|
"i18next": "^26.1.0",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"i18next-http-backend": "^4.0.0",
|
"i18next-http-backend": "^4.0.0",
|
||||||
|
|||||||
5
src/app/(player)/(main)/notifications/page.tsx
Normal file
5
src/app/(player)/(main)/notifications/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { NotificationsScreen } from "@/features/player/notifications-screen";
|
||||||
|
|
||||||
|
export default function NotificationsPage() {
|
||||||
|
return <NotificationsScreen />;
|
||||||
|
}
|
||||||
@@ -2,14 +2,11 @@
|
|||||||
|
|
||||||
import { Bell } from "lucide-react";
|
import { Bell } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState, type ReactElement } from "react";
|
import { useEffect, type ReactElement } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { usePendingWalletReconcile } from "@/hooks/use-pending-wallet-reconcile";
|
||||||
import { playerHeaderControl } from "@/lib/player-spacing";
|
import { playerHeaderControl } from "@/lib/player-spacing";
|
||||||
import { formatMinorAsCurrency } from "@/lib/money";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type PlayerNotificationBellProps = {
|
type PlayerNotificationBellProps = {
|
||||||
@@ -19,22 +16,15 @@ type PlayerNotificationBellProps = {
|
|||||||
/** 顶栏铃铛:待对账划转等提醒(Popover 右上角展开) */
|
/** 顶栏铃铛:待对账划转等提醒(Popover 右上角展开) */
|
||||||
export function PlayerNotificationBell({ className }: PlayerNotificationBellProps): ReactElement {
|
export function PlayerNotificationBell({ className }: PlayerNotificationBellProps): ReactElement {
|
||||||
const { t } = useTranslation("common");
|
const { t } = useTranslation("common");
|
||||||
const { t: tp } = useTranslation("player");
|
const { hasUnread, refresh } = usePendingWalletReconcile();
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const { pending, hasPending, loading, refresh } = usePendingWalletReconcile();
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover
|
<Link
|
||||||
open={open}
|
href="/notifications"
|
||||||
onOpenChange={(next) => {
|
|
||||||
setOpen(next);
|
|
||||||
if (next) void refresh();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PopoverTrigger
|
|
||||||
render={
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
playerHeaderControl,
|
playerHeaderControl,
|
||||||
"relative size-8 rounded-full text-[#1d57b7] hover:bg-[#f4f7fb]",
|
"relative size-8 rounded-full text-[#1d57b7] hover:bg-[#f4f7fb]",
|
||||||
@@ -43,65 +33,9 @@ export function PlayerNotificationBell({ className }: PlayerNotificationBellProp
|
|||||||
aria-label={t("navigation.notifications")}
|
aria-label={t("navigation.notifications")}
|
||||||
>
|
>
|
||||||
<Bell className="size-4" aria-hidden />
|
<Bell className="size-4" aria-hidden />
|
||||||
{hasPending ? (
|
{hasUnread ? (
|
||||||
<span className="absolute right-1.5 top-1.5 size-1.5 rounded-full bg-[#ff143d]" />
|
<span className="absolute right-1.5 top-1.5 size-1.5 rounded-full bg-[#ff143d]" />
|
||||||
) : null}
|
) : 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>
|
</Link>
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
119
src/features/player/notifications-screen.tsx
Normal file
119
src/features/player/notifications-screen.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { usePlayerSessionStore } from "@/stores/player-session-store";
|
|||||||
import type { WalletPendingTransfer } from "@/types/api/wallet-logs";
|
import type { WalletPendingTransfer } from "@/types/api/wallet-logs";
|
||||||
|
|
||||||
export const WALLET_LOGS_REFRESH_EVENT = "lottery:wallet-logs-refreshed";
|
export const WALLET_LOGS_REFRESH_EVENT = "lottery:wallet-logs-refreshed";
|
||||||
|
const WALLET_NOTIFICATION_READ_KEY = "lottery:wallet-notification-read-transfer-nos";
|
||||||
|
|
||||||
type WalletLogsRefreshDetail = {
|
type WalletLogsRefreshDetail = {
|
||||||
pending?: WalletPendingTransfer[];
|
pending?: WalletPendingTransfer[];
|
||||||
@@ -14,14 +15,42 @@ type WalletLogsRefreshDetail = {
|
|||||||
|
|
||||||
export function usePendingWalletReconcile(): {
|
export function usePendingWalletReconcile(): {
|
||||||
pending: WalletPendingTransfer[];
|
pending: WalletPendingTransfer[];
|
||||||
|
unreadPending: WalletPendingTransfer[];
|
||||||
|
unreadCount: number;
|
||||||
hasPending: boolean;
|
hasPending: boolean;
|
||||||
|
hasUnread: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
|
markAsRead: (transferNo: string) => void;
|
||||||
|
markAllAsRead: () => void;
|
||||||
} {
|
} {
|
||||||
const bearerToken = usePlayerSessionStore((s) => s.bearerToken);
|
const bearerToken = usePlayerSessionStore((s) => s.bearerToken);
|
||||||
const [pending, setPending] = useState<WalletPendingTransfer[]>([]);
|
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);
|
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> => {
|
const refresh = useCallback(async (): Promise<void> => {
|
||||||
if (!bearerToken?.trim()) {
|
if (!bearerToken?.trim()) {
|
||||||
setPending([]);
|
setPending([]);
|
||||||
@@ -39,7 +68,9 @@ export function usePendingWalletReconcile(): {
|
|||||||
}, [bearerToken]);
|
}, [bearerToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
queueMicrotask(() => {
|
||||||
void refresh();
|
void refresh();
|
||||||
|
});
|
||||||
|
|
||||||
function onWalletLogsRefreshed(event: Event): void {
|
function onWalletLogsRefreshed(event: Event): void {
|
||||||
const detail = (event as CustomEvent<WalletLogsRefreshDetail>).detail;
|
const detail = (event as CustomEvent<WalletLogsRefreshDetail>).detail;
|
||||||
@@ -54,11 +85,34 @@ export function usePendingWalletReconcile(): {
|
|||||||
return () => window.removeEventListener(WALLET_LOGS_REFRESH_EVENT, onWalletLogsRefreshed);
|
return () => window.removeEventListener(WALLET_LOGS_REFRESH_EVENT, onWalletLogsRefreshed);
|
||||||
}, [refresh]);
|
}, [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 {
|
return {
|
||||||
pending,
|
pending,
|
||||||
|
unreadPending,
|
||||||
|
unreadCount: unreadPending.length,
|
||||||
hasPending: pending.length > 0,
|
hasPending: pending.length > 0,
|
||||||
|
hasUnread: unreadPending.length > 0,
|
||||||
loading,
|
loading,
|
||||||
refresh,
|
refresh,
|
||||||
|
markAsRead,
|
||||||
|
markAllAsRead,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,15 @@
|
|||||||
"wallet": "Wallet"
|
"wallet": "Wallet"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"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": {
|
"panel": {
|
||||||
"home": "Home"
|
"home": "Home"
|
||||||
|
|||||||
@@ -27,7 +27,15 @@
|
|||||||
"wallet": "वालेट"
|
"wallet": "वालेट"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"empty": "कुनै सूचना छैन"
|
"title": "सूचनाहरू",
|
||||||
|
"empty": "कुनै सूचना छैन",
|
||||||
|
"unread": "नपढिएको",
|
||||||
|
"read": "पढिएको",
|
||||||
|
"open": "खोल्नुहोस्",
|
||||||
|
"markRead": "पढिएको चिन्ह लगाउनुहोस्",
|
||||||
|
"markAllRead": "सबै पढिएको",
|
||||||
|
"unreadCount_one": "{{count}} नपढिएको",
|
||||||
|
"unreadCount_other": "{{count}} नपढिएको"
|
||||||
},
|
},
|
||||||
"panel": {
|
"panel": {
|
||||||
"home": "गृह"
|
"home": "गृह"
|
||||||
|
|||||||
@@ -27,7 +27,15 @@
|
|||||||
"wallet": "钱包"
|
"wallet": "钱包"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"empty": "暂无通知"
|
"title": "通知",
|
||||||
|
"empty": "暂无通知",
|
||||||
|
"unread": "未读",
|
||||||
|
"read": "已读",
|
||||||
|
"open": "打开",
|
||||||
|
"markRead": "标记已读",
|
||||||
|
"markAllRead": "全部已读",
|
||||||
|
"unreadCount_one": "未读 {{count}} 条",
|
||||||
|
"unreadCount_other": "未读 {{count}} 条"
|
||||||
},
|
},
|
||||||
"panel": {
|
"panel": {
|
||||||
"home": "首页"
|
"home": "首页"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { normalizeLanguage, type AppLanguage } from "@/i18n/language";
|
import { normalizeLanguage, type AppLanguage } from "@/i18n/language";
|
||||||
|
import DOMPurify from "dompurify";
|
||||||
|
|
||||||
const KEY_LEGACY = "frontend.play_rules_html";
|
const KEY_LEGACY = "frontend.play_rules_html";
|
||||||
const KEY_ZH = "frontend.play_rules_html_zh";
|
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 };
|
type SettingItem = { key: string; value: unknown };
|
||||||
|
|
||||||
function removeScriptTags(html: string | null): string | null {
|
function sanitizeHtml(html: string | null): string | null {
|
||||||
if (!html) return html;
|
if (!html) return html;
|
||||||
// React won't execute scripts injected via `dangerouslySetInnerHTML`.
|
|
||||||
// Removing them avoids the warning and prevents unintended script injection.
|
return DOMPurify.sanitize(html, {
|
||||||
return html
|
USE_PROFILES: { html: true },
|
||||||
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "")
|
FORBID_TAGS: [
|
||||||
.replace(/<script\b[^>]*\/>/gi, "");
|
"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 {
|
function asNonEmptyString(value: unknown): string | null {
|
||||||
@@ -38,10 +56,10 @@ export function resolvePlayRulesHtml(
|
|||||||
const lang: AppLanguage = normalizeLanguage(language);
|
const lang: AppLanguage = normalizeLanguage(language);
|
||||||
|
|
||||||
if (lang === "zh") {
|
if (lang === "zh") {
|
||||||
return removeScriptTags(zh ?? legacy);
|
return sanitizeHtml(zh ?? legacy);
|
||||||
}
|
}
|
||||||
if (lang === "ne") {
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user