"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"; import { createStompClient } from "@/lib/chatSocket"; const API_BASE = ""; 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 (
{blobUrl ? ( {name window.open(blobUrl, "_blank")} /> ) : ( πŸ“Ž Loading image… )}
); } return (
{ e.preventDefault(); if (!blobUrl) return; const a = document.createElement("a"); a.href = blobUrl; a.download = name || "attachment"; a.click(); }} > πŸ“Ž {name || "Attachment"}
); } 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 [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 stompRef = useRef(null); const lastMessageIdRef = useRef(null); const fileInputRef = useRef(null); const lastScrolledIdRef = useRef(null); const initialLoadDoneRef = useRef(false); 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; if (!initialLoadDoneRef.current) return; lastScrolledIdRef.current = lastMsg.id; const area = messagesAreaRef.current; if (!area) return; 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}` }, }); 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; 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."); } }, [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 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; let stale = false; 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 (stale) return; 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 { if (stale) return; setError("Failed to load conversations."); } } if (!convId) { await fetchConversations(); if (stale) return; setLoadingConv(false); setConversation(null); return; } await Promise.all([ fetchConversation(convId), fetchMessages(convId), fetchConversations(), ]); if (stale) return; setLoadingConv(false); connectStomp(convId); } init(); return () => { stale = true; if (stompRef.current) { stompRef.current.deactivate(); stompRef.current = null; } }; }, [token, authLoading, conversationIdParam, fetchConversation, fetchMessages, connectStomp, 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); connectStomp(conv.id); router.replace(`/chat?id=${conv.id}`, { scroll: false }); } catch { setError("Network error. Please try again."); setLoadingConv(false); } } async function switchConversation(convId) { if (stompRef.current) { stompRef.current.deactivate(); stompRef.current = null; } setMessages([]); setError(null); 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 (

Loading...

); } 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 (

Live Support Chat

Chat with our support team in real time

All Conversations
{convsLoading &&

Loading...

} {!convsLoading && conversations.length === 0 && (

No conversations yet.

)}
{conversations.filter(c => c.status !== "CLOSED").map((conv) => ( ))}
{conversations.some(c => c.status === "CLOSED") && ( <> {closedExpanded && conversations.filter(c => c.status === "CLOSED").map((conv) => ( ))} )}
{!conversation ? (
πŸ’¬

No active conversation

Start a new conversation to chat with our support team.

{error &&
{error}
}
) : (
πŸ‘€
{hasStaff ? "Support Agent" : "Leon's Pet Store Support"}
{staffStatusLabel}
{!isClosed && ( )} {!isHuman && ( )}
{!hasStaff && !hasStaffMessage && !isClosed && (
A support agent will be with you shortly. You can send messages while you wait.
)}
{messages.length === 0 && (

Your conversation has started. A support agent will join soon.

)} {messages.map((msg) => { const isOwn = msg.senderId === user.id; return (
{!isOwn && (
πŸ‘€
)}
{msg.content && msg.content.split("\n").map((line, i, arr) => ( {line} {i < arr.length - 1 &&
}
))} {msg.attachmentUrl && ( )}
{msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : ""}
{isOwn && (
{user.fullName ? user.fullName.charAt(0).toUpperCase() : "U"}
)}
); })} {switchingConv && (
Loading messages…
)}
{error && (
{error}
)} {isClosed ? (
This conversation has been closed.
) : (
{selectedFile && (
πŸ“Ž {selectedFile.name}
)}