mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 16:35:42 +00:00
style: improve the ui of the toast for notifications
This commit is contained in:
@@ -1,10 +1,12 @@
|
|||||||
import type { NotificationListResult } from "@edr/types";
|
import type { NotificationDto, NotificationListResult } from "@edr/types";
|
||||||
import {
|
import {
|
||||||
NotificationBell,
|
NotificationBell,
|
||||||
NotificationDrawer,
|
NotificationDrawer,
|
||||||
|
NotificationToast,
|
||||||
type NotificationItemData,
|
type NotificationItemData,
|
||||||
} from "@edr/ui-common";
|
} from "@edr/ui-common";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -19,22 +21,25 @@ import {
|
|||||||
} from "./useNotifications";
|
} from "./useNotifications";
|
||||||
import { useNotificationSocket } from "./useNotificationSocket";
|
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. */
|
/** Flatten an infinite query's pages into the drawer's item shape. */
|
||||||
function toItems(
|
function toItems(
|
||||||
data: { pages: NotificationListResult[] } | undefined,
|
data: { pages: NotificationListResult[] } | undefined,
|
||||||
): NotificationItemData[] {
|
): NotificationItemData[] {
|
||||||
return (data?.pages ?? [])
|
return (data?.pages ?? []).flatMap((p) => p.items).map(toItem);
|
||||||
.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,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,7 +60,6 @@ export default function NotificationBellContainer({
|
|||||||
const unread = useUnreadCount(enabled);
|
const unread = useUnreadCount(enabled);
|
||||||
const markRead = useMarkRead();
|
const markRead = useMarkRead();
|
||||||
const markAllRead = useMarkAllRead();
|
const markAllRead = useMarkAllRead();
|
||||||
useNotificationSocket(enabled);
|
|
||||||
|
|
||||||
const unreadItems = toItems(unreadQ.data);
|
const unreadItems = toItems(unreadQ.data);
|
||||||
const readItems = toItems(readQ.data);
|
const readItems = toItems(readQ.data);
|
||||||
@@ -68,6 +72,26 @@ export default function NotificationBellContainer({
|
|||||||
if (href) navigate(href);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<NotificationBell
|
<NotificationBell
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import {
|
|||||||
type NotificationDto,
|
type NotificationDto,
|
||||||
} from "@edr/types";
|
} from "@edr/types";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import toast from "react-hot-toast";
|
|
||||||
import { io } from "socket.io-client";
|
import { io } from "socket.io-client";
|
||||||
|
|
||||||
import { API_BASE_URL } from "@/constants/apiConfig";
|
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
|
* 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 qc = useQueryClient();
|
||||||
|
const onNewRef = useRef(onNew);
|
||||||
|
onNewRef.current = onNew;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
@@ -37,7 +42,8 @@ export function useNotificationSocket(enabled: boolean) {
|
|||||||
|
|
||||||
socket.on(NOTIFICATION_WS_EVENTS.NEW, (n: NotificationDto) => {
|
socket.on(NOTIFICATION_WS_EVENTS.NEW, (n: NotificationDto) => {
|
||||||
qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY });
|
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) => {
|
socket.on(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, (count: number) => {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import type { NotificationListResult } from "@edr/types";
|
import type { NotificationDto, NotificationListResult } from "@edr/types";
|
||||||
import {
|
import {
|
||||||
NotificationBell,
|
NotificationBell,
|
||||||
NotificationDrawer,
|
NotificationDrawer,
|
||||||
|
NotificationToast,
|
||||||
type NotificationItemData,
|
type NotificationItemData,
|
||||||
} from "@edr/ui-common";
|
} from "@edr/ui-common";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -19,22 +21,25 @@ import {
|
|||||||
} from "./useNotifications";
|
} from "./useNotifications";
|
||||||
import { useNotificationSocket } from "./useNotificationSocket";
|
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. */
|
/** Flatten an infinite query's pages into the drawer's item shape. */
|
||||||
function toItems(
|
function toItems(
|
||||||
data: { pages: NotificationListResult[] } | undefined,
|
data: { pages: NotificationListResult[] } | undefined,
|
||||||
): NotificationItemData[] {
|
): NotificationItemData[] {
|
||||||
return (data?.pages ?? [])
|
return (data?.pages ?? []).flatMap((p) => p.items).map(toItem);
|
||||||
.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,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,7 +60,6 @@ export default function NotificationBellContainer({
|
|||||||
const unread = useUnreadCount(enabled);
|
const unread = useUnreadCount(enabled);
|
||||||
const markRead = useMarkRead();
|
const markRead = useMarkRead();
|
||||||
const markAllRead = useMarkAllRead();
|
const markAllRead = useMarkAllRead();
|
||||||
useNotificationSocket(enabled);
|
|
||||||
|
|
||||||
const unreadItems = toItems(unreadQ.data);
|
const unreadItems = toItems(unreadQ.data);
|
||||||
const readItems = toItems(readQ.data);
|
const readItems = toItems(readQ.data);
|
||||||
@@ -68,6 +72,26 @@ export default function NotificationBellContainer({
|
|||||||
if (href) navigate(href);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<NotificationBell
|
<NotificationBell
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import {
|
|||||||
type NotificationDto,
|
type NotificationDto,
|
||||||
} from "@edr/types";
|
} from "@edr/types";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import toast from "react-hot-toast";
|
|
||||||
import { io } from "socket.io-client";
|
import { io } from "socket.io-client";
|
||||||
|
|
||||||
import { API_BASE_URL } from "@/constants/apiConfig";
|
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
|
* 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 qc = useQueryClient();
|
||||||
|
const onNewRef = useRef(onNew);
|
||||||
|
onNewRef.current = onNew;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
@@ -43,7 +48,8 @@ export function useNotificationSocket(enabled: boolean) {
|
|||||||
|
|
||||||
socket.on(NOTIFICATION_WS_EVENTS.NEW, (n: NotificationDto) => {
|
socket.on(NOTIFICATION_WS_EVENTS.NEW, (n: NotificationDto) => {
|
||||||
qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY });
|
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) => {
|
socket.on(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, (count: number) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -7,6 +7,9 @@ export type { NotificationItemProps } from "./NotificationItem";
|
|||||||
export { NotificationDrawer } from "./NotificationDrawer";
|
export { NotificationDrawer } from "./NotificationDrawer";
|
||||||
export type { NotificationDrawerProps } from "./NotificationDrawer";
|
export type { NotificationDrawerProps } from "./NotificationDrawer";
|
||||||
|
|
||||||
|
export { NotificationToast } from "./NotificationToast";
|
||||||
|
export type { NotificationToastProps } from "./NotificationToast";
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
NotificationItemData,
|
NotificationItemData,
|
||||||
NotificationVisual,
|
NotificationVisual,
|
||||||
|
|||||||
@@ -32,11 +32,13 @@ export {
|
|||||||
NotificationBell,
|
NotificationBell,
|
||||||
NotificationItem,
|
NotificationItem,
|
||||||
NotificationDrawer,
|
NotificationDrawer,
|
||||||
|
NotificationToast,
|
||||||
} from "./components/NotificationBell";
|
} from "./components/NotificationBell";
|
||||||
export type {
|
export type {
|
||||||
NotificationBellProps,
|
NotificationBellProps,
|
||||||
NotificationItemProps,
|
NotificationItemProps,
|
||||||
NotificationDrawerProps,
|
NotificationDrawerProps,
|
||||||
|
NotificationToastProps,
|
||||||
NotificationItemData,
|
NotificationItemData,
|
||||||
NotificationVisual,
|
NotificationVisual,
|
||||||
ResolveNotificationVisual,
|
ResolveNotificationVisual,
|
||||||
|
|||||||
Reference in New Issue
Block a user