feat: setup notification to the backoffice

This commit is contained in:
Nathnael
2026-07-06 06:52:09 +00:00
parent 5a10c14ceb
commit 439e1ec29e
6 changed files with 186 additions and 15 deletions

View File

@@ -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",

View File

@@ -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 = ({
</UnstyledButton>
</Tooltip>
<Tooltip label="Notifications" withArrow openDelay={300}>
<Indicator
color="edr-accent"
size={8}
offset={6}
withBorder
aria-label="Unread notifications"
>
<UnstyledButton className={ISLAND} aria-label="Notifications">
<Bell size={17} strokeWidth={1.8} />
</UnstyledButton>
</Indicator>
</Tooltip>
<NotificationBellContainer />
{enableThemeToggle && (
<Tooltip

View File

@@ -0,0 +1,53 @@
import { NotificationBell, type NotificationBellItem } from "@edr/ui-common";
import { useNavigate } from "react-router-dom";
import {
useMarkAllRead,
useMarkRead,
useNotificationsList,
useUnreadCount,
} from "./useNotifications";
import { useNotificationSocket } from "./useNotificationSocket";
/**
* Wires react-query + the notification WebSocket into the shared presentational
* bell. Mounted inside the authenticated dashboard header.
*/
export default function NotificationBellContainer({
enabled = true,
}: {
enabled?: boolean;
}) {
const navigate = useNavigate();
const list = useNotificationsList(enabled);
const unread = useUnreadCount(enabled);
const markRead = useMarkRead();
const markAllRead = useMarkAllRead();
useNotificationSocket(enabled);
const items: NotificationBellItem[] = (list.data?.items ?? []).map((n) => ({
id: n.id,
title: n.title,
body: n.body,
createdAt: n.createdAt,
isRead: n.isRead,
link: n.link,
}));
return (
<NotificationBell
items={items}
unreadCount={unread.data ?? list.data?.unreadCount ?? 0}
loading={list.isLoading}
onOpen={() => {
void list.refetch();
void unread.refetch();
}}
onItemClick={(item) => {
if (!item.isRead) markRead.mutate(item.id);
if (item.link) navigate(item.link);
}}
onMarkAllRead={() => markAllRead.mutate()}
/>
);
}

View File

@@ -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<NotificationListResult> => {
const { data } = await api.get<NotificationListResult>("/notifications", {
params,
});
return data;
},
unreadCount: async (): Promise<number> => {
const { data } = await api.get<{ unreadCount: number }>(
"/notifications/unread-count",
);
return data.unreadCount;
},
markRead: async (id: string): Promise<void> => {
await api.patch(`/notifications/${id}/read`);
},
markAllRead: async (): Promise<void> => {
await api.post("/notifications/read-all");
},
};

View File

@@ -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]);
}

View File

@@ -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 }),
});
}