mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
301 lines
7.9 KiB
TypeScript
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;
|