diff --git a/apps/edr-freight-web/portal/src/components/AppLayout.tsx b/apps/edr-freight-web/portal/src/components/AppLayout.tsx index 901c492c8..35641f9fc 100644 --- a/apps/edr-freight-web/portal/src/components/AppLayout.tsx +++ b/apps/edr-freight-web/portal/src/components/AppLayout.tsx @@ -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} + {/* Floating customer-support chat launcher. */} + + {/* Create-profile modal β opens when switching to a mode the company doesn't have a profile for yet. */} 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(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 ( + + + + + + + + + Support + + + We usually reply within a few minutes + + + + + + + + + + {isLoading ? ( + + + + ) : isEmpty ? ( + + + + + + Send us a message and our team will help you out. + + + ) : ( + + {(messages ?? []).map((m) => ( + + ))} + + )} + + + + + 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(); + } + }} + /> + + + + + + + ); +} + +function MessageBubble({ m }: { m: SupportMessageDto }) { + const mine = m.authorRole === SupportAuthorRole.CUSTOMER; + return ( + + {!mine && ( + + + + )} + + {!mine && ( + + {m.authorName || "Support agent"} + + )} + + + {m.body} + + + + {formatTime(m.createdAt)} + + + + ); +} + +export default SupportPanel; diff --git a/apps/edr-freight-web/portal/src/features/support/SupportWidget.tsx b/apps/edr-freight-web/portal/src/features/support/SupportWidget.tsx new file mode 100644 index 000000000..e525c2496 --- /dev/null +++ b/apps/edr-freight-web/portal/src/features/support/SupportWidget.tsx @@ -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 ( + + + {(styles) => ( + + setOpen(false)} /> + + )} + + + + {(styles) => ( + + 9 ? "9+" : unread} + size={20} + offset={8} + color="red" + disabled={unread === 0} + processing + withBorder + > + 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 && ( + + )} + + + + + + + + π Need help? + + + Chat with our team + + + + + + )} + + + ); +} + +export default SupportWidget; diff --git a/apps/edr-freight-web/portal/src/features/support/supportApi.ts b/apps/edr-freight-web/portal/src/features/support/supportApi.ts new file mode 100644 index 000000000..f04c73545 --- /dev/null +++ b/apps/edr-freight-web/portal/src/features/support/supportApi.ts @@ -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 => { + const { data } = await client.get("/api/support/conversation"); + return data.data; + }, + listMessages: async (): Promise => { + const { data } = await client.get("/api/support/conversation/messages"); + return data.data; + }, + sendMessage: async (body: string): Promise => { + 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 => { + const { data } = await client.get("/api/support/unread-count"); + return data.data.unreadCount; + }, +}; diff --git a/apps/edr-freight-web/portal/src/features/support/useSupport.ts b/apps/edr-freight-web/portal/src/features/support/useSupport.ts new file mode 100644 index 000000000..d89a8f701 --- /dev/null +++ b/apps/edr-freight-web/portal/src/features/support/useSupport.ts @@ -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 }; diff --git a/apps/edr-freight-web/portal/src/features/support/useSupportSocket.ts b/apps/edr-freight-web/portal/src/features/support/useSupportSocket.ts new file mode 100644 index 000000000..0e5c578c8 --- /dev/null +++ b/apps/edr-freight-web/portal/src/features/support/useSupportSocket.ts @@ -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]); +}