mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: add support to freight backoffice
This commit is contained in:
@@ -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<SupportConversationListResult> => {
|
||||
const { data } = await api.get<SupportConversationListResult>(
|
||||
"/support/agent/conversations",
|
||||
{ params },
|
||||
);
|
||||
return data;
|
||||
},
|
||||
listMessages: async (id: string): Promise<SupportMessageDto[]> => {
|
||||
const { data } = await api.get<SupportMessageDto[]>(
|
||||
`/support/agent/conversations/${id}/messages`,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
sendMessage: async (
|
||||
id: string,
|
||||
body: SendSupportMessageDto,
|
||||
): Promise<SupportMessageDto> => {
|
||||
const { data } = await api.post<SupportMessageDto>(
|
||||
`/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<SupportConversationDto> => {
|
||||
const { data } = await api.post<SupportConversationDto>(
|
||||
"/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<number> => {
|
||||
const { data } = await api.get<{ unreadCount: number }>(
|
||||
"/support/agent/unread-count",
|
||||
);
|
||||
return data.unreadCount;
|
||||
},
|
||||
};
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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<ReadFilter>("ALL");
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedId, setSelectedId] = useState<string | null>(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 (
|
||||
<Box p="md">
|
||||
<Group mb="md" gap="sm">
|
||||
<ThemeIcon size="lg" radius="md" color="edr-green" variant="light">
|
||||
<Headset size={20} />
|
||||
</ThemeIcon>
|
||||
<Box>
|
||||
<Text fw={700} size="lg">
|
||||
Customer Support
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Shared inbox — chat with customers in real time
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
<Paper
|
||||
withBorder
|
||||
radius="lg"
|
||||
style={{
|
||||
display: "flex",
|
||||
height: "calc(100vh - 190px)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* ── Conversation list ── */}
|
||||
<Stack
|
||||
gap={0}
|
||||
style={{
|
||||
width: 340,
|
||||
flexShrink: 0,
|
||||
borderRight: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<Box p="sm" style={{ borderBottom: "1px solid var(--mantine-color-gray-2)" }}>
|
||||
<Button
|
||||
fullWidth
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
size="xs"
|
||||
leftSection={<Plus size={16} />}
|
||||
onClick={() => setPickerOpen(true)}
|
||||
mb="sm"
|
||||
>
|
||||
New chat
|
||||
</Button>
|
||||
<TextInput
|
||||
placeholder="Search company"
|
||||
leftSection={<Search size={16} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
radius="md"
|
||||
mb="sm"
|
||||
/>
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
size="xs"
|
||||
value={readFilter}
|
||||
onChange={(v) => setReadFilter(v as ReadFilter)}
|
||||
data={[
|
||||
{ label: "All", value: "ALL" },
|
||||
{ label: "Unread", value: "UNREAD" },
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
<ScrollArea style={{ flex: 1 }} type="hover">
|
||||
{isLoading ? (
|
||||
<Group justify="center" p="xl">
|
||||
<Loader size="sm" color="edr-green" />
|
||||
</Group>
|
||||
) : items.length === 0 ? (
|
||||
<Text c="dimmed" size="sm" ta="center" p="xl">
|
||||
{readFilter === "UNREAD" ? "Nothing unread." : "No conversations."}
|
||||
</Text>
|
||||
) : (
|
||||
items.map((c) => (
|
||||
<InboxRow
|
||||
key={c.id}
|
||||
c={c}
|
||||
active={c.id === selectedId}
|
||||
onClick={() => setSelectedId(c.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</ScrollArea>
|
||||
</Stack>
|
||||
|
||||
{/* ── Thread ── */}
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
{selected ? (
|
||||
<ConversationThread conversation={selected} />
|
||||
) : (
|
||||
<Stack align="center" justify="center" h="100%" c="dimmed" gap="xs">
|
||||
<ThemeIcon variant="light" color="edr-green" radius="xl" size={56}>
|
||||
<Headset size={28} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm">Select a conversation, or start a new chat.</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<CompanyPicker
|
||||
opened={pickerOpen}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
onStarted={(id) => {
|
||||
setSelectedId(id);
|
||||
setPickerOpen(false);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string | null>(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 (
|
||||
<Modal opened={opened} onClose={onClose} title="Start a chat" radius="md" centered>
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label="Customer"
|
||||
placeholder={isLoading ? "Loading companies…" : "Search for a company"}
|
||||
data={options}
|
||||
value={companyId}
|
||||
onChange={setCompanyId}
|
||||
searchable
|
||||
nothingFoundMessage="No companies match."
|
||||
disabled={isLoading}
|
||||
radius="md"
|
||||
/>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
loading={start.isPending}
|
||||
disabled={!companyId}
|
||||
onClick={submit}
|
||||
>
|
||||
Start chatting
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function InboxRow({
|
||||
c,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
c: SupportConversationDto;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const unread = c.unreadCount > 0;
|
||||
return (
|
||||
<Box
|
||||
component="button"
|
||||
onClick={onClick}
|
||||
px="md"
|
||||
py="sm"
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
cursor: "pointer",
|
||||
border: "none",
|
||||
borderLeft: active
|
||||
? "3px solid var(--mantine-color-edr-green-6)"
|
||||
: "3px solid transparent",
|
||||
background: active
|
||||
? "var(--mantine-color-edr-green-0)"
|
||||
: unread
|
||||
? "var(--mantine-color-gray-0)"
|
||||
: "transparent",
|
||||
borderBottom: "1px solid var(--mantine-color-gray-1)",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" gap="xs">
|
||||
<Group gap={6} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
|
||||
<Building2 size={13} color="var(--mantine-color-gray-6)" />
|
||||
<Text fw={unread ? 700 : 600} size="sm" truncate style={{ flex: 1 }}>
|
||||
{c.companyName ?? "Unknown company"}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
|
||||
{formatTime(c.lastMessageAt)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group justify="space-between" wrap="nowrap" gap="xs" mt={4}>
|
||||
<Text size="xs" c="dimmed" truncate style={{ flex: 1 }}>
|
||||
{c.lastMessageAuthorRole === SupportAuthorRole.AGENT ? "You: " : ""}
|
||||
{c.lastMessagePreview ?? "No messages yet"}
|
||||
</Text>
|
||||
{unread && (
|
||||
<Badge size="sm" circle color="edr-green">
|
||||
{c.unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ConversationThread({
|
||||
conversation,
|
||||
}: {
|
||||
conversation: SupportConversationDto;
|
||||
}) {
|
||||
const { data: messages, isLoading } = useMessages(conversation.id);
|
||||
const send = useSendMessage(conversation.id);
|
||||
const markRead = useMarkConversationRead();
|
||||
const [draft, setDraft] = useState("");
|
||||
const viewport = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
markRead.mutate(conversation.id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [conversation.id, messages?.length]);
|
||||
|
||||
useEffect(() => {
|
||||
viewport.current?.scrollTo({ top: viewport.current.scrollHeight });
|
||||
}, [messages?.length, conversation.id]);
|
||||
|
||||
const submit = async () => {
|
||||
const body = draft.trim();
|
||||
if (!body) return;
|
||||
setDraft("");
|
||||
await send.mutateAsync(body);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap={0} h="100%">
|
||||
{/* Header */}
|
||||
<Group
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
p="md"
|
||||
style={{ borderBottom: "1px solid var(--mantine-color-gray-2)" }}
|
||||
>
|
||||
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon variant="light" color="edr-green" radius="xl" size="md">
|
||||
<Building2 size={14} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700} truncate>
|
||||
{conversation.companyName ?? "Unknown company"}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Messages */}
|
||||
<ScrollArea style={{ flex: 1 }} viewportRef={viewport} type="hover">
|
||||
{isLoading ? (
|
||||
<Group justify="center" p="xl">
|
||||
<Loader size="sm" color="edr-green" />
|
||||
</Group>
|
||||
) : (messages ?? []).length === 0 ? (
|
||||
<Text c="dimmed" size="sm" ta="center" p="xl">
|
||||
No messages yet — say hello.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="sm" p="md">
|
||||
{(messages ?? []).map((m) => (
|
||||
<AgentBubble key={m.id} m={m} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{/* Composer */}
|
||||
<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 your message… (Enter to send, Shift+Enter for newline)"
|
||||
autosize
|
||||
minRows={1}
|
||||
maxRows={5}
|
||||
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>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentBubble({ m }: { m: SupportMessageDto }) {
|
||||
const mine = m.authorRole === SupportAuthorRole.AGENT;
|
||||
return (
|
||||
<Group
|
||||
justify={mine ? "flex-end" : "flex-start"}
|
||||
wrap="nowrap"
|
||||
align="flex-end"
|
||||
gap="xs"
|
||||
>
|
||||
{!mine && (
|
||||
<Avatar size="sm" radius="xl" color="gray" variant="filled">
|
||||
<User size={14} />
|
||||
</Avatar>
|
||||
)}
|
||||
<Box style={{ maxWidth: "70%" }}>
|
||||
<Text size="xs" c="dimmed" mb={2} ml={mine ? 0 : 4} ta={mine ? "right" : "left"}>
|
||||
{mine ? m.authorName || "You" : m.authorName || "Customer"}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user