feat(notification): enhance NotificationDetailsPage and Notification component with improved UI and functionality

- Refactored NotificationDetailsPage to utilize a presenter hook for better state management and loading handling.
- Improved UI elements in NotificationDetailsPage, including a hero header and detail grid for displaying notification information.
- Updated Notification component to streamline the marking of notifications as seen and enhance the display of notification details.
- Added gradient backgrounds and improved tooltip functionality for better visual feedback and user experience.
This commit is contained in:
AmirReza Jamali
2026-06-03 16:34:43 +03:30
parent bb3695ee74
commit a99f86b2f9
7 changed files with 553 additions and 63 deletions
@@ -1,39 +1,188 @@
"use client";
import Loading from "@/components/loading";
import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb";
import { NotificationDetailsCard } from "@/components/ui/notification-details-card";
import { useGetNotificationDetails } from "@/hooks/queries/useNotificationQueries";
import { BreadcrumbItem } from "@/types/shared";
import { use } from "react";
import Loading from "@/components/loading";
import Status from "@/components/ui/Status";
import { BellIcon } from "@/components/Layouts/header/notification/icons";
import Link from "next/link";
import { useNotificationDetailsPresenter } from "./useNotificationDetailsPresenter";
interface IProps {
params: Promise<{ id: string }>;
}
const NotificationDetailsPage = ({ params }: IProps) => {
const { id } = use(params);
const { data, isLoading } = useGetNotificationDetails(id);
const { isLoading, rootRef, breadcrumbItems, meta, details } =
useNotificationDetailsPresenter({ params });
const breadcrumbItems: BreadcrumbItem[] = [
{
href: "/",
name: "Dashboard",
},
{
href: "/notification-history",
name: "Notification history",
},
{
href: `/notification-history/${id}`,
name: "Notification details",
},
];
if (isLoading) return <Loading />;
return (
<>
<Breadcrumb items={breadcrumbItems} />
{data?.data && <NotificationDetailsCard data={data.data} />}
</>
<div ref={rootRef} className="flex flex-col gap-6">
<div data-anim="breadcrumb">
<Breadcrumb items={breadcrumbItems} />
</div>
{/* Hero header */}
<section
data-anim="hero"
className="relative isolate overflow-hidden rounded-3xl border border-stroke/60 bg-gradient-to-br from-primary via-primary to-blue-700 p-6 text-white shadow-1 md:p-9 dark:border-transparent dark:shadow-card"
>
<div
data-anim="aurora"
aria-hidden
className="pointer-events-none absolute -inset-[40%] z-0 opacity-50"
style={{
backgroundImage:
"conic-gradient(from 0deg at 50% 50%, #5750F1 0deg, #0ABEF9 80deg, transparent 140deg, #8155FF 200deg, #5750F1 280deg, transparent 320deg, #5750F1 360deg)",
filter: "blur(64px)",
}}
/>
<div
data-orb="a"
aria-hidden
className="pointer-events-none absolute -right-24 -top-32 z-0 h-72 w-72 rounded-full bg-white/20 blur-3xl"
/>
<div
data-orb="b"
aria-hidden
className="pointer-events-none absolute -bottom-32 -left-16 z-0 h-64 w-64 rounded-full bg-[#0ABEF9]/40 blur-3xl"
/>
<div
data-orb="c"
aria-hidden
className="pointer-events-none absolute left-1/2 top-1/3 z-0 h-52 w-52 -translate-x-1/2 rounded-full bg-[#8155FF]/30 blur-3xl"
/>
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-0 rounded-3xl ring-1 ring-inset ring-white/10"
/>
<div className="relative z-10 flex flex-col gap-6 md:flex-row md:items-start">
<span
data-anim="hero-icon"
className="grid size-16 shrink-0 place-items-center rounded-2xl bg-white/20 text-white shadow-theme-sm ring-1 ring-white/30 backdrop-blur-md"
>
<BellIcon />
</span>
<div className="min-w-0 flex-1">
<h1
className="relative z-[1] text-2xl font-bold leading-tight md:text-3xl"
aria-label={meta?.title}
>
<span
data-title-inner
className="relative z-[1] inline-block will-change-transform"
>
{meta?.title}
</span>
<span
aria-hidden
className="pointer-events-none absolute inset-0 z-0 overflow-hidden"
>
<span
data-anim="shine"
className="absolute inset-y-0 block w-1/3 opacity-0 blur-[10px] [background:linear-gradient(100deg,transparent_0%,rgba(255,255,255,0.55)_50%,transparent_100%)]"
/>
</span>
</h1>
<div className="mt-4 flex flex-wrap items-center gap-2.5">
{meta?.priority && (
<span data-anim="badge" className="inline-flex">
<Status status={meta.priority}>{meta.priority}</Status>
</span>
)}
{meta?.type && (
<span
data-anim="badge"
className="rounded-full bg-white/20 px-3 py-1 text-xs font-semibold capitalize text-white ring-1 ring-white/25 backdrop-blur-sm"
>
{meta.type}
</span>
)}
{meta?.createdAt && (
<span
data-anim="badge"
className="rounded-full bg-white/15 px-3 py-1 text-xs font-medium text-white/85 ring-1 ring-white/15"
>
{meta.createdAt}
</span>
)}
</div>
{meta?.link && (
<Link
data-anim="badge"
href={meta.link}
className="mt-4 inline-flex items-center gap-2 rounded-xl bg-white/95 px-4 py-2 text-sm font-semibold text-primary shadow-theme-sm transition-transform hover:scale-[1.03]"
>
Open link
<span aria-hidden></span>
</Link>
)}
</div>
</div>
</section>
{/* Detail grid */}
<section className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{details.map((item) => (
<div
key={item.label}
data-anim="card"
className="group relative overflow-hidden rounded-2xl border border-stroke/60 bg-white p-5 shadow-theme-xs transition-shadow hover:shadow-1 dark:border-dark-3 dark:bg-gray-dark"
>
<span
aria-hidden
className="pointer-events-none absolute -right-6 -top-6 size-16 rounded-full bg-primary/5 blur-xl transition-opacity group-hover:opacity-100 dark:bg-primary/10"
/>
<small className="text-xs font-medium uppercase tracking-wide text-dark-5 dark:text-dark-6">
{item.label}
</small>
<div className="mt-2">
{item.kind === "status" ? (
<Status status={item.tone ?? ""}>{item.value}</Status>
) : (
<p className="break-words text-base font-semibold text-dark dark:text-white">
{item.value}
</p>
)}
</div>
</div>
))}
</section>
{/* Image + message */}
{(meta?.image || meta?.hasMessage) && (
<section
data-anim="message"
className="overflow-hidden rounded-3xl border border-stroke/60 bg-white shadow-theme-xs dark:border-dark-3 dark:bg-gray-dark"
>
{meta?.image && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={meta.image}
alt={meta.title}
className="max-h-72 w-full object-cover"
/>
)}
{meta?.hasMessage && (
<div className="p-6 md:p-8">
<h2 className="mb-4 flex items-center gap-2 text-lg font-bold text-dark dark:text-white">
<span className="inline-block h-5 w-1 rounded-full bg-gradient-to-b from-primary to-blue-700" />
Message
</h2>
<div
className="text-base leading-relaxed text-dark-4 dark:text-dark-6 [&_a]:text-primary [&_a]:underline"
dangerouslySetInnerHTML={{ __html: meta.messageHtml }}
/>
</div>
)}
</section>
)}
</div>
);
};
@@ -0,0 +1,274 @@
"use client";
import { useGetNotificationDetails } from "@/hooks/queries/useNotificationQueries";
import { sanitizeHtmlWithStyles } from "@/lib/utils";
import { BreadcrumbItem } from "@/types/shared";
import { capitalize, unixToDate } from "@/utils/shared";
import gsap from "gsap";
import { use, useLayoutEffect, useMemo, useRef } from "react";
interface IParams {
params: Promise<{ id: string }>;
}
export interface IDetailItem {
label: string;
value: string;
kind?: "text" | "status";
tone?: string;
}
const prefersReducedMotion = () =>
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
export const useNotificationDetailsPresenter = ({ params }: IParams) => {
const { id } = use(params);
const { data, isLoading } = useGetNotificationDetails(id);
const notification = data?.data;
const rootRef = useRef<HTMLDivElement>(null);
const breadcrumbItems: BreadcrumbItem[] = [
{ href: "/", name: "Dashboard" },
{ href: "/notification-history", name: "Notification history" },
{ href: `/notification-history/${id}`, name: "Notification details" },
];
const meta = useMemo(() => {
if (!notification) return null;
return {
title: notification.title || "Notification",
messageHtml: sanitizeHtmlWithStyles(notification.message || ""),
hasMessage: !!notification.message,
image: notification.image,
priority: notification.priority,
type: notification.type,
status: notification.status,
link: notification.link,
createdAt: unixToDate({ unix: notification.created_at, hasTime: true }),
recipient: notification.recipient?.full_name,
};
}, [notification]);
const details: IDetailItem[] = useMemo(() => {
if (!notification) return [];
return [
{
label: "Recipient",
value: notification.recipient?.full_name || "—",
},
{ label: "Event type", value: notification.event_type || "—" },
{
label: "Status",
value: capitalize(notification.status || "—"),
kind: "status",
tone: notification.status,
},
{
label: "Priority",
value: capitalize(notification.priority || "—"),
kind: "status",
tone: notification.priority,
},
{
label: "Channel type",
value: capitalize(notification.type || "—"),
kind: "status",
tone: notification.type,
},
{
label: "Seen",
value: notification.seen ? "Yes" : "No",
kind: "status",
tone: notification.seen ? "success" : "failed",
},
{
label: "Seen at",
value: unixToDate({ unix: notification.seen_at ?? 0, hasTime: true }),
},
{
label: "Scheduled",
value: notification.is_scheduled ? "Yes" : "No",
},
];
}, [notification]);
// Complex GSAP intro + ambient motion, scoped to the page root.
useLayoutEffect(() => {
if (isLoading || !notification) return;
const root = rootRef.current;
if (!root) return;
const ctx = gsap.context((self) => {
if (prefersReducedMotion()) return;
const q = self.selector!;
// Entrance timeline
const tl = gsap.timeline({ defaults: { ease: "power3.out" } });
tl.from(root, { y: 28, duration: 0.7 }, 0)
.from(
q("[data-anim='breadcrumb']"),
{ y: -12, opacity: 0, duration: 0.5 },
0.05,
)
.from(
q("[data-anim='hero']"),
{ y: 40, opacity: 0, duration: 0.8 },
0.1,
)
.from(
q("[data-anim='hero-icon']"),
{
scale: 0,
rotate: -120,
opacity: 0,
duration: 0.8,
ease: "back.out(2)",
},
0.3,
)
.from(
q("[data-title-inner]"),
{ y: 32, opacity: 0, duration: 0.7, ease: "back.out(1.6)" },
0.4,
)
.from(
q("[data-anim='badge']"),
{
y: 16,
scale: 0.8,
opacity: 0,
duration: 0.5,
stagger: 0.08,
ease: "back.out(1.8)",
},
0.55,
)
.from(
q("[data-anim='card']"),
{
y: 36,
opacity: 0,
scale: 0.94,
duration: 0.6,
stagger: 0.07,
ease: "back.out(1.4)",
},
0.5,
)
.from(
q("[data-anim='message']"),
{ y: 30, opacity: 0, duration: 0.7 },
0.75,
);
// Ambient aurora rotation
const aurora = q("[data-anim='aurora']");
if (aurora.length) {
gsap.to(aurora, {
rotate: 360,
duration: 44,
ease: "none",
repeat: -1,
});
}
// Floating orbs
gsap.to(q("[data-orb='a']"), {
x: 40,
y: -28,
scale: 1.18,
duration: 7,
repeat: -1,
yoyo: true,
ease: "sine.inOut",
});
gsap.to(q("[data-orb='b']"), {
x: -34,
y: 26,
scale: 1.12,
duration: 8.5,
repeat: -1,
yoyo: true,
ease: "sine.inOut",
});
gsap.to(q("[data-orb='c']"), {
x: 24,
y: 18,
scale: 1.08,
duration: 9.5,
repeat: -1,
yoyo: true,
ease: "sine.inOut",
});
// Title shine sweep
const shine = q("[data-anim='shine']");
if (shine.length) {
const shineTl = gsap.timeline({ repeat: -1, repeatDelay: 3.5 });
shineTl
.set(shine, { xPercent: -160, opacity: 0 })
.to(shine, { opacity: 1, duration: 0.2, delay: 1.6 })
.to(shine, { xPercent: 260, duration: 1.8, ease: "power2.inOut" }, "<")
.to(shine, { opacity: 0, duration: 0.2 });
}
// Card hover lift + magnetic mouse parallax on the hero orbs
const cards = q("[data-anim='card']");
cards.forEach((card: Element) => {
const el = card as HTMLElement;
const enter = () =>
gsap.to(el, { y: -6, duration: 0.35, ease: "power2.out" });
const leave = () =>
gsap.to(el, { y: 0, duration: 0.45, ease: "power2.out" });
el.addEventListener("mouseenter", enter);
el.addEventListener("mouseleave", leave);
});
const onMove = (e: MouseEvent) => {
const r = root.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width - 0.5;
const py = (e.clientY - r.top) / r.height - 0.5;
gsap.to(q("[data-orb='a']"), {
xPercent: px * 22,
yPercent: py * 18,
duration: 1.2,
ease: "power2.out",
overwrite: "auto",
});
gsap.to(q("[data-orb='b']"), {
xPercent: px * -18,
yPercent: py * 20,
duration: 1.3,
ease: "power2.out",
overwrite: "auto",
});
gsap.to(q("[data-orb='c']"), {
xPercent: px * 14,
yPercent: py * -14,
duration: 1.4,
ease: "power2.out",
overwrite: "auto",
});
};
root.addEventListener("mousemove", onMove);
return () => root.removeEventListener("mousemove", onMove);
}, rootRef);
return () => ctx.revert();
}, [isLoading, notification]);
return {
isLoading,
rootRef,
breadcrumbItems,
notification,
meta,
details,
};
};