feat: update the ui of the notification

This commit is contained in:
Nathnael
2026-07-06 10:29:59 +00:00
parent 68c60f9e61
commit abe26c2c60
13 changed files with 820 additions and 218 deletions

View File

@@ -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 (
<>
<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()}
unreadCount={unreadCount}
onClick={() => setOpened(true)}
/>
<NotificationDrawer
opened={opened}
onClose={() => 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}
/>
</>
);
}

View File

@@ -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: <Inbox size={ICON_SIZE} />, color: "blue" };
case NotificationType.PAYMENT_RECEIVED:
return { icon: <Wallet size={ICON_SIZE} />, color: "teal" };
case NotificationType.CLEARANCE_REVIEW:
return { icon: <ClipboardCheck size={ICON_SIZE} />, color: "orange" };
default:
return { icon: <Bell size={ICON_SIZE} />, 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;
}
}

View File

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

View File

@@ -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 (
<>
<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()}
unreadCount={unreadCount}
onClick={() => setOpened(true)}
/>
<NotificationDrawer
opened={opened}
onClose={() => 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}
/>
</>
);
}

View File

@@ -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: <BadgeCheck size={ICON_SIZE} />, color: "teal" };
case NotificationType.DOCUMENT_ACTION:
return { icon: <FileWarning size={ICON_SIZE} />, color: "orange" };
case NotificationType.BOOKING_STATUS:
return { icon: <Package size={ICON_SIZE} />, color: "blue" };
case NotificationType.INVOICE_ISSUED:
return { icon: <Receipt size={ICON_SIZE} />, color: "violet" };
default:
return { icon: <Bell size={ICON_SIZE} />, 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;
}
}

View File

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

View File

@@ -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 (
<Menu
width={360}
position="bottom-end"
offset={8}
radius="md"
shadow="md"
withinPortal
onOpen={onOpen}
>
<Menu.Target>
<Indicator
color="red"
size={16}
offset={4}
disabled={!hasUnread}
label={unreadCount > 99 ? "99+" : unreadCount}
styles={{ indicator: { fontSize: 10, fontWeight: 700, padding: "0 4px" } }}
styles={{
indicator: { fontSize: 10, fontWeight: 700, padding: "0 4px" },
}}
>
<ActionIcon
variant="subtle"
radius="xl"
size={36}
aria-label="Notifications"
aria-label={ariaLabel}
className={triggerClassName}
onClick={onClick}
>
<Bell size={17} strokeWidth={1.8} />
</ActionIcon>
</Indicator>
</Menu.Target>
<Menu.Dropdown p={0}>
<Group justify="space-between" px="sm" py="xs" wrap="nowrap">
<Text fw={600} size="sm">
Notifications
</Text>
{hasUnread && onMarkAllRead && (
<Button
variant="subtle"
size="compact-xs"
leftSection={<CheckCheck size={13} />}
onClick={onMarkAllRead}
>
Mark all read
</Button>
)}
</Group>
<Menu.Divider m={0} />
{loading && items.length === 0 ? (
<Group justify="center" py="lg">
<Loader size="sm" />
</Group>
) : items.length === 0 ? (
<Box py="xl" px="md">
<Text c="dimmed" size="sm" ta="center">
{emptyLabel}
</Text>
</Box>
) : (
<ScrollArea.Autosize mah={380} type="hover">
<Stack gap={0}>
{items.map((item) => (
<Box
key={item.id}
px="sm"
py="xs"
onClick={() => onItemClick?.(item)}
style={{
cursor: onItemClick ? "pointer" : "default",
background: item.isRead
? undefined
: "var(--mantine-color-blue-light)",
borderBottom: "1px solid var(--mantine-color-default-border)",
}}
>
<Group justify="space-between" wrap="nowrap" gap={8}>
<Text fw={item.isRead ? 500 : 700} size="sm" lineClamp={1}>
{item.title}
</Text>
<Text c="dimmed" size="xs" style={{ whiteSpace: "nowrap" }}>
{timeAgo(item.createdAt)}
</Text>
</Group>
<Text c="dimmed" size="xs" lineClamp={2}>
{item.body}
</Text>
</Box>
))}
</Stack>
</ScrollArea.Autosize>
)}
</Menu.Dropdown>
</Menu>
);
}

View File

