252 lines
9.0 KiB
JavaScript
252 lines
9.0 KiB
JavaScript
/*
|
|
* Provides state and actions for both AI chat and live support chat.
|
|
*
|
|
* Author: Shiv
|
|
* Date: April 2026
|
|
*/
|
|
|
|
"use client";
|
|
|
|
import { createContext, useContext, useState, useRef, useCallback, useEffect } from "react";
|
|
import { createStompClient } from "@/lib/chatSocket";
|
|
import { useAuth } from "@/context/AuthContext";
|
|
|
|
//Chat widget context
|
|
//Manages both the AI chat and the live support chat
|
|
const ChatWidgetContext = createContext(null);
|
|
const API_BASE = "";
|
|
|
|
//Provides chat state and actions for the floating chat widget
|
|
export function ChatWidgetProvider({ children }) {
|
|
const { user } = useAuth();
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [view, setView] = useState("ai"); // "ai" | "history" | "live"
|
|
|
|
//AI chat messages, loaded from localStorage so they survive page refreshes
|
|
const [aiMessages, setAiMessages] = useState(() => {
|
|
try {
|
|
const saved = localStorage.getItem("fc_aiMessages");
|
|
return saved ? JSON.parse(saved) : [];
|
|
} catch { return []; }
|
|
});
|
|
const [aiSending, setAiSending] = useState(false);
|
|
const [aiError, setAiError] = useState(null);
|
|
|
|
//Save AI messages to localStorage whenever they change
|
|
useEffect(() => {
|
|
localStorage.setItem("fc_aiMessages", JSON.stringify(aiMessages));
|
|
}, [aiMessages]);
|
|
|
|
//Ref to the latest messages so the send function always sees current state
|
|
const aiMessagesRef = useRef(aiMessages);
|
|
useEffect(() => { aiMessagesRef.current = aiMessages; }, [aiMessages]);
|
|
|
|
//Sends a message to the AI and appends the response to the chat
|
|
const sendAiMessage = useCallback(async (text, token) => {
|
|
if (!text.trim() || !token) return;
|
|
const userMsg = { role: "user", content: text, id: Date.now() };
|
|
setAiMessages((prev) => [...prev, userMsg]);
|
|
setAiSending(true);
|
|
setAiError(null);
|
|
try {
|
|
const history = aiMessagesRef.current.map((m) => ({ role: m.role, content: m.content }));
|
|
const res = await fetch(`${API_BASE}/api/v1/ai-chat/message`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
body: JSON.stringify({ message: text, history }),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok || !data.success) { setAiError(data.error || "Failed to get a response."); return; }
|
|
setAiMessages((prev) => [...prev, { role: "assistant", content: data.message, id: Date.now() + 1 }]);
|
|
} catch {
|
|
setAiError("Network error. Please try again.");
|
|
} finally {
|
|
setAiSending(false);
|
|
}
|
|
}, []);
|
|
|
|
//Live chat state
|
|
const [conversations, setConversations] = useState([]);
|
|
const [convsLoading, setConvsLoading] = useState(false);
|
|
const [activeConvId, setActiveConvId] = useState(null);
|
|
const [activeConv, setActiveConv] = useState(null);
|
|
const [liveMessages, setLiveMessages] = useState([]);
|
|
const [liveSending, setLiveSending] = useState(false);
|
|
const [switchingToHuman, setSwitchingToHuman] = useState(false);
|
|
|
|
const stompRef = useRef(null);
|
|
const activeConvIdRef = useRef(null);
|
|
const tokenRef = useRef(null);
|
|
|
|
//Disconnects the WebSocket if it is active
|
|
const disconnectStomp = useCallback(() => {
|
|
if (stompRef.current) {
|
|
stompRef.current.deactivate();
|
|
stompRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
//Clears all chat state when the user logs out or switches accounts
|
|
const prevUserIdRef = useRef(user?.id);
|
|
useEffect(() => {
|
|
const currentId = user?.id ?? null;
|
|
const prevId = prevUserIdRef.current;
|
|
prevUserIdRef.current = currentId;
|
|
if (prevId !== null && prevId !== currentId) {
|
|
setAiMessages([]);
|
|
localStorage.removeItem("fc_aiMessages");
|
|
setConversations([]);
|
|
setActiveConvId(null);
|
|
setActiveConv(null);
|
|
setLiveMessages([]);
|
|
disconnectStomp();
|
|
setView("ai");
|
|
setIsOpen(false);
|
|
}
|
|
}, [user?.id, disconnectStomp]);
|
|
|
|
//Subscribes to incoming messages and conversation updates for a given chat
|
|
const subscribeToConversation = useCallback((client, convId) => {
|
|
client.subscribe(`/topic/chat/conversations/${convId}`, (frame) => {
|
|
try {
|
|
const msg = JSON.parse(frame.body);
|
|
setLiveMessages((prev) => prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]);
|
|
} catch { /* silent */ }
|
|
});
|
|
client.subscribe(`/user/queue/chat/conversations`, (frame) => {
|
|
try {
|
|
const conv = JSON.parse(frame.body);
|
|
if (conv.id === convId) setActiveConv(conv);
|
|
} catch { /* silent */ }
|
|
});
|
|
}, []);
|
|
|
|
const loadConversations = useCallback(async (token) => {
|
|
if (!token) return;
|
|
setConvsLoading(true);
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/v1/chat/conversations?mine=true`, {
|
|
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);
|
|
}
|
|
}, []);
|
|
|
|
const openLiveConversation = useCallback(async (convId, token) => {
|
|
if (!convId || !token) return;
|
|
disconnectStomp();
|
|
tokenRef.current = token;
|
|
setActiveConvId(convId);
|
|
activeConvIdRef.current = convId;
|
|
setLiveMessages([]);
|
|
setView("live");
|
|
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${convId}`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
if (res.ok) setActiveConv(await res.json());
|
|
} catch { /* silent */ }
|
|
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${convId}/messages`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
if (Array.isArray(data)) setLiveMessages(data);
|
|
}
|
|
} catch { /* silent */ }
|
|
|
|
const client = createStompClient(token);
|
|
client.onConnect = () => subscribeToConversation(client, convId);
|
|
stompRef.current = client;
|
|
client.activate();
|
|
}, [disconnectStomp, subscribeToConversation]);
|
|
|
|
const sendLiveMessage = useCallback(async (text, token, convId) => {
|
|
if (!text.trim() || liveSending || !token || !convId) return;
|
|
setLiveSending(true);
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${convId}/messages`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
body: JSON.stringify({ content: text }),
|
|
});
|
|
if (res.ok) {
|
|
const msg = await res.json();
|
|
setLiveMessages((prev) => prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]);
|
|
}
|
|
} catch { /* silent */ } finally {
|
|
setLiveSending(false);
|
|
}
|
|
}, [liveSending]);
|
|
|
|
//Creates a new live chat conversation and requests a human agent
|
|
const startLiveChat = useCallback(async (token) => {
|
|
if (!token || switchingToHuman) return;
|
|
setSwitchingToHuman(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) return;
|
|
const conv = await res.json();
|
|
await fetch(`${API_BASE}/api/v1/chat/conversations/${conv.id}/request-human`, {
|
|
method: "POST", headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
setConversations((prev) => [conv, ...prev.filter((c) => c.id !== conv.id)]);
|
|
await openLiveConversation(conv.id, token);
|
|
} catch { /* silent */ } finally {
|
|
setSwitchingToHuman(false);
|
|
}
|
|
}, [switchingToHuman, openLiveConversation]);
|
|
|
|
useEffect(() => {
|
|
if (!isOpen || view !== "live") {
|
|
disconnectStomp();
|
|
} else if (isOpen && view === "live" && activeConvIdRef.current && tokenRef.current && !stompRef.current) {
|
|
const convId = activeConvIdRef.current;
|
|
const token = tokenRef.current;
|
|
const client = createStompClient(token);
|
|
client.onConnect = () => subscribeToConversation(client, convId);
|
|
stompRef.current = client;
|
|
client.activate();
|
|
}
|
|
}, [isOpen, view, disconnectStomp, subscribeToConversation]);
|
|
|
|
useEffect(() => {
|
|
return () => disconnectStomp();
|
|
}, [disconnectStomp]);
|
|
|
|
const toggleOpen = useCallback(() => setIsOpen((o) => !o), []);
|
|
const openView = useCallback((v) => setView(v), []);
|
|
|
|
return (
|
|
<ChatWidgetContext.Provider value={{
|
|
isOpen, toggleOpen,
|
|
view, openView,
|
|
aiMessages, aiSending, aiError, setAiError, sendAiMessage,
|
|
conversations, convsLoading, loadConversations,
|
|
activeConvId, activeConv, liveMessages, liveSending,
|
|
openLiveConversation, sendLiveMessage,
|
|
startLiveChat, switchingToHuman,
|
|
}}>
|
|
{children}
|
|
</ChatWidgetContext.Provider>
|
|
);
|
|
}
|
|
|
|
//Hook to access chat widget state - must be used inside a ChatWidgetProvider
|
|
export function useChatWidget() {
|
|
const ctx = useContext(ChatWidgetContext);
|
|
if (!ctx) throw new Error("useChatWidget must be used within ChatWidgetProvider");
|
|
return ctx;
|
|
}
|