style: improve the ui of the toast for notifications

This commit is contained in:
Nathnael
2026-07-06 10:38:27 +00:00
parent abe26c2c60
commit ca3a6072d8
7 changed files with 244 additions and 38 deletions

View File

@@ -1,10 +1,12 @@
import type { NotificationListResult } from "@edr/types";
import type { NotificationDto, NotificationListResult } from "@edr/types";
import {
NotificationBell,
NotificationDrawer,
NotificationToast,
type NotificationItemData,
} from "@edr/ui-common";
import { useState } from "react";
import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import {
@@ -19,22 +21,25 @@ import {
} from "./useNotifications";
import { useNotificationSocket } from "./useNotificationSocket";
/** Map a server notification into the shared presentational item shape. */
function toItem(n: NotificationDto): NotificationItemData {
return {
id: n.id,
type: n.type,
title: n.title,
body: n.body,
createdAt: n.createdAt,
isRead: n.isRead,
link: n.link,
data: n.data,
};
}
/** 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,
}));
return (data?.pages ?? []).flatMap((p) => p.items).map(toItem);
}
/**
@@ -55,7 +60,6 @@ export default function NotificationBellContainer({
const unread = useUnreadCount(enabled);
const markRead = useMarkRead();
const markAllRead = useMarkAllRead();
useNotificationSocket(enabled);
const unreadItems = toItems(unreadQ.data);
const readItems = toItems(readQ.data);
@@ -68,6 +72,26 @@ export default function NotificationBellContainer({
if (href) navigate(href);
};
// Live push → rich toast that reuses the same registry + click action.
useNotificationSocket(enabled, (n) => {
const item = toItem(n);
toast.custom(
(t) => (
<NotificationToast
item={item}
visual={resolveNotificationVisual(item)}
visible={t.visible}
onClick={() => {
toast.dismiss(t.id);
handleItemClick(item);
}}
onDismiss={() => toast.dismiss(t.id)}
/>
),
{ duration: 6000 },
);
});
return (
<>
<NotificationBell

View File

@@ -4,8 +4,7 @@ import {
type NotificationDto,
} from "@edr/types";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
import toast from "react-hot-toast";
import { useEffect, useRef } from "react";
import { io } from "socket.io-client";
import { API_BASE_URL } from "@/constants/apiConfig";
@@ -19,10 +18,16 @@ const SOCKET_ORIGIN = String(API_BASE_URL ?? "").replace(/\/api\/?$/, "");
/**
* Subscribes to live notification pushes for the signed-in staff user. New
* items invalidate the list + toast; unread-count pushes update the badge.
* items invalidate the cached lists + fire `onNew` (the host shows a rich
* toast); unread-count pushes update the badge.
*/
export function useNotificationSocket(enabled: boolean) {
export function useNotificationSocket(
enabled: boolean,
onNew?: (notification: NotificationDto) => void,
) {
const qc = useQueryClient();
const onNewRef = useRef(onNew);
onNewRef.current = onNew;
useEffect(() => {
if (!enabled) return;
@@ -37,7 +42,8 @@ export function useNotificationSocket(enabled: boolean) {
socket.on(NOTIFICATION_WS_EVENTS.NEW, (n: NotificationDto) => {
qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY });
toast(n.title);
qc.invalidateQueries({ queryKey: UNREAD_KEY });
onNewRef.current?.(n);
});
socket.on(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, (count: number) => {

View File

@@ -1,10 +1,12 @@
import type { NotificationListResult } from "@edr/types";
import type { NotificationDto, NotificationListResult } from "@edr/types";
import {
NotificationBell,
NotificationDrawer,
NotificationToast,
type NotificationItemData,
} from "@edr/ui-common";
import { useState } from "react";
import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import {
@@ -19,22 +21,25 @@ import {
} from "./useNotifications";
import { useNotificationSocket } from "./useNotificationSocket";
/** Map a server notification into the shared presentational item shape. */
function toItem(n: NotificationDto): NotificationItemData {
return {
id: n.id,
type: n.type,
title: n.title,
body: n.body,
createdAt: n.createdAt,
isRead: n.isRead,
link: n.link,
data: n.data,
};
}
/** 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,
}));
return (data?.pages ?? []).flatMap((p) => p.items).map(toItem);
}
/**
@@ -55,7 +60,6 @@ export default function NotificationBellContainer({
const unread = useUnreadCount(enabled);
const markRead = useMarkRead();
const markAllRead = useMarkAllRead();
useNotificationSocket(enabled);
const unreadItems = toItems(unreadQ.data);
const readItems = toItems(readQ.data);
@@ -68,6 +72,26 @@ export default function NotificationBellContainer({
if (href) navigate(href);
};
// Live push → rich toast that reuses the same registry + click action.
useNotificationSocket(enabled, (n) => {
const item = toItem(n);
toast.custom(
(t) => (
<NotificationToast
item={item}
visual={resolveNotificationVisual(item)}
visible={t.visible}
onClick={() => {
toast.dismiss(t.id);
handleItemClick(item);
}}
onDismiss={() => toast.dismiss(t.id)}
/>
),
{ duration: 6000 },
);
});
return (
<>
<NotificationBell

View File

@@ -4,8 +4,7 @@ import {
type NotificationDto,
} from "@edr/types";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
import toast from "react-hot-toast";
import { useEffect, useRef } from "react";
import { io } from "socket.io-client";
import { API_BASE_URL } from "@/constants/apiConfig";
@@ -25,10 +24,16 @@ const SOCKET_ORIGIN = String(API_BASE_URL ?? "").replace(/\/api\/?$/, "");
/**
* Subscribes to live notification pushes for the signed-in user. New items
* invalidate the list + toast; unread-count pushes update the badge instantly.
* invalidate the cached lists + fire `onNew` (the host shows a rich toast);
* unread-count pushes update the badge instantly.
*/
export function useNotificationSocket(enabled: boolean) {
export function useNotificationSocket(
enabled: boolean,
onNew?: (notification: NotificationDto) => void,
) {
const qc = useQueryClient();
const onNewRef = useRef(onNew);
onNewRef.current = onNew;
useEffect(() => {
if (!enabled) return;
@@ -43,7 +48,8 @@ export function useNotificationSocket(enabled: boolean) {
socket.on(NOTIFICATION_WS_EVENTS.NEW, (n: NotificationDto) => {
qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY });
toast(n.title);
qc.invalidateQueries({ queryKey: UNREAD_KEY });
onNewRef.current?.(n);
});
socket.on(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, (count: number) => {

View File

@@ -0,0 +1,141 @@
import { ActionIcon, Box, Group, Stack, Text, ThemeIcon } from "@mantine/core";
import { Bell, ChevronRight, X } from "lucide-react";
import { timeAgo } from "./timeAgo";
import type { NotificationItemData, NotificationVisual } from "./types";
export interface NotificationToastProps {
item: NotificationItemData;
/** Resolved by the hosting app's registry from `item.type`/`item.data`. */
visual?: NotificationVisual;
/** react-hot-toast enter/exit flag; drives the slide/fade animation. */
visible?: boolean;
/** Whole-card click (usually: mark read + navigate). Shows a chevron affordance. */
onClick?: () => void;
onDismiss?: () => void;
width?: number;
}
/**
* 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`.
*/
export function NotificationToast({
item,
visual,
visible = true,
onClick,
onDismiss,
width = 380,
}: NotificationToastProps) {
const color = visual?.color ?? "blue";
const icon = visual?.icon ?? <Bell size={18} strokeWidth={2} />;
const clickable = Boolean(onClick);
return (
<Box
style={{
position: "relative",
width,
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)",
pointerEvents: "auto",
transform: visible ? "translateX(0)" : "translateX(16px)",
opacity: visible ? 1 : 0,
transition: "transform 180ms ease, opacity 180ms ease",
}}
>
{/* colored accent rail */}
<Box
aria-hidden
style={{
width: 4,
flexShrink: 0,
background: `var(--mantine-color-${color}-6)`,
}}
/>
<Box
role={clickable ? "button" : undefined}
tabIndex={clickable ? 0 : undefined}
onClick={onClick}
onKeyDown={(e) => {
if (clickable && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
onClick?.();
}
}}
p="sm"
style={{ flex: 1, minWidth: 0, cursor: clickable ? "pointer" : "default" }}
>
<Group align="flex-start" wrap="nowrap" gap="sm">
<ThemeIcon
variant="light"
color={color}
radius="xl"
size={40}
style={{ flexShrink: 0 }}
>
{icon}
</ThemeIcon>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Text
fw={600}
size="sm"
lineClamp={1}
pr={onDismiss ? 20 : 0}
>
{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" }}>
{timeAgo(item.createdAt)}
</Text>
{clickable && (
<Group gap={2} wrap="nowrap">
<Text size="xs" fw={600} c={`${color}.6`}>
View details
</Text>
<ChevronRight
size={13}
color={`var(--mantine-color-${color}-6)`}
/>
</Group>
)}
</Group>
</Stack>
</Group>
</Box>
{onDismiss && (
<ActionIcon
variant="subtle"
color="gray"
size="sm"
radius="xl"
aria-label="Dismiss"
onClick={(e) => {
e.stopPropagation();
onDismiss();
}}
style={{ position: "absolute", top: 6, right: 6 }}
>
<X size={14} />
</ActionIcon>
)}
</Box>
);
}
export default NotificationToast;

View File

@@ -7,6 +7,9 @@ export type { NotificationItemProps } from "./NotificationItem";
export { NotificationDrawer } from "./NotificationDrawer";
export type { NotificationDrawerProps } from "./NotificationDrawer";
export { NotificationToast } from "./NotificationToast";
export type { NotificationToastProps } from "./NotificationToast";
export type {
NotificationItemData,
NotificationVisual,

View File

@@ -32,11 +32,13 @@ export {
NotificationBell,
NotificationItem,
NotificationDrawer,
NotificationToast,
} from "./components/NotificationBell";
export type {
NotificationBellProps,
NotificationItemProps,
NotificationDrawerProps,
NotificationToastProps,
NotificationItemData,
NotificationVisual,
ResolveNotificationVisual,