feat(notifications): add dedicated page and localized rendering
This commit is contained in:
@@ -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 (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
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<HTMLDivElement>(null)
|
||||
} = useNotifications();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(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 (
|
||||
<div className="relative" ref={containerRef}>
|
||||
@@ -136,8 +61,8 @@ export function NotificationBell() {
|
||||
{t.notifications?.title || "Notifications"}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{t.notifications?.summary?.(totalCount, unreadCount) ||
|
||||
`${totalCount} total, ${unreadCount} unread`}
|
||||
{t.notifications?.summary?.(unreadNotifications.length, unreadCount) ||
|
||||
`${unreadNotifications.length} total, ${unreadCount} unread`}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -159,45 +84,31 @@ export function NotificationBell() {
|
||||
<Loader2 className="me-2 h-4 w-4 animate-spin" />
|
||||
{t.notifications?.loading || "Loading notifications..."}
|
||||
</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) => (
|
||||
<NotificationRow
|
||||
key={notification.id}
|
||||
notification={notification}
|
||||
locale={lang}
|
||||
onClick={(item) => void handleNotificationClick(item)}
|
||||
onDelete={(item) => void deleteOne(item)}
|
||||
/>
|
||||
))
|
||||
<NotificationList
|
||||
notifications={unreadNotifications}
|
||||
emptyLabel={t.notifications?.emptyUnread || "No unread notifications."}
|
||||
onClick={(item) => void handleNotificationClick(item)}
|
||||
onDelete={(item) => void deleteOne(item)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{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 className="border-t border-slate-100 p-3 dark:border-slate-800">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
navigate("/notifications");
|
||||
}}
|
||||
>
|
||||
{t.notifications?.viewAll || "View all notifications"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
105
src/components/notifications/NotificationList.tsx
Normal file
105
src/components/notifications/NotificationList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user