feat: add support to freight portal

This commit is contained in:
Nathnael
2026-07-17 11:00:17 +00:00
parent f278756d76
commit e862a228d4
6 changed files with 492 additions and 0 deletions

View File

@@ -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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
},
};

View File

@@ -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 };

View File

@@ -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]);
}