diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index 72c782e11..4efc5c2d4 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -33,6 +33,7 @@ "react-hot-toast": "^2.6.0", "react-router-dom": "^6.27.0", "recharts": "^3.8.1", + "socket.io-client": "^4.8.3", "sonner": "^2.0.7", "stream-browserify": "^3.0.0", "tailwind-merge": "^3.6.0", diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx index 8a45c7cf0..8748f589e 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx @@ -5,14 +5,12 @@ import { Burger, Divider, Group, - Indicator, Menu, Text, Tooltip, UnstyledButton, } from "@mantine/core"; import { - Bell, ChevronDown, FileSignature, Languages, @@ -25,6 +23,8 @@ import { import { type ReactNode } from "react"; import { useNavigate } from "react-router-dom"; +import NotificationBellContainer from "@/features/notifications/NotificationBellContainer"; + import type { PageMeta } from "./types"; export interface FreightDashboardHeaderProps { @@ -117,19 +117,7 @@ const FreightDashboardHeader = ({ - - - - - - - + {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/backoffice/src/features/notifications/notificationsApi.ts b/apps/edr-freight-web/backoffice/src/features/notifications/notificationsApi.ts new file mode 100644 index 000000000..1982aa238 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/features/notifications/notificationsApi.ts @@ -0,0 +1,37 @@ +import type { NotificationListResult } from "@edr/types"; + +import { api } from "@/auth/http"; + +export interface ListNotificationsParams { + page?: number; + limit?: number; + isRead?: boolean; +} + +/** + * Backoffice notification REST calls. The backoffice axios `api` response + * interceptor already unwraps the `{ success, data }` envelope, so `.data` here + * is the payload itself. + */ +export const notificationsApi = { + list: async ( + params: ListNotificationsParams = {}, + ): Promise => { + const { data } = await api.get("/notifications", { + params, + }); + return data; + }, + unreadCount: async (): Promise => { + const { data } = await api.get<{ unreadCount: number }>( + "/notifications/unread-count", + ); + return data.unreadCount; + }, + markRead: async (id: string): Promise => { + await api.patch(`/notifications/${id}/read`); + }, + markAllRead: async (): Promise => { + await api.post("/notifications/read-all"); + }, +}; diff --git a/apps/edr-freight-web/backoffice/src/features/notifications/useNotificationSocket.ts b/apps/edr-freight-web/backoffice/src/features/notifications/useNotificationSocket.ts new file mode 100644 index 000000000..ade0ce97d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/features/notifications/useNotificationSocket.ts @@ -0,0 +1,52 @@ +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 { AUTH_TOKEN_COOKIE, getCookie } from "@/auth/cookies"; + +import { NOTIFICATIONS_KEY, UNREAD_KEY } from "./useNotifications"; + +// 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 staff user. New + * items invalidate the list + toast; unread-count pushes update the badge. + */ +export function useNotificationSocket(enabled: boolean) { + const qc = useQueryClient(); + + useEffect(() => { + if (!enabled) return; + const token = getCookie(AUTH_TOKEN_COOKIE); + 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/backoffice/src/features/notifications/useNotifications.ts b/apps/edr-freight-web/backoffice/src/features/notifications/useNotifications.ts new file mode 100644 index 000000000..58f69df3e --- /dev/null +++ b/apps/edr-freight-web/backoffice/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 }), + }); +}