@@ -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<HTMLDivElement>(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 <div ref={ref} style={{ height: 1 }} aria-hidden />;
}
function SectionLabel({
children,
right,
}: {
children: React.ReactNode;
right?: React.ReactNode;
}) {
return (
<Group
justify="space-between"
px="md"
py={6}
wrap="nowrap"
style={{
position: "sticky",
top: 0,
zIndex: 1,
background: "var(--mantine-color-body)",
backdropFilter: "blur(6px)",
}}
>
<Text
size="xs"
fw={700}
tt="uppercase"
c="dimmed"
style={{ letterSpacing: 0.6 }}
>
{children}
</Text>
{right}
</Group>
);
}
function LoadingRow() {
return (
<Group justify="center" py="md">
<Loader size="xs" />
</Group>
);
}
/**
* 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<HTMLElement | null>(null);
const viewportRef = useRef<HTMLDivElement>(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) => (
<NotificationItem
key={item.id}
item={item}
visual={resolveVisual?.(item)}
onClick={onItemClick}
/>
);
return (
<Drawer
opened={opened}
onClose={onClose}
position="right"
size={width}
padding={0}
withCloseButton={false}
overlayProps={{ backgroundOpacity: 0.35, blur: 2 }}
transitionProps={{ transition: "slide-left" }}
styles={{
content: { display: "flex", flexDirection: "column" },
body: {
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
padding: 0,
},
}}
>
<Group
justify="space-between"
px="md"
py="sm"
wrap="nowrap"
style={{
borderBottom: "1px solid var(--mantine-color-default-border)",
}}
>
<Group gap="xs" wrap="nowrap">
<Text fw={700} size="md">
{title}
</Text>
{totalUnread > 0 && (
<Badge color="red" variant="filled" size="sm" radius="sm">
{totalUnread > 99 ? "99+" : totalUnread}
</Badge>
)}
</Group>
<Group gap={4} wrap="nowrap">
{totalUnread > 0 && onMarkAllRead && (
<Button
variant="subtle"
size="compact-xs"
color="gray"
leftSection={<CheckCheck size={13} />}
onClick={onMarkAllRead}
>
Mark all read
</Button>
)}
<ActionIcon
variant="subtle"
color="gray"
radius="xl"
size={32}
aria-label="Close notifications"
onClick={onClose}
>
<X size={18} />
</ActionIcon>
</Group>
</Group>
<ScrollArea
type="hover"
viewportRef={viewportRef}
style={{ flex: 1, minHeight: 0 }}
>
{loading && unread.length === 0 && read.length === 0 ? (
<LoadingRow />
) : isEmpty ? (
<Stack align="center" gap="xs" py={64} px="lg">
<Inbox
size={40}
strokeWidth={1.4}
color="var(--mantine-color-dimmed)"
/>
<Text c="dimmed" size="sm" ta="center">
{emptyLabel}
</Text>
</Stack>
) : (
<Box pb="md">
{unread.length > 0 && (
<>
<SectionLabel>Unread</SectionLabel>
<Stack gap={0}>{unread.map(renderItem)}</Stack>
{hasMoreUnread && (
<Sentinel
root={viewport}
onVisible={() => onLoadMoreUnread?.()}
/>
)}
{loadingMoreUnread && <LoadingRow />}
</>
)}
{read.length > 0 && (
<>
<SectionLabel>Earlier</SectionLabel>
<Stack gap={0}>{read.map(renderItem)}</Stack>
{hasMoreRead && (
<Sentinel
root={viewport}
onVisible={() => onLoadMoreRead?.()}
/>
)}
{loadingMoreRead && <LoadingRow />}
</>
)}
</Box>
)}
</ScrollArea>
</Drawer>
);
}
export default NotificationDrawer;

View File

@@ -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 ?? <Bell size={16} strokeWidth={2} />;
const unread = !item.isRead;
const clickable = Boolean(onClick);
return (
<Box
role={clickable ? "button" : undefined}
tabIndex={clickable ? 0 : undefined}
onClick={() => 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",
}}
>
<Group align="flex-start" wrap="nowrap" gap="sm">
<ThemeIcon
variant="light"
color={color}
radius="xl"
size={38}
style={{ flexShrink: 0 }}
>
{icon}
</ThemeIcon>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Group justify="space-between" wrap="nowrap" gap={8} align="flex-start">
<Text
fw={unread ? 600 : 500}
size="sm"
lineClamp={1}
c={unread ? undefined : "dimmed.9"}
>
{item.title}
</Text>
<Text
c="dimmed"
size="xs"
style={{ whiteSpace: "nowrap", flexShrink: 0, marginTop: 1 }}
>
{timeAgo(item.createdAt)}
</Text>
</Group>
<Text c="dimmed" size="xs" lineClamp={2} style={{ lineHeight: 1.4 }}>
{item.body}
</Text>
</Stack>
{unread && (
<Box
aria-hidden
style={{
flexShrink: 0,
width: 8,
height: 8,
borderRadius: "50%",
marginTop: 6,
background: `var(--mantine-color-${color}-6)`,
}}
/>
)}
</Group>
</Box>
);
}
export default NotificationItem;

View File

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

View File

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

View File

@@ -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<string, unknown> | 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;

View File

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