Merge pull request 'feat(notifications): Implement functional header notification dropdown' (#41) from company-details into develop

Reviewed-on: https://repo.ravanertebat.com/TM/tm_panel/pulls/41
Reviewed-by: Sina Nakhostin <s.nakhostin@ravanertebat.com>
This commit is contained in:
Sina Nakhostin
2025-09-29 17:16:27 +03:30
11 changed files with 245 additions and 121 deletions
@@ -7,6 +7,7 @@ import { useGetNotificationDetails } from "@/hooks/queries/useNotificationQuerie
import { BreadcrumbItem } from "@/types/shared"; import { BreadcrumbItem } from "@/types/shared";
import { unixToDate } from "@/utils/shared"; import { unixToDate } from "@/utils/shared";
import { use } from "react"; import { use } from "react";
import { capitalize } from "../../../../utils/shared";
interface IProps { interface IProps {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@@ -36,11 +37,6 @@ const NotificationDetailsPage = ({ params }: IProps) => {
<Breadcrumb items={breadcrumbItems} /> <Breadcrumb items={breadcrumbItems} />
<ShowcaseSection title={`${data?.data.title}`}> <ShowcaseSection title={`${data?.data.title}`}>
<div className="mt-4 grid grid-cols-4 gap-15 pb-4"> <div className="mt-4 grid grid-cols-4 gap-15 pb-4">
<IsVisible condition={!!data?.data.message}>
<ShowcaseSection title="Message">
{data?.data.message}
</ShowcaseSection>
</IsVisible>
<IsVisible condition={!!data?.data.event_type}> <IsVisible condition={!!data?.data.event_type}>
<ShowcaseSection title="Event Type"> <ShowcaseSection title="Event Type">
{data?.data.event_type} {data?.data.event_type}
@@ -52,12 +48,14 @@ const NotificationDetailsPage = ({ params }: IProps) => {
<IsVisible condition={!!data?.data.priority}> <IsVisible condition={!!data?.data.priority}>
<ShowcaseSection title="Priority"> <ShowcaseSection title="Priority">
{data?.data.priority} {capitalize(data?.data.priority ?? "")}
</ShowcaseSection> </ShowcaseSection>
</IsVisible> </IsVisible>
<IsVisible condition={!!data?.data.type}> <IsVisible condition={!!data?.data.type}>
<ShowcaseSection title="Type">{data?.data.type}</ShowcaseSection> <ShowcaseSection title="Type">
{capitalize(data?.data.type ?? "")}
</ShowcaseSection>
</IsVisible> </IsVisible>
<IsVisible condition={!!data?.data.seen_at}> <IsVisible condition={!!data?.data.seen_at}>
<ShowcaseSection title="Seen at"> <ShowcaseSection title="Seen at">
@@ -72,6 +70,12 @@ const NotificationDetailsPage = ({ params }: IProps) => {
{data?.data.seen ? "Yes" : "No"} {data?.data.seen ? "Yes" : "No"}
</ShowcaseSection> </ShowcaseSection>
</div> </div>
<hr className="py-4" />
<IsVisible condition={!!data?.data.message}>
<ShowcaseSection title="Message">
{data?.data.message}
</ShowcaseSection>
</IsVisible>
</ShowcaseSection> </ShowcaseSection>
</> </>
); );
+2 -1
View File
@@ -3,10 +3,11 @@ import { cn } from "../lib/utils";
interface IProps { interface IProps {
className?: string | null; className?: string | null;
} }
const Loading = ({ className = "w-svw h-svh" }: IProps) => { const Loading = ({ className = "w-svw h-svh" }: IProps) => {
return ( return (
<div className={cn("flex items-center justify-center", className)}> <div className={cn("flex items-center justify-center", className)}>
<span className="loading loading-dots loading-xl"></span> <div className="h-5 w-5 animate-spin rounded-full border-b-2 border-primary"></div>
</div> </div>
); );
}; };
@@ -93,7 +93,7 @@ const DatePicker = <T extends FieldValues>({
onChange(date.isValid ? date.toUnix() : ""); onChange(date.isValid ? date.toUnix() : "");
} }
}} }}
format={"YYYY/MM/DD"} format={`YYYY/MM/DD ${timePicker ? "HH:mm" : ""}`}
containerClassName="w-full" containerClassName="w-full"
inputClass={cn( inputClass={cn(
"w-full rounded-lg border-[1.5px] border-stroke bg-transparent px-5.5 py-3 pl-12.5 text-dark outline-none transition focus:border-primary disabled:cursor-default disabled:bg-gray-2 dark:border-dark-3 dark:bg-dark-2 dark:text-white dark:focus:border-primary dark:disabled:bg-dark", "w-full rounded-lg border-[1.5px] border-stroke bg-transparent px-5.5 py-3 pl-12.5 text-dark outline-none transition focus:border-primary disabled:cursor-default disabled:bg-gray-2 dark:border-dark-3 dark:bg-dark-2 dark:text-white dark:focus:border-primary dark:disabled:bg-dark",
@@ -5,125 +5,109 @@ import {
DropdownContent, DropdownContent,
DropdownTrigger, DropdownTrigger,
} from "@/components/ui/dropdown"; } from "@/components/ui/dropdown";
import Modal from "@/components/ui/modal";
import { useMarkNotificationAsSeen } from "@/hooks/queries/useNotificationQueries";
import { useIsMobile } from "@/hooks/use-mobile"; import { useIsMobile } from "@/hooks/use-mobile";
import { TNotificationDetailsResponse } from "@/lib/api/types/NotificationHistory"; // Import the type
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { BellIcon } from "./icons"; import { BellIcon } from "./icons";
import NotificationList from "./notification-list";
const notificationList = [
{
image: "/images/user/user-15.png",
title: "Piter Joined the Team!",
subTitle: "Congratulate him",
},
{
image: "/images/user/user-03.png",
title: "New message",
subTitle: "Devid sent a new message",
},
{
image: "/images/user/user-26.png",
title: "New Payment received",
subTitle: "Check your earnings",
},
{
image: "/images/user/user-28.png",
title: "Jolly completed tasks",
subTitle: "Assign new task",
},
{
image: "/images/user/user-27.png",
title: "Roman Joined the Team!",
subTitle: "Congratulate him",
},
];
export function Notification() { export function Notification() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isDotVisible, setIsDotVisible] = useState(true); const [isDotVisible, setIsDotVisible] = useState(true);
const [unreadNotificationCount, setUnreadNotificationCount] =
useState<number>(0);
const [isModalOpen, setIsModalOpen] = useState(false);
const [currentNotification, setCurrentNotification] =
useState<TNotificationDetailsResponse | null>(null);
const { mutate } = useMarkNotificationAsSeen(
currentNotification?.id ?? "",
() => setIsModalOpen(false),
);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const handleNotificationClick = (
notification: TNotificationDetailsResponse,
) => {
setCurrentNotification(notification);
setIsModalOpen(true);
setIsOpen(false);
};
return ( return (
<Dropdown <>
isOpen={isOpen} <Dropdown
setIsOpen={(open) => { isOpen={isOpen}
setIsOpen(open); setIsOpen={(open) => {
setIsOpen(open);
if (setIsDotVisible) setIsDotVisible(false); if (setIsDotVisible) setIsDotVisible(false);
}} }}
>
<DropdownTrigger
className="grid size-12 place-items-center rounded-full border bg-gray-2 text-dark outline-none hover:text-primary focus-visible:border-primary focus-visible:text-primary dark:border-dark-4 dark:bg-dark-3 dark:text-white dark:focus-visible:border-primary"
aria-label="View Notifications"
> >
<span className="relative"> <DropdownTrigger
<BellIcon /> className="grid size-12 place-items-center rounded-full border bg-gray-2 text-dark outline-none hover:text-primary focus-visible:border-primary focus-visible:text-primary dark:border-dark-4 dark:bg-dark-3 dark:text-white dark:focus-visible:border-primary"
aria-label="View Notifications"
{isDotVisible && (
<span
className={cn(
"absolute right-0 top-0 z-1 size-2 rounded-full bg-red-light ring-2 ring-gray-2 dark:ring-dark-3",
)}
>
<span className="absolute inset-0 -z-1 animate-ping rounded-full bg-red-light opacity-75" />
</span>
)}
</span>
</DropdownTrigger>
<DropdownContent
align={isMobile ? "end" : "center"}
className="border border-stroke bg-white px-3.5 py-3 shadow-md dark:border-dark-3 dark:bg-gray-dark min-[350px]:min-w-[20rem]"
>
<div className="mb-1 flex items-center justify-between px-2 py-1.5">
<span className="text-lg font-medium text-dark dark:text-white">
Notifications
</span>
<span className="rounded-md bg-primary px-[9px] py-0.5 text-xs font-medium text-white">
5 new
</span>
</div>
<ul className="mb-3 max-h-[23rem] space-y-1.5 overflow-y-auto">
{notificationList.map((item, index) => (
<li key={index} role="menuitem">
<Link
href="#"
onClick={() => setIsOpen(false)}
className="flex items-center gap-4 rounded-lg px-2 py-1.5 outline-none hover:bg-gray-2 focus-visible:bg-gray-2 dark:hover:bg-dark-3 dark:focus-visible:bg-dark-3"
>
<Image
src={item.image}
className="size-14 rounded-full object-cover"
width={200}
height={200}
alt="User"
/>
<div>
<strong className="block text-sm font-medium text-dark dark:text-white">
{item.title}
</strong>
<span className="truncate text-sm font-medium text-dark-5 dark:text-dark-6">
{item.subTitle}
</span>
</div>
</Link>
</li>
))}
</ul>
<Link
href="#"
onClick={() => setIsOpen(false)}
className="block rounded-lg border border-primary p-2 text-center text-sm font-medium tracking-wide text-primary outline-none transition-colors hover:bg-blue-light-5 focus:bg-blue-light-5 focus:text-primary focus-visible:border-primary dark:border-dark-3 dark:text-dark-6 dark:hover:border-dark-5 dark:hover:bg-dark-3 dark:hover:text-dark-7 dark:focus-visible:border-dark-5 dark:focus-visible:bg-dark-3 dark:focus-visible:text-dark-7"
> >
See all notifications <span className="relative">
</Link> <BellIcon />
</DropdownContent> {isDotVisible && (
</Dropdown> <span
className={cn(
"bg-red-light absolute right-0 top-0 z-1 size-2 rounded-full ring-2 ring-gray-2 dark:ring-dark-3",
)}
>
<span className="bg-red-light absolute inset-0 -z-1 animate-ping rounded-full opacity-75" />
</span>
)}
</span>
</DropdownTrigger>
<DropdownContent
align={isMobile ? "end" : "center"}
className="border border-stroke bg-white px-3.5 py-3 shadow-md dark:border-dark-3 dark:bg-gray-dark min-[350px]:min-w-[20rem]"
>
<div className="mb-1 flex items-center justify-between px-2 py-1.5">
<span className="text-lg font-medium text-dark dark:text-white">
Notifications
</span>
<span className="rounded-md bg-primary px-[9px] py-0.5 text-xs font-medium text-white">
{unreadNotificationCount} new
</span>
</div>
<NotificationList
setUnreadNotificationCount={setUnreadNotificationCount}
onNotificationClick={handleNotificationClick}
/>
<Link
href="/notification-history"
onClick={() => setIsOpen(false)}
className="block rounded-lg border border-primary p-2 text-center text-sm font-medium tracking-wide text-primary outline-none transition-colors hover:bg-blue-light-5 focus:bg-blue-light-5 focus:text-primary focus-visible:border-primary dark:border-dark-3 dark:text-dark-6 dark:hover:border-dark-5 dark:hover:bg-dark-3 dark:hover:text-dark-7 dark:focus-visible:border-dark-5 dark:focus-visible:bg-dark-3 dark:focus-visible:text-dark-7"
>
See all notifications
</Link>
</DropdownContent>
</Dropdown>
<Modal
isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false);
}}
confirmText="Mark as read"
onConfirm={() => {
mutate();
}}
>
<div className="flex flex-col gap-3 max-w-29">
<h2 className="font-lg font-bold">{currentNotification?.title}</h2>
<p>{currentNotification?.message}</p>
</div>
</Modal>
</>
); );
} }
@@ -0,0 +1,51 @@
"use client";
import Loading from "@/app/loading";
import IsVisible from "@/components/ui/IsVisible";
import { useGetMyNotifications } from "@/hooks/queries/useNotificationQueries";
import { TNotificationDetailsResponse } from "@/lib/api/types/NotificationHistory";
import { truncateString } from "@/utils/shared";
import { Dispatch, SetStateAction, useEffect } from "react";
interface IProps {
setUnreadNotificationCount: Dispatch<SetStateAction<number>>;
onNotificationClick: (notification: TNotificationDetailsResponse) => void;
}
const NotificationList = ({
setUnreadNotificationCount,
onNotificationClick,
}: IProps) => {
const { data, isLoading } = useGetMyNotifications({ seen: false });
useEffect(() => {
setUnreadNotificationCount(data?.data.length ?? 0);
}, [data, isLoading]);
if (isLoading) return <Loading className={"p-6"} />;
return (
<ul className="mb-3 max-h-[23rem] space-y-1.5 overflow-y-auto">
<IsVisible condition={data?.data.length === 0}>
<h2 className="cursor-default ps-2">No new notification</h2>
</IsVisible>
{data?.data.map((notification) => (
<li
role="menuitem"
key={notification.id}
onClick={() => onNotificationClick(notification)}
>
<div className="cursor-pointer rounded-lg px-1 py-2 hover:bg-gray-2">
<strong className="block text-lg font-medium capitalize text-dark dark:text-white">
{notification.title}
</strong>
<span className="text-sm font-medium text-dark-5 dark:text-dark-6">
{truncateString(notification.message, 50, 50)}
</span>
</div>
</li>
))}
</ul>
);
};
export default NotificationList;
@@ -51,6 +51,7 @@ const CreateNotificationForm = () => {
name="schedule_at" name="schedule_at"
label="schedule at" label="schedule at"
range={false} range={false}
timePicker
placeholder="Select schedule date" placeholder="Select schedule date"
/> />
</div> </div>
@@ -100,7 +101,7 @@ const CreateNotificationForm = () => {
{...register("link", { {...register("link", {
pattern: { pattern: {
value: REGEX.URL, value: REGEX.URL,
message: FormErrorMessages.invalidPattern, message: FormErrorMessages.invalidPattern,
}, },
})} })}
name="link" name="link"
@@ -152,7 +153,13 @@ const CreateNotificationForm = () => {
</div> </div>
<div className="col-span-2 flex flex-col gap-2"> <div className="col-span-2 flex flex-col gap-2">
<TextAreaGroup <TextAreaGroup
{...register("description")} required
{...register("description", {
required: {
value: true,
message: FormErrorMessages.required,
},
})}
label="Description" label="Description"
name="description" name="description"
placeholder="Enter description" placeholder="Enter description"
+8 -4
View File
@@ -8,6 +8,8 @@ interface ModalProps {
onClose: () => void; onClose: () => void;
onConfirm?: () => void; onConfirm?: () => void;
children: ReactNode; children: ReactNode;
confirmText?: string;
cancelText?: string;
} }
const Modal: React.FC<ModalProps> = ({ const Modal: React.FC<ModalProps> = ({
@@ -15,6 +17,8 @@ const Modal: React.FC<ModalProps> = ({
onClose, onClose,
children, children,
onConfirm, onConfirm,
confirmText = "confirm",
cancelText = "close",
}) => { }) => {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const isMutating = useIsMutating(); const isMutating = useIsMutating();
@@ -55,21 +59,21 @@ const Modal: React.FC<ModalProps> = ({
<div className="flex gap-5"> <div className="flex gap-5">
<button <button
type="submit" type="submit"
className="mt-6 flex w-fit justify-center rounded-lg bg-primary p-[13px] font-medium text-white hover:bg-opacity-90" className="mt-6 flex w-fit justify-center rounded-lg bg-primary p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
onClick={onConfirm} onClick={onConfirm}
> >
{isMutating ? ( {isMutating ? (
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-gray-1"></div> <div className="h-5 w-5 animate-spin rounded-full border-b-2 border-gray-1"></div>
) : ( ) : (
"Confirm" <span>{confirmText}</span>
)} )}
</button> </button>
<button <button
type="button" type="button"
className="mt-6 flex w-fit justify-center rounded-lg bg-error p-[13px] font-medium text-white hover:bg-opacity-90" className="mt-6 flex w-fit justify-center rounded-lg bg-error p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
onClick={onClose} onClick={onClose}
> >
Close {cancelText}
</button> </button>
</div> </div>
</div> </div>
@@ -16,6 +16,38 @@ export const useGetNotificationHistoryQuery = (
queryFn: () => notificationService.getNotifications(params), queryFn: () => notificationService.getNotifications(params),
}); });
}; };
export const useGetMyNotifications = (params?: Record<string, any>) => {
const queryKey = useMemo(
() => [API_ENDPOINTS.NOTIFICATIONS.MY_NOTIFICATIONS, params],
[params],
);
return useQuery({
queryKey,
queryFn: () => notificationService.getMyNotifications(params),
});
};
export const useMarkNotificationAsSeen = (
id: string,
successCallback?: () => void,
) => {
const queryClient = useQueryClient();
const mutationKey = useMemo(
() => [API_ENDPOINTS.NOTIFICATIONS.MARK_AS_SEEN(id), id],
[id],
);
return useMutation({
mutationKey,
mutationFn: () => notificationService.markNotificationAsSeen(id),
onSuccess: () => {
if (successCallback) {
successCallback();
}
queryClient.invalidateQueries({
queryKey: [API_ENDPOINTS.NOTIFICATIONS.MY_NOTIFICATIONS],
});
},
});
};
export const useGetNotificationDetails = (id: string) => { export const useGetNotificationDetails = (id: string) => {
const queryKey = useMemo( const queryKey = useMemo(
() => [API_ENDPOINTS.NOTIFICATIONS.DETAILS(id), "NOTIFICATION DETAILS"], () => [API_ENDPOINTS.NOTIFICATIONS.DETAILS(id), "NOTIFICATION DETAILS"],
@@ -40,6 +72,9 @@ export const useCreateNotificationQuery = (successCallback?: () => void) => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: [API_ENDPOINTS.NOTIFICATIONS.HISTORY], queryKey: [API_ENDPOINTS.NOTIFICATIONS.HISTORY],
}); });
queryClient.invalidateQueries({
queryKey: [API_ENDPOINTS.NOTIFICATIONS.MY_NOTIFICATIONS],
});
if (successCallback) { if (successCallback) {
successCallback(); successCallback();
} }
+2
View File
@@ -54,5 +54,7 @@ export const API_ENDPOINTS = {
HISTORY: "notifications", HISTORY: "notifications",
CREATE: "notifications", CREATE: "notifications",
DETAILS: (id: string) => `notifications/view/${id}`, DETAILS: (id: string) => `notifications/view/${id}`,
MY_NOTIFICATIONS: "notifications/my",
MARK_AS_SEEN: (id: string) => `notifications/mark-seen/${id}`,
}, },
} as const; } as const;
@@ -8,6 +8,32 @@ import {
import { ApiResponse } from "../types/shared"; import { ApiResponse } from "../types/shared";
export const notificationService = { export const notificationService = {
getMyNotifications: async (
params?: Record<string, any>,
): Promise<TNotificationHistoryResponse> => {
try {
return (
await api.get(API_ENDPOINTS.NOTIFICATIONS.MY_NOTIFICATIONS, { params })
).data;
} catch (error) {
console.error(
"ERROR Caught in notification service => getMyNotifications",
error,
);
throw error;
}
},
markNotificationAsSeen: async (id: string) => {
try {
return (await api.get(API_ENDPOINTS.NOTIFICATIONS.MARK_AS_SEEN(id))).data;
} catch (error) {
console.error(
"ERROR Caught in notification service => markNotificationAsRead",
error,
);
throw error;
}
},
getNotifications: async ( getNotifications: async (
params?: Record<string, any>, params?: Record<string, any>,
): Promise<TNotificationHistoryResponse> => { ): Promise<TNotificationHistoryResponse> => {
+10
View File
@@ -72,3 +72,13 @@ export const getEnumAsArray = <T extends object>(target: T): ILabelValue[] => {
value, value,
})); }));
}; };
export const truncateString = (
str: string,
startLength: number = 10,
endLength: number = 10,
): string => {
if (str.length <= startLength + endLength) {
return str;
}
return str.slice(0, startLength) + "..." + str.slice(-endLength);
};