mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: update the ui of the notification
This commit is contained in:
@@ -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()}
|
||||
/>
|
||||
<>
|
||||
<NotificationBell
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
<>
|
||||
<NotificationBell
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
<Indicator
|
||||
color="red"
|
||||
size={16}
|
||||
offset={4}
|
||||
disabled={!hasUnread}
|
||||
label={unreadCount > 99 ? "99+" : unreadCount}
|
||||
styles={{
|
||||
indicator: { fontSize: 10, fontWeight: 700, padding: "0 4px" },
|
||||
}}
|
||||
>
|
||||
<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" } }}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="xl"
|
||||
size={36}
|
||||
aria-label="Notifications"
|
||||
className={triggerClassName}
|
||||
>
|
||||
<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>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="xl"
|
||||
size={36}
|
||||
aria-label={ariaLabel}
|
||||
className={triggerClassName}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Bell size={17} strokeWidth={1.8} />
|
||||
</ActionIcon>
|
||||
</Indicator>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
31
packages/ui-common/src/components/NotificationBell/types.ts
Normal file
31
packages/ui-common/src/components/NotificationBell/types.ts
Normal 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;
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user