diff --git a/apps/edr-freight-web/backoffice/src/features/notifications/NotificationBellContainer.tsx b/apps/edr-freight-web/backoffice/src/features/notifications/NotificationBellContainer.tsx index aa10f66f5..8c8ed749f 100644 --- a/apps/edr-freight-web/backoffice/src/features/notifications/NotificationBellContainer.tsx +++ b/apps/edr-freight-web/backoffice/src/features/notifications/NotificationBellContainer.tsx @@ -1,10 +1,12 @@ -import type { NotificationListResult } from "@edr/types"; +import type { NotificationDto, NotificationListResult } from "@edr/types"; import { NotificationBell, NotificationDrawer, + NotificationToast, type NotificationItemData, } from "@edr/ui-common"; import { useState } from "react"; +import toast from "react-hot-toast"; import { useNavigate } from "react-router-dom"; import { @@ -19,22 +21,25 @@ import { } from "./useNotifications"; import { useNotificationSocket } from "./useNotificationSocket"; +/** Map a server notification into the shared presentational item shape. */ +function toItem(n: NotificationDto): NotificationItemData { + return { + id: n.id, + type: n.type, + title: n.title, + body: n.body, + createdAt: n.createdAt, + isRead: n.isRead, + link: n.link, + data: n.data, + }; +} + /** Flatten an infinite query's pages into the drawer's item shape. */ function toItems( data: { pages: NotificationListResult[] } | undefined, ): NotificationItemData[] { - return (data?.pages ?? []) - .flatMap((p) => p.items) - .map((n) => ({ - id: n.id, - type: n.type, - title: n.title, - body: n.body, - createdAt: n.createdAt, - isRead: n.isRead, - link: n.link, - data: n.data, - })); + return (data?.pages ?? []).flatMap((p) => p.items).map(toItem); } /** @@ -55,7 +60,6 @@ export default function NotificationBellContainer({ const unread = useUnreadCount(enabled); const markRead = useMarkRead(); const markAllRead = useMarkAllRead(); - useNotificationSocket(enabled); const unreadItems = toItems(unreadQ.data); const readItems = toItems(readQ.data); @@ -68,6 +72,26 @@ export default function NotificationBellContainer({ if (href) navigate(href); }; + // Live push → rich toast that reuses the same registry + click action. + useNotificationSocket(enabled, (n) => { + const item = toItem(n); + toast.custom( + (t) => ( + { + toast.dismiss(t.id); + handleItemClick(item); + }} + onDismiss={() => toast.dismiss(t.id)} + /> + ), + { duration: 6000 }, + ); + }); + return ( <> void, +) { const qc = useQueryClient(); + const onNewRef = useRef(onNew); + onNewRef.current = onNew; useEffect(() => { if (!enabled) return; @@ -37,7 +42,8 @@ export function useNotificationSocket(enabled: boolean) { socket.on(NOTIFICATION_WS_EVENTS.NEW, (n: NotificationDto) => { qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY }); - toast(n.title); + qc.invalidateQueries({ queryKey: UNREAD_KEY }); + onNewRef.current?.(n); }); socket.on(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, (count: number) => { diff --git a/apps/edr-freight-web/portal/src/features/notifications/NotificationBellContainer.tsx b/apps/edr-freight-web/portal/src/features/notifications/NotificationBellContainer.tsx index aa10f66f5..8c8ed749f 100644 --- a/apps/edr-freight-web/portal/src/features/notifications/NotificationBellContainer.tsx +++ b/apps/edr-freight-web/portal/src/features/notifications/NotificationBellContainer.tsx @@ -1,10 +1,12 @@ -import type { NotificationListResult } from "@edr/types"; +import type { NotificationDto, NotificationListResult } from "@edr/types"; import { NotificationBell, NotificationDrawer, + NotificationToast, type NotificationItemData, } from "@edr/ui-common"; import { useState } from "react"; +import toast from "react-hot-toast"; import { useNavigate } from "react-router-dom"; import { @@ -19,22 +21,25 @@ import { } from "./useNotifications"; import { useNotificationSocket } from "./useNotificationSocket"; +/** Map a server notification into the shared presentational item shape. */ +function toItem(n: NotificationDto): NotificationItemData { + return { + id: n.id, + type: n.type, + title: n.title, + body: n.body, + createdAt: n.createdAt, + isRead: n.isRead, + link: n.link, + data: n.data, + }; +} + /** Flatten an infinite query's pages into the drawer's item shape. */ function toItems( data: { pages: NotificationListResult[] } | undefined, ): NotificationItemData[] { - return (data?.pages ?? []) - .flatMap((p) => p.items) - .map((n) => ({ - id: n.id, - type: n.type, - title: n.title, - body: n.body, - createdAt: n.createdAt, - isRead: n.isRead, - link: n.link, - data: n.data, - })); + return (data?.pages ?? []).flatMap((p) => p.items).map(toItem); } /** @@ -55,7 +60,6 @@ export default function NotificationBellContainer({ const unread = useUnreadCount(enabled); const markRead = useMarkRead(); const markAllRead = useMarkAllRead(); - useNotificationSocket(enabled); const unreadItems = toItems(unreadQ.data); const readItems = toItems(readQ.data); @@ -68,6 +72,26 @@ export default function NotificationBellContainer({ if (href) navigate(href); }; + // Live push → rich toast that reuses the same registry + click action. + useNotificationSocket(enabled, (n) => { + const item = toItem(n); + toast.custom( + (t) => ( + { + toast.dismiss(t.id); + handleItemClick(item); + }} + onDismiss={() => toast.dismiss(t.id)} + /> + ), + { duration: 6000 }, + ); + }); + return ( <> void, +) { const qc = useQueryClient(); + const onNewRef = useRef(onNew); + onNewRef.current = onNew; useEffect(() => { if (!enabled) return; @@ -43,7 +48,8 @@ export function useNotificationSocket(enabled: boolean) { socket.on(NOTIFICATION_WS_EVENTS.NEW, (n: NotificationDto) => { qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY }); - toast(n.title); + qc.invalidateQueries({ queryKey: UNREAD_KEY }); + onNewRef.current?.(n); }); socket.on(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, (count: number) => { diff --git a/packages/ui-common/src/components/NotificationBell/NotificationToast.tsx b/packages/ui-common/src/components/NotificationBell/NotificationToast.tsx new file mode 100644 index 000000000..f3fb5aafe --- /dev/null +++ b/packages/ui-common/src/components/NotificationBell/NotificationToast.tsx @@ -0,0 +1,141 @@ +import { ActionIcon, Box, Group, Stack, Text, ThemeIcon } from "@mantine/core"; +import { Bell, ChevronRight, X } from "lucide-react"; + +import { timeAgo } from "./timeAgo"; +import type { NotificationItemData, NotificationVisual } from "./types"; + +export interface NotificationToastProps { + item: NotificationItemData; + /** Resolved by the hosting app's registry from `item.type`/`item.data`. */ + visual?: NotificationVisual; + /** react-hot-toast enter/exit flag; drives the slide/fade animation. */ + visible?: boolean; + /** Whole-card click (usually: mark read + navigate). Shows a chevron affordance. */ + onClick?: () => void; + onDismiss?: () => void; + width?: number; +} + +/** + * Rich, ERP-grade notification toast: a colored-accent card with an icon, a + * bold title, a two-line body, a relative timestamp, and a dismiss button. + * Presentational — the host resolves the visual and click action and fires it + * via `toast.custom`. + */ +export function NotificationToast({ + item, + visual, + visible = true, + onClick, + onDismiss, + width = 380, +}: NotificationToastProps) { + const color = visual?.color ?? "blue"; + const icon = visual?.icon ?? ; + const clickable = Boolean(onClick); + + return ( + + {/* colored accent rail */} + + + { + if (clickable && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + onClick?.(); + } + }} + p="sm" + style={{ flex: 1, minWidth: 0, cursor: clickable ? "pointer" : "default" }} + > + + + {icon} + + + + + {item.title} + + + {item.body} + + + + {timeAgo(item.createdAt)} + + {clickable && ( + + + View details + + + + )} + + + + + + {onDismiss && ( + { + e.stopPropagation(); + onDismiss(); + }} + style={{ position: "absolute", top: 6, right: 6 }} + > + + + )} + + ); +} + +export default NotificationToast; diff --git a/packages/ui-common/src/components/NotificationBell/index.ts b/packages/ui-common/src/components/NotificationBell/index.ts index 547c4cd71..e7025c6e7 100644 --- a/packages/ui-common/src/components/NotificationBell/index.ts +++ b/packages/ui-common/src/components/NotificationBell/index.ts @@ -7,6 +7,9 @@ export type { NotificationItemProps } from "./NotificationItem"; export { NotificationDrawer } from "./NotificationDrawer"; export type { NotificationDrawerProps } from "./NotificationDrawer"; +export { NotificationToast } from "./NotificationToast"; +export type { NotificationToastProps } from "./NotificationToast"; + export type { NotificationItemData, NotificationVisual, diff --git a/packages/ui-common/src/index.ts b/packages/ui-common/src/index.ts index 05cf2adf3..f7e609412 100644 --- a/packages/ui-common/src/index.ts +++ b/packages/ui-common/src/index.ts @@ -32,11 +32,13 @@ export { NotificationBell, NotificationItem, NotificationDrawer, + NotificationToast, } from "./components/NotificationBell"; export type { NotificationBellProps, NotificationItemProps, NotificationDrawerProps, + NotificationToastProps, NotificationItemData, NotificationVisual, ResolveNotificationVisual,