style: ui improvement to the notification cards.

This commit is contained in:
Nathnael
2026-07-06 10:54:43 +00:00
parent ca3a6072d8
commit 298ad6227b
11 changed files with 211 additions and 109 deletions

View File

@@ -9,11 +9,13 @@ import {
ScrollArea,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import { CheckCheck, Inbox, X } from "lucide-react";
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,
@@ -73,36 +75,39 @@ function Sentinel({
}
function SectionLabel({
children,
right,
label,
count,
}: {
children: React.ReactNode;
right?: React.ReactNode;
label: string;
count?: number;
}) {
return (
<Group
justify="space-between"
gap={8}
px="md"
py={6}
py={8}
wrap="nowrap"
style={{
position: "sticky",
top: 0,
zIndex: 1,
background: "var(--mantine-color-body)",
backdropFilter: "blur(6px)",
background: edr.card,
borderBottom: `1px solid ${edr.border}`,
}}
>
<Text
size="xs"
fw={700}
tt="uppercase"
c="dimmed"
style={{ letterSpacing: 0.6 }}
style={{ color: edr.muted, letterSpacing: 0.6 }}
>
{children}
{label}
</Text>
{right}
{typeof count === "number" && count > 0 && (
<Text size="xs" fw={600} style={{ color: edr.muted }}>
{count}
</Text>
)}
</Group>
);
}
@@ -110,15 +115,16 @@ function SectionLabel({
function LoadingRow() {
return (
<Group justify="center" py="md">
<Loader size="xs" />
<Loader size="xs" color="edr-green" />
</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.
* 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,
@@ -138,7 +144,7 @@ export function NotificationDrawer({
resolveVisual,
emptyLabel = "You're all caught up",
title = "Notifications",
width = 420,
width = 424,
}: NotificationDrawerProps) {
const [viewport, setViewport] = useState<HTMLElement | null>(null);
const viewportRef = useRef<HTMLDivElement>(null);
@@ -168,10 +174,14 @@ export function NotificationDrawer({
size={width}
padding={0}
withCloseButton={false}
overlayProps={{ backgroundOpacity: 0.35, blur: 2 }}
transitionProps={{ transition: "slide-left" }}
overlayProps={{ backgroundOpacity: 0.45, blur: 3 }}
transitionProps={{ transition: "slide-left", duration: 220 }}
styles={{
content: { display: "flex", flexDirection: "column" },
content: {
display: "flex",
flexDirection: "column",
background: edr.card,
},
body: {
flex: 1,
minHeight: 0,
@@ -184,18 +194,24 @@ export function NotificationDrawer({
<Group
justify="space-between"
px="md"
py="sm"
py="md"
wrap="nowrap"
style={{
borderBottom: "1px solid var(--mantine-color-default-border)",
}}
style={{ borderBottom: `1px solid ${edr.border}` }}
>
<Group gap="xs" wrap="nowrap">
<Text fw={700} size="md">
{title}
</Text>
<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="sm">
<Badge color="red" variant="filled" size="sm" radius="xl">
{totalUnread > 99 ? "99+" : totalUnread}
</Badge>
)}
@@ -205,7 +221,7 @@ export function NotificationDrawer({
<Button
variant="subtle"
size="compact-xs"
color="gray"
color="edr-green"
leftSection={<CheckCheck size={13} />}
onClick={onMarkAllRead}
>
@@ -233,21 +249,23 @@ export function NotificationDrawer({
{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">
<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>Unread</SectionLabel>
<SectionLabel label="Unread" count={totalUnread} />
<Stack gap={0}>{unread.map(renderItem)}</Stack>
{hasMoreUnread && (
<Sentinel
@@ -261,7 +279,7 @@ export function NotificationDrawer({
{read.length > 0 && (
<>
<SectionLabel>Earlier</SectionLabel>
<SectionLabel label="Earlier" />
<Stack gap={0}>{read.map(renderItem)}</Stack>
{hasMoreRead && (
<Sentinel

View File

@@ -1,6 +1,8 @@
import { Box, Group, Stack, Text, ThemeIcon } from "@mantine/core";
import { Bell } from "lucide-react";
import { useState } from "react";
import { edr, isHighPriority, PriorityTag } from "./PriorityTag";
import { timeAgo } from "./timeAgo";
import type { NotificationItemData, NotificationVisual } from "./types";
@@ -12,74 +14,100 @@ export interface NotificationItemProps {
}
/**
* 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.
* One notification row, styled to the EDR design system. Unread rows get a
* colored left accent (per-type, or amber for HIGH priority) and a soft tint;
* HIGH-priority rows also show a "High" pill. Icon/color come from the app.
*/
export function NotificationItem({ item, visual, onClick }: NotificationItemProps) {
const [hovered, setHovered] = useState(false);
const color = visual?.color ?? "blue";
const icon = visual?.icon ?? <Bell size={16} strokeWidth={2} />;
const icon = visual?.icon ?? <Bell size={17} strokeWidth={2} />;
const unread = !item.isRead;
const high = isHighPriority(item.priority);
const clickable = Boolean(onClick);
// Accent + tint follow the type color, except HIGH priority which goes amber.
const accent = high ? edr.amber : `var(--mantine-color-${color}-6)`;
const tintBase = high
? edr.amberSoft
: `var(--mantine-color-${color}-0)`;
const hoverBase = high
? edr.amberSoft
: `var(--mantine-color-${color}-1)`;
const background = unread
? hovered
? hoverBase
: tintBase
: hovered
? "var(--mantine-color-edr-slate-soft-6, var(--mantine-color-gray-0))"
: "transparent";
return (
<Box
role={clickable ? "button" : undefined}
tabIndex={clickable ? 0 : undefined}
onClick={() => onClick?.(item)}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
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",
borderLeftColor: unread ? accent : "transparent",
background,
transition: "background 120ms ease",
}}
>
<Group align="flex-start" wrap="nowrap" gap="sm">
<ThemeIcon
variant="light"
color={color}
radius="xl"
size={38}
color={high ? "edr-accent" : color}
radius="md"
size={40}
style={{ flexShrink: 0 }}
>
{icon}
</ThemeIcon>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Stack gap={3} style={{ flex: 1, minWidth: 0 }}>
<Group justify="space-between" wrap="nowrap" gap={8} align="flex-start">
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
<Text
fw={unread ? 600 : 500}
size="sm"
lineClamp={1}
style={{ color: unread ? edr.text : edr.muted }}
>
{item.title}
</Text>
{high && <PriorityTag />}
</Group>
<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 }}
style={{
color: edr.muted,
whiteSpace: "nowrap",
flexShrink: 0,
marginTop: 1,
}}
>
{timeAgo(item.createdAt)}
</Text>
</Group>
<Text c="dimmed" size="xs" lineClamp={2} style={{ lineHeight: 1.4 }}>
<Text
size="xs"
lineClamp={2}
style={{ color: edr.muted, lineHeight: 1.45 }}
>
{item.body}
</Text>
</Stack>
@@ -93,7 +121,7 @@ export function NotificationItem({ item, visual, onClick }: NotificationItemProp
height: 8,
borderRadius: "50%",
marginTop: 6,
background: `var(--mantine-color-${color}-6)`,
background: accent,
}}
/>
)}

View File

@@ -1,6 +1,7 @@
import { ActionIcon, Box, Group, Stack, Text, ThemeIcon } from "@mantine/core";
import { Bell, ChevronRight, X } from "lucide-react";
import { edr, isHighPriority, PriorityTag } from "./PriorityTag";
import { timeAgo } from "./timeAgo";
import type { NotificationItemData, NotificationVisual } from "./types";
@@ -17,10 +18,9 @@ export interface NotificationToastProps {
}
/**
* Rich, ERP-grade notification toast: a colored-accent card with an icon, a
* bold title, a two-line body, a relative timestamp, and a dismiss button.
* Presentational — the host resolves the visual and click action and fires it
* via `toast.custom`.
* Rich, ERP-grade notification toast styled to the EDR design system: a
* colored-accent card (per-type, amber for HIGH) with an icon tile, a bold
* title, a two-line body, a relative timestamp, and a dismiss button.
*/
export function NotificationToast({
item,
@@ -28,11 +28,14 @@ export function NotificationToast({
visible = true,
onClick,
onDismiss,
width = 380,
width = 392,
}: NotificationToastProps) {
const color = visual?.color ?? "blue";
const icon = visual?.icon ?? <Bell size={18} strokeWidth={2} />;
const high = isHighPriority(item.priority);
const clickable = Boolean(onClick);
const accent = high ? edr.amber : `var(--mantine-color-${color}-6)`;
const linkColor = high ? edr.amberText : `var(--mantine-color-${color}-7)`;
return (
<Box
@@ -42,25 +45,18 @@ export function NotificationToast({
maxWidth: "calc(100vw - 32px)",
display: "flex",
overflow: "hidden",
borderRadius: "var(--mantine-radius-md)",
background: "var(--mantine-color-body)",
border: "1px solid var(--mantine-color-default-border)",
boxShadow: "var(--mantine-shadow-lg)",
borderRadius: "var(--mantine-radius-lg)",
background: edr.card,
border: `1px solid ${edr.border}`,
boxShadow: "0 10px 30px rgba(16, 24, 40, 0.12)",
pointerEvents: "auto",
transform: visible ? "translateX(0)" : "translateX(16px)",
opacity: visible ? 1 : 0,
transition: "transform 180ms ease, opacity 180ms ease",
transition: "transform 200ms ease, opacity 200ms ease",
}}
>
{/* colored accent rail */}
<Box
aria-hidden
style={{
width: 4,
flexShrink: 0,
background: `var(--mantine-color-${color}-6)`,
}}
/>
<Box aria-hidden style={{ width: 4, flexShrink: 0, background: accent }} />
<Box
role={clickable ? "button" : undefined}
@@ -78,39 +74,38 @@ export function NotificationToast({
<Group align="flex-start" wrap="nowrap" gap="sm">
<ThemeIcon
variant="light"
color={color}
radius="xl"
color={high ? "edr-accent" : color}
radius="md"
size={40}
style={{ flexShrink: 0 }}
>
{icon}
</ThemeIcon>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Stack gap={3} style={{ flex: 1, minWidth: 0 }}>
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }} pr={20}>
<Text fw={700} size="sm" lineClamp={1} style={{ color: edr.text }}>
{item.title}
</Text>
{high && <PriorityTag />}
</Group>
<Text
fw={600}
size="sm"
lineClamp={1}
pr={onDismiss ? 20 : 0}
size="xs"
lineClamp={2}
style={{ color: edr.muted, lineHeight: 1.45 }}
>
{item.title}
</Text>
<Text c="dimmed" size="xs" lineClamp={2} style={{ lineHeight: 1.4 }}>
{item.body}
</Text>
<Group justify="space-between" wrap="nowrap" gap={6} mt={4}>
<Text c="dimmed" size="xs" style={{ whiteSpace: "nowrap" }}>
<Text size="xs" style={{ color: edr.muted, whiteSpace: "nowrap" }}>
{timeAgo(item.createdAt)}
</Text>
{clickable && (
<Group gap={2} wrap="nowrap">
<Text size="xs" fw={600} c={`${color}.6`}>
<Text size="xs" fw={600} style={{ color: linkColor }}>
View details
</Text>
<ChevronRight
size={13}
color={`var(--mantine-color-${color}-6)`}
/>
<ChevronRight size={13} color={linkColor} />
</Group>
)}
</Group>

View File

@@ -0,0 +1,41 @@
/**
* EDR design-system tokens used across the notification surfaces, with safe
* fallbacks so the shared components still render outside the freight apps.
*/
export const edr = {
text: "var(--mantine-color-edr-text-6, var(--mantine-color-text))",
muted: "var(--mantine-color-edr-muted-6, var(--mantine-color-dimmed))",
border: "var(--mantine-color-edr-border-6, var(--mantine-color-default-border))",
card: "var(--mantine-color-edr-card-6, var(--mantine-color-body))",
amber: "var(--mantine-color-edr-accent-6, #F2A516)",
amberSoft: "var(--mantine-color-edr-amber-soft-6, #FDF3E0)",
amberText: "var(--mantine-color-edr-amber-text-6, #9A5B00)",
} as const;
/** True when a notification's priority string denotes HIGH urgency. */
export const isHighPriority = (priority?: string | null): boolean =>
typeof priority === "string" && priority.toUpperCase() === "HIGH";
/** Small amber "High" pill for urgent notifications. */
export function PriorityTag() {
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
fontSize: 10,
fontWeight: 700,
letterSpacing: 0.4,
lineHeight: 1.4,
textTransform: "uppercase",
padding: "1px 6px",
borderRadius: 999,
color: edr.amberText,
background: edr.amberSoft,
whiteSpace: "nowrap",
}}
>
High
</span>
);
}

View File

@@ -9,6 +9,8 @@ export interface NotificationItemData {
body: string;
createdAt: string;
isRead: boolean;
/** Priority key (e.g. `HIGH`). `HIGH` gets an amber urgency cue. */
priority?: string | null;
/** Deep-link path the item points to, if any. */
link?: string | null;
/** Arbitrary structured payload (bookingId, invoiceId, …). */