From b2101a2e227b9cb642cd0c0fa03c631b396711ed Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Wed, 29 Apr 2026 01:31:15 +0330 Subject: [PATCH] feat(notifications): add dedicated page and localized rendering --- src/App.tsx | 2 + .../notifications/NotificationBell.tsx | 177 +++++------------- .../notifications/NotificationList.tsx | 105 +++++++++++ src/context/NotificationsContext.tsx | 6 +- src/lib/notificationPresenter.ts | 78 ++++++++ src/locales/en.ts | 58 ++++-- src/locales/fa.ts | 58 ++++-- src/pages/Notifications.tsx | 101 ++++++++++ 8 files changed, 416 insertions(+), 169 deletions(-) create mode 100644 src/components/notifications/NotificationList.tsx create mode 100644 src/lib/notificationPresenter.ts create mode 100644 src/pages/Notifications.tsx diff --git a/src/App.tsx b/src/App.tsx index 97f3629..b7326c2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,6 +22,7 @@ import Tags from "./pages/Tags" import Reports from "./pages/Reports" import Timesheet from "./pages/Timesheet" import Logs from "./pages/Logs" +import NotificationsPage from "./pages/Notifications" const MainLayout = () => { const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false) @@ -66,6 +67,7 @@ const router = createBrowserRouter([ { path: "/profile", element: }, { path: "/timesheet", element: }, { path: "/reports", element: }, + { path: "/notifications", element: }, { path: "/logs", element: }, { path: "/tags", element: }, { path: "/workspaces", element: }, diff --git a/src/components/notifications/NotificationBell.tsx b/src/components/notifications/NotificationBell.tsx index 5e6fbb7..bd5ff4a 100644 --- a/src/components/notifications/NotificationBell.tsx +++ b/src/components/notifications/NotificationBell.tsx @@ -1,105 +1,30 @@ -import { useEffect, useMemo, useRef, useState } from "react" -import { Bell, CheckCheck, Loader2, Trash2 } from "lucide-react" -import { useTranslation } from "../../hooks/useTranslation" -import { cn } from "../../lib/utils" -import { useNotifications } from "../../context/NotificationsContext" -import type { NotificationItem } from "../../api/notifications" -import { Button } from "../ui/button" +import { useEffect, useMemo, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { Bell, CheckCheck, Loader2 } from "lucide-react"; -const formatNotificationTimestamp = (value: string, locale: string) => { - const date = new Date(value) - if (Number.isNaN(date.getTime())) { - return value - } - return new Intl.DateTimeFormat(locale === "fa" ? "fa-IR" : "en-US", { - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }).format(date) -} - -function NotificationRow({ - notification, - locale, - onClick, - onDelete, -}: { - notification: NotificationItem - locale: string - onClick: (notification: NotificationItem) => void - onDelete: (notification: NotificationItem) => void -}) { - return ( -
-
- - -
-
- ) -} +import { NotificationList } from "./NotificationList"; +import { useTranslation } from "../../hooks/useTranslation"; +import { useNotifications } from "../../context/NotificationsContext"; +import { Button } from "../ui/button"; export function NotificationBell() { - const { t, lang } = useTranslation() + const { t } = useTranslation(); + const navigate = useNavigate(); const { notifications, unreadCount, - totalCount, - hasMore, isLoading, - isLoadingMore, - loadMore, markAllAsSeen, deleteOne, handleNotificationClick, - } = useNotifications() - const [isOpen, setIsOpen] = useState(false) - const containerRef = useRef(null) + } = useNotifications(); + const [isOpen, setIsOpen] = useState(false); + const containerRef = useRef(null); + + const unreadNotifications = useMemo( + () => notifications.filter((notification) => !notification.is_seen), + [notifications], + ); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -107,12 +32,12 @@ export function NotificationBell() { containerRef.current && !containerRef.current.contains(event.target as Node) ) { - setIsOpen(false) + setIsOpen(false); } - } - document.addEventListener("mousedown", handleClickOutside) - return () => document.removeEventListener("mousedown", handleClickOutside) - }, []) + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); return (
@@ -136,8 +61,8 @@ export function NotificationBell() { {t.notifications?.title || "Notifications"}

- {t.notifications?.summary?.(totalCount, unreadCount) || - `${totalCount} total, ${unreadCount} unread`} + {t.notifications?.summary?.(unreadNotifications.length, unreadCount) || + `${unreadNotifications.length} total, ${unreadCount} unread`}

- - ) : null} +
+ +
) : null} - ) + ); } diff --git a/src/components/notifications/NotificationList.tsx b/src/components/notifications/NotificationList.tsx new file mode 100644 index 0000000..f48ae7c --- /dev/null +++ b/src/components/notifications/NotificationList.tsx @@ -0,0 +1,105 @@ +import { Trash2 } from "lucide-react"; + +import type { NotificationItem } from "../../api/notifications"; +import { useTranslation } from "../../hooks/useTranslation"; +import { presentNotification } from "../../lib/notificationPresenter"; +import { cn } from "../../lib/utils"; + +const formatNotificationTimestamp = (value: string, locale: string) => { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + return new Intl.DateTimeFormat(locale === "fa" ? "fa-IR" : "en-US", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(date); +}; + +export function NotificationList({ + notifications, + emptyLabel, + onClick, + onDelete, + className = "", +}: { + notifications: NotificationItem[]; + emptyLabel: string; + onClick: (notification: NotificationItem) => void; + onDelete: (notification: NotificationItem) => void; + className?: string; +}) { + const { t, lang } = useTranslation(); + + if (notifications.length === 0) { + return ( +
+ {emptyLabel} +
+ ); + } + + return ( +
+ {notifications.map((notification) => { + const presented = presentNotification(notification, t); + + return ( +
+
+ + +
+
+ ); + })} +
+ ); +} diff --git a/src/context/NotificationsContext.tsx b/src/context/NotificationsContext.tsx index 5b5454b..e833a72 100644 --- a/src/context/NotificationsContext.tsx +++ b/src/context/NotificationsContext.tsx @@ -22,6 +22,7 @@ import { type NotificationLevel, } from "../api/notifications" import { useTranslation } from "../hooks/useTranslation" +import { presentNotification } from "../lib/notificationPresenter" import { getAccessToken, SESSION_CHANGED_EVENT, @@ -153,8 +154,9 @@ export function NotificationsProvider({ children }: { children: ReactNode }) { toastedNotificationIdsRef.current.add(notification.id) const notify = getToastMethod(notification.level) - notify(notification.title || (t.notifications?.newTitle || "New notification"), { - description: notification.message || undefined, + const presented = presentNotification(notification, t) + notify(presented.title || (t.notifications?.newTitle || "New notification"), { + description: presented.message || undefined, action: notification.action_url ? { label: t.notifications?.openAction || "Open", diff --git a/src/lib/notificationPresenter.ts b/src/lib/notificationPresenter.ts new file mode 100644 index 0000000..5684646 --- /dev/null +++ b/src/lib/notificationPresenter.ts @@ -0,0 +1,78 @@ +import type { NotificationItem } from "../api/notifications"; + +const roleLabel = (role: unknown, dictionary: any) => { + if (typeof role !== "string" || !role) return ""; + return dictionary?.workspace?.roles?.[role] || role; +}; + +export const presentNotification = (notification: NotificationItem, dictionary: any) => { + const notifications = dictionary?.notifications; + const meta = notification.meta || {}; + const workspaceName = typeof meta.workspace_name === "string" ? meta.workspace_name : ""; + const actorName = typeof meta.actor_name === "string" ? meta.actor_name : ""; + const exportType = typeof meta.export_type === "string" ? meta.export_type : "report"; + const fileName = typeof meta.file_name === "string" ? meta.file_name : null; + const previousRole = roleLabel(meta.previous_role, dictionary); + const newRole = roleLabel(meta.new_role, dictionary); + + switch (notification.type) { + case "workspace_membership_added": + return { + title: notifications?.workspaceMembershipAddedTitle || notification.title || notification.type, + message: + notifications?.workspaceMembershipAddedMessage?.(actorName, workspaceName, newRole) || + notification.message || + "", + }; + case "workspace_membership_role_changed": + return { + title: notifications?.workspaceMembershipRoleChangedTitle || notification.title || notification.type, + message: + notifications?.workspaceMembershipRoleChangedMessage?.( + actorName, + workspaceName, + previousRole, + newRole, + ) || + notification.message || + "", + }; + case "workspace_membership_deactivated": + return { + title: notifications?.workspaceMembershipDeactivatedTitle || notification.title || notification.type, + message: + notifications?.workspaceMembershipDeactivatedMessage?.(actorName, workspaceName) || + notification.message || + "", + }; + case "workspace_membership_removed": + return { + title: notifications?.workspaceMembershipRemovedTitle || notification.title || notification.type, + message: + notifications?.workspaceMembershipRemovedMessage?.(actorName, workspaceName) || + notification.message || + "", + }; + case "report_export_ready": + return { + title: notifications?.reportExportReadyTitle || notification.title || notification.type, + message: + notifications?.reportExportReadyMessage?.(exportType, workspaceName, fileName) || + notification.message || + "", + }; + case "report_export_failed": + return { + title: notifications?.reportExportFailedTitle || notification.title || notification.type, + message: + notifications?.reportExportFailedMessage?.(exportType, workspaceName) || + notification.message || + "", + }; + default: + return { + title: notification.title || notification.type, + message: notification.message || "", + }; + } +}; diff --git a/src/locales/en.ts b/src/locales/en.ts index be4c449..67e2b53 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -580,20 +580,44 @@ export const en = { }, notifications: { - title: "Notifications", - open: "Open notifications", - empty: "No notifications yet.", - loading: "Loading notifications...", - loadingMore: "Loading more...", - loadMore: "Load more", - markAllRead: "Mark all as read", - markSeenError: "Failed to update notification", - markAllError: "Failed to update notifications", - deleteError: "Failed to delete notification", - loadError: "Failed to load notifications", - openError: "Failed to open notification", - newTitle: "New notification", - openAction: "Open", - summary: (total: number, unread: number) => `${total} total, ${unread} unread`, - }, -} + title: "Notifications", + pageDescription: "Review all notifications and export updates.", + open: "Open notifications", + empty: "No notifications yet.", + emptyUnread: "No unread notifications.", + loading: "Loading notifications...", + loadingMore: "Loading more...", + loadMore: "Load more", + markAllRead: "Mark all as read", + viewAll: "View all notifications", + totalLabel: "Total notifications", + unreadLabel: "Unread notifications", + deleteLabel: "Delete notification", + markSeenError: "Failed to update notification", + markAllError: "Failed to update notifications", + deleteError: "Failed to delete notification", + loadError: "Failed to load notifications", + openError: "Failed to open notification", + newTitle: "New notification", + openAction: "Open", + summary: (total: number, unread: number) => `${total} total, ${unread} unread`, + workspaceMembershipAddedTitle: "Added to workspace", + workspaceMembershipAddedMessage: (actor: string, workspace: string, role: string) => + `${actor} added you to ${workspace} as ${role}.`, + workspaceMembershipRoleChangedTitle: "Workspace role changed", + workspaceMembershipRoleChangedMessage: (actor: string, workspace: string, previousRole: string, newRole: string) => + `${actor} changed your role in ${workspace} from ${previousRole} to ${newRole}.`, + workspaceMembershipDeactivatedTitle: "Workspace access deactivated", + workspaceMembershipDeactivatedMessage: (actor: string, workspace: string) => + `${actor} deactivated your access to ${workspace}.`, + workspaceMembershipRemovedTitle: "Removed from workspace", + workspaceMembershipRemovedMessage: (actor: string, workspace: string) => + `${actor} removed you from ${workspace}.`, + reportExportReadyTitle: "Report export is ready", + reportExportReadyMessage: (exportType: string, workspace: string, fileName?: string | null) => + `Your ${exportType.toUpperCase()} report for ${workspace} is ready${fileName ? `: ${fileName}` : ""}.`, + reportExportFailedTitle: "Report export failed", + reportExportFailedMessage: (exportType: string, workspace: string) => + `Your ${exportType.toUpperCase()} report for ${workspace} could not be generated.`, + }, +} diff --git a/src/locales/fa.ts b/src/locales/fa.ts index e1a3f57..9257927 100644 --- a/src/locales/fa.ts +++ b/src/locales/fa.ts @@ -574,20 +574,44 @@ export const fa = { }, }, notifications: { - title: "اعلان‌ها", - open: "باز کردن اعلان‌ها", - empty: "هنوز اعلانی وجود ندارد.", - loading: "در حال بارگذاری اعلان‌ها...", - loadingMore: "در حال بارگذاری بیشتر...", - loadMore: "بارگذاری بیشتر", - markAllRead: "خواندن همه", - markSeenError: "به‌روزرسانی اعلان با خطا مواجه شد.", - markAllError: "به‌روزرسانی اعلان‌ها با خطا مواجه شد.", - deleteError: "حذف اعلان با خطا مواجه شد.", - loadError: "دریافت اعلان‌ها با خطا مواجه شد.", - openError: "باز کردن اعلان با خطا مواجه شد.", - newTitle: "اعلان جدید", - openAction: "باز کردن", - summary: (total: number, unread: number) => `${total} کل، ${unread} خوانده‌نشده`, - }, -} + title: "اعلان‌ها", + pageDescription: "مرور همه اعلان‌ها و وضعیت خروجی‌های گزارش.", + open: "باز کردن اعلان‌ها", + empty: "هنوز اعلانی وجود ندارد.", + emptyUnread: "اعلان خوانده‌نشده‌ای وجود ندارد.", + loading: "در حال بارگذاری اعلان‌ها...", + loadingMore: "در حال بارگذاری بیشتر...", + loadMore: "بارگذاری بیشتر", + markAllRead: "خواندن همه", + viewAll: "نمایش همه اعلان‌ها", + totalLabel: "مجموع اعلان‌ها", + unreadLabel: "اعلان‌های خوانده‌نشده", + deleteLabel: "حذف اعلان", + markSeenError: "به‌روزرسانی اعلان با خطا مواجه شد.", + markAllError: "به‌روزرسانی اعلان‌ها با خطا مواجه شد.", + deleteError: "حذف اعلان با خطا مواجه شد.", + loadError: "دریافت اعلان‌ها با خطا مواجه شد.", + openError: "باز کردن اعلان با خطا مواجه شد.", + newTitle: "اعلان جدید", + openAction: "باز کردن", + summary: (total: number, unread: number) => `${total} کل، ${unread} خوانده‌نشده`, + workspaceMembershipAddedTitle: "به ورک‌اسپیس اضافه شدید", + workspaceMembershipAddedMessage: (actor: string, workspace: string, role: string) => + `${actor} شما را با نقش ${role} به ${workspace} اضافه کرد.`, + workspaceMembershipRoleChangedTitle: "نقش شما در ورک‌اسپیس تغییر کرد", + workspaceMembershipRoleChangedMessage: (actor: string, workspace: string, previousRole: string, newRole: string) => + `${actor} نقش شما را در ${workspace} از ${previousRole} به ${newRole} تغییر داد.`, + workspaceMembershipDeactivatedTitle: "دسترسی ورک‌اسپیس غیرفعال شد", + workspaceMembershipDeactivatedMessage: (actor: string, workspace: string) => + `${actor} دسترسی شما به ${workspace} را غیرفعال کرد.`, + workspaceMembershipRemovedTitle: "از ورک‌اسپیس حذف شدید", + workspaceMembershipRemovedMessage: (actor: string, workspace: string) => + `${actor} شما را از ${workspace} حذف کرد.`, + reportExportReadyTitle: "خروجی گزارش آماده است", + reportExportReadyMessage: (exportType: string, workspace: string, fileName?: string | null) => + `خروجی ${exportType.toUpperCase()} گزارش ${workspace}${fileName ? ` با نام ${fileName}` : ""} آماده دانلود است.`, + reportExportFailedTitle: "خروجی گزارش ناموفق بود", + reportExportFailedMessage: (exportType: string, workspace: string) => + `تولید خروجی ${exportType.toUpperCase()} گزارش ${workspace} با خطا مواجه شد.`, + }, +} diff --git a/src/pages/Notifications.tsx b/src/pages/Notifications.tsx new file mode 100644 index 0000000..43f7a0f --- /dev/null +++ b/src/pages/Notifications.tsx @@ -0,0 +1,101 @@ +import { CheckCheck, Loader2 } from "lucide-react"; + +import { NotificationList } from "../components/notifications/NotificationList"; +import { Button } from "../components/ui/button"; +import { useNotifications } from "../context/NotificationsContext"; +import { useTranslation } from "../hooks/useTranslation"; + +export default function NotificationsPage() { + const { t } = useTranslation(); + const { + notifications, + unreadCount, + totalCount, + hasMore, + isLoading, + isLoadingMore, + loadMore, + markAllAsSeen, + deleteOne, + handleNotificationClick, + } = useNotifications(); + + return ( +
+
+
+
+

+ {t.notifications?.title || "Notifications"} +

+

+ {t.notifications?.pageDescription || "Review all notifications and export updates."} +

+
+ +
+
+ +
+
+
+ {t.notifications?.totalLabel || "Total notifications"} +
+
{totalCount}
+
+
+
+ {t.notifications?.unreadLabel || "Unread notifications"} +
+
{unreadCount}
+
+
+ +
+ {isLoading ? ( +
+ + {t.notifications?.loading || "Loading notifications..."} +
+ ) : ( + void handleNotificationClick(item)} + onDelete={(item) => void deleteOne(item)} + /> + )} + + {hasMore ? ( +
+ +
+ ) : null} +
+
+ ); +}