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(
+