feat(notifications): add dedicated page and localized rendering

This commit is contained in:
2026-04-29 01:31:15 +03:30
parent 05f2b4a4bb
commit b2101a2e22
8 changed files with 416 additions and 169 deletions

View File

@@ -22,6 +22,7 @@ import Tags from "./pages/Tags"
import Reports from "./pages/Reports" import Reports from "./pages/Reports"
import Timesheet from "./pages/Timesheet" import Timesheet from "./pages/Timesheet"
import Logs from "./pages/Logs" import Logs from "./pages/Logs"
import NotificationsPage from "./pages/Notifications"
const MainLayout = () => { const MainLayout = () => {
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false) const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
@@ -66,6 +67,7 @@ const router = createBrowserRouter([
{ path: "/profile", element: <Profile /> }, { path: "/profile", element: <Profile /> },
{ path: "/timesheet", element: <Timesheet /> }, { path: "/timesheet", element: <Timesheet /> },
{ path: "/reports", element: <Reports /> }, { path: "/reports", element: <Reports /> },
{ path: "/notifications", element: <NotificationsPage /> },
{ path: "/logs", element: <Logs /> }, { path: "/logs", element: <Logs /> },
{ path: "/tags", element: <Tags /> }, { path: "/tags", element: <Tags /> },
{ path: "/workspaces", element: <Workspaces /> }, { path: "/workspaces", element: <Workspaces /> },

View File

@@ -1,105 +1,30 @@
import { useEffect, useMemo, useRef, useState } from "react" import { useEffect, useMemo, useRef, useState } from "react";
import { Bell, CheckCheck, Loader2, Trash2 } from "lucide-react" import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../hooks/useTranslation" import { Bell, CheckCheck, Loader2 } from "lucide-react";
import { cn } from "../../lib/utils"
import { useNotifications } from "../../context/NotificationsContext"
import type { NotificationItem } from "../../api/notifications"
import { Button } from "../ui/button"
const formatNotificationTimestamp = (value: string, locale: string) => { import { NotificationList } from "./NotificationList";
const date = new Date(value) import { useTranslation } from "../../hooks/useTranslation";
if (Number.isNaN(date.getTime())) { import { useNotifications } from "../../context/NotificationsContext";
return value import { Button } from "../ui/button";
}
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 (
<div
className={cn(
"border-b border-slate-100 px-4 py-3 transition-colors dark:border-slate-800",
notification.is_seen
? "bg-white hover:bg-slate-50 dark:bg-slate-900 dark:hover:bg-slate-800/80"
: "bg-sky-50/70 hover:bg-sky-100/70 dark:bg-sky-500/10 dark:hover:bg-sky-500/15",
)}
>
<div className="flex items-start gap-3">
<button
type="button"
onClick={() => onClick(notification)}
className="flex min-w-0 flex-1 items-start gap-3 text-start"
>
<span
className={cn(
"mt-1 h-2.5 w-2.5 shrink-0 rounded-full",
notification.is_seen ? "bg-slate-300 dark:bg-slate-700" : "bg-sky-500",
)}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<p className="truncate text-sm font-semibold text-slate-900 dark:text-slate-100">
{notification.title || notification.type}
</p>
<span className="shrink-0 text-xs text-slate-500 dark:text-slate-400">
{formatNotificationTimestamp(notification.created_at, locale)}
</span>
</div>
{notification.message ? (
<p className="mt-1 line-clamp-2 text-sm text-slate-600 dark:text-slate-300">
{notification.message}
</p>
) : null}
</div>
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation()
void onDelete(notification)
}}
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-red-50 hover:text-red-600 dark:text-slate-500 dark:hover:bg-red-950/40 dark:hover:text-red-400"
aria-label="Delete notification"
title="Delete notification"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
)
}
export function NotificationBell() { export function NotificationBell() {
const { t, lang } = useTranslation() const { t } = useTranslation();
const navigate = useNavigate();
const { const {
notifications, notifications,
unreadCount, unreadCount,
totalCount,
hasMore,
isLoading, isLoading,
isLoadingMore,
loadMore,
markAllAsSeen, markAllAsSeen,
deleteOne, deleteOne,
handleNotificationClick, handleNotificationClick,
} = useNotifications() } = useNotifications();
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null);
const unreadNotifications = useMemo(
() => notifications.filter((notification) => !notification.is_seen),
[notifications],
);
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
@@ -107,12 +32,12 @@ export function NotificationBell() {
containerRef.current && containerRef.current &&
!containerRef.current.contains(event.target as Node) !containerRef.current.contains(event.target as Node)
) { ) {
setIsOpen(false) setIsOpen(false);
} }
} };
document.addEventListener("mousedown", handleClickOutside) document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside) return () => document.removeEventListener("mousedown", handleClickOutside);
}, []) }, []);
return ( return (
<div className="relative" ref={containerRef}> <div className="relative" ref={containerRef}>
@@ -136,8 +61,8 @@ export function NotificationBell() {
{t.notifications?.title || "Notifications"} {t.notifications?.title || "Notifications"}
</p> </p>
<p className="text-xs text-slate-500 dark:text-slate-400"> <p className="text-xs text-slate-500 dark:text-slate-400">
{t.notifications?.summary?.(totalCount, unreadCount) || {t.notifications?.summary?.(unreadNotifications.length, unreadCount) ||
`${totalCount} total, ${unreadCount} unread`} `${unreadNotifications.length} total, ${unreadCount} unread`}
</p> </p>
</div> </div>
<Button <Button
@@ -159,45 +84,31 @@ export function NotificationBell() {
<Loader2 className="me-2 h-4 w-4 animate-spin" /> <Loader2 className="me-2 h-4 w-4 animate-spin" />
{t.notifications?.loading || "Loading notifications..."} {t.notifications?.loading || "Loading notifications..."}
</div> </div>
) : notifications.length === 0 ? (
<div className="px-4 py-12 text-center text-sm text-slate-500 dark:text-slate-400">
{t.notifications?.empty || "No notifications yet."}
</div>
) : ( ) : (
notifications.map((notification) => ( <NotificationList
<NotificationRow notifications={unreadNotifications}
key={notification.id} emptyLabel={t.notifications?.emptyUnread || "No unread notifications."}
notification={notification} onClick={(item) => void handleNotificationClick(item)}
locale={lang} onDelete={(item) => void deleteOne(item)}
onClick={(item) => void handleNotificationClick(item)} />
onDelete={(item) => void deleteOne(item)}
/>
))
)} )}
</div> </div>
{hasMore ? ( <div className="border-t border-slate-100 p-3 dark:border-slate-800">
<div className="border-t border-slate-100 p-3 dark:border-slate-800"> <Button
<Button type="button"
type="button" variant="outline"
variant="outline" className="w-full"
className="w-full" onClick={() => {
onClick={() => void loadMore()} setIsOpen(false);
disabled={isLoadingMore} navigate("/notifications");
> }}
{isLoadingMore ? ( >
<> {t.notifications?.viewAll || "View all notifications"}
<Loader2 className="me-2 h-4 w-4 animate-spin" /> </Button>
{t.notifications?.loadingMore || "Loading more..."} </div>
</>
) : (
t.notifications?.loadMore || "Load more"
)}
</Button>
</div>
) : null}
</div> </div>
) : null} ) : null}
</div> </div>
) );
} }

