"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 AiChatPage() { 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 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); useEffect(() => { if (!authLoading && !user) { router.push("/login?next=" + encodeURIComponent("/ai-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 { // silent } }, [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; 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 openAi = Array.isArray(list) ? list.find((c) => c.status === "OPEN" && c.mode === "AUTOMATED") : null; if (openAi) convId = openAi.id; } } catch { setError("Failed to load conversations."); } } if (!convId) { // Auto-create a new AI conversation 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: "Hello! I'd like to chat with the AI assistant." }), }); if (res.ok) { const conv = await res.json(); convId = conv.id; } } catch { // silent } } if (!convId) { await fetchConversations(); setLoadingConv(false); return; } await Promise.all([ fetchConversation(convId), fetchMessages(convId), fetchConversations(), ]); setLoadingConv(false); connectStomp(convId); router.replace(`/ai-chat?id=${convId}`, { scroll: false }); } init(); return () => { if (stompRef.current) { stompRef.current.deactivate(); stompRef.current = null; } }; }, [token, authLoading, conversationIdParam, fetchConversation, fetchMessages, connectStomp, fetchConversations, router]); 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("/ai-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) => prev.some((m) => m.id === msg.id) ? prev : [...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("/ai-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() { if (stompRef.current) { stompRef.current.deactivate(); stompRef.current = null; } 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: "Hello! I'd like to chat with the AI assistant." }), }); 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(); setConversation(conv); await Promise.all([fetchMessages(conv.id), fetchConversations()]); setLoadingConv(false); connectStomp(conv.id); router.replace(`/ai-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); setLoadingConv(true); await fetchConversation(convId); await fetchMessages(convId); setLoadingConv(false); connectStomp(convId); router.replace(`/ai-chat?id=${convId}`, { scroll: false }); } async function handleSwitchToHuman() { if (!conversation) return; try { await fetch(`${API_BASE}/api/v1/chat/conversations/${conversation.id}/request-human`, { method: "POST", headers: { Authorization: `Bearer ${token}` }, }); setConversation((prev) => prev ? { ...prev, mode: "HUMAN" } : prev); } catch { 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) { return (

Loading...

); } if (!user) return null; const isEscalated = conversation?.mode === "HUMAN"; const isClosed = conversation?.status === "CLOSED"; const hasStaff = !!conversation?.staffId; const hasStaffMessage = messages.some((m) => m.senderId !== user?.id); return (

AI Pet Assistant

Ask me anything about pet care, adoption advice, or your pets!

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 with the AI assistant.

{error &&
{error}
}
) : (
{isEscalated ? "👤" : "🐾"}
{isEscalated ? (hasStaff ? "Support Agent" : "Leon's Pet Store Support") : "Leon's Pet Assistant"}
{isClosed ? "Conversation closed" : isEscalated && hasStaff ? "Support agent connected" : isEscalated ? "Waiting for a support agent..." : "Online"}
{!isEscalated && !isClosed && ( )} {isEscalated && !isClosed && ( )}
{isEscalated && !hasStaff && !hasStaffMessage && !isClosed && (
A support agent will be with you shortly. You can send messages while you wait.
)}
{messages.length === 0 && (
{isEscalated ? "💬" : "🐾"}

{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!`}

)} {messages.map((msg) => { const isOwn = msg.senderId === user.id; return (
{!isOwn &&
{isEscalated ? "👤" : "🐾"}
}
{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"}
)}
); })}
{error && (
{error}
)} {isClosed ? (
This conversation has been closed.
) : (
{selectedFile && (
📎 {selectedFile.name}
)}