fix chat escalation and sidebar

This commit is contained in:
2026-04-15 22:46:49 -06:00
parent 52404422bd
commit cd7ef12085
2 changed files with 195 additions and 36 deletions

View File

@@ -82,6 +82,7 @@ function AiChatPage() {
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [conversations, setConversations] = useState([]); const [conversations, setConversations] = useState([]);
const [convsLoading, setConvsLoading] = useState(false); const [convsLoading, setConvsLoading] = useState(false);
const [closedExpanded, setClosedExpanded] = useState(false);
const [selectedFile, setSelectedFile] = useState(null); const [selectedFile, setSelectedFile] = useState(null);
const messagesEndRef = useRef(null); const messagesEndRef = useRef(null);
@@ -418,12 +419,28 @@ function AiChatPage() {
method: "POST", method: "POST",
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}); });
router.push(`/chat?id=${conversation.id}`); setConversation((prev) => prev ? { ...prev, mode: "HUMAN" } : prev);
} catch { } catch {
setError("Could not connect to live support. Please try again."); setError("Could not connect to live support. Please try again.");
} }
} }
async function handleCloseConversation() {
if (!conversation || conversation.status === "CLOSED") return;
try {
const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${conversation.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ status: "CLOSED" }),
});
if (!res.ok) return;
const updated = await res.json();
setConversation(updated);
await fetchConversations();
} catch {
}
}
if (authLoading || loadingConv) { if (authLoading || loadingConv) {
return ( return (
<main style={s.page}> <main style={s.page}>
@@ -436,6 +453,8 @@ function AiChatPage() {
const isEscalated = conversation?.mode === "HUMAN"; const isEscalated = conversation?.mode === "HUMAN";
const isClosed = conversation?.status === "CLOSED"; const isClosed = conversation?.status === "CLOSED";
const hasStaff = !!conversation?.staffId;
const hasStaffMessage = messages.some((m) => m.senderId !== user?.id);
return ( return (
<main style={s.page}> <main style={s.page}>
@@ -456,7 +475,7 @@ function AiChatPage() {
<p style={s.sidebarEmpty}>No conversations yet.</p> <p style={s.sidebarEmpty}>No conversations yet.</p>
)} )}
<div style={{ overflowY: "auto", flex: 1 }}> <div style={{ overflowY: "auto", flex: 1 }}>
{conversations.map((conv) => ( {conversations.filter(c => c.status !== "CLOSED").map((conv) => (
<button <button
key={conv.id} key={conv.id}
style={{ ...s.convItem, ...(conv.id === conversation?.id ? s.convItemActive : {}) }} style={{ ...s.convItem, ...(conv.id === conversation?.id ? s.convItemActive : {}) }}
@@ -464,9 +483,7 @@ function AiChatPage() {
> >
<div style={s.convItemTop}> <div style={s.convItemTop}>
<span style={s.convItemSubject}>{conv.subject || `Conversation #${conv.id}`}</span> <span style={s.convItemSubject}>{conv.subject || `Conversation #${conv.id}`}</span>
<span style={{ ...s.convStatusBadge, ...(conv.status === "OPEN" ? s.convStatusOpen : s.convStatusClosed) }}> <span style={{ ...s.convStatusBadge, ...s.convStatusOpen }}>{conv.status}</span>
{conv.status}
</span>
</div> </div>
<div style={s.convItemBottom}> <div style={s.convItemBottom}>
<span style={s.convItemMode}>{conv.mode === "HUMAN" ? "👤 Live" : "🤖 AI"}</span> <span style={s.convItemMode}>{conv.mode === "HUMAN" ? "👤 Live" : "🤖 AI"}</span>
@@ -474,6 +491,33 @@ function AiChatPage() {
</div> </div>
</button> </button>
))} ))}
{conversations.some(c => c.status === "CLOSED") && (
<>
<button
style={s.closedSectionToggle}
onClick={() => setClosedExpanded(p => !p)}
>
<span>Closed ({conversations.filter(c => c.status === "CLOSED").length})</span>
<span>{closedExpanded ? "▲" : "▼"}</span>
</button>
{closedExpanded && conversations.filter(c => c.status === "CLOSED").map((conv) => (
<button
key={conv.id}
style={{ ...s.convItem, ...s.convItemClosed, ...(conv.id === conversation?.id ? s.convItemActive : {}) }}
onClick={() => switchConversation(conv.id)}
>
<div style={s.convItemTop}>
<span style={s.convItemSubject}>{conv.subject || `Conversation #${conv.id}`}</span>
<span style={{ ...s.convStatusBadge, ...s.convStatusClosed }}>CLOSED</span>
</div>
<div style={s.convItemBottom}>
<span style={s.convItemMode}>{conv.mode === "HUMAN" ? "👤 Live" : "🤖 AI"}</span>
<span style={s.convItemDate}>{conv.createdAt ? new Date(conv.createdAt).toLocaleDateString() : ""}</span>
</div>
</button>
))}
</>
)}
</div> </div>
<button style={s.newConvSidebarBtn} onClick={handleNewConversation}> <button style={s.newConvSidebarBtn} onClick={handleNewConversation}>
+ New Conversation + New Conversation
@@ -498,41 +542,44 @@ function AiChatPage() {
<div style={s.chatCard}> <div style={s.chatCard}>
<div style={s.chatHeader}> <div style={s.chatHeader}>
<div style={s.chatHeaderLeft}> <div style={s.chatHeaderLeft}>
<div style={s.aiAvatar}>🐾</div> <div style={isEscalated ? s.agentAvatar : s.aiAvatar}>{isEscalated ? "👤" : "🐾"}</div>
<div> <div>
<div style={s.chatHeaderTitle}>Leon's Pet Assistant</div> <div style={s.chatHeaderTitle}>
<div style={s.chatHeaderStatus}> {isEscalated ? (hasStaff ? "Support Agent" : "Leon's Pet Store Support") : "Leon's Pet Assistant"}
<span style={s.statusDot} /> Online </div>
<div style={{ ...s.chatHeaderStatus, color: isClosed ? "#999" : isEscalated && hasStaff ? "#4CAF50" : isEscalated ? "#ff8c00" : undefined }}>
<span style={{ ...s.statusDot, background: isClosed ? "#999" : isEscalated && hasStaff ? "#4CAF50" : isEscalated ? "#ff8c00" : undefined }} />
{isClosed ? "Conversation closed" : isEscalated && hasStaff ? "Support agent connected" : isEscalated ? "Waiting for a support agent..." : "Online"}
</div> </div>
</div> </div>
</div> </div>
<div style={{ display: "flex", gap: "0.5rem" }}> <div style={{ display: "flex", gap: "0.5rem" }}>
{!isEscalated && !isClosed && ( {!isEscalated && !isClosed && (
<button <button style={s.humanBtn} onClick={handleSwitchToHuman} title="Connect with a human support agent">
style={s.humanBtn}
onClick={handleSwitchToHuman}
title="Connect with a human support agent"
>
Chat with a Real Person Chat with a Real Person
</button> </button>
)} )}
<button {isEscalated && !isClosed && (
style={s.liveBtn} <button style={s.closeConvBtn} onClick={handleCloseConversation} title="Close this conversation">
onClick={() => router.push("/chat")} Close Chat
title="Go to Live Support"
>
Live Support
</button> </button>
)}
</div> </div>
</div> </div>
{isEscalated && !hasStaff && !hasStaffMessage && !isClosed && (
<div style={s.waitingBanner}>
<span style={s.waitingSpinner} />
A support agent will be with you shortly. You can send messages while you wait.
</div>
)}
<div style={s.messagesArea} ref={messagesAreaRef}> <div style={s.messagesArea} ref={messagesAreaRef}>
{messages.length === 0 && ( {messages.length === 0 && (
<div style={s.emptyState}> <div style={s.emptyState}>
<div style={s.emptyIcon}>🐾</div> <div style={s.emptyIcon}>{isEscalated ? "💬" : "🐾"}</div>
<p style={s.emptyText}> <p style={s.emptyText}>
Hello{user.fullName ? `, ${user.fullName.split(" ")[0]}` : ""}! I'm your pet care assistant. {isEscalated ? "Your conversation has started. A support agent will join soon." : `Hello${user.fullName ? `, ${user.fullName.split(" ")[0]}` : ""}! I'm your pet care assistant. Ask me about pet recommendations, care tips, supplies, or anything pet-related!`}
Ask me about pet recommendations, care tips, supplies, or anything pet-related!
</p> </p>
</div> </div>
)} )}
@@ -547,7 +594,7 @@ function AiChatPage() {
...(isOwn ? s.messageRowUser : s.messageRowAgent), ...(isOwn ? s.messageRowUser : s.messageRowAgent),
}} }}
> >
{!isOwn && <div style={s.aiAvatarSmall}>🐾</div>} {!isOwn && <div style={isEscalated ? s.agentAvatarSmall : s.aiAvatarSmall}>{isEscalated ? "👤" : "🐾"}</div>}
<div <div
style={{ style={{
...s.messageBubble, ...s.messageBubble,
@@ -771,6 +818,21 @@ const s = {
}, },
convStatusOpen: { background: "#e6f9ee", color: "#1a7a3c" }, convStatusOpen: { background: "#e6f9ee", color: "#1a7a3c" },
convStatusClosed: { background: "#f0f0f0", color: "#888" }, convStatusClosed: { background: "#f0f0f0", color: "#888" },
convItemClosed: { opacity: 0.7 },
closedSectionToggle: {
width: "100%",
background: "#f5f5f5",
border: "none",
borderTop: "1px solid #e8e8e8",
padding: "0.5rem 1rem",
fontSize: "0.78rem",
fontWeight: 600,
color: "#666",
cursor: "pointer",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
},
newConvSidebarBtn: { newConvSidebarBtn: {
margin: "0.65rem 1rem", margin: "0.65rem 1rem",
background: "#333", background: "#333",
@@ -860,6 +922,60 @@ const s = {
cursor: "pointer", cursor: "pointer",
whiteSpace: "nowrap", whiteSpace: "nowrap",
}, },
agentAvatar: {
width: 44,
height: 44,
borderRadius: "50%",
background: "linear-gradient(135deg, #444, #666)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "1.3rem",
flexShrink: 0,
},
agentAvatarSmall: {
width: 30,
height: 30,
borderRadius: "50%",
background: "#e0e0e0",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "0.9rem",
flexShrink: 0,
},
closeConvBtn: {
background: "white",
border: "2px solid #c0392b",
color: "#c0392b",
borderRadius: 8,
padding: "0.45rem 0.9rem",
fontSize: "0.82rem",
fontWeight: 600,
cursor: "pointer",
whiteSpace: "nowrap",
},
waitingBanner: {
background: "#fff8f0",
borderBottom: "1px solid #ffe0b2",
padding: "0.6rem 1.25rem",
fontSize: "0.85rem",
color: "#7c4a00",
display: "flex",
alignItems: "center",
gap: "0.5rem",
flexShrink: 0,
},
waitingSpinner: {
display: "inline-block",
width: 12,
height: 12,
borderRadius: "50%",
border: "2px solid #ff8c00",
borderTopColor: "transparent",
animation: "spin 0.8s linear infinite",
flexShrink: 0,
},
noConvCard: { noConvCard: {
background: "white", background: "white",
borderRadius: 16, borderRadius: 16,

View File

@@ -82,6 +82,7 @@ function ChatPage() {
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [conversations, setConversations] = useState([]); const [conversations, setConversations] = useState([]);
const [convsLoading, setConvsLoading] = useState(false); const [convsLoading, setConvsLoading] = useState(false);
const [closedExpanded, setClosedExpanded] = useState(false);
const [selectedFile, setSelectedFile] = useState(null); const [selectedFile, setSelectedFile] = useState(null);
const messagesEndRef = useRef(null); const messagesEndRef = useRef(null);
@@ -478,7 +479,7 @@ function ChatPage() {
<p style={s.sidebarEmpty}>No conversations yet.</p> <p style={s.sidebarEmpty}>No conversations yet.</p>
)} )}
<div style={{ overflowY: "auto", flex: 1 }}> <div style={{ overflowY: "auto", flex: 1 }}>
{conversations.map((conv) => ( {conversations.filter(c => c.status !== "CLOSED").map((conv) => (
<button <button
key={conv.id} key={conv.id}
style={{ ...s.convItem, ...(conv.id === conversation?.id ? s.convItemActive : {}) }} style={{ ...s.convItem, ...(conv.id === conversation?.id ? s.convItemActive : {}) }}
@@ -486,9 +487,7 @@ function ChatPage() {
> >
<div style={s.convItemTop}> <div style={s.convItemTop}>
<span style={s.convItemSubject}>{conv.subject || `Conversation #${conv.id}`}</span> <span style={s.convItemSubject}>{conv.subject || `Conversation #${conv.id}`}</span>
<span style={{ ...s.convStatusBadge, ...(conv.status === "OPEN" ? s.convStatusOpen : s.convStatusClosed) }}> <span style={{ ...s.convStatusBadge, ...s.convStatusOpen }}>{conv.status}</span>
{conv.status}
</span>
</div> </div>
<div style={s.convItemBottom}> <div style={s.convItemBottom}>
<span style={s.convItemMode}>{conv.mode === "HUMAN" ? "👤 Live" : "🤖 AI"}</span> <span style={s.convItemMode}>{conv.mode === "HUMAN" ? "👤 Live" : "🤖 AI"}</span>
@@ -496,6 +495,33 @@ function ChatPage() {
</div> </div>
</button> </button>
))} ))}
{conversations.some(c => c.status === "CLOSED") && (
<>
<button
style={s.closedSectionToggle}
onClick={() => setClosedExpanded(p => !p)}
>
<span>Closed ({conversations.filter(c => c.status === "CLOSED").length})</span>
<span>{closedExpanded ? "▲" : "▼"}</span>
</button>
{closedExpanded && conversations.filter(c => c.status === "CLOSED").map((conv) => (
<button
key={conv.id}
style={{ ...s.convItem, ...s.convItemClosed, ...(conv.id === conversation?.id ? s.convItemActive : {}) }}
onClick={() => switchConversation(conv.id)}
>
<div style={s.convItemTop}>
<span style={s.convItemSubject}>{conv.subject || `Conversation #${conv.id}`}</span>
<span style={{ ...s.convStatusBadge, ...s.convStatusClosed }}>CLOSED</span>
</div>
<div style={s.convItemBottom}>
<span style={s.convItemMode}>{conv.mode === "HUMAN" ? "👤 Live" : "🤖 AI"}</span>
<span style={s.convItemDate}>{conv.createdAt ? new Date(conv.createdAt).toLocaleDateString() : ""}</span>
</div>
</button>
))}
</>
)}
</div> </div>
<button style={s.newConvSidebarBtn} onClick={() => handleNewConversation()}> <button style={s.newConvSidebarBtn} onClick={() => handleNewConversation()}>
+ New Conversation + New Conversation
@@ -540,6 +566,7 @@ function ChatPage() {
Close Chat Close Chat
</button> </button>
)} )}
{!isHuman && (
<button <button
style={s.aiBtn} style={s.aiBtn}
onClick={() => router.push("/ai-chat")} onClick={() => router.push("/ai-chat")}
@@ -547,6 +574,7 @@ function ChatPage() {
> >
AI Assistant AI Assistant
</button> </button>
)}
</div> </div>
</div> </div>
@@ -1145,6 +1173,21 @@ const s = {
}, },
convStatusOpen: { background: "#e6f9ee", color: "#1a7a3c" }, convStatusOpen: { background: "#e6f9ee", color: "#1a7a3c" },
convStatusClosed: { background: "#f0f0f0", color: "#888" }, convStatusClosed: { background: "#f0f0f0", color: "#888" },
convItemClosed: { opacity: 0.7 },
closedSectionToggle: {
width: "100%",
background: "#f5f5f5",
border: "none",
borderTop: "1px solid #e8e8e8",
padding: "0.5rem 1rem",
fontSize: "0.78rem",
fontWeight: 600,
color: "#666",
cursor: "pointer",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
},
newConvSidebarBtn: { newConvSidebarBtn: {
margin: "0.65rem 1rem", margin: "0.65rem 1rem",
background: "#333", background: "#333",