mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
feat: add support to freight portal
This commit is contained in:
@@ -35,6 +35,7 @@ import {
|
||||
import { type CSSProperties, Fragment, type ReactNode, useState } from "react";
|
||||
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
|
||||
import NotificationBellContainer from "@/features/notifications/NotificationBellContainer";
|
||||
import SupportWidget from "@/features/support/SupportWidget";
|
||||
|
||||
export interface SidebarItem {
|
||||
label: string;
|
||||
@@ -781,6 +782,9 @@ export function AppLayout({
|
||||
{children}
|
||||
</AppShell.Main>
|
||||
|
||||
{/* Floating customer-support chat launcher. */}
|
||||
<SupportWidget />
|
||||
|
||||
{/* Create-profile modal — opens when switching to a mode the company
|
||||
doesn't have a profile for yet. */}
|
||||
<Modal
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { SupportAuthorRole, type SupportMessageDto } from "@edr/types";
|
||||
import {
|
||||
ActionIcon,
|
||||
Avatar,
|
||||
Box,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { Headset, Send, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
useConversation,
|
||||
useMarkConversationRead,
|
||||
useMessages,
|
||||
useSendMessage,
|
||||
} from "./useSupport";
|
||||
|
||||
function formatTime(iso?: string | null): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
const sameDay = d.toDateString() === now.toDateString();
|
||||
return sameDay
|
||||
? d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
: d.toLocaleDateString([], { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
export interface SupportPanelProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole support surface: one ongoing thread with the EDR team. There is
|
||||
* nothing to pick and nothing to open — the company has a single conversation,
|
||||
* created server-side the moment the first message is sent.
|
||||
*/
|
||||
export function SupportPanel({ onClose }: SupportPanelProps) {
|
||||
const { data: conversation } = useConversation();
|
||||
const { data: messages, isLoading } = useMessages();
|
||||
const send = useSendMessage();
|
||||
const markRead = useMarkConversationRead();
|
||||
const [draft, setDraft] = useState("");
|
||||
const viewport = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Mark read on open + whenever new messages arrive. No-op before the thread
|
||||
// exists, so opening the panel never creates one.
|
||||
useEffect(() => {
|
||||
if (conversation) markRead.mutate();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [conversation?.id, messages?.length]);
|
||||
|
||||
// Auto-scroll to newest.
|
||||
useEffect(() => {
|
||||
viewport.current?.scrollTo({ top: viewport.current.scrollHeight });
|
||||
}, [messages?.length]);
|
||||
|
||||
const submit = async () => {
|
||||
const body = draft.trim();
|
||||
if (!body) return;
|
||||
setDraft("");
|
||||
await send.mutateAsync(body);
|
||||
};
|
||||
|
||||
const isEmpty = !isLoading && (messages ?? []).length === 0;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
shadow="xl"
|
||||
radius="lg"
|
||||
withBorder
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: 384,
|
||||
height: 560,
|
||||
maxHeight: "calc(100vh - 120px)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Group
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
px="md"
|
||||
py="sm"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(135deg, var(--mantine-color-edr-green-7), var(--mantine-color-edr-green-5))",
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon variant="white" radius="xl" size="lg" color="edr-green">
|
||||
<Headset size={18} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fw={600} size="sm" truncate>
|
||||
Support
|
||||
</Text>
|
||||
<Text size="xs" opacity={0.85} truncate>
|
||||
We usually reply within a few minutes
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<ActionIcon variant="transparent" color="white" onClick={onClose}>
|
||||
<X size={20} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
|
||||
<ScrollArea style={{ flex: 1 }} viewportRef={viewport} type="hover">
|
||||
{isLoading ? (
|
||||
<Group justify="center" p="xl">
|
||||
<Loader size="sm" color="edr-green" />
|
||||
</Group>
|
||||
) : isEmpty ? (
|
||||
<Stack align="center" gap="xs" px="lg" py={48} c="dimmed">
|
||||
<ThemeIcon variant="light" color="edr-green" radius="xl" size={48}>
|
||||
<Headset size={24} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" ta="center">
|
||||
Send us a message and our team will help you out.
|
||||
</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack gap="sm" p="md">
|
||||
{(messages ?? []).map((m) => (
|
||||
<MessageBubble key={m.id} m={m} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
<Box p="sm" style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}>
|
||||
<Group gap="xs" align="flex-end" wrap="nowrap">
|
||||
<Textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.currentTarget.value)}
|
||||
placeholder="Type a message…"
|
||||
autosize
|
||||
minRows={1}
|
||||
maxRows={4}
|
||||
radius="md"
|
||||
style={{ flex: 1 }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ActionIcon
|
||||
size={38}
|
||||
radius="md"
|
||||
color="edr-green"
|
||||
variant="filled"
|
||||
loading={send.isPending}
|
||||
disabled={!draft.trim()}
|
||||
onClick={submit}
|
||||
>
|
||||
<Send size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageBubble({ m }: { m: SupportMessageDto }) {
|
||||
const mine = m.authorRole === SupportAuthorRole.CUSTOMER;
|
||||
return (
|
||||
<Group
|
||||
justify={mine ? "flex-end" : "flex-start"}
|
||||
wrap="nowrap"
|
||||
align="flex-end"
|
||||
gap="xs"
|
||||
>
|
||||
{!mine && (
|
||||
<Avatar size="sm" radius="xl" color="edr-green" variant="filled">
|
||||
<Headset size={14} />
|
||||
</Avatar>
|
||||
)}
|
||||
<Box style={{ maxWidth: "78%" }}>
|
||||
{!mine && (
|
||||
<Text size="xs" c="dimmed" mb={2} ml={4}>
|
||||
{m.authorName || "Support agent"}
|
||||
</Text>
|
||||
)}
|
||||
<Paper
|
||||
px="sm"
|
||||
py={8}
|
||||
radius="lg"
|
||||
style={{
|
||||
background: mine
|
||||
? "var(--mantine-color-edr-green-6)"
|
||||
: "var(--mantine-color-gray-1)",
|
||||
color: mine ? "white" : "var(--mantine-color-dark-7)",
|
||||
borderBottomRightRadius: mine ? 4 : undefined,
|
||||
borderBottomLeftRadius: mine ? undefined : 4,
|
||||
}}
|
||||
>
|
||||
<Text size="sm" style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
|
||||
{m.body}
|
||||
</Text>
|
||||
</Paper>
|
||||
<Text size="10px" c="dimmed" mt={2} ta={mine ? "right" : "left"}>
|
||||
{formatTime(m.createdAt)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default SupportPanel;
|
||||
@@ -0,0 +1,91 @@
|
||||
import { SupportAuthorRole } from "@edr/types";
|
||||
import { Affix, Indicator, Transition } from "@mantine/core";
|
||||
import { Headset } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
|
||||
import { SupportPanel } from "./SupportPanel";
|
||||
import { useSupportUnreadCount } from "./useSupport";
|
||||
import { useSupportSocket } from "./useSupportSocket";
|
||||
|
||||
/**
|
||||
* Floating customer-support launcher, mounted in the authenticated app shell.
|
||||
* Shows an unread badge and opens the chat panel; live pushes keep the badge
|
||||
* fresh and toast the customer when the panel is closed.
|
||||
*/
|
||||
export function SupportWidget() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { data: unread = 0 } = useSupportUnreadCount(isAuthenticated);
|
||||
|
||||
useSupportSocket(isAuthenticated, (event) => {
|
||||
// Only the customer-visible side matters here; agent replies arrive as AGENT.
|
||||
if (!open && event.message.authorRole === SupportAuthorRole.AGENT) {
|
||||
toast(
|
||||
`Support replied: ${event.message.body.slice(0, 60)}${
|
||||
event.message.body.length > 60 ? "…" : ""
|
||||
}`,
|
||||
{ icon: "💬" },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (!isAuthenticated) return null;
|
||||
|
||||
return (
|
||||
<Affix position={{ bottom: 24, right: 24 }} zIndex={300}>
|
||||
<Transition mounted={open} transition="pop-bottom-right" duration={200}>
|
||||
{(styles) => (
|
||||
<div style={styles} className="mb-3">
|
||||
<SupportPanel onClose={() => setOpen(false)} />
|
||||
</div>
|
||||
)}
|
||||
</Transition>
|
||||
|
||||
<Transition mounted={!open} transition="pop" duration={150}>
|
||||
{(styles) => (
|
||||
<div style={styles} className="flex justify-end">
|
||||
<Indicator
|
||||
label={unread > 9 ? "9+" : unread}
|
||||
size={20}
|
||||
offset={8}
|
||||
color="red"
|
||||
disabled={unread === 0}
|
||||
processing
|
||||
withBorder
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Open support chat"
|
||||
onClick={() => setOpen(true)}
|
||||
className="group relative flex cursor-pointer items-center gap-2.5 rounded-full bg-linear-135 from-edr-primary-dark to-edr-primary py-2 pr-2 pl-2 text-white shadow-[0_10px_28px_rgba(13,92,44,0.4)] transition-transform duration-150 hover:-translate-y-0.5 focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-edr-primary active:translate-y-0 motion-reduce:transition-none motion-reduce:hover:translate-y-0 sm:pr-5"
|
||||
>
|
||||
{/* Faint breathing ring; the unread badge already pulses, so stand down then. */}
|
||||
{unread === 0 && (
|
||||
<span className="pointer-events-none absolute -inset-1 animate-pulse rounded-full ring-2 ring-edr-primary/50 motion-reduce:animate-none" />
|
||||
)}
|
||||
|
||||
<span className="relative grid size-[42px] shrink-0 place-items-center rounded-full bg-white/20">
|
||||
<Headset size={22} />
|
||||
</span>
|
||||
|
||||
<span className="relative hidden text-left leading-tight sm:block">
|
||||
<span className="block text-sm font-semibold whitespace-nowrap">
|
||||
👋 Need help?
|
||||
</span>
|
||||
<span className="block text-[11px] whitespace-nowrap opacity-85">
|
||||
Chat with our team
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</Indicator>
|
||||
</div>
|
||||
)}
|
||||
</Transition>
|
||||
</Affix>
|
||||
);
|
||||
}
|
||||
|
||||
export default SupportWidget;
|
||||
@@ -0,0 +1,41 @@
|
||||
import type {
|
||||
SendSupportMessageResult,
|
||||
SupportConversationDto,
|
||||
SupportMessageDto,
|
||||
} from "@edr/types";
|
||||
|
||||
import { client } from "@/utils/api";
|
||||
|
||||
/**
|
||||
* Portal support-chat REST calls. My company has exactly one thread, so nothing
|
||||
* here takes a conversation id — the server derives it from the caller.
|
||||
*
|
||||
* The portal axios `client` returns the raw response and the API wraps payloads
|
||||
* in a `{ success, data }` envelope, so we unwrap `.data.data` (same convention
|
||||
* as the other portal services).
|
||||
*/
|
||||
export const supportApi = {
|
||||
/** Null until someone has actually sent a message. */
|
||||
getConversation: async (): Promise<SupportConversationDto | null> => {
|
||||
const { data } = await client.get("/api/support/conversation");
|
||||
return data.data;
|
||||
},
|
||||
listMessages: async (): Promise<SupportMessageDto[]> => {
|
||||
const { data } = await client.get("/api/support/conversation/messages");
|
||||
return data.data;
|
||||
},
|
||||
sendMessage: async (body: string): Promise<SendSupportMessageResult> => {
|
||||
const { data } = await client.post("/api/support/conversation/messages", {
|
||||
body,
|
||||
});
|
||||
return data.data;
|
||||
},
|
||||
markRead: async (): Promise<{ unreadCount: number }> => {
|
||||
const { data } = await client.post("/api/support/conversation/read");
|
||||
return data.data;
|
||||
},
|
||||
unreadCount: async (): Promise<number> => {
|
||||
const { data } = await client.get("/api/support/unread-count");
|
||||
return data.data.unreadCount;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { SupportConversationDto } from "@edr/types";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { supportApi } from "./supportApi";
|
||||
|
||||
export const SUPPORT_KEY = ["support"] as const;
|
||||
export const SUPPORT_CONVERSATION_KEY = ["support", "conversation"] as const;
|
||||
export const SUPPORT_MESSAGES_KEY = ["support", "messages"] as const;
|
||||
export const SUPPORT_UNREAD_KEY = ["support", "unread"] as const;
|
||||
|
||||
/** My company's support thread — null until the first message is sent. */
|
||||
export function useConversation(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: SUPPORT_CONVERSATION_KEY,
|
||||
queryFn: () => supportApi.getConversation(),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
/** The thread's messages, oldest first. Empty until the thread exists. */
|
||||
export function useMessages(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: SUPPORT_MESSAGES_KEY,
|
||||
queryFn: () => supportApi.listMessages(),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSupportUnreadCount(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: SUPPORT_UNREAD_KEY,
|
||||
queryFn: () => supportApi.unreadCount(),
|
||||
enabled,
|
||||
// WebSocket keeps this fresh; poll as a fallback if the socket drops.
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Send as the customer. Opens the thread server-side if this is the first one. */
|
||||
export function useSendMessage() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: string) => supportApi.sendMessage(body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: SUPPORT_MESSAGES_KEY });
|
||||
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATION_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarkConversationRead() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => supportApi.markRead(),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATION_KEY });
|
||||
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type { SupportConversationDto };
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
SUPPORT_CHAT_WS_EVENTS,
|
||||
SUPPORT_CHAT_WS_NAMESPACE,
|
||||
type SupportConversationDto,
|
||||
type SupportMessageEvent,
|
||||
} from "@edr/types";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { io } from "socket.io-client";
|
||||
|
||||
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||
|
||||
import {
|
||||
SUPPORT_CONVERSATION_KEY,
|
||||
SUPPORT_MESSAGES_KEY,
|
||||
SUPPORT_UNREAD_KEY,
|
||||
} from "./useSupport";
|
||||
|
||||
function getAuthToken(): string | undefined {
|
||||
return document.cookie
|
||||
.split("; ")
|
||||
.find((row) => row.startsWith("auth-token="))
|
||||
?.split("=")[1];
|
||||
}
|
||||
|
||||
// The socket namespace lives at the server root, not under the `/api` REST
|
||||
// prefix — strip a trailing `/api` if the base URL carries one.
|
||||
const SOCKET_ORIGIN = String(API_BASE_URL ?? "").replace(/\/api\/?$/, "");
|
||||
|
||||
/**
|
||||
* Subscribes to live support-chat pushes for the signed-in customer. The company
|
||||
* only has one thread, so any push refreshes it wholesale — messages, the
|
||||
* conversation itself, and the unread badge — and fires `onMessage` (the widget
|
||||
* shows a toast when the panel is closed).
|
||||
*/
|
||||
export function useSupportSocket(
|
||||
enabled: boolean,
|
||||
onMessage?: (event: SupportMessageEvent) => void,
|
||||
) {
|
||||
const qc = useQueryClient();
|
||||
const onMessageRef = useRef(onMessage);
|
||||
onMessageRef.current = onMessage;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const token = getAuthToken();
|
||||
if (!token) return;
|
||||
|
||||
const socket = io(`${SOCKET_ORIGIN}/${SUPPORT_CHAT_WS_NAMESPACE}`, {
|
||||
auth: { token },
|
||||
transports: ["websocket"],
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
socket.on(SUPPORT_CHAT_WS_EVENTS.MESSAGE_NEW, (event: SupportMessageEvent) => {
|
||||
qc.invalidateQueries({ queryKey: SUPPORT_MESSAGES_KEY });
|
||||
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATION_KEY });
|
||||
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
|
||||
onMessageRef.current?.(event);
|
||||
});
|
||||
|
||||
socket.on(
|
||||
SUPPORT_CHAT_WS_EVENTS.CONVERSATION_UPDATED,
|
||||
(_conversation: SupportConversationDto) => {
|
||||
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATION_KEY });
|
||||
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
socket.off();
|
||||
socket.disconnect();
|
||||
};
|
||||
}, [enabled, qc]);
|
||||
}
|
||||
Reference in New Issue
Block a user