diff --git a/apps/edr-freight-web/backoffice/src/features/support/supportApi.ts b/apps/edr-freight-web/backoffice/src/features/support/supportApi.ts new file mode 100644 index 000000000..8047fc822 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/features/support/supportApi.ts @@ -0,0 +1,70 @@ +import type { + SendSupportMessageDto, + SupportConversationDto, + SupportConversationListResult, + SupportMessageDto, +} from "@edr/types"; + +import { api } from "@/auth/http"; + +export interface ListConversationsParams { + search?: string; + unreadOnly?: boolean; + page?: number; + limit?: number; +} + +/** + * Backoffice (agent) support-chat REST calls. The backoffice axios `api` + * response interceptor already unwraps the `{ success, data }` envelope, so + * `.data` here is the payload itself. + */ +export const supportApi = { + listConversations: async ( + params: ListConversationsParams = {}, + ): Promise => { + const { data } = await api.get( + "/support/agent/conversations", + { params }, + ); + return data; + }, + listMessages: async (id: string): Promise => { + const { data } = await api.get( + `/support/agent/conversations/${id}/messages`, + ); + return data; + }, + sendMessage: async ( + id: string, + body: SendSupportMessageDto, + ): Promise => { + const { data } = await api.post( + `/support/agent/conversations/${id}/messages`, + body, + ); + return data; + }, + /** Open the thread with a company, or hand back the existing one. */ + startConversation: async ( + companyId: string, + ): Promise => { + const { data } = await api.post( + "/support/agent/conversations", + { companyId }, + ); + return data; + }, + markRead: async (id: string): Promise<{ unreadCount: number }> => { + const { data } = await api.post<{ unreadCount: number }>( + `/support/agent/conversations/${id}/read`, + ); + return data; + }, + unreadCount: async (): Promise => { + const { data } = await api.get<{ unreadCount: number }>( + "/support/agent/unread-count", + ); + return data.unreadCount; + }, +}; diff --git a/apps/edr-freight-web/backoffice/src/features/support/useSupport.ts b/apps/edr-freight-web/backoffice/src/features/support/useSupport.ts new file mode 100644 index 000000000..da152191e --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/features/support/useSupport.ts @@ -0,0 +1,71 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { supportApi, type ListConversationsParams } from "./supportApi"; + +export const SUPPORT_KEY = ["support"] as const; +export const SUPPORT_CONVERSATIONS_KEY = ["support", "conversations"] as const; +export const SUPPORT_UNREAD_KEY = ["support", "unread"] as const; +export const supportMessagesKey = (id: string) => + ["support", "messages", id] as const; + +/** Shared inbox: every thread, filterable by unread + company-name search. */ +export function useConversations(params: ListConversationsParams = {}) { + return useQuery({ + queryKey: [...SUPPORT_CONVERSATIONS_KEY, params], + queryFn: () => supportApi.listConversations({ limit: 100, ...params }), + }); +} + +export function useMessages(conversationId: string | null) { + return useQuery({ + queryKey: supportMessagesKey(conversationId ?? ""), + queryFn: () => supportApi.listMessages(conversationId as string), + enabled: !!conversationId, + }); +} + +export function useSupportUnreadCount(enabled = true) { + return useQuery({ + queryKey: SUPPORT_UNREAD_KEY, + queryFn: () => supportApi.unreadCount(), + enabled, + refetchInterval: 60_000, + }); +} + +export function useSendMessage(conversationId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (body: string) => + supportApi.sendMessage(conversationId, { body }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: supportMessagesKey(conversationId) }); + qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY }); + }, + }); +} + +/** + * Start chatting with a company. Idempotent server-side, so picking a company + * that already has a thread just selects it. + */ +export function useStartConversation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (companyId: string) => supportApi.startConversation(companyId), + onSuccess: () => { + qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY }); + }, + }); +} + +export function useMarkConversationRead() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => supportApi.markRead(id), + onSuccess: () => { + qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY }); + qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY }); + }, + }); +} diff --git a/apps/edr-freight-web/backoffice/src/features/support/useSupportSocket.ts b/apps/edr-freight-web/backoffice/src/features/support/useSupportSocket.ts new file mode 100644 index 000000000..574ef7b5c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/features/support/useSupportSocket.ts @@ -0,0 +1,70 @@ +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 { AUTH_TOKEN_COOKIE, getCookie } from "@/auth/cookies"; +import { API_BASE_URL } from "@/constants/apiConfig"; + +import { + SUPPORT_CONVERSATIONS_KEY, + SUPPORT_UNREAD_KEY, + supportMessagesKey, +} from "./useSupport"; + +// 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 the signed-in agent to live support-chat pushes for the whole + * shared inbox. Any new message or conversation change refreshes the affected + * thread, the inbox list, and the unread badge; `onMessage` fires for toasts. + */ +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 = getCookie(AUTH_TOKEN_COOKIE); + 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: supportMessagesKey(event.message.conversationId), + }); + qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY }); + qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY }); + onMessageRef.current?.(event); + }); + + socket.on( + SUPPORT_CHAT_WS_EVENTS.CONVERSATION_UPDATED, + (_conversation: SupportConversationDto) => { + qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY }); + qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY }); + }, + ); + + return () => { + socket.off(); + socket.disconnect(); + }; + }, [enabled, qc]); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/support/SupportInboxPage.tsx b/apps/edr-freight-web/backoffice/src/pages/support/SupportInboxPage.tsx new file mode 100644 index 000000000..861b3bee7 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/support/SupportInboxPage.tsx @@ -0,0 +1,459 @@ +import { + SupportAuthorRole, + type SupportConversationDto, + type SupportMessageDto, +} from "@edr/types"; +import { + ActionIcon, + Avatar, + Badge, + Box, + Button, + Group, + Loader, + Modal, + Paper, + ScrollArea, + SegmentedControl, + Select, + Stack, + Text, + Textarea, + TextInput, + ThemeIcon, +} from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { Building2, Headset, Plus, Search, Send, User } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import toast from "react-hot-toast"; + +import { + useConversations, + useMarkConversationRead, + useMessages, + useSendMessage, + useStartConversation, +} from "@/features/support/useSupport"; +import { useSupportSocket } from "@/features/support/useSupportSocket"; +import { customersService } from "@/services/customers.service"; + +type ReadFilter = "ALL" | "UNREAD"; + +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 default function SupportInboxPage() { + const [readFilter, setReadFilter] = useState("ALL"); + const [search, setSearch] = useState(""); + const [selectedId, setSelectedId] = useState(null); + const [pickerOpen, setPickerOpen] = useState(false); + + const { data, isLoading } = useConversations({ + search, + unreadOnly: readFilter === "UNREAD", + }); + const items = data?.items ?? []; + + useSupportSocket(true, (event) => { + if (event.message.authorRole === SupportAuthorRole.CUSTOMER) { + toast( + `New message from ${event.conversation.companyName ?? "a customer"}`, + { icon: "💬" }, + ); + } + }); + + const selected = useMemo( + () => items.find((c) => c.id === selectedId) ?? null, + [items, selectedId], + ); + + return ( + + + + + + + + Customer Support + + + Shared inbox — chat with customers in real time + + + + + + {/* ── Conversation list ── */} + + + + } + value={search} + onChange={(e) => setSearch(e.currentTarget.value)} + radius="md" + mb="sm" + /> + setReadFilter(v as ReadFilter)} + data={[ + { label: "All", value: "ALL" }, + { label: "Unread", value: "UNREAD" }, + ]} + /> + + + {isLoading ? ( + + + + ) : items.length === 0 ? ( + + {readFilter === "UNREAD" ? "Nothing unread." : "No conversations."} + + ) : ( + items.map((c) => ( + setSelectedId(c.id)} + /> + )) + )} + + + + {/* ── Thread ── */} + + {selected ? ( + + ) : ( + + + + + Select a conversation, or start a new chat. + + )} + + + + setPickerOpen(false)} + onStarted={(id) => { + setSelectedId(id); + setPickerOpen(false); + }} + /> + + ); +} + +/** + * Pick a company to chat with. Starting is idempotent server-side, so choosing a + * company that already has a thread simply selects it rather than erroring. + */ +function CompanyPicker({ + opened, + onClose, + onStarted, +}: { + opened: boolean; + onClose: () => void; + onStarted: (conversationId: string) => void; +}) { + const [companyId, setCompanyId] = useState(null); + const start = useStartConversation(); + + const { data: companies, isLoading } = useQuery({ + queryKey: ["companies", "list"], + queryFn: () => customersService.list({ page: 1, pageSize: 1000 }), + enabled: opened, + }); + + const options = useMemo( + () => + (companies?.items ?? []).map((c) => ({ + value: c.id, + label: c.name || c.email || c.tin || c.id, + })), + [companies], + ); + + const submit = async () => { + if (!companyId) return; + const conversation = await start.mutateAsync(companyId); + setCompanyId(null); + onStarted(conversation.id); + }; + + return ( + + +