feat: add dedicated notifications page

Replace the bell popover with an RTL notification inbox, inline Markdown details, read filters, and explicit destination and delete actions. Remove retired email-link screens and client calls.
This commit is contained in:
2026-09-26 12:23:18 +03:30
parent b49747e822
commit c714c32a58
15 changed files with 308 additions and 440 deletions

View File

@@ -20,8 +20,7 @@ const isProfileOrAuthPath = (pathname: string) =>
pathname.startsWith("/profile/") ||
pathname === "/auth" ||
pathname.startsWith("/auth/") ||
pathname.startsWith("/reset-password") ||
pathname.startsWith("/verify-email");
pathname.startsWith("/reset-password");
export default function MobileBottomNav() {
const pathname = usePathname() || "/";

View File

@@ -1,184 +1,24 @@
"use client";
import { Bell, CheckCheck, Loader2, Trash2 } from "lucide-react";
import ConfirmAction from "@/components/ConfirmAction";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Bell } from "lucide-react";
import { useNotifications } from "@/contexts/NotificationsContext";
import type { NotificationSchema } from "@/lib/types";
import { cn, formatJalali } from "@/lib/utils";
const connectionLabels = {
idle: "خاموش",
connecting: "در حال اتصال",
connected: "متصل",
disconnected: "قطع شده",
} as const;
function NotificationItem({
notification,
onOpen,
onDelete,
}: {
notification: NotificationSchema;
onOpen: (notification: NotificationSchema) => Promise<unknown>;
onDelete: (notification: NotificationSchema) => Promise<unknown>;
}) {
return (
<div
className={cn(
"rounded-2xl border border-border/70 bg-background/75 p-3 text-right transition hover:border-primary/30 hover:bg-muted/35",
!notification.is_seen && "border-primary/30 bg-primary/5",
)}
>
<div className="flex items-start justify-between gap-3">
<ConfirmAction
title="حذف اعلان"
description="آیا از حذف این اعلان مطمئن هستید؟"
onConfirm={() => onDelete(notification)}
trigger={
<Button
type="button"
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0 rounded-full text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
}
/>
<button
type="button"
onClick={() => void onOpen(notification)}
className="min-w-0 flex-1 space-y-1 text-right"
>
<div className="flex items-center justify-end gap-2">
{!notification.is_seen ? (
<span className="h-2.5 w-2.5 rounded-full bg-primary" />
) : null}
<p className="truncate font-semibold">{notification.title}</p>
</div>
<p className="line-clamp-2 text-sm text-muted-foreground">
{notification.message}
</p>
<p className="text-xs text-muted-foreground/80">
{formatJalali(notification.created_at, false)}
</p>
</button>
</div>
</div>
);
}
import { Link } from "@/lib/router";
export default function NotificationsBell() {
const {
notifications,
unreadCount,
totalCount,
hasMore,
isLoading,
isLoadingMore,
connectionStatus,
loadMore,
markAllAsSeen,
deleteNotification,
openNotification,
} = useNotifications();
const { unreadCount } = useNotifications();
return (
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="relative h-10 w-10 rounded-full border-0 bg-transparent shadow-none transition hover:bg-background/45 hover:shadow-sm"
aria-label="اعلان‌ها"
>
<Bell className="h-4 w-4" />
{unreadCount > 0 ? (
<span className="absolute -left-1 -top-1 flex min-w-5 items-center justify-center rounded-full bg-primary px-1.5 py-0.5 text-[10px] font-semibold text-primary-foreground">
{unreadCount > 9 ? "9+" : unreadCount}
</span>
) : null}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[min(92vw,26rem)] rounded-[1.5rem] border border-border/70 bg-background/95 p-0 shadow-xl backdrop-blur-xl" align="end" sideOffset={12}>
<div className="border-b border-border/70 px-4 py-4 text-right">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Badge variant={connectionStatus === "connected" ? "default" : "secondary"}>
{connectionLabels[connectionStatus]}
</Badge>
{unreadCount > 0 ? (
<Button
type="button"
variant="ghost"
size="sm"
className="rounded-full"
onClick={() => void markAllAsSeen()}
>
<CheckCheck className="ml-2 h-4 w-4" />
خواندن همه
</Button>
) : null}
</div>
<div>
<p className="font-semibold">اعلان‌ها</p>
<p className="mt-1 text-xs text-muted-foreground">
{totalCount > 0 ? `${totalCount} مورد ثبت شده` : "هنوز اعلانی ندارید."}
</p>
</div>
</div>
</div>
<ScrollArea className="max-h-[24rem] px-4 py-4">
<div className="space-y-3">
{isLoading ? (
<div className="flex items-center justify-center gap-2 py-10 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
در حال بارگذاری اعلان‌ها...
</div>
) : notifications.length ? (
notifications.map((notification) => (
<NotificationItem
key={notification.id}
notification={notification}
onOpen={openNotification}
onDelete={deleteNotification}
/>
))
) : (
<div className="rounded-2xl border border-dashed border-border/70 bg-muted/20 px-4 py-10 text-center text-sm text-muted-foreground">
اعلان تازه‌ای برای شما ثبت نشده است.
</div>
)}
</div>
</ScrollArea>
{hasMore ? (
<div className="border-t border-border/70 px-4 py-3">
<Button
type="button"
variant="secondary"
className="w-full rounded-2xl"
onClick={() => void loadMore()}
disabled={isLoadingMore}
>
{isLoadingMore ? (
<>
<Loader2 className="ml-2 h-4 w-4 animate-spin" />
در حال بارگذاری...
</>
) : (
"نمایش موارد بیشتر"
)}
</Button>
</div>
) : null}
</PopoverContent>
</Popover>
<Link
to="/notifications"
aria-label={unreadCount ? `اعلان‌ها، ${unreadCount} خوانده‌نشده` : "اعلان‌ها"}
className="relative inline-flex h-10 w-10 items-center justify-center rounded-full text-foreground transition hover:bg-background/45"
>
<Bell className="h-5 w-5" aria-hidden="true" />
{unreadCount > 0 ? (
<span className="absolute -left-1 -top-1 flex min-w-5 items-center justify-center rounded-full bg-primary px-1.5 py-0.5 text-[10px] font-semibold text-primary-foreground">
{unreadCount > 9 ? "9+" : unreadCount}
</span>
) : null}
</Link>
);
}

