From 85d95530a898d302bf18a48a2e66f4eabe086fe3 Mon Sep 17 00:00:00 2001 From: AmirReza Jamali Date: Thu, 25 Sep 2025 17:09:42 +0330 Subject: [PATCH] feat(notifications): add notification history page and API This commit introduces the notification history feature, allowing users to view a log of their past notifications. Key changes include: - Added a "Notifications" link to the sidebar with a new icon. - Defined API endpoints for fetching notification history and details. - Added a "sent" status variant for displaying notification status. Additionally, this commit includes related UI improvements: - The user's full name is now dynamically displayed in the header, replacing the hardcoded placeholder. - Table headers are now styled in uppercase for improved readability and consistency. --- .../notification-history/[id]/page.tsx | 79 ++++++++++++++ .../notification-history/page.tsx | 24 ++++ .../Layouts/header/user-info/index.tsx | 13 ++- src/components/Layouts/sidebar/data/index.ts | 8 +- src/components/Layouts/sidebar/icons.tsx | 14 +++ .../Tables/notification-history/index.tsx | 103 ++++++++++++++++++ .../useNotificationHistoryTablePresenter.ts | 37 +++++++ src/components/ui/Status.tsx | 1 + src/components/ui/table.tsx | 2 +- src/hooks/queries/useNotificationQueries.ts | 27 +++++ src/lib/api/endpoints.ts | 4 + src/lib/api/services/notification-service.ts | 37 +++++++ src/lib/api/types/Customers.ts | 6 +- src/lib/api/types/NotificationHistory.ts | 43 ++++++++ src/utils/shared.ts | 12 ++ 15 files changed, 400 insertions(+), 10 deletions(-) create mode 100644 src/app/(notifications)/notification-history/[id]/page.tsx create mode 100644 src/app/(notifications)/notification-history/page.tsx create mode 100644 src/components/Tables/notification-history/index.tsx create mode 100644 src/components/Tables/notification-history/useNotificationHistoryTablePresenter.ts create mode 100644 src/hooks/queries/useNotificationQueries.ts create mode 100644 src/lib/api/services/notification-service.ts create mode 100644 src/lib/api/types/NotificationHistory.ts diff --git a/src/app/(notifications)/notification-history/[id]/page.tsx b/src/app/(notifications)/notification-history/[id]/page.tsx new file mode 100644 index 0000000..ecc32c4 --- /dev/null +++ b/src/app/(notifications)/notification-history/[id]/page.tsx @@ -0,0 +1,79 @@ +"use client"; +import Loading from "@/app/loading"; +import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb"; +import { ShowcaseSection } from "@/components/Layouts/showcase-section"; +import IsVisible from "@/components/ui/IsVisible"; +import { useGetNotificationDetails } from "@/hooks/queries/useNotificationQueries"; +import { BreadcrumbItem } from "@/types/shared"; +import { use } from "react"; + +interface IProps { + params: Promise<{ id: string }>; +} + +const NotificationDetailsPage = ({ params }: IProps) => { + const { id } = use(params); + const { data, isLoading } = useGetNotificationDetails(id); + + 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.message} + + + + + {data?.data.event_type} + + + + {data?.data.link} + + + + + {data?.data.priority} + + + + + {data?.data.type} + + + + {data?.data.seen_at} + + + + + {data?.data.is_scheduled ? "Yes" : "No"} + + + {data?.data.seen ? "Yes" : "No"} + +
+
+ + ); +}; + +export default NotificationDetailsPage; diff --git a/src/app/(notifications)/notification-history/page.tsx b/src/app/(notifications)/notification-history/page.tsx new file mode 100644 index 0000000..81854d7 --- /dev/null +++ b/src/app/(notifications)/notification-history/page.tsx @@ -0,0 +1,24 @@ +import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb"; +import NotificationHistoryTable from "@/components/Tables/notification-history"; +import { BreadcrumbItem } from "@/types/shared"; + +const NotificationHistoryPage = () => { + const breadcrumbItems: BreadcrumbItem[] = [ + { + href: "/", + name: "Dashboard", + }, + { + href: "/notification-history", + name: "Notification history", + }, + ]; + return ( + <> + + + + ); +}; + +export default NotificationHistoryPage; diff --git a/src/components/Layouts/header/user-info/index.tsx b/src/components/Layouts/header/user-info/index.tsx index dee1d75..640e94e 100644 --- a/src/components/Layouts/header/user-info/index.tsx +++ b/src/components/Layouts/header/user-info/index.tsx @@ -6,16 +6,16 @@ import { DropdownContent, DropdownTrigger, } from "@/components/ui/dropdown"; +import { useUser } from "@/contexts"; import { cn } from "@/lib/utils"; import Image from "next/image"; import Link from "next/link"; import { useState } from "react"; import { LogOutIcon, SettingsIcon, UserIcon } from "./icons"; -import { useUser } from "@/contexts"; export function UserInfo() { const [isOpen, setIsOpen] = useState(false); - const { logout } = useUser(); + const { logout, user } = useUser(); const USER = { name: "John Smith", email: "johnson@nextadmin.com", @@ -28,16 +28,19 @@ export function UserInfo() { My Account
- + + + {/* {`Avatar + /> */}
- {USER.name} + {user?.full_name} = z.lazy(() => title: z.string(), url: z.string(), icon: z.any(), - items: z.array(navSubItemSchema).default([]), + items: z.array(navSubItemSchema), }), ); @@ -66,6 +66,12 @@ export const NAV_DATA: NavData = [ icon: Icons.Table, items: [], }, + { + title: "Notifications", + url: "/notification-history", + icon: Icons.NotificationIcon, + items: [], + }, ], }, ]; diff --git a/src/components/Layouts/sidebar/icons.tsx b/src/components/Layouts/sidebar/icons.tsx index 7a1e650..d0a177e 100644 --- a/src/components/Layouts/sidebar/icons.tsx +++ b/src/components/Layouts/sidebar/icons.tsx @@ -19,6 +19,20 @@ export function ChevronUp(props: PropsType) { ); } +export function NotificationIcon(props: PropsType) { + return ( + + + + ); +} export function Building(props: PropsType) { return ( diff --git a/src/components/Tables/notification-history/index.tsx b/src/components/Tables/notification-history/index.tsx new file mode 100644 index 0000000..edcb969 --- /dev/null +++ b/src/components/Tables/notification-history/index.tsx @@ -0,0 +1,103 @@ +"use client"; +import { EyeIcon } from "@/assets/icons"; +import { ShowcaseSection } from "@/components/Layouts/showcase-section"; +import Pagination from "@/components/ui/pagination"; +import Status from "@/components/ui/Status"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import TableSkeleton from "@/components/ui/TableSkeleton"; +import { isoStringToDate } from "@/utils/shared"; +import useNotificationHistoryTablePresenter from "./useNotificationHistoryTablePresenter"; + +const NotificationHistoryTable = () => { + const { + columns, + isLoading, + metadata, + notificationHistory, + pathName, + router, + setParams, + } = useNotificationHistoryTablePresenter(); + + return ( + + + + + {columns.map((column) => ( + + {column} + + ))} + + + {isLoading && } + + {!notificationHistory?.length ? ( + + +

No notifications

+
+
+ ) : ( + notificationHistory.map((item, index) => ( + + {index + 1} + {item.title} + {item.event_type} + + {isoStringToDate({ + hasTime: true, + isoString: item.created_at, + })} + + {item.message} + + {item.priority} + + + {item.type} + + + {item.seen ? "Seen" : "Unseen"} + + + {item.status} + + + + + + )) + )} +
+
+ { + setParams((currentParams) => ({ + ...currentParams, + offset: e.selected * (metadata?.limit ?? 10), + limit: metadata?.limit ?? 1000, + })); + }} + /> +
+ ); +}; + +export default NotificationHistoryTable; diff --git a/src/components/Tables/notification-history/useNotificationHistoryTablePresenter.ts b/src/components/Tables/notification-history/useNotificationHistoryTablePresenter.ts new file mode 100644 index 0000000..f24e7f3 --- /dev/null +++ b/src/components/Tables/notification-history/useNotificationHistoryTablePresenter.ts @@ -0,0 +1,37 @@ +"use client"; +import { apiDefaultParams } from "@/constants/Api_Params"; +import { useGetNotificationHistoryQuery } from "@/hooks/queries/useNotificationQueries"; +import { usePathname, useRouter } from "next/navigation"; +import { useState } from "react"; + +const useNotificationHistoryTablePresenter = () => { + const [params, setParams] = useState>({ + ...apiDefaultParams, + }); + const { data, isLoading } = useGetNotificationHistoryQuery(params); + const columns: string[] = [ + "row", + "title", + "channel", + "created at", + "message", + "priority", + "type", + "seen", + "status", + "actions", + ]; + const pathName = usePathname(); + const router = useRouter(); + return { + notificationHistory: data?.data.notifications, + metadata: data?.meta, + isLoading, + columns, + router, + pathName, + setParams, + }; +}; + +export default useNotificationHistoryTablePresenter; diff --git a/src/components/ui/Status.tsx b/src/components/ui/Status.tsx index 4684e37..a8d4274 100644 --- a/src/components/ui/Status.tsx +++ b/src/components/ui/Status.tsx @@ -22,6 +22,7 @@ const Status = ({ status, children }: IProps) => { "success", "verified", "published", + "sent" ]; const blues = ["awarded", "processing", "in-review", "scheduled"]; const yellows = ["pending", "warning", "on-hold", "delayed"]; diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx index dcebaa8..8d39467 100644 --- a/src/components/ui/table.tsx +++ b/src/components/ui/table.tsx @@ -70,7 +70,7 @@ export function TableHead({ return ( , +) => { + const queryKey = useMemo( + () => [API_ENDPOINTS.NOTIFICATIONS.HISTORY, "READ NOTIFICATIONS HISTORY"], + [], + ); + return useQuery({ + queryKey, + queryFn: () => notificationService.getNotifications(params), + }); +}; +export const useGetNotificationDetails = (id: string) => { + const queryKey = useMemo( + () => [API_ENDPOINTS.NOTIFICATIONS.DETAILS(id), "NOTIFICATION DETAILS"], + [id], + ); + return useQuery({ + queryKey, + queryFn: () => notificationService.getNotificationDetails(id), + }); +}; diff --git a/src/lib/api/endpoints.ts b/src/lib/api/endpoints.ts index 587cb86..29c7d8b 100644 --- a/src/lib/api/endpoints.ts +++ b/src/lib/api/endpoints.ts @@ -50,4 +50,8 @@ export const API_ENDPOINTS = { DETAILS: (id: string) => `feedback/${id}`, DELETE: (id: string) => `feedback/${id}`, }, + NOTIFICATIONS: { + HISTORY: "notifications", + DETAILS: (id: string) => `notifications/view/${id}`, + }, } as const; diff --git a/src/lib/api/services/notification-service.ts b/src/lib/api/services/notification-service.ts new file mode 100644 index 0000000..2d888bf --- /dev/null +++ b/src/lib/api/services/notification-service.ts @@ -0,0 +1,37 @@ +import api from "../axios"; +import { API_ENDPOINTS } from "../endpoints"; +import { + TNotificationDetailsResponse, + TNotificationHistoryResponse, +} from "../types/NotificationHistory"; +import { ApiResponse } from "../types/shared"; + +export const notificationService = { + getNotifications: async ( + params?: Record, + ): Promise => { + try { + return (await api.get(API_ENDPOINTS.NOTIFICATIONS.HISTORY, { params })) + .data; + } catch (error) { + console.error( + "ERROR Caught in notification service => getNotifications", + error, + ); + throw error; + } + }, + getNotificationDetails: async ( + id: string, + ): Promise> => { + try { + return (await api.get(API_ENDPOINTS.NOTIFICATIONS.DETAILS(id))).data; + } catch (error) { + console.error( + "ERROR Caught in notification service => getNotificationDetails", + error, + ); + throw error; + } + }, +}; diff --git a/src/lib/api/types/Customers.ts b/src/lib/api/types/Customers.ts index f13f7b4..8213774 100644 --- a/src/lib/api/types/Customers.ts +++ b/src/lib/api/types/Customers.ts @@ -31,9 +31,6 @@ const customersSchema = z.object({ username: z.string(), }); -export const customersListResponse = z.object({ - customers: z.array(customersSchema), -}); const createCustomerCredentials = z.object({ address: z .object({ @@ -99,6 +96,9 @@ const createCustomerCredentials = z.object({ "Username must contain only alphanumeric characters", ), }); +export const customersListResponse = z.object({ + customers: z.array(customersSchema), +}); const assignCustomerToCompanySchema = z.object({ company_ids: z.array(z.string()), }); diff --git a/src/lib/api/types/NotificationHistory.ts b/src/lib/api/types/NotificationHistory.ts new file mode 100644 index 0000000..dc738d6 --- /dev/null +++ b/src/lib/api/types/NotificationHistory.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; +import { createApiResponseSchema } from "./Factory"; + +export const notificationSchema = z.object({ + created_at: z.string(), + event_type: z.string(), + id: z.string(), + image: z.string(), + is_scheduled: z.boolean(), + link: z.string(), + message: z.string(), + metadata: z.object({ + additionalProp1: z.string(), + additionalProp2: z.string(), + additionalProp3: z.string(), + }), + method: z.object({ + additionalProp1: z.string(), + additionalProp2: z.string(), + additionalProp3: z.string(), + }), + priority: z.string(), + schedule_at: z.number(), + scheduled_at: z.number(), + seen: z.boolean(), + seen_at: z.number(), + status: z.string(), + title: z.string(), + type: z.string(), + updated_at: z.string(), + user_id: z.string(), +}); + +const notificationHistoryResponseSchema = createApiResponseSchema( + z.object({ + notifications: z.array(notificationSchema), + }), +); +export type TNotificationHistory = z.infer; +export type TNotificationDetailsResponse = z.infer; +export type TNotificationHistoryResponse = z.infer< + typeof notificationHistoryResponseSchema +>; diff --git a/src/utils/shared.ts b/src/utils/shared.ts index 2585746..5544524 100644 --- a/src/utils/shared.ts +++ b/src/utils/shared.ts @@ -6,6 +6,18 @@ export const msToDate = (ms: number) => { return moment.default(ms).format("YYYY/MM/DD"); }; +export const isoStringToDate = ({ + hasTime = false, + isoString, +}: { + isoString: string; + hasTime: boolean; +}): string => { + return moment + .default(isoString) + .format(`YYYY/MM/DD ${hasTime ? "HH:MM" : ""}`); +}; + export const getStatusColor = (status: T) => { switch (status) { case "active":