mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
style: ui improvement to the notification cards.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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, …). */
|
||||
|
||||
Reference in New Issue
Block a user