diff --git a/src/app/(notifications)/notification-history/[id]/page.tsx b/src/app/(notifications)/notification-history/[id]/page.tsx index 708de6b..a5e3e14 100644 --- a/src/app/(notifications)/notification-history/[id]/page.tsx +++ b/src/app/(notifications)/notification-history/[id]/page.tsx @@ -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 ; + return ( - <> - - {data?.data && } - +
+
+ +
+ + {/* Hero header */} +
+
+
+
+
+
+ +
+ + + + +
+

+ + {meta?.title} + + + + +

+ +
+ {meta?.priority && ( + + {meta.priority} + + )} + {meta?.type && ( + + {meta.type} + + )} + {meta?.createdAt && ( + + {meta.createdAt} + + )} +
+ + {meta?.link && ( + + Open link + + + )} +
+
+
+ + {/* Detail grid */} +
+ {details.map((item) => ( +
+ + + {item.label} + +
+ {item.kind === "status" ? ( + {item.value} + ) : ( +

+ {item.value} +

+ )} +
+
+ ))} +
+ + {/* Image + message */} + {(meta?.image || meta?.hasMessage) && ( +
+ {meta?.image && ( + // eslint-disable-next-line @next/next/no-img-element + {meta.title} + )} + {meta?.hasMessage && ( +
+

+ + Message +

+
+
+ )} +
+ )} +
); }; diff --git a/src/app/(notifications)/notification-history/[id]/useNotificationDetailsPresenter.ts b/src/app/(notifications)/notification-history/[id]/useNotificationDetailsPresenter.ts new file mode 100644 index 0000000..4b46cd0 --- /dev/null +++ b/src/app/(notifications)/notification-history/[id]/useNotificationDetailsPresenter.ts @@ -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(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, + }; +}; diff --git a/src/components/Layouts/header/notification/index.tsx b/src/components/Layouts/header/notification/index.tsx index f1137c5..68977b5 100644 --- a/src/components/Layouts/header/notification/index.tsx +++ b/src/components/Layouts/header/notification/index.tsx @@ -6,10 +6,12 @@ import { DropdownTrigger, } from "@/components/ui/dropdown"; import Modal from "@/components/ui/modal"; +import Status from "@/components/ui/Status"; import { useMarkNotificationAsSeen } from "@/hooks/queries/useNotificationQueries"; import { useIsMobile } from "@/hooks/use-mobile"; import { TNotificationDetailsResponse } from "@/lib/api/types/NotificationHistory"; -import { cn } from "@/lib/utils"; +import { cn, sanitizeHtmlWithStyles } from "@/lib/utils"; +import { capitalize, unixToDate } from "@/utils/shared"; import Link from "next/link"; import { useState } from "react"; import { BellIcon } from "./icons"; @@ -24,10 +26,7 @@ export function Notification() { const [currentNotification, setCurrentNotification] = useState(null); - const { mutate } = useMarkNotificationAsSeen( - currentNotification?.id ?? "", - () => setIsModalOpen(false), - ); + const { mutate } = useMarkNotificationAsSeen(); const isMobile = useIsMobile(); const handleNotificationClick = ( @@ -36,7 +35,7 @@ export function Notification() { setCurrentNotification(notification); setIsModalOpen(true); setIsOpen(false); - mutate(); + if (notification.id) mutate(notification.id); }; return ( @@ -49,18 +48,20 @@ export function Notification() { }} > - {isDotVisible && ( + {isDotVisible && unreadNotificationCount > 0 && ( 9 ? "h-[18px]" : "size-[18px]", )} > - + + {unreadNotificationCount > 99 ? "99+" : unreadNotificationCount} )} @@ -68,13 +69,13 @@ export function Notification() {
- + Notifications - + {unreadNotificationCount} new
@@ -101,10 +102,86 @@ export function Notification() { setIsModalOpen(false); }} confirmText="Mark as read" + classNames="relative !p-0 w-[34rem] max-w-[calc(100vw-2rem)] !rounded-3xl border border-stroke/60 dark:border-dark-3 [&>button]:absolute [&>button]:right-3 [&>button]:top-3 [&>button]:z-10 [&>button]:!mb-0 [&>button]:!text-white/80 [&>button]:hover:!text-white" > -
-

{currentNotification?.title}

-

{currentNotification?.message}

+
+ {/* Gradient header */} +
+
+
+ +
+ + + + +
+
+ {currentNotification?.priority && ( + + {capitalize(currentNotification.priority)} + + )} + {currentNotification?.type && ( + + {capitalize(currentNotification.type)} + + )} +
+

+ {currentNotification?.title || "Notification"} +

+ {!!currentNotification?.created_at && ( +

+ {unixToDate({ + unix: currentNotification.created_at, + hasTime: true, + })} +

+ )} +
+
+
+ + {/* Body */} +
+ {currentNotification?.image && ( + // eslint-disable-next-line @next/next/no-img-element + {currentNotification.title + )} + +
+ +
+ {currentNotification?.link && ( + setIsModalOpen(false)} + className="rounded-xl border border-primary/40 bg-primary/5 px-4 py-2 text-sm font-semibold text-primary transition-colors hover:bg-primary/10" + > + Open link + + )} + +
+
diff --git a/src/components/Tables/customers/index.tsx b/src/components/Tables/customers/index.tsx index 0336908..e13a69a 100644 --- a/src/components/Tables/customers/index.tsx +++ b/src/components/Tables/customers/index.tsx @@ -206,7 +206,6 @@ const CustomersTable = () => { setIsModalOpen(true); }} > - Delete Company @@ -219,7 +218,6 @@ const CustomersTable = () => { router.push(`${pathName}/edit/${customer.id}`) } > - Edit Company @@ -237,9 +235,6 @@ const CustomersTable = () => { }); }} > - Assign To Company @@ -253,11 +248,6 @@ const CustomersTable = () => { openResetPasswordModalForCustomer(customer) } > - Reset Customer Password @@ -272,7 +262,6 @@ const CustomersTable = () => { router.push(`${pathName}/feedback/${customer.id}`); }} > -
@@ -346,6 +335,11 @@ const CustomersTable = () => { onCancel={() => setIsModalOpen(false)} /> )} + + + + + ); }; diff --git a/src/constants/tooltip.ts b/src/constants/tooltip.ts index e5f3908..ba2dfb7 100644 --- a/src/constants/tooltip.ts +++ b/src/constants/tooltip.ts @@ -70,10 +70,10 @@ export const _TooltipDefaultParams = ({ delayHide: 120, variant, className: "fancy-tooltip", + border: `1px solid ${palette.border}`, style: { background: `linear-gradient(135deg, ${palette.from} 0%, ${palette.to} 100%)`, boxShadow: palette.shadow, - border: `1px solid ${palette.border}`, ...styles, } satisfies CSSProperties, }; diff --git a/src/hooks/queries/useCustomerQueries.ts b/src/hooks/queries/useCustomerQueries.ts index 4b3d24b..ffd3e31 100644 --- a/src/hooks/queries/useCustomerQueries.ts +++ b/src/hooks/queries/useCustomerQueries.ts @@ -124,6 +124,9 @@ export const useUpdateCustomer = (id: string) => { queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.CUSTOMERS.READ_ALL], }); + queryClient.invalidateQueries({ + queryKey: ["GET ALL CUSTOMERS", id], + }); router.push("/customers"); }, }); diff --git a/src/hooks/queries/useNotificationQueries.ts b/src/hooks/queries/useNotificationQueries.ts index 5926cd2..d3175fa 100644 --- a/src/hooks/queries/useNotificationQueries.ts +++ b/src/hooks/queries/useNotificationQueries.ts @@ -28,18 +28,11 @@ export const useGetMyNotifications = (params?: Record) => { queryFn: () => notificationService.getMyNotifications(params), }); }; -export const useMarkNotificationAsSeen = ( - id: string, - successCallback?: () => void, -) => { +export const useMarkNotificationAsSeen = (successCallback?: () => void) => { const queryClient = useQueryClient(); - const mutationKey = useMemo( - () => [API_ENDPOINTS.NOTIFICATIONS.MARK_AS_SEEN(id), id], - [id], - ); return useMutation({ - mutationKey, - mutationFn: () => notificationService.markNotificationAsSeen(id), + mutationKey: ["MARK_NOTIFICATION_AS_SEEN"], + mutationFn: (id: string) => notificationService.markNotificationAsSeen(id), onSuccess: () => { if (successCallback) { successCallback();