View File

@@ -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 (
<div className={cn("px-4 py-12 text-center text-sm text-slate-500 dark:text-slate-400", className)}>
{emptyLabel}
</div>
);
}
return (
<div className={className}>
{notifications.map((notification) => {
const presented = presentNotification(notification, t);
return (
<div
key={notification.id}
className={cn(
"border-b border-slate-100 px-4 py-3 transition-colors dark:border-slate-800",
notification.is_seen
? "bg-white hover:bg-slate-50 dark:bg-slate-900 dark:hover:bg-slate-800/80"
: "bg-sky-50/70 hover:bg-sky-100/70 dark:bg-sky-500/10 dark:hover:bg-sky-500/15",
)}
>
<div className="flex items-start gap-3">
<button
type="button"
onClick={() => onClick(notification)}
className="flex min-w-0 flex-1 items-start gap-3 text-start"
>
<span
className={cn(
"mt-1 h-2.5 w-2.5 shrink-0 rounded-full",
notification.is_seen ? "bg-slate-300 dark:bg-slate-700" : "bg-sky-500",
)}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<p className="truncate text-sm font-semibold text-slate-900 dark:text-slate-100">
{presented.title}
</p>
<span className="shrink-0 text-xs text-slate-500 dark:text-slate-400">
{formatNotificationTimestamp(notification.created_at, lang)}
</span>
</div>
{presented.message ? (
<p className="mt-1 line-clamp-2 text-sm text-slate-600 dark:text-slate-300">
{presented.message}
</p>
) : null}
</div>
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
void onDelete(notification);
}}
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-red-50 hover:text-red-600 dark:text-slate-500 dark:hover:bg-red-950/40 dark:hover:text-red-400"
aria-label={t.notifications?.deleteLabel || "Delete notification"}
title={t.notifications?.deleteLabel || "Delete notification"}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
);
})}
</div>
);
}

