mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 02:28:18 +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 { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
resolveNotificationHref,
|
||||||
|
resolveNotificationVisual,
|
||||||
|
} from "./notificationConfig";
|
||||||
|
import {
|
||||||
|
useInfiniteNotifications,
|
||||||
useMarkAllRead,
|
useMarkAllRead,
|
||||||
useMarkRead,
|
useMarkRead,
|
||||||
useNotificationsList,
|
|
||||||
useUnreadCount,
|
useUnreadCount,
|
||||||
} from "./useNotifications";
|
} from "./useNotifications";
|
||||||
import { useNotificationSocket } from "./useNotificationSocket";
|
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
|
* Wires react-query (infinite unread/read lists) + the notification WebSocket
|
||||||
* bell. Mounted inside the authenticated dashboard header.
|
* 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({
|
export default function NotificationBellContainer({
|
||||||
enabled = true,
|
enabled = true,
|
||||||
@@ -19,35 +48,57 @@ export default function NotificationBellContainer({
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate();
|
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 unread = useUnreadCount(enabled);
|
||||||
const markRead = useMarkRead();
|
const markRead = useMarkRead();
|
||||||
const markAllRead = useMarkAllRead();
|
const markAllRead = useMarkAllRead();
|
||||||
useNotificationSocket(enabled);
|
useNotificationSocket(enabled);
|
||||||
|
|
||||||
const items: NotificationBellItem[] = (list.data?.items ?? []).map((n) => ({
|
const unreadItems = toItems(unreadQ.data);
|
||||||
id: n.id,
|
const readItems = toItems(readQ.data);
|
||||||
title: n.title,
|
const unreadCount = unread.data ?? 0;
|
||||||
body: n.body,
|
|
||||||
createdAt: n.createdAt,
|
const handleItemClick = (item: NotificationItemData) => {
|
||||||
isRead: n.isRead,
|
if (!item.isRead) markRead.mutate(item.id);
|
||||||
link: n.link,
|
const href = resolveNotificationHref(item);
|
||||||
}));
|
setOpened(false);
|
||||||
|
if (href) navigate(href);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NotificationBell
|
<>
|
||||||
items={items}
|
<NotificationBell
|
||||||
unreadCount={unread.data ?? list.data?.unreadCount ?? 0}
|
unreadCount={unreadCount}
|
||||||
loading={list.isLoading}
|
onClick={() => setOpened(true)}
|
||||||
onOpen={() => {
|
/>
|
||||||
void list.refetch();
|
<NotificationDrawer
|
||||||
void unread.refetch();
|
opened={opened}
|
||||||
}}
|
onClose={() => setOpened(false)}
|
||||||
onItemClick={(item) => {
|
unread={unreadItems}
|
||||||
if (!item.isRead) markRead.mutate(item.id);
|
read={readItems}
|
||||||
if (item.link) navigate(item.link);
|
unreadCount={unreadCount}
|
||||||
}}
|
loading={opened && (unreadQ.isLoading || readQ.isLoading)}
|
||||||
onMarkAllRead={() => markAllRead.mutate()}
|
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";
|
import { notificationsApi } from "./notificationsApi";
|
||||||
|
|
||||||
export const NOTIFICATIONS_KEY = ["notifications"] as const;
|
export const NOTIFICATIONS_KEY = ["notifications"] as const;
|
||||||
export const UNREAD_KEY = ["notifications", "unread"] as const;
|
export const UNREAD_KEY = ["notifications", "unread"] as const;
|
||||||
|
|
||||||
export function useNotificationsList(enabled = true) {
|
const PAGE_SIZE = 20;
|
||||||
return useQuery({
|
|
||||||
queryKey: [...NOTIFICATIONS_KEY, "list"],
|
/**
|
||||||
queryFn: () => notificationsApi.list({ page: 1, limit: 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,
|
enabled,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -27,7 +45,10 @@ export function useMarkRead() {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (id: string) => notificationsApi.markRead(id),
|
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();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: () => notificationsApi.markAllRead(),
|
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 { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
resolveNotificationHref,
|
||||||
|
resolveNotificationVisual,
|
||||||
|
} from "./notificationConfig";
|
||||||
|
import {
|
||||||
|
useInfiniteNotifications,
|
||||||
useMarkAllRead,
|
useMarkAllRead,
|
||||||
useMarkRead,
|
useMarkRead,
|
||||||
useNotificationsList,
|
|
||||||
useUnreadCount,
|
useUnreadCount,
|
||||||
} from "./useNotifications";
|
} from "./useNotifications";
|
||||||
import { useNotificationSocket } from "./useNotificationSocket";
|
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
|
* Wires react-query (infinite unread/read lists) + the notification WebSocket
|
||||||
* bell. Mount inside an authenticated layout.
|
* 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({
|
export default function NotificationBellContainer({
|
||||||
enabled = true,
|
enabled = true,
|
||||||
@@ -19,35 +48,57 @@ export default function NotificationBellContainer({
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate();
|
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 unread = useUnreadCount(enabled);
|
||||||
const markRead = useMarkRead();
|
const markRead = useMarkRead();
|
||||||
const markAllRead = useMarkAllRead();
|
const markAllRead = useMarkAllRead();
|
||||||
useNotificationSocket(enabled);
|
useNotificationSocket(enabled);
|
||||||
|
|
||||||
const items: NotificationBellItem[] = (list.data?.items ?? []).map((n) => ({
|
const unreadItems = toItems(unreadQ.data);
|
||||||
id: n.id,
|
const readItems = toItems(readQ.data);
|
||||||
title: n.title,
|
const unreadCount = unread.data ?? 0;
|
||||||
body: n.body,
|
|
||||||
createdAt: n.createdAt,
|
const handleItemClick = (item: NotificationItemData) => {
|
||||||
isRead: n.isRead,
|
if (!item.isRead) markRead.mutate(item.id);
|
||||||
link: n.link,
|
const href = resolveNotificationHref(item);
|
||||||
}));
|
setOpened(false);
|
||||||
|
if (href) navigate(href);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NotificationBell
|
<>
|
||||||
items={items}
|
<NotificationBell
|
||||||
unreadCount={unread.data ?? list.data?.unreadCount ?? 0}
|
unreadCount={unreadCount}
|
||||||
loading={list.isLoading}
|
onClick={() => setOpened(true)}
|
||||||
onOpen={() => {
|
/>
|
||||||
void list.refetch();
|
<NotificationDrawer
|
||||||
void unread.refetch();
|
opened={opened}
|
||||||
}}
|
onClose={() => setOpened(false)}
|
||||||
onItemClick={(item) => {
|
unread={unreadItems}
|
||||||
if (!item.isRead) markRead.mutate(item.id);
|
read={readItems}
|
||||||
if (item.link) navigate(item.link);
|
unreadCount={unreadCount}
|
||||||
}}
|
loading={opened && (unreadQ.isLoading || readQ.isLoading)}
|
||||||
onMarkAllRead={() => markAllRead.mutate()}
|
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";
|
import { notificationsApi } from "./notificationsApi";
|
||||||
|
|
||||||
export const NOTIFICATIONS_KEY = ["notifications"] as const;
|
export const NOTIFICATIONS_KEY = ["notifications"] as const;
|
||||||
export const UNREAD_KEY = ["notifications", "unread"] as const;
|
export const UNREAD_KEY = ["notifications", "unread"] as const;
|
||||||
|
|
||||||
export function useNotificationsList(enabled = true) {
|
const PAGE_SIZE = 20;
|
||||||
return useQuery({
|
|
||||||
queryKey: [...NOTIFICATIONS_KEY, "list"],
|
/**
|
||||||
queryFn: () => notificationsApi.list({ page: 1, limit: 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,
|
enabled,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -27,7 +45,10 @@ export function useMarkRead() {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (id: string) => notificationsApi.markRead(id),
|
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();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: () => notificationsApi.markAllRead(),
|
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 {
|
import { ActionIcon, Indicator } from "@mantine/core";
|
||||||
ActionIcon,
|
import { Bell } from "lucide-react";
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NotificationBellProps {
|
export interface NotificationBellProps {
|
||||||
items: NotificationBellItem[];
|
|
||||||
unreadCount: number;
|
unreadCount: number;
|
||||||
loading?: boolean;
|
/** Opens the notification drawer. */
|
||||||
/** Fired when the dropdown opens — a good place to refetch. */
|
onClick?: () => void;
|
||||||
onOpen?: () => void;
|
ariaLabel?: string;
|
||||||
onItemClick?: (item: NotificationBellItem) => void;
|
|
||||||
onMarkAllRead?: () => void;
|
|
||||||
emptyLabel?: string;
|
|
||||||
/** Extra className for the trigger button (e.g. the app's header "island"). */
|
/** Extra className for the trigger button (e.g. the app's header "island"). */
|
||||||
triggerClassName?: string;
|
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
|
* Header bell trigger: an icon button with an unread-count indicator. Purely
|
||||||
* hosting app wires react-query/websocket and passes items + handlers. Built on
|
* presentational — clicking calls `onClick` (the app opens {@link NotificationDrawer}).
|
||||||
* Mantine primitives to match the freight app headers.
|
|
||||||
*/
|
*/
|
||||||
export function NotificationBell({
|
export function NotificationBell({
|
||||||
items,
|
|
||||||
unreadCount,
|
unreadCount,
|
||||||
loading = false,
|
onClick,
|
||||||
onOpen,
|
ariaLabel = "Notifications",
|
||||||
onItemClick,
|
|
||||||
onMarkAllRead,
|
|
||||||
emptyLabel = "You're all caught up",
|
|
||||||
triggerClassName,
|
triggerClassName,
|
||||||
}: NotificationBellProps) {
|
}: NotificationBellProps) {
|
||||||
const hasUnread = unreadCount > 0;
|
const hasUnread = unreadCount > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu
|
<Indicator
|
||||||
width={360}
|
color="red"
|
||||||
position="bottom-end"
|
size={16}
|
||||||
offset={8}
|
offset={4}
|
||||||
radius="md"
|
disabled={!hasUnread}
|
||||||
shadow="md"
|
label={unreadCount > 99 ? "99+" : unreadCount}
|
||||||
withinPortal
|
styles={{
|
||||||
onOpen={onOpen}
|
indicator: { fontSize: 10, fontWeight: 700, padding: "0 4px" },
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Menu.Target>
|
<ActionIcon
|
||||||
<Indicator
|
variant="subtle"
|
||||||
color="red"
|
radius="xl"
|
||||||
size={16}
|
size={36}
|
||||||
offset={4}
|
aria-label={ariaLabel}
|
||||||
disabled={!hasUnread}
|
className={triggerClassName}
|
||||||
label={unreadCount > 99 ? "99+" : unreadCount}
|
onClick={onClick}
|
||||||
styles={{ indicator: { fontSize: 10, fontWeight: 700, padding: "0 4px" } }}
|
>
|
||||||
>
|
<Bell size={17} strokeWidth={1.8} />
|
||||||
<ActionIcon
|
</ActionIcon>
|
||||||
variant="subtle"
|
</Indicator>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 { 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 {
|
export type {
|
||||||
NotificationBellProps,
|
NotificationItemData,
|
||||||
NotificationBellItem,
|
NotificationVisual,
|
||||||
} from "./NotificationBell";
|
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 { CountdownTimer } from "./components/CountdownTimer";
|
||||||
export type { CountdownTimerProps } from "./components/CountdownTimer";
|
export type { CountdownTimerProps } from "./components/CountdownTimer";
|
||||||
|
|
||||||
export { NotificationBell } from "./components/NotificationBell";
|
export {
|
||||||
|
NotificationBell,
|
||||||
|
NotificationItem,
|
||||||
|
NotificationDrawer,
|
||||||
|
} from "./components/NotificationBell";
|
||||||
export type {
|
export type {
|
||||||
NotificationBellProps,
|
NotificationBellProps,
|
||||||
NotificationBellItem,
|
NotificationItemProps,
|
||||||
|
NotificationDrawerProps,
|
||||||
|
NotificationItemData,
|
||||||
|
NotificationVisual,
|
||||||
|
ResolveNotificationVisual,
|
||||||
} from "./components/NotificationBell";
|
} from "./components/NotificationBell";
|
||||||
|
|
||||||
export { Badge } from "./components/badge";
|
export { Badge } from "./components/badge";
|
||||||
|
|||||||
Reference in New Issue
Block a user