merge main into branch

This commit is contained in:
2026-04-16 08:12:46 -06:00
49 changed files with 2187 additions and 400 deletions

View File

@@ -4,9 +4,69 @@ import dynamic from "next/dynamic";
import { useState, useEffect, useRef, useCallback } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
import { createStompClient } from "@/lib/chatSocket";
const API_BASE = "";
const POLL_INTERVAL = 2500;
function isImageFilename(name) {
return /\.(jpe?g|png|gif|webp|bmp|svg)$/i.test(name || "");
}
function AttachmentPreview({ url, name, token }) {
const [blobUrl, setBlobUrl] = useState(null);
const isImage = isImageFilename(name);
useEffect(() => {
if (!url || !token) return;
let objectUrl;
fetch(url, { headers: { Authorization: `Bearer ${token}` } })
.then((r) => (r.ok ? r.blob() : null))
.then((blob) => {
if (blob) {
objectUrl = URL.createObjectURL(blob);
setBlobUrl(objectUrl);
}
})
.catch(() => {});
return () => { if (objectUrl) URL.revokeObjectURL(objectUrl); };
}, [url, token]);
if (isImage) {
return (
<div style={{ marginTop: "0.5rem" }}>
{blobUrl ? (
<img
src={blobUrl}
alt={name || "attachment"}
style={{ maxWidth: "220px", maxHeight: "220px", borderRadius: "8px", cursor: "pointer", display: "block" }}
onClick={() => window.open(blobUrl, "_blank")}
/>
) : (
<span style={{ fontSize: "0.8rem", opacity: 0.7 }}>📎 Loading image</span>
)}
</div>
);
}
return (
<div style={{ marginTop: "0.4rem" }}>
<a
href="#"
style={{ color: "inherit", fontSize: "0.85rem", opacity: 0.85 }}
onClick={(e) => {
e.preventDefault();
if (!blobUrl) return;
const a = document.createElement("a");
a.href = blobUrl;
a.download = name || "attachment";
a.click();
}}
>
📎 {name || "Attachment"}
</a>
</div>
);
}
function ChatPage() {
const { user, token, loading: authLoading } = useAuth();
@@ -22,15 +82,18 @@ function ChatPage() {
const [error, setError] = useState(null);
const [conversations, setConversations] = useState([]);
const [convsLoading, setConvsLoading] = useState(false);
const [closedExpanded, setClosedExpanded] = useState(false);
const [selectedFile, setSelectedFile] = useState(null);
const [switchingConv, setSwitchingConv] = useState(false);
const messagesEndRef = useRef(null);
const messagesAreaRef = useRef(null);
const inputRef = useRef(null);
const pollRef = useRef(null);
const stompRef = useRef(null);
const lastMessageIdRef = useRef(null);
const fileInputRef = useRef(null);
const lastScrolledIdRef = useRef(null);
const initialLoadDoneRef = useRef(false);
useEffect(() => {
if (!authLoading && !user) {
@@ -42,17 +105,17 @@ function ChatPage() {
if (messages.length === 0) return;
const lastMsg = messages[messages.length - 1];
if (lastMsg.id === lastScrolledIdRef.current) return;
if (!initialLoadDoneRef.current) return;
lastScrolledIdRef.current = lastMsg.id;
const area = messagesAreaRef.current;
if (!area) return;
const nearBottom = area.scrollHeight - area.scrollTop - area.clientHeight < 80;
if (nearBottom) {
area.scrollTop = area.scrollHeight;
}
const nearBottom = area.scrollHeight - area.scrollTop - area.clientHeight < 150;
if (nearBottom) area.scrollTop = area.scrollHeight;
}, [messages]);
const fetchMessages = useCallback(async (convId) => {
if (!token || !convId) return;
initialLoadDoneRef.current = false;
try {
const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${convId}/messages`, {
headers: { Authorization: `Bearer ${token}` },
@@ -67,10 +130,16 @@ function ChatPage() {
if (data.length > 0) {
lastMessageIdRef.current = data[data.length - 1].id;
lastScrolledIdRef.current = data[data.length - 1].id;
}
setTimeout(() => {
const area = messagesAreaRef.current;
if (area) area.scrollTop = area.scrollHeight;
initialLoadDoneRef.current = true;
}, 50);
}
}
}
catch {
setError("Failed to load messages.");
}
@@ -114,40 +183,34 @@ function ChatPage() {
}
}, [token]);
const startPolling = useCallback((convId) => {
if (pollRef.current) clearInterval(pollRef.current);
pollRef.current = setInterval(async () => {
if (!token || !convId) return;
try {
const [msgsRes, convRes] = await Promise.all([
fetch(`${API_BASE}/api/v1/chat/conversations/${convId}/messages`, {
headers: { Authorization: `Bearer ${token}` },
}),
fetch(`${API_BASE}/api/v1/chat/conversations/${convId}`, {
headers: { Authorization: `Bearer ${token}` },
}),
]);
if (msgsRes.ok) {
const data = await msgsRes.json();
if (Array.isArray(data)) {
const lastId = data.length > 0 ? data[data.length - 1].id : null;
if (lastId !== lastMessageIdRef.current) {
lastMessageIdRef.current = lastId;
setMessages(data);
}
}
}
if (convRes.ok) {
const convData = await convRes.json();
setConversation(convData);
}
}
catch {
//Silent
}
}, POLL_INTERVAL);
}, [token]);
const connectStomp = useCallback((convId) => {
if (stompRef.current) {
stompRef.current.deactivate();
stompRef.current = null;
}
const client = createStompClient(token);
client.onConnect = () => {
client.subscribe(`/topic/chat/conversations/${convId}`, (frame) => {
try {
const msg = JSON.parse(frame.body);
setMessages((prev) => prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]);
lastMessageIdRef.current = msg.id;
} catch { /* silent */ }
});
const convTopic = user?.role === "CUSTOMER"
? `/user/queue/chat/conversations`
: `/topic/chat/conversations`;
client.subscribe(convTopic, (frame) => {
try {
const conv = JSON.parse(frame.body);
if (conv.id === convId) setConversation(conv);
setConversations((prev) => prev.map((c) => c.id === conv.id ? conv : c));
} catch { /* silent */ }
});
};
stompRef.current = client;
client.activate();
}, [token, user?.role]);
useEffect(() => {
if (!token || authLoading) return;
@@ -194,16 +257,16 @@ function ChatPage() {
]);
if (stale) return;
setLoadingConv(false);
startPolling(convId);
connectStomp(convId);
}
init();
return () => {
stale = true;
if (pollRef.current) clearInterval(pollRef.current);
if (stompRef.current) { stompRef.current.deactivate(); stompRef.current = null; }
};
}, [token, authLoading, conversationIdParam, fetchConversation, fetchMessages, startPolling, fetchConversations]);
}, [token, authLoading, conversationIdParam, fetchConversation, fetchMessages, connectStomp, fetchConversations]);
async function handleSend(e) {
e?.preventDefault();
@@ -347,7 +410,7 @@ function ChatPage() {
setConversation(conv);
await Promise.all([fetchMessages(conv.id), fetchConversations()]);
setLoadingConv(false);
startPolling(conv.id);
connectStomp(conv.id);
router.replace(`/chat?id=${conv.id}`, { scroll: false });
} catch {
setError("Network error. Please try again.");
@@ -356,7 +419,7 @@ function ChatPage() {
}
async function switchConversation(convId) {
if (pollRef.current) clearInterval(pollRef.current);
if (stompRef.current) { stompRef.current.deactivate(); stompRef.current = null; }
setMessages([]);
setError(null);
router.replace(`/chat?id=${convId}`, { scroll: false });
@@ -426,7 +489,7 @@ function ChatPage() {
<p style={s.sidebarEmpty}>No conversations yet.</p>
)}
<div style={{ overflowY: "auto", flex: 1 }}>
{conversations.map((conv) => (
{conversations.filter(c => c.status !== "CLOSED").map((conv) => (
<button
key={conv.id}
style={{ ...s.convItem, ...(conv.id === conversation?.id ? s.convItemActive : {}) }}
@@ -434,9 +497,7 @@ function ChatPage() {
>
<div style={s.convItemTop}>
<span style={s.convItemSubject}>{conv.subject || `Conversation #${conv.id}`}</span>
<span style={{ ...s.convStatusBadge, ...(conv.status === "OPEN" ? s.convStatusOpen : s.convStatusClosed) }}>
{conv.status}
</span>
<span style={{ ...s.convStatusBadge, ...s.convStatusOpen }}>{conv.status}</span>
</div>
<div style={s.convItemBottom}>
<span style={s.convItemMode}>{conv.mode === "HUMAN" ? "👤 Live" : "🤖 AI"}</span>
@@ -445,6 +506,30 @@ function ChatPage() {
</button>
))}
</div>
{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>
))}
</>
)}
<button style={s.newConvSidebarBtn} onClick={() => handleNewConversation()}>
+ New Conversation
</button>
@@ -488,13 +573,15 @@ function ChatPage() {
Close Chat
</button>
)}
<button
style={s.aiBtn}
onClick={() => router.push("/ai-chat")}
title="Back to AI Assistant"
>
AI Assistant
</button>
{!isHuman && (
<button
style={s.aiBtn}
onClick={() => router.push("/ai-chat")}
title="Back to AI Assistant"
>
AI Assistant
</button>
)}
</div>
</div>
@@ -540,16 +627,7 @@ function ChatPage() {
</span>
))}
{msg.attachmentUrl && (
<div style={s.attachment}>
<a
href={msg.attachmentUrl}
target="_blank"
rel="noopener noreferrer"
style={s.attachmentLink}
>
📎 {msg.attachmentName || "Attachment"}
</a>
</div>
<AttachmentPreview url={msg.attachmentUrl} name={msg.attachmentName} token={token} />
)}
<div style={{ ...s.timestamp, ...(isOwn ? s.timestampUser : {}) }}>
{msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : ""}
@@ -563,6 +641,11 @@ function ChatPage() {
</div>
);
})}
{switchingConv && (
<div style={{ textAlign: "center", padding: "1rem", color: "#aaa", fontSize: "0.85rem" }}>
Loading messages
</div>
)}
<div ref={messagesEndRef} />
</div>
@@ -1102,6 +1185,21 @@ const s = {
},
convStatusOpen: { background: "#e6f9ee", color: "#1a7a3c" },
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: {
margin: "0.65rem 1rem",
background: "#333",