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`}
{t.notifications?.loading || "Loading notifications..."}
- ) : notifications.length === 0 ? (
-
- {t.notifications?.empty || "No notifications yet."}
-
) : (
- notifications.map((notification) => (
- void handleNotificationClick(item)}
- onDelete={(item) => void deleteOne(item)}
- />
- ))
+ void handleNotificationClick(item)}
+ onDelete={(item) => void deleteOne(item)}
+ />
)}
- {hasMore ? (
-
-
-
- ) : 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}
+
+
+ );
+}