From c8f65829d18c37c6a8531021decf681157f1473a Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 6 Jul 2026 06:52:26 +0000 Subject: [PATCH] feat: setup notification to the portal --- apps/edr-freight-web/portal/package.json | 1 + .../portal/src/components/AppLayout.tsx | 23 +------- .../NotificationBellContainer.tsx | 53 +++++++++++++++++ .../notifications/notificationsApi.ts | 33 +++++++++++ .../notifications/useNotificationSocket.ts | 58 +++++++++++++++++++ .../notifications/useNotifications.ts | 40 +++++++++++++ apps/edr-freight-web/portal/src/main.tsx | 2 + 7 files changed, 190 insertions(+), 20 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/features/notifications/NotificationBellContainer.tsx create mode 100644 apps/edr-freight-web/portal/src/features/notifications/notificationsApi.ts create mode 100644 apps/edr-freight-web/portal/src/features/notifications/useNotificationSocket.ts create mode 100644 apps/edr-freight-web/portal/src/features/notifications/useNotifications.ts diff --git a/apps/edr-freight-web/portal/package.json b/apps/edr-freight-web/portal/package.json index a9bcf4ecc..433b83c1a 100644 --- a/apps/edr-freight-web/portal/package.json +++ b/apps/edr-freight-web/portal/package.json @@ -34,6 +34,7 @@ "react-phone-number-input": "^3.4.17", "react-router-dom": "^6.27.0", "recharts": "^3.8.1", + "socket.io-client": "^4.8.3", "tailwind-merge": "^3.6.0", "zod": "^4.4.3", "zustand": "^5.0.0" diff --git a/apps/edr-freight-web/portal/src/components/AppLayout.tsx b/apps/edr-freight-web/portal/src/components/AppLayout.tsx index 5f4de9f87..02df1f3e9 100644 --- a/apps/edr-freight-web/portal/src/components/AppLayout.tsx +++ b/apps/edr-freight-web/portal/src/components/AppLayout.tsx @@ -19,7 +19,6 @@ import { } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; import { - Bell, ChevronDown, FileSignature, LogOut, @@ -40,6 +39,7 @@ import { useState, } from "react"; import { PROFILE_TYPE_LABELS } from "@/constants/profileMode"; +import NotificationBellContainer from "@/features/notifications/NotificationBellContainer"; export interface SidebarItem { label: string; @@ -353,25 +353,8 @@ export function AppLayout({ - {/* Bell */} - - - - - - + {/* Notifications */} + {enableThemeToggle && ( ({ + id: n.id, + title: n.title, + body: n.body, + createdAt: n.createdAt, + isRead: n.isRead, + link: n.link, + })); + + return ( + { + void list.refetch(); + void unread.refetch(); + }} + onItemClick={(item) => { + if (!item.isRead) markRead.mutate(item.id); + if (item.link) navigate(item.link); + }} + onMarkAllRead={() => markAllRead.mutate()} + /> + ); +} diff --git a/apps/edr-freight-web/portal/src/features/notifications/notificationsApi.ts b/apps/edr-freight-web/portal/src/features/notifications/notificationsApi.ts new file mode 100644 index 000000000..5da09298a --- /dev/null +++ b/apps/edr-freight-web/portal/src/features/notifications/notificationsApi.ts @@ -0,0 +1,33 @@ +import type { NotificationListResult } from "@edr/types"; + +import { client } from "@/utils/api"; + +export interface ListNotificationsParams { + page?: number; + limit?: number; + isRead?: boolean; +} + +/** + * Portal notification REST calls. The portal axios `client` returns the raw + * response, and the API wraps payloads in a `{ success, data }` envelope — so we + * unwrap `.data.data` here (same convention as the other portal services). + */ +export const notificationsApi = { + list: async ( + params: ListNotificationsParams = {}, + ): Promise => { + const { data } = await client.get("/notifications", { params }); + return data.data; + }, + unreadCount: async (): Promise => { + const { data } = await client.get("/notifications/unread-count"); + return data.data.unreadCount; + }, + markRead: async (id: string): Promise => { + await client.patch(`/notifications/${id}/read`); + }, + markAllRead: async (): Promise => { + await client.post("/notifications/read-all"); + }, +}; diff --git a/apps/edr-freight-web/portal/src/features/notifications/useNotificationSocket.ts b/apps/edr-freight-web/portal/src/features/notifications/useNotificationSocket.ts new file mode 100644 index 000000000..f2e5f8bb1 --- /dev/null +++ b/apps/edr-freight-web/portal/src/features/notifications/useNotificationSocket.ts @@ -0,0 +1,58 @@ +import { + NOTIFICATION_WS_EVENTS, + NOTIFICATION_WS_NAMESPACE, + type NotificationDto, +} from "@edr/types"; +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect } from "react"; +import toast from "react-hot-toast"; +import { io } from "socket.io-client"; + +import { API_BASE_URL } from "@/constants/apiConfig"; + +import { NOTIFICATIONS_KEY, UNREAD_KEY } from "./useNotifications"; + +function getAuthToken(): string | undefined { + return document.cookie + .split("; ") + .find((row) => row.startsWith("auth-token=")) + ?.split("=")[1]; +} + +// The socket namespace lives at the server root, not under the `/api` REST +// prefix — strip a trailing `/api` if the base URL carries one. +const SOCKET_ORIGIN = String(API_BASE_URL ?? "").replace(/\/api\/?$/, ""); + +/** + * Subscribes to live notification pushes for the signed-in user. New items + * invalidate the list + toast; unread-count pushes update the badge instantly. + */ +export function useNotificationSocket(enabled: boolean) { + const qc = useQueryClient(); + + useEffect(() => { + if (!enabled) return; + const token = getAuthToken(); + if (!token) return; + + const socket = io(`${SOCKET_ORIGIN}/${NOTIFICATION_WS_NAMESPACE}`, { + auth: { token }, + transports: ["websocket"], + withCredentials: true, + }); + + socket.on(NOTIFICATION_WS_EVENTS.NEW, (n: NotificationDto) => { + qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY }); + toast(n.title); + }); + + socket.on(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, (count: number) => { + if (typeof count === "number") qc.setQueryData(UNREAD_KEY, count); + }); + + return () => { + socket.off(); + socket.disconnect(); + }; + }, [enabled, qc]); +} diff --git a/apps/edr-freight-web/portal/src/features/notifications/useNotifications.ts b/apps/edr-freight-web/portal/src/features/notifications/useNotifications.ts new file mode 100644 index 000000000..58f69df3e --- /dev/null +++ b/apps/edr-freight-web/portal/src/features/notifications/useNotifications.ts @@ -0,0 +1,40 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { notificationsApi } from "./notificationsApi"; + +export const NOTIFICATIONS_KEY = ["notifications"] as const; +export const UNREAD_KEY = ["notifications", "unread"] as const; + +export function useNotificationsList(enabled = true) { + return useQuery({ + queryKey: [...NOTIFICATIONS_KEY, "list"], + queryFn: () => notificationsApi.list({ page: 1, limit: 20 }), + enabled, + }); +} + +export function useUnreadCount(enabled = true) { + return useQuery({ + queryKey: UNREAD_KEY, + queryFn: () => notificationsApi.unreadCount(), + enabled, + // WebSocket keeps this fresh; poll as a fallback if the socket drops. + refetchInterval: 60_000, + }); +} + +export function useMarkRead() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => notificationsApi.markRead(id), + onSuccess: () => qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY }), + }); +} + +export function useMarkAllRead() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => notificationsApi.markAllRead(), + onSuccess: () => qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY }), + }); +} diff --git a/apps/edr-freight-web/portal/src/main.tsx b/apps/edr-freight-web/portal/src/main.tsx index d932bb0f3..44f2d3fb9 100644 --- a/apps/edr-freight-web/portal/src/main.tsx +++ b/apps/edr-freight-web/portal/src/main.tsx @@ -8,6 +8,7 @@ import "@mantine/dates/styles.css"; import "@edr/ui-common/styles.css"; import "../index.css"; import "@edr/ui-common/theme.css"; +import { Toaster } from "react-hot-toast"; import { mantineTheme } from "./theme/mantine"; import App from "./App"; @@ -39,6 +40,7 @@ createRoot(document.getElementById("root")!).render( +