1118 lines
31 KiB
JavaScript
1118 lines
31 KiB
JavaScript
"use client";
|
|
|
|
import dynamic from "next/dynamic";
|
|
import { useState, useEffect, useRef, useCallback } from "react";
|
|
import { useRouter, useSearchParams } from "next/navigation";
|
|
import { useAuth } from "@/context/AuthContext";
|
|
|
|
const API_BASE = "";
|
|
const POLL_INTERVAL = 2500;
|
|
|
|
function ChatPage() {
|
|
const { user, token, loading: authLoading } = useAuth();
|
|
const router = useRouter();
|
|
const searchParams = useSearchParams();
|
|
const conversationIdParam = searchParams.get("id");
|
|
|
|
const [conversation, setConversation] = useState(null);
|
|
const [messages, setMessages] = useState([]);
|
|
const [input, setInput] = useState("");
|
|
const [sending, setSending] = useState(false);
|
|
const [loadingConv, setLoadingConv] = useState(true);
|
|
const [error, setError] = useState(null);
|
|
const [conversations, setConversations] = useState([]);
|
|
const [convsLoading, setConvsLoading] = useState(false);
|
|
const [selectedFile, setSelectedFile] = useState(null);
|
|
|
|
const messagesEndRef = useRef(null);
|
|
const messagesAreaRef = useRef(null);
|
|
const inputRef = useRef(null);
|
|
const pollRef = useRef(null);
|
|
const lastMessageIdRef = useRef(null);
|
|
const fileInputRef = useRef(null);
|
|
const lastScrolledIdRef = useRef(null);
|
|
|
|
useEffect(() => {
|
|
if (!authLoading && !user) {
|
|
router.push("/login?next=" + encodeURIComponent("/chat" + (conversationIdParam ? `?id=${conversationIdParam}` : "")));
|
|
}
|
|
}, [authLoading, user, router, conversationIdParam]);
|
|
|
|
useEffect(() => {
|
|
if (messages.length === 0) return;
|
|
const lastMsg = messages[messages.length - 1];
|
|
if (lastMsg.id === lastScrolledIdRef.current) return;
|
|
lastScrolledIdRef.current = lastMsg.id;
|
|
const area = messagesAreaRef.current;
|
|
if (!area) return;
|
|
const nearBottom = area.scrollHeight - area.scrollTop - area.clientHeight < 150;
|
|
if (nearBottom) {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
}
|
|
}, [messages]);
|
|
|
|
const fetchMessages = useCallback(async (convId) => {
|
|
if (!token || !convId) return;
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${convId}/messages`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
|
|
if (!res.ok) return;
|
|
|
|
const data = await res.json();
|
|
|
|
if (Array.isArray(data)) {
|
|
setMessages(data);
|
|
|
|
if (data.length > 0) {
|
|
lastMessageIdRef.current = data[data.length - 1].id;
|
|
}
|
|
}
|
|
}
|
|
|
|
catch {
|
|
setError("Failed to load messages.");
|
|
}
|
|
}, [token]);
|
|
|
|
const fetchConversation = useCallback(async (convId) => {
|
|
if (!token || !convId) return null;
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${convId}`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
|
|
if (!res.ok) return null;
|
|
const data = await res.json();
|
|
setConversation(data);
|
|
|
|
return data;
|
|
}
|
|
|
|
|
|
catch {
|
|
|
|
return null;
|
|
}
|
|
}, [token]);
|
|
|
|
const fetchConversations = useCallback(async () => {
|
|
if (!token) return;
|
|
setConvsLoading(true);
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/v1/chat/conversations`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
if (!res.ok) return;
|
|
const data = await res.json();
|
|
setConversations(Array.isArray(data) ? data : (data.content ?? []));
|
|
} catch {
|
|
// silent
|
|
} finally {
|
|
setConvsLoading(false);
|
|
}
|
|
}, [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]);
|
|
|
|
useEffect(() => {
|
|
if (!token || authLoading) return;
|
|
|
|
async function init() {
|
|
setLoadingConv(true);
|
|
setError(null);
|
|
|
|
let convId = conversationIdParam ? Number(conversationIdParam) : null;
|
|
|
|
if (!convId) {
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/v1/chat/conversations`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
if (res.ok) {
|
|
const list = await res.json();
|
|
const open = Array.isArray(list)
|
|
? list.find((c) => c.status === "OPEN")
|
|
: null;
|
|
if (open) convId = open.id;
|
|
}
|
|
} catch {
|
|
setError("Failed to load conversations.");
|
|
}
|
|
}
|
|
|
|
if (!convId) {
|
|
await fetchConversations();
|
|
setLoadingConv(false);
|
|
setConversation(null);
|
|
return;
|
|
}
|
|
|
|
await Promise.all([
|
|
fetchConversation(convId),
|
|
fetchMessages(convId),
|
|
fetchConversations(),
|
|
]);
|
|
setLoadingConv(false);
|
|
startPolling(convId);
|
|
}
|
|
|
|
init();
|
|
|
|
return () => {
|
|
if (pollRef.current) clearInterval(pollRef.current);
|
|
};
|
|
}, [token, authLoading, conversationIdParam, fetchConversation, fetchMessages, startPolling, fetchConversations]);
|
|
|
|
async function handleSend(e) {
|
|
e?.preventDefault();
|
|
const text = input.trim();
|
|
if ((!text && !selectedFile) || sending || !conversation) return;
|
|
if (selectedFile) {
|
|
await handleSendAttachment(text);
|
|
} else {
|
|
await handleSendText(text);
|
|
}
|
|
}
|
|
|
|
async function handleSendText(text) {
|
|
setInput("");
|
|
setSending(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${conversation.id}/messages`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
body: JSON.stringify({ content: text }),
|
|
});
|
|
|
|
if (res.status === 401) {
|
|
router.push("/login?next=" + encodeURIComponent("/chat"));
|
|
return;
|
|
}
|
|
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => null);
|
|
setError(data?.message || "Failed to send message.");
|
|
setInput(text);
|
|
return;
|
|
}
|
|
|
|
const msg = await res.json();
|
|
setMessages((prev) => {
|
|
if (prev.some((m) => m.id === msg.id)) return prev;
|
|
return [...prev, msg];
|
|
});
|
|
lastMessageIdRef.current = msg.id;
|
|
} catch {
|
|
setError("Network error. Please try again.");
|
|
setInput(text);
|
|
} finally {
|
|
setSending(false);
|
|
inputRef.current?.focus();
|
|
}
|
|
}
|
|
|
|
async function handleSendAttachment(optionalText) {
|
|
setSending(true);
|
|
setError(null);
|
|
const file = selectedFile;
|
|
setSelectedFile(null);
|
|
setInput("");
|
|
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append("file", file);
|
|
if (optionalText) formData.append("content", optionalText);
|
|
|
|
const res = await fetch(
|
|
`${API_BASE}/api/v1/chat/conversations/${conversation.id}/attachments`,
|
|
{
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
body: formData,
|
|
}
|
|
);
|
|
|
|
if (res.status === 401) {
|
|
router.push("/login?next=" + encodeURIComponent("/chat"));
|
|
return;
|
|
}
|
|
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => null);
|
|
setError(data?.message || "Failed to send attachment.");
|
|
setSelectedFile(file);
|
|
setInput(optionalText);
|
|
return;
|
|
}
|
|
|
|
const msg = await res.json();
|
|
setMessages((prev) => prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]);
|
|
lastMessageIdRef.current = msg.id;
|
|
} catch {
|
|
setError("Network error. Please try again.");
|
|
setSelectedFile(file);
|
|
setInput(optionalText);
|
|
} finally {
|
|
setSending(false);
|
|
inputRef.current?.focus();
|
|
}
|
|
}
|
|
|
|
function handleFileChange(e) {
|
|
const file = e.target.files?.[0];
|
|
if (file) setSelectedFile(file);
|
|
e.target.value = "";
|
|
}
|
|
|
|
function handleKeyDown(e) {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleSend();
|
|
}
|
|
}
|
|
|
|
async function handleNewConversation() {
|
|
setError(null);
|
|
setLoadingConv(true);
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/v1/chat/conversations`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
body: JSON.stringify({ message: "Hi, I'd like to speak with a support agent." }),
|
|
});
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => null);
|
|
setError(data?.message || "Failed to start a conversation.");
|
|
setLoadingConv(false);
|
|
return;
|
|
}
|
|
const conv = await res.json();
|
|
|
|
// Mark as human-requested
|
|
await fetch(`${API_BASE}/api/v1/chat/conversations/${conv.id}/request-human`, {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
|
|
setConversation(conv);
|
|
await Promise.all([fetchMessages(conv.id), fetchConversations()]);
|
|
setLoadingConv(false);
|
|
startPolling(conv.id);
|
|
router.replace(`/chat?id=${conv.id}`, { scroll: false });
|
|
} catch {
|
|
setError("Network error. Please try again.");
|
|
setLoadingConv(false);
|
|
}
|
|
}
|
|
|
|
async function switchConversation(convId) {
|
|
if (pollRef.current) clearInterval(pollRef.current);
|
|
setMessages([]);
|
|
setError(null);
|
|
setLoadingConv(true);
|
|
await fetchConversation(convId);
|
|
await fetchMessages(convId);
|
|
setLoadingConv(false);
|
|
startPolling(convId);
|
|
router.replace(`/chat?id=${convId}`, { scroll: false });
|
|
}
|
|
|
|
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 {
|
|
// silent
|
|
}
|
|
}
|
|
|
|
if (authLoading || loadingConv) {
|
|
return (
|
|
<main style={s.page}>
|
|
<p style={s.loading}>Loading...</p>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
if (!user) return null;
|
|
|
|
const isHuman = conversation?.mode === "HUMAN";
|
|
const hasStaff = !!conversation?.staffId;
|
|
const isClosed = conversation?.status === "CLOSED";
|
|
const hasStaffMessage = messages.some((m) => m.senderId !== user?.id);
|
|
|
|
const staffStatusLabel = isClosed
|
|
? "Conversation closed"
|
|
: hasStaff
|
|
? "Support agent connected"
|
|
: isHuman
|
|
? "Waiting for a support agent..."
|
|
: "Support";
|
|
|
|
const staffStatusColor = isClosed ? "#999" : hasStaff ? "#4CAF50" : "#ff8c00";
|
|
|
|
return (
|
|
<main style={s.page}>
|
|
<section style={s.hero}>
|
|
<h1 style={s.heroTitle}>Live Support Chat</h1>
|
|
<p style={s.heroSubtitle}>Chat with our support team in real time</p>
|
|
<div style={s.titleDecoration} />
|
|
</section>
|
|
|
|
<section style={s.chatSection}>
|
|
<div style={{ display: "flex", gap: "1rem", alignItems: "flex-start" }}>
|
|
<div style={s.sidebar}>
|
|
<div style={s.sidebarHeader}>
|
|
<span style={s.sidebarTitle}>All Conversations</span>
|
|
</div>
|
|
{convsLoading && <p style={s.sidebarEmpty}>Loading...</p>}
|
|
{!convsLoading && conversations.length === 0 && (
|
|
<p style={s.sidebarEmpty}>No conversations yet.</p>
|
|
)}
|
|
<div style={{ overflowY: "auto", flex: 1 }}>
|
|
{conversations.map((conv) => (
|
|
<button
|
|
key={conv.id}
|
|
style={{ ...s.convItem, ...(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, ...(conv.status === "OPEN" ? s.convStatusOpen : s.convStatusClosed) }}>
|
|
{conv.status}
|
|
</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>
|
|
<button style={s.newConvSidebarBtn} onClick={() => handleNewConversation()}>
|
|
+ New Conversation
|
|
</button>
|
|
</div>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
{!conversation ? (
|
|
<div style={s.noConvCard}>
|
|
<div style={s.noConvIcon}>💬</div>
|
|
<h2 style={s.noConvTitle}>No active conversation</h2>
|
|
<p style={s.noConvText}>Start a new conversation to chat with our support team.</p>
|
|
{error && <div style={s.errorInline}>{error}</div>}
|
|
<button style={s.startBtn} onClick={handleNewConversation}>
|
|
Start a Conversation
|
|
</button>
|
|
<button style={s.backBtn} onClick={() => router.push("/ai-chat")}>
|
|
Back to AI Assistant
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div style={s.chatCard}>
|
|
<div style={s.chatHeader}>
|
|
<div style={s.chatHeaderLeft}>
|
|
<div style={s.agentAvatar}>👤</div>
|
|
<div>
|
|
<div style={s.chatHeaderTitle}>
|
|
{hasStaff ? "Support Agent" : "Leon's Pet Store Support"}
|
|
</div>
|
|
<div style={{ ...s.chatHeaderStatus, color: staffStatusColor }}>
|
|
<span style={{ ...s.statusDot, background: staffStatusColor }} />
|
|
{staffStatusLabel}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
|
{!isClosed && (
|
|
<button
|
|
style={s.closeConvBtn}
|
|
onClick={handleCloseConversation}
|
|
title="Close this conversation"
|
|
>
|
|
Close Chat
|
|
</button>
|
|
)}
|
|
<button
|
|
style={s.aiBtn}
|
|
onClick={() => router.push("/ai-chat")}
|
|
title="Back to AI Assistant"
|
|
>
|
|
AI Assistant
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{!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}>
|
|
{messages.length === 0 && (
|
|
<div style={s.emptyState}>
|
|
<p style={s.emptyText}>
|
|
Your conversation has started. A support agent will join soon.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{messages.map((msg) => {
|
|
const isOwn = msg.senderId === user.id;
|
|
return (
|
|
<div
|
|
key={msg.id}
|
|
style={{
|
|
...s.messageRow,
|
|
...(isOwn ? s.messageRowUser : s.messageRowAgent),
|
|
}}
|
|
>
|
|
{!isOwn && (
|
|
<div style={s.agentAvatarSmall}>👤</div>
|
|
)}
|
|
<div
|
|
style={{
|
|
...s.messageBubble,
|
|
...(isOwn ? s.bubbleUser : s.bubbleAgent),
|
|
}}
|
|
>
|
|
{msg.content && msg.content.split("\n").map((line, i, arr) => (
|
|
<span key={i}>
|
|
{line}
|
|
{i < arr.length - 1 && <br />}
|
|
</span>
|
|
))}
|
|
{msg.attachmentUrl && (
|
|
<div style={s.attachment}>
|
|
<a
|
|
href={msg.attachmentUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
style={s.attachmentLink}
|
|
>
|
|
📎 {msg.attachmentName || "Attachment"}
|
|
</a>
|
|
</div>
|
|
)}
|
|
<div style={{ ...s.timestamp, ...(isOwn ? s.timestampUser : {}) }}>
|
|
{msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : ""}
|
|
</div>
|
|
</div>
|
|
{isOwn && (
|
|
<div style={s.userAvatarSmall}>
|
|
{user.fullName ? user.fullName.charAt(0).toUpperCase() : "U"}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
|
|
{error && (
|
|
<div style={s.errorBar}>
|
|
{error}
|
|
<button style={s.errorClose} onClick={() => setError(null)}>✕</button>
|
|
</div>
|
|
)}
|
|
|
|
{isClosed ? (
|
|
<div style={s.closedBanner}>
|
|
This conversation has been closed.
|
|
<button style={s.newConvBtn} onClick={handleNewConversation}>
|
|
Start New Conversation
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<form style={s.inputArea} onSubmit={handleSend}>
|
|
<input
|
|
type="file"
|
|
ref={fileInputRef}
|
|
style={{ display: "none" }}
|
|
onChange={handleFileChange}
|
|
/>
|
|
{selectedFile && (
|
|
<div style={s.filePreview}>
|
|
<span style={s.filePreviewName}>📎 {selectedFile.name}</span>
|
|
<button
|
|
type="button"
|
|
style={s.filePreviewRemove}
|
|
onClick={() => setSelectedFile(null)}
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
)}
|
|
<div style={s.inputRow}>
|
|
<button
|
|
type="button"
|
|
style={s.attachBtn}
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={sending}
|
|
title="Attach a file"
|
|
>
|
|
📎
|
|
</button>
|
|
<textarea
|
|
ref={inputRef}
|
|
style={s.textarea}
|
|
value={input}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder={selectedFile ? "Add a caption (optional)..." : "Type a message..."}
|
|
rows={1}
|
|
disabled={sending}
|
|
maxLength={2000}
|
|
/>
|
|
<button
|
|
type="submit"
|
|
style={{
|
|
...s.sendBtn,
|
|
...((!input.trim() && !selectedFile) || sending ? s.sendBtnDisabled : {}),
|
|
}}
|
|
disabled={(!input.trim() && !selectedFile) || sending}
|
|
>
|
|
{sending ? "..." : "Send"}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
const s = {
|
|
page: {
|
|
minHeight: "100vh",
|
|
background: "#fafaf8",
|
|
fontFamily: "inherit",
|
|
},
|
|
loading: {
|
|
textAlign: "center",
|
|
padding: "4rem",
|
|
color: "#888",
|
|
fontSize: "1rem",
|
|
},
|
|
hero: {
|
|
background: "linear-gradient(135deg, #333 0%, #555 100%)",
|
|
padding: "2.5rem 1.5rem 2rem",
|
|
textAlign: "center",
|
|
color: "white",
|
|
},
|
|
heroTitle: {
|
|
fontSize: "clamp(1.6rem, 4vw, 2.4rem)",
|
|
fontWeight: 800,
|
|
margin: 0,
|
|
letterSpacing: "-0.5px",
|
|
},
|
|
heroSubtitle: {
|
|
fontSize: "clamp(0.9rem, 2vw, 1.1rem)",
|
|
marginTop: "0.5rem",
|
|
opacity: 0.85,
|
|
},
|
|
titleDecoration: {
|
|
width: 60,
|
|
height: 4,
|
|
background: "rgba(255,255,255,0.4)",
|
|
borderRadius: 2,
|
|
margin: "1rem auto 0",
|
|
},
|
|
chatSection: {
|
|
maxWidth: 1060,
|
|
margin: "0 auto",
|
|
padding: "1.5rem 1rem 2rem",
|
|
},
|
|
noConvCard: {
|
|
background: "white",
|
|
borderRadius: 16,
|
|
boxShadow: "0 4px 24px rgba(0,0,0,0.08)",
|
|
padding: "3rem 2rem",
|
|
textAlign: "center",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
alignItems: "center",
|
|
gap: "1rem",
|
|
},
|
|
noConvIcon: { fontSize: "3rem" },
|
|
noConvTitle: { fontSize: "1.4rem", fontWeight: 700, color: "#1a1a1a", margin: 0 },
|
|
noConvText: { color: "#666", fontSize: "0.95rem", maxWidth: 360 },
|
|
errorInline: {
|
|
background: "#fff0f0",
|
|
color: "#c0392b",
|
|
border: "1px solid #ffd0d0",
|
|
borderRadius: 8,
|
|
padding: "0.6rem 1rem",
|
|
fontSize: "0.875rem",
|
|
width: "100%",
|
|
maxWidth: 360,
|
|
},
|
|
startBtn: {
|
|
background: "#333",
|
|
color: "white",
|
|
border: "none",
|
|
borderRadius: 10,
|
|
padding: "0.7rem 2rem",
|
|
fontSize: "0.95rem",
|
|
fontWeight: 600,
|
|
cursor: "pointer",
|
|
},
|
|
backBtn: {
|
|
background: "none",
|
|
border: "1.5px solid #ff8c00",
|
|
color: "#ff8c00",
|
|
borderRadius: 10,
|
|
padding: "0.6rem 1.5rem",
|
|
fontSize: "0.9rem",
|
|
fontWeight: 600,
|
|
cursor: "pointer",
|
|
},
|
|
chatCard: {
|
|
background: "white",
|
|
borderRadius: 16,
|
|
boxShadow: "0 4px 24px rgba(0,0,0,0.08)",
|
|
overflow: "hidden",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
height: "calc(100vh - 220px)",
|
|
minHeight: 450,
|
|
},
|
|
chatHeader: {
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
padding: "1rem 1.25rem",
|
|
borderBottom: "1px solid #f0f0f0",
|
|
background: "#fff",
|
|
flexShrink: 0,
|
|
},
|
|
chatHeaderLeft: {
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "0.75rem",
|
|
},
|
|
agentAvatar: {
|
|
width: 44,
|
|
height: 44,
|
|
borderRadius: "50%",
|
|
background: "linear-gradient(135deg, #444, #666)",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
fontSize: "1.2rem",
|
|
flexShrink: 0,
|
|
},
|
|
chatHeaderTitle: {
|
|
fontWeight: 700,
|
|
fontSize: "1rem",
|
|
color: "#1a1a1a",
|
|
},
|
|
chatHeaderStatus: {
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "0.35rem",
|
|
fontSize: "0.8rem",
|
|
marginTop: 2,
|
|
},
|
|
statusDot: {
|
|
display: "inline-block",
|
|
width: 8,
|
|
height: 8,
|
|
borderRadius: "50%",
|
|
},
|
|
aiBtn: {
|
|
background: "white",
|
|
border: "2px solid #ff8c00",
|
|
color: "#ff8c00",
|
|
borderRadius: 8,
|
|
padding: "0.45rem 0.9rem",
|
|
fontSize: "0.82rem",
|
|
fontWeight: 600,
|
|
cursor: "pointer",
|
|
whiteSpace: "nowrap",
|
|
},
|
|
waitingBanner: {
|
|
background: "#fff8f0",
|
|
borderBottom: "1px solid #ffe0b2",
|
|
color: "#e65100",
|
|
padding: "0.6rem 1.25rem",
|
|
fontSize: "0.83rem",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "0.6rem",
|
|
flexShrink: 0,
|
|
},
|
|
waitingSpinner: {
|
|
display: "inline-block",
|
|
width: 12,
|
|
height: 12,
|
|
border: "2px solid #ff8c00",
|
|
borderTopColor: "transparent",
|
|
borderRadius: "50%",
|
|
animation: "spin 0.8s linear infinite",
|
|
flexShrink: 0,
|
|
},
|
|
messagesArea: {
|
|
flex: 1,
|
|
overflowY: "auto",
|
|
padding: "1.25rem",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: "0.75rem",
|
|
},
|
|
emptyState: {
|
|
flex: 1,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
margin: "auto",
|
|
},
|
|
emptyText: {
|
|
color: "#888",
|
|
fontSize: "0.9rem",
|
|
textAlign: "center",
|
|
},
|
|
messageRow: {
|
|
display: "flex",
|
|
alignItems: "flex-end",
|
|
gap: "0.5rem",
|
|
},
|
|
messageRowUser: { flexDirection: "row-reverse" },
|
|
messageRowAgent: { flexDirection: "row" },
|
|
agentAvatarSmall: {
|
|
width: 30,
|
|
height: 30,
|
|
borderRadius: "50%",
|
|
background: "linear-gradient(135deg, #444, #666)",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
fontSize: "0.85rem",
|
|
flexShrink: 0,
|
|
},
|
|
userAvatarSmall: {
|
|
width: 30,
|
|
height: 30,
|
|
borderRadius: "50%",
|
|
background: "#ff8c00",
|
|
color: "white",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
fontSize: "0.8rem",
|
|
fontWeight: 700,
|
|
flexShrink: 0,
|
|
},
|
|
messageBubble: {
|
|
maxWidth: "72%",
|
|
padding: "0.65rem 0.9rem",
|
|
borderRadius: 14,
|
|
fontSize: "0.92rem",
|
|
lineHeight: 1.55,
|
|
wordBreak: "break-word",
|
|
},
|
|
bubbleUser: {
|
|
background: "#ff8c00",
|
|
color: "white",
|
|
borderBottomRightRadius: 4,
|
|
},
|
|
bubbleAgent: {
|
|
background: "#f4f4f4",
|
|
color: "#1a1a1a",
|
|
borderBottomLeftRadius: 4,
|
|
},
|
|
timestamp: {
|
|
fontSize: "0.7rem",
|
|
color: "#aaa",
|
|
marginTop: "0.3rem",
|
|
textAlign: "left",
|
|
},
|
|
timestampUser: { textAlign: "right", color: "rgba(255,255,255,0.7)" },
|
|
attachment: { marginTop: "0.4rem" },
|
|
attachmentLink: {
|
|
color: "inherit",
|
|
fontSize: "0.85rem",
|
|
opacity: 0.85,
|
|
},
|
|
errorBar: {
|
|
background: "#fff0f0",
|
|
borderTop: "1px solid #ffd0d0",
|
|
color: "#c0392b",
|
|
padding: "0.65rem 1.25rem",
|
|
fontSize: "0.875rem",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
flexShrink: 0,
|
|
},
|
|
errorClose: {
|
|
background: "none",
|
|
border: "none",
|
|
color: "#c0392b",
|
|
cursor: "pointer",
|
|
fontSize: "0.9rem",
|
|
padding: "0 0.25rem",
|
|
},
|
|
closedBanner: {
|
|
background: "#f5f5f5",
|
|
borderTop: "1px solid #e0e0e0",
|
|
color: "#666",
|
|
padding: "0.85rem 1.25rem",
|
|
fontSize: "0.875rem",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
flexShrink: 0,
|
|
},
|
|
newConvBtn: {
|
|
background: "#333",
|
|
color: "white",
|
|
border: "none",
|
|
borderRadius: 8,
|
|
padding: "0.4rem 1rem",
|
|
fontSize: "0.82rem",
|
|
fontWeight: 600,
|
|
cursor: "pointer",
|
|
},
|
|
inputArea: {
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: "0.4rem",
|
|
padding: "0.85rem 1.25rem",
|
|
borderTop: "1px solid #f0f0f0",
|
|
background: "#fff",
|
|
flexShrink: 0,
|
|
},
|
|
inputRow: {
|
|
display: "flex",
|
|
gap: "0.6rem",
|
|
alignItems: "flex-end",
|
|
},
|
|
textarea: {
|
|
flex: 1,
|
|
border: "1.5px solid #e0e0e0",
|
|
borderRadius: 10,
|
|
padding: "0.6rem 0.85rem",
|
|
fontSize: "0.92rem",
|
|
resize: "none",
|
|
outline: "none",
|
|
fontFamily: "inherit",
|
|
lineHeight: 1.5,
|
|
maxHeight: 120,
|
|
overflowY: "auto",
|
|
},
|
|
sendBtn: {
|
|
background: "#333",
|
|
color: "white",
|
|
border: "none",
|
|
borderRadius: 10,
|
|
padding: "0.6rem 1.2rem",
|
|
fontSize: "0.92rem",
|
|
fontWeight: 600,
|
|
cursor: "pointer",
|
|
flexShrink: 0,
|
|
},
|
|
sendBtnDisabled: {
|
|
background: "#aaa",
|
|
cursor: "not-allowed",
|
|
},
|
|
attachBtn: {
|
|
background: "none",
|
|
border: "1.5px solid #e0e0e0",
|
|
borderRadius: 8,
|
|
padding: "0.5rem 0.6rem",
|
|
fontSize: "1rem",
|
|
cursor: "pointer",
|
|
flexShrink: 0,
|
|
color: "#666",
|
|
lineHeight: 1,
|
|
},
|
|
filePreview: {
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "0.5rem",
|
|
background: "#f8f8f8",
|
|
border: "1px solid #e8e8e8",
|
|
borderRadius: 8,
|
|
padding: "0.35rem 0.75rem",
|
|
},
|
|
filePreviewName: {
|
|
fontSize: "0.82rem",
|
|
color: "#555",
|
|
flex: 1,
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
whiteSpace: "nowrap",
|
|
},
|
|
filePreviewRemove: {
|
|
background: "none",
|
|
border: "none",
|
|
cursor: "pointer",
|
|
color: "#999",
|
|
fontSize: "0.8rem",
|
|
padding: "0 0.15rem",
|
|
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",
|
|
},
|
|
sidebar: {
|
|
width: 230,
|
|
flexShrink: 0,
|
|
background: "white",
|
|
borderRadius: 16,
|
|
boxShadow: "0 4px 24px rgba(0,0,0,0.08)",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
overflow: "hidden",
|
|
maxHeight: "calc(100vh - 220px)",
|
|
minHeight: 300,
|
|
},
|
|
sidebarHeader: {
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
padding: "0.85rem 1rem",
|
|
borderBottom: "1px solid #f0f0f0",
|
|
flexShrink: 0,
|
|
},
|
|
sidebarTitle: { fontWeight: 700, fontSize: "0.88rem", color: "#333" },
|
|
sidebarEmpty: {
|
|
color: "#aaa",
|
|
fontSize: "0.82rem",
|
|
padding: "1rem",
|
|
textAlign: "center",
|
|
margin: 0,
|
|
},
|
|
convItem: {
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: "0.2rem",
|
|
padding: "0.65rem 1rem",
|
|
borderBottom: "1px solid #f0f0f0",
|
|
background: "white",
|
|
border: "none",
|
|
borderBottomWidth: 1,
|
|
borderBottomStyle: "solid",
|
|
borderBottomColor: "#f0f0f0",
|
|
textAlign: "left",
|
|
cursor: "pointer",
|
|
width: "100%",
|
|
},
|
|
convItemActive: { background: "#f8f8f8" },
|
|
convItemTop: {
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
gap: "0.4rem",
|
|
},
|
|
convItemBottom: {
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
},
|
|
convItemSubject: {
|
|
fontSize: "0.82rem",
|
|
fontWeight: 600,
|
|
color: "#222",
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
whiteSpace: "nowrap",
|
|
flex: 1,
|
|
},
|
|
convItemMode: { fontSize: "0.7rem", color: "#999" },
|
|
convItemDate: { fontSize: "0.68rem", color: "#bbb" },
|
|
convStatusBadge: {
|
|
fontSize: "0.62rem",
|
|
fontWeight: 700,
|
|
borderRadius: 20,
|
|
padding: "0.1rem 0.45rem",
|
|
flexShrink: 0,
|
|
textTransform: "uppercase",
|
|
letterSpacing: "0.04em",
|
|
},
|
|
convStatusOpen: { background: "#e6f9ee", color: "#1a7a3c" },
|
|
convStatusClosed: { background: "#f0f0f0", color: "#888" },
|
|
newConvSidebarBtn: {
|
|
margin: "0.65rem 1rem",
|
|
background: "#333",
|
|
color: "white",
|
|
border: "none",
|
|
borderRadius: 8,
|
|
padding: "0.5rem 0.75rem",
|
|
fontSize: "0.8rem",
|
|
fontWeight: 600,
|
|
cursor: "pointer",
|
|
flexShrink: 0,
|
|
},
|
|
};
|
|
|
|
export default dynamic(() => Promise.resolve(ChatPage), { ssr: false });
|