View File

@@ -22,6 +22,7 @@ import {
type NotificationLevel, type NotificationLevel,
} from "../api/notifications" } from "../api/notifications"
import { useTranslation } from "../hooks/useTranslation" import { useTranslation } from "../hooks/useTranslation"
import { presentNotification } from "../lib/notificationPresenter"
import { import {
getAccessToken, getAccessToken,
SESSION_CHANGED_EVENT, SESSION_CHANGED_EVENT,
@@ -153,8 +154,9 @@ export function NotificationsProvider({ children }: { children: ReactNode }) {
toastedNotificationIdsRef.current.add(notification.id) toastedNotificationIdsRef.current.add(notification.id)
const notify = getToastMethod(notification.level) const notify = getToastMethod(notification.level)
notify(notification.title || (t.notifications?.newTitle || "New notification"), { const presented = presentNotification(notification, t)
description: notification.message || undefined, notify(presented.title || (t.notifications?.newTitle || "New notification"), {
description: presented.message || undefined,
action: notification.action_url action: notification.action_url
? { ? {
label: t.notifications?.openAction || "Open", label: t.notifications?.openAction || "Open",

View File

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

View File

@@ -580,20 +580,44 @@ export const en = {
}, },
notifications: { notifications: {
title: "Notifications", title: "Notifications",
open: "Open notifications", pageDescription: "Review all notifications and export updates.",
empty: "No notifications yet.", open: "Open notifications",
loading: "Loading notifications...", empty: "No notifications yet.",
loadingMore: "Loading more...", emptyUnread: "No unread notifications.",
loadMore: "Load more", loading: "Loading notifications...",
markAllRead: "Mark all as read", loadingMore: "Loading more...",
markSeenError: "Failed to update notification", loadMore: "Load more",
markAllError: "Failed to update notifications", markAllRead: "Mark all as read",
deleteError: "Failed to delete notification", viewAll: "View all notifications",
loadError: "Failed to load notifications", totalLabel: "Total notifications",
openError: "Failed to open notification", unreadLabel: "Unread notifications",
newTitle: "New notification", deleteLabel: "Delete notification",
openAction: "Open", markSeenError: "Failed to update notification",
summary: (total: number, unread: number) => `${total} total, ${unread} unread`, 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.`,
},
}

View File

@@ -574,20 +574,44 @@ export const fa = {
}, },
}, },
notifications: { notifications: {
title: "اعلان‌ها", title: "اعلان‌ها",
open: "باز کردن اعلان‌ها", pageDescription: "مرور همه اعلان‌ها و وضعیت خروجی‌های گزارش.",
empty: "هنوز اعلانی وجود ندارد.", open: "باز کردن اعلان‌ها",
loading: "در حال بارگذاری اعلان‌ها...", empty: "هنوز اعلانی وجود ندارد.",
loadingMore: "در حال بارگذاری بیشتر...", emptyUnread: "اعلان خوانده‌نشده‌ای وجود ندارد.",
loadMore: "بارگذاری بیشتر", loading: "در حال بارگذاری اعلان‌ها...",
markAllRead: "خواندن همه", loadingMore: "در حال بارگذاری بیشتر...",
markSeenError: "به‌روزرسانی اعلان با خطا مواجه شد.", loadMore: "بارگذاری بیشتر",
markAllError: "به‌روزرسانی اعلان‌ها با خطا مواجه شد.", markAllRead: "خواندن همه",
deleteError: "حذف اعلان با خطا مواجه شد.", viewAll: "نمایش همه اعلان‌ها",
loadError: "دریافت اعلان‌ها با خطا مواجه شد.", totalLabel: "مجموع اعلان‌ها",
openError: "باز کردن اعلان با خطا مواجه شد.", unreadLabel: "اعلان‌های خوانده‌نشده",
newTitle: "اعلان جدید", deleteLabel: "حذف اعلان",
openAction: "باز کردن", markSeenError: "به‌روزرسانی اعلان با خطا مواجه شد.",
summary: (total: number, unread: number) => `${total} کل، ${unread} خوانده‌نشده`, 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} با خطا مواجه شد.`,
},
}

101
src/pages/Notifications.tsx Normal file
View File

@@ -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 (
<div className="mx-auto max-w-7xl space-y-5 p-4 md:p-6">
<div className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">
{t.notifications?.title || "Notifications"}
</h1>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
{t.notifications?.pageDescription || "Review all notifications and export updates."}
</p>
</div>
<Button
type="button"
variant="outline"
onClick={() => void markAllAsSeen()}
disabled={unreadCount === 0}
className="gap-2"
>
<CheckCheck className="h-4 w-4" />
{t.notifications?.markAllRead || "Mark all as read"}
</Button>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="text-xs font-semibold uppercase tracking-[0.14em] text-slate-400 dark:text-slate-500">
{t.notifications?.totalLabel || "Total notifications"}
</div>
<div className="mt-2 text-3xl font-bold text-slate-900 dark:text-white">{totalCount}</div>
</div>
<div className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="text-xs font-semibold uppercase tracking-[0.14em] text-slate-400 dark:text-slate-500">
{t.notifications?.unreadLabel || "Unread notifications"}
</div>
<div className="mt-2 text-3xl font-bold text-slate-900 dark:text-white">{unreadCount}</div>
</div>
</div>
<div className="overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
{isLoading ? (
<div className="flex items-center justify-center px-4 py-12 text-sm text-slate-500 dark:text-slate-400">
<Loader2 className="me-2 h-4 w-4 animate-spin" />
{t.notifications?.loading || "Loading notifications..."}
</div>
) : (
<NotificationList
notifications={notifications}
emptyLabel={t.notifications?.empty || "No notifications yet."}
onClick={(item) => void handleNotificationClick(item)}
onDelete={(item) => void deleteOne(item)}
/>
)}
{hasMore ? (
<div className="border-t border-slate-100 p-3 dark:border-slate-800">
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => void loadMore()}
disabled={isLoadingMore}
>
{isLoadingMore ? (
<>
<Loader2 className="me-2 h-4 w-4 animate-spin" />
{t.notifications?.loadingMore || "Loading more..."}
</>
) : (
t.notifications?.loadMore || "Load more"
)}
</Button>
</div>
) : null}
</div>
</div>
);
}