Files
edr-platform/packages/ui-common/src/components/NotificationBell/NotificationDrawer.tsx
2026-07-06 10:54:43 +00:00

301 lines
7.9 KiB
TypeScript

import {
ActionIcon,
Badge,
Box,
Button,
Drawer,
Group,
Loader,
ScrollArea,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import { Bell, CheckCheck, Inbox, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { NotificationItem } from "./NotificationItem";
import { edr } from "./PriorityTag";
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({
label,
count,
}: {
label: string;
count?: number;
}) {
return (
<Group
gap={8}
px="md"
py={8}
wrap="nowrap"
style={{
position: "sticky",
top: 0,
zIndex: 1,
background: edr.card,
borderBottom: `1px solid ${edr.border}`,
}}
>
<Text
size="xs"
fw={700}
tt="uppercase"
style={{ color: edr.muted, letterSpacing: 0.6 }}
>
{label}
</Text>
{typeof count === "number" && count > 0 && (
<Text size="xs" fw={600} style={{ color: edr.muted }}>
{count}
</Text>
)}
</Group>
);
}
function LoadingRow() {
return (
<Group justify="center" py="md">
<Loader size="xs" color="edr-green" />
</Group>
);
}
/**
* Right slide-in notification center, styled to the EDR design system: a
* branded header, 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 = 424,
}: 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.45, blur: 3 }}
transitionProps={{ transition: "slide-left", duration: 220 }}
styles={{
content: {
display: "flex",
flexDirection: "column",
background: edr.card,
},
body: {
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
padding: 0,
},
}}
>
<Group
justify="space-between"
px="md"
py="md"
wrap="nowrap"
style={{ borderBottom: `1px solid ${edr.border}` }}
>
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" color="edr-green" radius="md" size={36}>
<Bell size={18} strokeWidth={2} />
</ThemeIcon>
<Box>
<Text fw={700} size="md" style={{ color: edr.text, lineHeight: 1.2 }}>
{title}
</Text>
<Text size="xs" style={{ color: edr.muted }}>
{totalUnread > 0 ? `${totalUnread} unread` : "All caught up"}
</Text>
</Box>
{totalUnread > 0 && (
<Badge color="red" variant="filled" size="sm" radius="xl">
{totalUnread > 99 ? "99+" : totalUnread}
</Badge>
)}
</Group>
<Group gap={4} wrap="nowrap">
{totalUnread > 0 && onMarkAllRead && (
<Button
variant="subtle"
size="compact-xs"
color="edr-green"
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="sm" py={72} px="lg">
<ThemeIcon variant="light" color="edr-green" radius="xl" size={56}>
<Inbox size={26} strokeWidth={1.6} />
</ThemeIcon>
<Text size="sm" fw={600} style={{ color: edr.text }}>
{emptyLabel}
</Text>
<Text size="xs" ta="center" style={{ color: edr.muted, maxWidth: 240 }}>
New notifications about your bookings, clearances and invoices will
show up here.
</Text>
</Stack>
) : (
<Box pb="md">
{unread.length > 0 && (
<>
<SectionLabel label="Unread" count={totalUnread} />
<Stack gap={0}>{unread.map(renderItem)}</Stack>
{hasMoreUnread && (
<Sentinel
root={viewport}
onVisible={() => onLoadMoreUnread?.()}
/>
)}
{loadingMoreUnread && <LoadingRow />}
</>
)}
{read.length > 0 && (
<>
<SectionLabel label="Earlier" />
<Stack gap={0}>{read.map(renderItem)}</Stack>
{hasMoreRead && (
<Sentinel
root={viewport}
onVisible={() => onLoadMoreRead?.()}
/>
)}
{loadingMoreRead && <LoadingRow />}
</>
)}
</Box>
)}
</ScrollArea>
</Drawer>
);
}
export default NotificationDrawer;