From abe26c2c606d65bae3cb7d7ac6e9d68672a6a990 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 6 Jul 2026 10:29:59 +0000 Subject: [PATCH] feat: update the ui of the notification --- .../NotificationBellContainer.tsx | 105 +++++-- .../notifications/notificationConfig.tsx | 53 ++++ .../notifications/useNotifications.ts | 38 ++- .../NotificationBellContainer.tsx | 105 +++++-- .../notifications/notificationConfig.tsx | 66 ++++ .../notifications/useNotifications.ts | 38 ++- .../NotificationBell/NotificationBell.tsx | 174 ++--------- .../NotificationBell/NotificationDrawer.tsx | 282 ++++++++++++++++++ .../NotificationBell/NotificationItem.tsx | 105 +++++++ .../src/components/NotificationBell/index.ts | 15 +- .../components/NotificationBell/timeAgo.ts | 14 + .../src/components/NotificationBell/types.ts | 31 ++ packages/ui-common/src/index.ts | 12 +- 13 files changed, 820 insertions(+), 218 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/features/notifications/notificationConfig.tsx create mode 100644 apps/edr-freight-web/portal/src/features/notifications/notificationConfig.tsx create mode 100644 packages/ui-common/src/components/NotificationBell/NotificationDrawer.tsx create mode 100644 packages/ui-common/src/components/NotificationBell/NotificationItem.tsx create mode 100644 packages/ui-common/src/components/NotificationBell/timeAgo.ts create mode 100644 packages/ui-common/src/components/NotificationBell/types.ts 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 f720089e7..aa10f66f5 100644 --- a/apps/edr-freight-web/backoffice/src/features/notifications/NotificationBellContainer.tsx +++ b/apps/edr-freight-web/backoffice/src/features/notifications/NotificationBellContainer.tsx @@ -1,17 +1,46 @@ -import { NotificationBell, type NotificationBellItem } from "@edr/ui-common"; +import type { NotificationListResult } from "@edr/types"; +import { + NotificationBell, + NotificationDrawer, + type NotificationItemData, +} from "@edr/ui-common"; +import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { + resolveNotificationHref, + resolveNotificationVisual, +} from "./notificationConfig"; +import { + useInfiniteNotifications, useMarkAllRead, useMarkRead, - useNotificationsList, useUnreadCount, } from "./useNotifications"; import { useNotificationSocket } from "./useNotificationSocket"; +/** 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, + })); +} + /** - * Wires react-query + the notification WebSocket into the shared presentational - * bell. Mounted inside the authenticated dashboard header. + * Wires react-query (infinite unread/read lists) + the notification WebSocket + * into the shared bell + drawer. Lists are only fetched while the drawer is + * open; the badge is driven by the lightweight unread-count query + socket. */ export default function NotificationBellContainer({ enabled = true, @@ -19,35 +48,57 @@ export default function NotificationBellContainer({ enabled?: boolean; }) { const navigate = useNavigate(); - const list = useNotificationsList(enabled); + const [opened, setOpened] = useState(false); + + const unreadQ = useInfiniteNotifications(false, enabled && opened); + const readQ = useInfiniteNotifications(true, enabled && opened); 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, - })); + const unreadItems = toItems(unreadQ.data); + const readItems = toItems(readQ.data); + const unreadCount = unread.data ?? 0; + + const handleItemClick = (item: NotificationItemData) => { + if (!item.isRead) markRead.mutate(item.id); + const href = resolveNotificationHref(item); + setOpened(false); + if (href) navigate(href); + }; 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()} - /> + <> + setOpened(true)} + /> + setOpened(false)} + unread={unreadItems} + read={readItems} + unreadCount={unreadCount} + loading={opened && (unreadQ.isLoading || readQ.isLoading)} + hasMoreUnread={unreadQ.hasNextPage} + hasMoreRead={readQ.hasNextPage} + loadingMoreUnread={unreadQ.isFetchingNextPage} + loadingMoreRead={readQ.isFetchingNextPage} + onLoadMoreUnread={() => { + if (unreadQ.hasNextPage && !unreadQ.isFetchingNextPage) { + void unreadQ.fetchNextPage(); + } + }} + onLoadMoreRead={() => { + if (readQ.hasNextPage && !readQ.isFetchingNextPage) { + void readQ.fetchNextPage(); + } + }} + onItemClick={handleItemClick} + onMarkAllRead={() => markAllRead.mutate()} + resolveVisual={resolveNotificationVisual} + /> + ); } diff --git a/apps/edr-freight-web/backoffice/src/features/notifications/notificationConfig.tsx b/apps/edr-freight-web/backoffice/src/features/notifications/notificationConfig.tsx new file mode 100644 index 000000000..8d18f9347 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/features/notifications/notificationConfig.tsx @@ -0,0 +1,53 @@ +import { NotificationType } from "@edr/types"; +import type { NotificationItemData, NotificationVisual } from "@edr/ui-common"; +import { Bell, ClipboardCheck, Inbox, Wallet } from "lucide-react"; + +const ICON_SIZE = 17; + +/** + * Backoffice notification registry. Maps a notification `type` → icon + Mantine + * color, and `type`/`data` → an in-app deep link. This is the single place to + * customize how each staff-facing notification looks and where it goes. + */ +export function resolveNotificationVisual( + item: NotificationItemData, +): NotificationVisual { + switch (item.type) { + case NotificationType.REQUEST_SUBMITTED: + return { icon: , color: "blue" }; + case NotificationType.PAYMENT_RECEIVED: + return { icon: , color: "teal" }; + case NotificationType.CLEARANCE_REVIEW: + return { icon: , color: "orange" }; + default: + return { icon: , color: "gray" }; + } +} + +function asId(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +/** + * Resolve where clicking a notification navigates. Prefers an explicit + * server-provided `link`, else derives a `/dashboard/*` route from `type` + + * `data`. Returns `null` when there's nowhere sensible to go. + */ +export function resolveNotificationHref( + item: NotificationItemData, +): string | null { + if (item.link) return item.link; + const data = item.data ?? {}; + switch (item.type) { + case NotificationType.REQUEST_SUBMITTED: + return "/dashboard/booking-requests"; + case NotificationType.PAYMENT_RECEIVED: { + const id = asId(data.customerId); + return id ? `/dashboard/customers/${id}` : "/dashboard/customers"; + } + case NotificationType.CLEARANCE_REVIEW: + return "/dashboard/arrival-queue"; + default: + return null; + } +} diff --git a/apps/edr-freight-web/backoffice/src/features/notifications/useNotifications.ts b/apps/edr-freight-web/backoffice/src/features/notifications/useNotifications.ts index 58f69df3e..9d488089d 100644 --- a/apps/edr-freight-web/backoffice/src/features/notifications/useNotifications.ts +++ b/apps/edr-freight-web/backoffice/src/features/notifications/useNotifications.ts @@ -1,14 +1,32 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + useInfiniteQuery, + 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 }), +const PAGE_SIZE = 20; + +/** + * Paginated (infinite) notifications for one read-state. Drives a drawer + * section; call `fetchNextPage` as the user scrolls. Each page carries the + * server `count` so we know when to stop. + */ +export function useInfiniteNotifications(isRead: boolean, enabled = true) { + return useInfiniteQuery({ + queryKey: [...NOTIFICATIONS_KEY, "list", { isRead }], + queryFn: ({ pageParam }) => + notificationsApi.list({ page: pageParam, limit: PAGE_SIZE, isRead }), + initialPageParam: 1, + getNextPageParam: (lastPage, allPages) => { + const loaded = allPages.reduce((sum, p) => sum + p.items.length, 0); + return loaded < lastPage.count ? allPages.length + 1 : undefined; + }, enabled, }); } @@ -27,7 +45,10 @@ export function useMarkRead() { const qc = useQueryClient(); return useMutation({ mutationFn: (id: string) => notificationsApi.markRead(id), - onSuccess: () => qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY }); + qc.invalidateQueries({ queryKey: UNREAD_KEY }); + }, }); } @@ -35,6 +56,9 @@ export function useMarkAllRead() { const qc = useQueryClient(); return useMutation({ mutationFn: () => notificationsApi.markAllRead(), - onSuccess: () => qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY }); + qc.invalidateQueries({ queryKey: UNREAD_KEY }); + }, }); } 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 bfd9af01a..aa10f66f5 100644 --- a/apps/edr-freight-web/portal/src/features/notifications/NotificationBellContainer.tsx +++ b/apps/edr-freight-web/portal/src/features/notifications/NotificationBellContainer.tsx @@ -1,17 +1,46 @@ -import { NotificationBell, type NotificationBellItem } from "@edr/ui-common"; +import type { NotificationListResult } from "@edr/types"; +import { + NotificationBell, + NotificationDrawer, + type NotificationItemData, +} from "@edr/ui-common"; +import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { + resolveNotificationHref, + resolveNotificationVisual, +} from "./notificationConfig"; +import { + useInfiniteNotifications, useMarkAllRead, useMarkRead, - useNotificationsList, useUnreadCount, } from "./useNotifications"; import { useNotificationSocket } from "./useNotificationSocket"; +/** 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, + })); +} + /** - * Wires react-query + the notification WebSocket into the shared presentational - * bell. Mount inside an authenticated layout. + * Wires react-query (infinite unread/read lists) + the notification WebSocket + * into the shared bell + drawer. Lists are only fetched while the drawer is + * open; the badge is driven by the lightweight unread-count query + socket. */ export default function NotificationBellContainer({ enabled = true, @@ -19,35 +48,57 @@ export default function NotificationBellContainer({ enabled?: boolean; }) { const navigate = useNavigate(); - const list = useNotificationsList(enabled); + const [opened, setOpened] = useState(false); + + const unreadQ = useInfiniteNotifications(false, enabled && opened); + const readQ = useInfiniteNotifications(true, enabled && opened); 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, - })); + const unreadItems = toItems(unreadQ.data); + const readItems = toItems(readQ.data); + const unreadCount = unread.data ?? 0; + + const handleItemClick = (item: NotificationItemData) => { + if (!item.isRead) markRead.mutate(item.id); + const href = resolveNotificationHref(item); + setOpened(false); + if (href) navigate(href); + }; 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()} - /> + <> + setOpened(true)} + /> + setOpened(false)} + unread={unreadItems} + read={readItems} + unreadCount={unreadCount} + loading={opened && (unreadQ.isLoading || readQ.isLoading)} + hasMoreUnread={unreadQ.hasNextPage} + hasMoreRead={readQ.hasNextPage} + loadingMoreUnread={unreadQ.isFetchingNextPage} + loadingMoreRead={readQ.isFetchingNextPage} + onLoadMoreUnread={() => { + if (unreadQ.hasNextPage && !unreadQ.isFetchingNextPage) { + void unreadQ.fetchNextPage(); + } + }} + onLoadMoreRead={() => { + if (readQ.hasNextPage && !readQ.isFetchingNextPage) { + void readQ.fetchNextPage(); + } + }} + onItemClick={handleItemClick} + onMarkAllRead={() => markAllRead.mutate()} + resolveVisual={resolveNotificationVisual} + /> + ); } diff --git a/apps/edr-freight-web/portal/src/features/notifications/notificationConfig.tsx b/apps/edr-freight-web/portal/src/features/notifications/notificationConfig.tsx new file mode 100644 index 000000000..89bf880fe --- /dev/null +++ b/apps/edr-freight-web/portal/src/features/notifications/notificationConfig.tsx @@ -0,0 +1,66 @@ +import { NotificationType } from "@edr/types"; +import type { NotificationItemData, NotificationVisual } from "@edr/ui-common"; +import { + BadgeCheck, + Bell, + FileWarning, + Package, + Receipt, +} from "lucide-react"; + +const ICON_SIZE = 17; + +/** + * Portal notification registry. Maps a notification `type` → icon + Mantine + * color, and `type`/`data` → an in-app deep link. This is the single place to + * customize how each notification looks and where clicking it goes. + */ +export function resolveNotificationVisual( + item: NotificationItemData, +): NotificationVisual { + switch (item.type) { + case NotificationType.CLEARANCE_DECISION: + return { icon: , color: "teal" }; + case NotificationType.DOCUMENT_ACTION: + return { icon: , color: "orange" }; + case NotificationType.BOOKING_STATUS: + return { icon: , color: "blue" }; + case NotificationType.INVOICE_ISSUED: + return { icon: , color: "violet" }; + default: + return { icon: , color: "gray" }; + } +} + +function asId(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +/** + * Resolve where clicking a notification navigates. Prefers an explicit + * server-provided `link`, else derives a route from `type` + `data`. + * Returns `null` when there's nowhere sensible to go (item just marks read). + */ +export function resolveNotificationHref( + item: NotificationItemData, +): string | null { + if (item.link) return item.link; + const data = item.data ?? {}; + switch (item.type) { + case NotificationType.INVOICE_ISSUED: { + const id = asId(data.invoiceId); + return id ? `/billing/${id}` : "/billing"; + } + case NotificationType.BOOKING_STATUS: { + const id = asId(data.bookingId); + return id ? `/bookings/${id}` : null; + } + case NotificationType.CLEARANCE_DECISION: + case NotificationType.DOCUMENT_ACTION: { + const id = asId(data.contractId); + return id ? `/contracts/${id}` : "/contracts"; + } + default: + return null; + } +} diff --git a/apps/edr-freight-web/portal/src/features/notifications/useNotifications.ts b/apps/edr-freight-web/portal/src/features/notifications/useNotifications.ts index 58f69df3e..9d488089d 100644 --- a/apps/edr-freight-web/portal/src/features/notifications/useNotifications.ts +++ b/apps/edr-freight-web/portal/src/features/notifications/useNotifications.ts @@ -1,14 +1,32 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + useInfiniteQuery, + 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 }), +const PAGE_SIZE = 20; + +/** + * Paginated (infinite) notifications for one read-state. Drives a drawer + * section; call `fetchNextPage` as the user scrolls. Each page carries the + * server `count` so we know when to stop. + */ +export function useInfiniteNotifications(isRead: boolean, enabled = true) { + return useInfiniteQuery({ + queryKey: [...NOTIFICATIONS_KEY, "list", { isRead }], + queryFn: ({ pageParam }) => + notificationsApi.list({ page: pageParam, limit: PAGE_SIZE, isRead }), + initialPageParam: 1, + getNextPageParam: (lastPage, allPages) => { + const loaded = allPages.reduce((sum, p) => sum + p.items.length, 0); + return loaded < lastPage.count ? allPages.length + 1 : undefined; + }, enabled, }); } @@ -27,7 +45,10 @@ export function useMarkRead() { const qc = useQueryClient(); return useMutation({ mutationFn: (id: string) => notificationsApi.markRead(id), - onSuccess: () => qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY }); + qc.invalidateQueries({ queryKey: UNREAD_KEY }); + }, }); } @@ -35,6 +56,9 @@ export function useMarkAllRead() { const qc = useQueryClient(); return useMutation({ mutationFn: () => notificationsApi.markAllRead(), - onSuccess: () => qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY }); + qc.invalidateQueries({ queryKey: UNREAD_KEY }); + }, }); } diff --git a/packages/ui-common/src/components/NotificationBell/NotificationBell.tsx b/packages/ui-common/src/components/NotificationBell/NotificationBell.tsx index bbc1b45b8..55b1e6465 100644 --- a/packages/ui-common/src/components/NotificationBell/NotificationBell.tsx +++ b/packages/ui-common/src/components/NotificationBell/NotificationBell.tsx @@ -1,165 +1,49 @@ -import { - ActionIcon, - Box, - Button, - Group, - Indicator, - Loader, - Menu, - ScrollArea, - Stack, - Text, -} from "@mantine/core"; -import { Bell, CheckCheck } from "lucide-react"; - -/** Minimal shape the bell needs to render one row. */ -export interface NotificationBellItem { - id: string; - title: string; - body: string; - createdAt: string; - isRead: boolean; - link?: string | null; -} +import { ActionIcon, Indicator } from "@mantine/core"; +import { Bell } from "lucide-react"; export interface NotificationBellProps { - items: NotificationBellItem[]; unreadCount: number; - loading?: boolean; - /** Fired when the dropdown opens — a good place to refetch. */ - onOpen?: () => void; - onItemClick?: (item: NotificationBellItem) => void; - onMarkAllRead?: () => void; - emptyLabel?: string; + /** Opens the notification drawer. */ + onClick?: () => void; + ariaLabel?: string; /** Extra className for the trigger button (e.g. the app's header "island"). */ triggerClassName?: string; } -const timeAgo = (iso: string): string => { - const then = new Date(iso).getTime(); - if (Number.isNaN(then)) return ""; - const secs = Math.max(0, Math.floor((Date.now() - then) / 1000)); - if (secs < 60) return "just now"; - const mins = Math.floor(secs / 60); - if (mins < 60) return `${mins}m ago`; - const hrs = Math.floor(mins / 60); - if (hrs < 24) return `${hrs}h ago`; - const days = Math.floor(hrs / 24); - if (days < 7) return `${days}d ago`; - return new Date(iso).toLocaleDateString(); -}; - /** - * Presentational notification bell + dropdown. Owns no data or transport — the - * hosting app wires react-query/websocket and passes items + handlers. Built on - * Mantine primitives to match the freight app headers. + * Header bell trigger: an icon button with an unread-count indicator. Purely + * presentational — clicking calls `onClick` (the app opens {@link NotificationDrawer}). */ export function NotificationBell({ - items, unreadCount, - loading = false, - onOpen, - onItemClick, - onMarkAllRead, - emptyLabel = "You're all caught up", + onClick, + ariaLabel = "Notifications", triggerClassName, }: NotificationBellProps) { const hasUnread = unreadCount > 0; return ( - 99 ? "99+" : unreadCount} + styles={{ + indicator: { fontSize: 10, fontWeight: 700, padding: "0 4px" }, + }} > - - 99 ? "99+" : unreadCount} - styles={{ indicator: { fontSize: 10, fontWeight: 700, padding: "0 4px" } }} - > - - - - - - - - - - Notifications - - {hasUnread && onMarkAllRead && ( - - )} - - - - {loading && items.length === 0 ? ( - - - - ) : items.length === 0 ? ( - - - {emptyLabel} - - - ) : ( - - - {items.map((item) => ( - onItemClick?.(item)} - style={{ - cursor: onItemClick ? "pointer" : "default", - background: item.isRead - ? undefined - : "var(--mantine-color-blue-light)", - borderBottom: "1px solid var(--mantine-color-default-border)", - }} - > - - - {item.title} - - - {timeAgo(item.createdAt)} - - - - {item.body} - - - ))} - - - )} - - + + + + ); } diff --git a/packages/ui-common/src/components/NotificationBell/NotificationDrawer.tsx b/packages/ui-common/src/components/NotificationBell/NotificationDrawer.tsx new file mode 100644 index 000000000..460848e25 --- /dev/null +++ b/packages/ui-common/src/components/NotificationBell/NotificationDrawer.tsx @@ -0,0 +1,282 @@ +import { + ActionIcon, + Badge, + Box, + Button, + Drawer, + Group, + Loader, + ScrollArea, + Stack, + Text, +} from "@mantine/core"; +import { CheckCheck, Inbox, X } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +import { NotificationItem } from "./NotificationItem"; +import type { + NotificationItemData, + ResolveNotificationVisual, +} from "./types"; + +export interface NotificationDrawerProps { + opened: boolean; + onClose: () => void; + /** Unread items (newest first), already flattened across pages. */ + unread: NotificationItemData[]; + /** Read items (newest first), already flattened across pages. */ + read: NotificationItemData[]; + unreadCount?: number; + /** Initial load spinner (before any page resolves). */ + loading?: boolean; + hasMoreUnread?: boolean; + hasMoreRead?: boolean; + loadingMoreUnread?: boolean; + loadingMoreRead?: boolean; + onLoadMoreUnread?: () => void; + onLoadMoreRead?: () => void; + onItemClick?: (item: NotificationItemData) => void; + onMarkAllRead?: () => void; + /** App-owned registry: `item` → icon + color. */ + resolveVisual?: ResolveNotificationVisual; + emptyLabel?: string; + title?: string; + width?: number; +} + +/** Fires `onVisible` when it scrolls into view within `root`. Infinite-scroll trigger. */ +function Sentinel({ + root, + onVisible, +}: { + root: HTMLElement | null; + onVisible: () => void; +}) { + const ref = useRef(null); + const cb = useRef(onVisible); + cb.current = onVisible; + + useEffect(() => { + const el = ref.current; + if (!el) return; + const obs = new IntersectionObserver( + (entries) => { + if (entries.some((e) => e.isIntersecting)) cb.current(); + }, + { root, rootMargin: "160px" }, + ); + obs.observe(el); + return () => obs.disconnect(); + }, [root]); + + return
; +} + +function SectionLabel({ + children, + right, +}: { + children: React.ReactNode; + right?: React.ReactNode; +}) { + return ( + + + {children} + + {right} + + ); +} + +function LoadingRow() { + return ( + + + + ); +} + +/** + * Right slide-in notification center: an "Unread" section over an "Earlier" + * (read) section, each with its own infinite-scroll trigger, in one scroll + * surface. Presentational — the host wires data, paging, and the visual registry. + */ +export function NotificationDrawer({ + opened, + onClose, + unread, + read, + unreadCount, + loading = false, + hasMoreUnread = false, + hasMoreRead = false, + loadingMoreUnread = false, + loadingMoreRead = false, + onLoadMoreUnread, + onLoadMoreRead, + onItemClick, + onMarkAllRead, + resolveVisual, + emptyLabel = "You're all caught up", + title = "Notifications", + width = 420, +}: NotificationDrawerProps) { + const [viewport, setViewport] = useState(null); + const viewportRef = useRef(null); + + // Capture the scroll viewport so the sentinels observe within it, not the page. + useEffect(() => { + if (opened) setViewport(viewportRef.current); + }, [opened]); + + const totalUnread = unreadCount ?? unread.length; + const isEmpty = !loading && unread.length === 0 && read.length === 0; + + const renderItem = (item: NotificationItemData) => ( + + ); + + return ( + + + + + {title} + + {totalUnread > 0 && ( + + {totalUnread > 99 ? "99+" : totalUnread} + + )} + + + {totalUnread > 0 && onMarkAllRead && ( + + )} + + + + + + + + {loading && unread.length === 0 && read.length === 0 ? ( + + ) : isEmpty ? ( + + + + {emptyLabel} + + + ) : ( + + {unread.length > 0 && ( + <> + Unread + {unread.map(renderItem)} + {hasMoreUnread && ( + onLoadMoreUnread?.()} + /> + )} + {loadingMoreUnread && } + + )} + + {read.length > 0 && ( + <> + Earlier + {read.map(renderItem)} + {hasMoreRead && ( + onLoadMoreRead?.()} + /> + )} + {loadingMoreRead && } + + )} + + )} + + + ); +} + +export default NotificationDrawer; diff --git a/packages/ui-common/src/components/NotificationBell/NotificationItem.tsx b/packages/ui-common/src/components/NotificationBell/NotificationItem.tsx new file mode 100644 index 000000000..bdfdba1f8 --- /dev/null +++ b/packages/ui-common/src/components/NotificationBell/NotificationItem.tsx @@ -0,0 +1,105 @@ +import { Box, Group, Stack, Text, ThemeIcon } from "@mantine/core"; +import { Bell } from "lucide-react"; + +import { timeAgo } from "./timeAgo"; +import type { NotificationItemData, NotificationVisual } from "./types"; + +export interface NotificationItemProps { + item: NotificationItemData; + /** Resolved by the hosting app's registry from `item.type`/`item.data`. */ + visual?: NotificationVisual; + onClick?: (item: NotificationItemData) => void; +} + +/** + * One presentational notification row. Owns no data — icon/color come from the + * app via `visual`, the click action via `onClick`. Unread rows get a tinted + * surface, a colored left accent, and an emphasized title. + */ +export function NotificationItem({ item, visual, onClick }: NotificationItemProps) { + const color = visual?.color ?? "blue"; + const icon = visual?.icon ?? ; + const unread = !item.isRead; + const clickable = Boolean(onClick); + + return ( + onClick?.(item)} + onKeyDown={(e) => { + if (clickable && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + onClick?.(item); + } + }} + className="edr-notification-item" + data-unread={unread || undefined} + px="md" + py="sm" + style={{ + position: "relative", + cursor: clickable ? "pointer" : "default", + borderLeft: "3px solid transparent", + borderLeftColor: unread + ? `var(--mantine-color-${color}-6)` + : "transparent", + background: unread + ? `var(--mantine-color-${color}-light)` + : "transparent", + transition: "background 120ms ease", + }} + > + + + {icon} + + + + + + {item.title} + + + {timeAgo(item.createdAt)} + + + + {item.body} + + + + {unread && ( + + )} + + + ); +} + +export default NotificationItem; diff --git a/packages/ui-common/src/components/NotificationBell/index.ts b/packages/ui-common/src/components/NotificationBell/index.ts index e666bc62d..547c4cd71 100644 --- a/packages/ui-common/src/components/NotificationBell/index.ts +++ b/packages/ui-common/src/components/NotificationBell/index.ts @@ -1,5 +1,14 @@ export { NotificationBell, default } from "./NotificationBell"; +export type { NotificationBellProps } from "./NotificationBell"; + +export { NotificationItem } from "./NotificationItem"; +export type { NotificationItemProps } from "./NotificationItem"; + +export { NotificationDrawer } from "./NotificationDrawer"; +export type { NotificationDrawerProps } from "./NotificationDrawer"; + export type { - NotificationBellProps, - NotificationBellItem, -} from "./NotificationBell"; + NotificationItemData, + NotificationVisual, + ResolveNotificationVisual, +} from "./types"; diff --git a/packages/ui-common/src/components/NotificationBell/timeAgo.ts b/packages/ui-common/src/components/NotificationBell/timeAgo.ts new file mode 100644 index 000000000..49f492296 --- /dev/null +++ b/packages/ui-common/src/components/NotificationBell/timeAgo.ts @@ -0,0 +1,14 @@ +/** Compact relative time: "just now", "5m ago", "3h ago", "2d ago", else date. */ +export const timeAgo = (iso: string): string => { + const then = new Date(iso).getTime(); + if (Number.isNaN(then)) return ""; + const secs = Math.max(0, Math.floor((Date.now() - then) / 1000)); + if (secs < 60) return "just now"; + const mins = Math.floor(secs / 60); + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + if (days < 7) return `${days}d ago`; + return new Date(iso).toLocaleDateString(); +}; diff --git a/packages/ui-common/src/components/NotificationBell/types.ts b/packages/ui-common/src/components/NotificationBell/types.ts new file mode 100644 index 000000000..84cb48a48 --- /dev/null +++ b/packages/ui-common/src/components/NotificationBell/types.ts @@ -0,0 +1,31 @@ +import type { ReactNode } from "react"; + +/** Minimal notification shape the presentational components render. */ +export interface NotificationItemData { + id: string; + /** Semantic type key (e.g. `INVOICE_ISSUED`). Drives the per-app visual. */ + type: string; + title: string; + body: string; + createdAt: string; + isRead: boolean; + /** Deep-link path the item points to, if any. */ + link?: string | null; + /** Arbitrary structured payload (bookingId, invoiceId, …). */ + data?: Record | null; +} + +/** + * Per-item visual resolved by the hosting app's registry. `color` is a Mantine + * color key (e.g. `"blue"`, `"green"`); `icon` is any node (usually a lucide + * icon). The app maps `item.type`/`item.data` → this. + */ +export interface NotificationVisual { + icon?: ReactNode; + color?: string; +} + +/** Signature of the app-owned registry passed into the drawer. */ +export type ResolveNotificationVisual = ( + item: NotificationItemData, +) => NotificationVisual; diff --git a/packages/ui-common/src/index.ts b/packages/ui-common/src/index.ts index 70b5f6ef1..05cf2adf3 100644 --- a/packages/ui-common/src/index.ts +++ b/packages/ui-common/src/index.ts @@ -28,10 +28,18 @@ export type { OperationDatePickerProps } from "./components/OperationDatePicker" export { CountdownTimer } from "./components/CountdownTimer"; export type { CountdownTimerProps } from "./components/CountdownTimer"; -export { NotificationBell } from "./components/NotificationBell"; +export { + NotificationBell, + NotificationItem, + NotificationDrawer, +} from "./components/NotificationBell"; export type { NotificationBellProps, - NotificationBellItem, + NotificationItemProps, + NotificationDrawerProps, + NotificationItemData, + NotificationVisual, + ResolveNotificationVisual, } from "./components/NotificationBell"; export { Badge } from "./components/badge";