View File

@@ -0,0 +1,255 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { ArrowLeft, ChevronDown, Loader2, Trash2 } from "lucide-react";
import ConfirmAction from "@/components/ConfirmAction";
import Markdown from "@/components/Markdown";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/contexts/AuthContext";
import { useNotifications } from "@/contexts/NotificationsContext";
import { toast } from "@/hooks/use-toast";
import { api } from "@/lib/api";
import { Link } from "@/lib/router";
import type { NotificationDetailSchema, NotificationSchema } from "@/lib/types";
import { cn, formatJalaliNumericDate } from "@/lib/utils";
type ReadFilter = "all" | "unread" | "read";
const PAGE_SIZE = 12;
function safeDestination(url?: string | null) {
if (!url) return null;
if (url.startsWith("/") && !url.startsWith("//")) return url;
if (/^https?:\/\//i.test(url)) return url;
return null;
}
export default function NotificationsPageContent() {
const { isAuthenticated, loading: authLoading } = useAuth();
const { notifications: liveNotifications, markAsSeen, deleteNotification } = useNotifications();
const knownLiveIds = useRef<Set<string> | null>(null);
const requestVersion = useRef(0);
const [filter, setFilter] = useState<ReadFilter>("all");
const [items, setItems] = useState<NotificationSchema[]>([]);
const [count, setCount] = useState(0);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [detail, setDetail] = useState<NotificationDetailSchema | null>(null);
const [openingId, setOpeningId] = useState<string | null>(null);
const loadFirst = useCallback(async (status: ReadFilter) => {
const version = ++requestVersion.current;
setLoading(true);
try {
const result = await api.getNotifications({ limit: PAGE_SIZE, status });
if (version === requestVersion.current) {
setItems(result.notifications);
setCount(result.count);
}
} catch {
if (version === requestVersion.current) toast({ title: "بارگذاری اعلان‌ها انجام نشد.", variant: "destructive" });
} finally {
if (version === requestVersion.current) setLoading(false);
}
}, []);
useEffect(() => {
if (authLoading) return;
if (!isAuthenticated) {
setItems([]);
setLoading(false);
return;
}
setExpandedId(null);
setDetail(null);
void loadFirst(filter);
}, [authLoading, filter, isAuthenticated, loadFirst]);
useEffect(() => {
const nextIds = new Set(liveNotifications.map((item) => item.id));
const previousIds = knownLiveIds.current;
knownLiveIds.current = nextIds;
if (previousIds && liveNotifications.some((item) => !previousIds.has(item.id)) && isAuthenticated && !expandedId) {
void loadFirst(filter);
}
}, [expandedId, filter, isAuthenticated, liveNotifications, loadFirst]);
const loadMore = async () => {
if (loadingMore) return;
const version = requestVersion.current;
setLoadingMore(true);
try {
const offset = filter === "unread" ? items.filter((item) => !item.is_seen).length : items.length;
const result = await api.getNotifications({ limit: PAGE_SIZE, offset, status: filter });
if (version !== requestVersion.current) return;
setItems((current) => {
const existing = new Set(current.map((item) => item.id));
return [...current, ...result.notifications.filter((item) => !existing.has(item.id))];
});
setCount(result.count);
} catch {
if (version === requestVersion.current) toast({ title: "بارگذاری اعلان‌های بیشتر انجام نشد.", variant: "destructive" });
} finally {
setLoadingMore(false);
}
};
const close = () => {
setExpandedId(null);
setDetail(null);
void loadFirst(filter);
};
const open = async (item: NotificationSchema) => {
if (expandedId === item.id) {
close();
return;
}
const version = requestVersion.current;
setOpeningId(item.id);
try {
const result = await api.getNotification(item.id);
if (version !== requestVersion.current) return;
if (expandedId && filter === "unread") {
setItems((current) => current.filter((entry) => entry.id !== expandedId));
}
setDetail(result);
setExpandedId(item.id);
if (!item.is_seen) {
const seen = await markAsSeen(item);
if (seen?.deleted) {
setItems((current) => current.filter((entry) => entry.id !== item.id));
setCount((current) => Math.max(0, current - 1));
setExpandedId(null);
setDetail(null);
return;
}
setItems((current) => current.map((entry) => entry.id === item.id ? { ...entry, is_seen: true } : entry));
if (filter === "unread") setCount((current) => Math.max(0, current - 1));
}
} catch {
toast({ title: "باز کردن اعلان انجام نشد.", variant: "destructive" });
} finally {
setOpeningId(null);
}
};
const remove = async (item: NotificationSchema) => {
try {
await deleteNotification(item);
setItems((current) => current.filter((entry) => entry.id !== item.id));
if (filter !== "unread" || !item.is_seen) setCount((current) => Math.max(0, current - 1));
if (expandedId === item.id) {
setExpandedId(null);
setDetail(null);
}
} catch {
toast({ title: "حذف اعلان انجام نشد.", variant: "destructive" });
}
};
if (authLoading) {
return <div className="mx-auto max-w-6xl px-4 py-16 text-center text-muted-foreground">در حال بارگذاری...</div>;
}
if (!isAuthenticated) {
return (
<main className="mx-auto max-w-6xl px-4 py-16 text-center" dir="rtl">
<h1 className="text-2xl font-bold">اعلان‌های من</h1>
<p className="mt-4 text-muted-foreground">برای دیدن اعلان‌های خود وارد حساب شوید.</p>
<Button asChild className="mt-6"><Link to="/auth">ورود به حساب</Link></Button>
</main>
);
}
return (
<main className="min-h-[70vh] bg-[#f7f8fa] px-3 py-5 text-foreground dark:bg-background sm:px-5 sm:py-8" dir="rtl">
<div className="mx-auto max-w-6xl space-y-5">
<header className="flex flex-wrap items-center justify-between gap-4 rounded-2xl border border-border/70 bg-background px-5 py-5 shadow-sm sm:px-7">
<h1 className="text-xl font-bold sm:text-2xl">اعلان‌های من</h1>
<label className="flex items-center gap-3 text-sm">
<span className="text-muted-foreground">نمایش:</span>
<span className="relative">
<select
value={filter}
onChange={(event) => setFilter(event.target.value as ReadFilter)}
className="min-w-28 appearance-none rounded-lg border border-transparent bg-transparent py-2 pe-8 ps-2 font-semibold outline-none focus-visible:border-primary"
aria-label="فیلتر اعلان‌ها"
>
<option value="all">همه</option>
<option value="unread">خوانده‌نشده</option>
<option value="read">خوانده‌شده</option>
</select>
<ChevronDown className="pointer-events-none absolute end-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
</span>
</label>
</header>
{loading ? (
<div className="flex items-center justify-center gap-2 py-16 text-muted-foreground"><Loader2 className="h-5 w-5 animate-spin" />در حال بارگذاری اعلان‌ها...</div>
) : items.length === 0 ? (
<div className="rounded-2xl bg-background px-6 py-16 text-center text-muted-foreground">اعلانی برای نمایش وجود ندارد.</div>
) : (
<div className="space-y-4">
{items.map((item) => {
const expanded = expandedId === item.id;
const destination = safeDestination(item.action_url);
return (
<article key={item.id} className={cn("overflow-hidden rounded-2xl bg-background shadow-sm", !item.is_seen && "ring-1 ring-primary/25")}>
<button
type="button"
disabled={Boolean(openingId)}
onClick={() => void open(item)}
aria-expanded={expanded}
aria-controls={`notification-body-${item.id}`}
className="flex min-h-20 w-full flex-col items-start justify-center gap-2 px-5 py-4 text-right transition hover:bg-muted/30 sm:flex-row sm:items-center sm:justify-between sm:gap-6 sm:px-7"
>
<span className="flex min-w-0 items-center gap-2 font-medium leading-7">
{!item.is_seen && <span className="h-2 w-2 shrink-0 rounded-full bg-primary" aria-label="خوانده‌نشده" />}
<span>{item.title}</span>
{openingId === item.id && <Loader2 className="h-4 w-4 animate-spin" />}
</span>
<span className="shrink-0 text-xs text-muted-foreground">ارسال شده {formatJalaliNumericDate(item.created_at)}</span>
</button>
{expanded && detail && (
<div id={`notification-body-${item.id}`}>
<div className="border-t border-border/70 px-5 py-5 text-sm text-muted-foreground sm:px-7">
{detail.body_markdown ? (
<>
<p className="mb-3">{detail.message}</p>
<Markdown content={detail.body_markdown} allowHtml={false} size="sm" />
</>
) : <p className="leading-8">{detail.message}</p>}
</div>
<div className="flex items-center justify-between border-t border-border/70 px-5 py-3 sm:px-7">
<ConfirmAction
title="حذف اعلان"
description="آیا از حذف این اعلان مطمئن هستید؟"
onConfirm={() => remove(item)}
trigger={<Button type="button" variant="ghost" size="sm" className="gap-2 text-muted-foreground"><Trash2 className="h-4 w-4" />حذف کردن</Button>}
/>
{destination ? (
<a href={destination} className="inline-flex items-center gap-2 text-sm font-medium text-primary hover:underline" rel={destination.startsWith("http") ? "noopener noreferrer" : undefined}>
مشاهده <ArrowLeft className="h-4 w-4" />
</a>
) : <span />}
</div>
</div>
)}
</article>
);
})}
</div>
)}
{!loading && (filter === "unread" ? items.filter((item) => !item.is_seen).length : items.length) < count && (
<div className="text-center">
<Button type="button" variant="outline" onClick={() => void loadMore()} disabled={loadingMore}>
{loadingMore ? "در حال بارگذاری..." : "نمایش موارد بیشتر"}
</Button>
</div>
)}
</div>
</main>
);
}