UNPKG

@candoa/chatbot

Version:

Headless React chatbot widget with TypeScript, TailwindCSS support. Customizable AI chat component with hooks for modern React applications.

346 lines (342 loc) 10.8 kB
"use client"; // src/components/ChatbotWidget.tsx import { Portal } from "@radix-ui/react-portal"; // src/hooks/useChatbot.ts import { useState, useCallback, useEffect, useRef } from "react"; function useChatbot(projectId, options = {}) { const { greetings = [], title, icon, initialConversationId, apiUrl, isDev } = options; const getApiUrl = () => { if (apiUrl) return apiUrl; if (isDev) { return "http://localhost:3000"; } return "https://crm.candoa.app"; }; const baseApiUrl = getApiUrl(); const [isOpen, setIsOpen] = useState(false); const [messages, setMessages] = useState([]); const [error, setError] = useState(null); const [isTyping, setIsTyping] = useState(false); const [conversationId, setConversationId] = useState( initialConversationId || null ); const [humanTakenOver, setHumanTakenOver] = useState(false); const [hasUnreadMessages, setHasUnreadMessages] = useState(false); useEffect(() => { if (isOpen && hasUnreadMessages) { setHasUnreadMessages(false); } }, [isOpen, hasUnreadMessages]); const pollingIntervalRef = useRef(null); const lastMessageCountRef = useRef(0); useEffect(() => { if (typeof window === "undefined" || initialConversationId) return; const storedConversationId = localStorage.getItem("conversationId"); if (storedConversationId && !conversationId) { setConversationId(storedConversationId); } }, [initialConversationId, conversationId]); useEffect(() => { if (typeof window === "undefined" || !conversationId) return; localStorage.setItem("conversationId", conversationId); }, [conversationId]); const pollForNewMessages = useCallback(async () => { if (!conversationId || !humanTakenOver) return; try { const response = await fetch( `${baseApiUrl}/api/messages?conversationId=${conversationId}` ); if (!response.ok) return; const data = await response.json(); if (!data.success) return; const serverMessages = data.messages.map((msg) => ({ id: msg.id, text: msg.content, isUser: msg.role === "user", timestamp: new Date(msg.createdAt) })); if (serverMessages.length > lastMessageCountRef.current) { const newMessages = serverMessages.slice(lastMessageCountRef.current); const hasNewAgentMessages = newMessages.some((msg) => !msg.isUser); setMessages(serverMessages); lastMessageCountRef.current = serverMessages.length; if (!isOpen && hasNewAgentMessages) { setHasUnreadMessages(true); if (typeof window !== "undefined" && "Notification" in window && Notification.permission === "granted") { const lastAgentMessage = newMessages.filter((msg) => !msg.isUser).pop(); if (lastAgentMessage) { new Notification("New message from support", { body: lastAgentMessage.text.length > 100 ? lastAgentMessage.text.substring(0, 100) + "..." : lastAgentMessage.text, icon: "/favicon.ico" }); } } } } } catch (err) { console.warn("Polling error:", err); } }, [conversationId, humanTakenOver, baseApiUrl]); useEffect(() => { if (humanTakenOver && conversationId) { pollingIntervalRef.current = setInterval(pollForNewMessages, 3e3); } else { if (pollingIntervalRef.current) { clearInterval(pollingIntervalRef.current); pollingIntervalRef.current = null; } } return () => { if (pollingIntervalRef.current) { clearInterval(pollingIntervalRef.current); pollingIntervalRef.current = null; } }; }, [humanTakenOver, conversationId, pollForNewMessages]); const loadConversation = useCallback( async (convoId) => { if (!convoId) return; setIsTyping(true); setError(null); try { const response = await fetch( `${baseApiUrl}/api/messages?conversationId=${convoId}` ); if (!response.ok) { if (response.status === 404) { if (typeof window !== "undefined") { localStorage.removeItem("conversationId"); } setConversationId(null); setMessages([]); setIsTyping(false); return; } throw new Error("Failed to load message history"); } const data = await response.json(); if (!data.success) { throw new Error(data.message || "Failed to load messages"); } const historyMessages = data.messages.map((msg) => ({ id: msg.id, text: msg.content, isUser: msg.role === "user", timestamp: new Date(msg.createdAt) })); setMessages(historyMessages); setConversationId(convoId); lastMessageCountRef.current = historyMessages.length; if (data.conversation?.humanTakenOver) { setHumanTakenOver(true); } } catch (err) { setError( err instanceof Error ? err.message : "Failed to load message history" ); setMessages([]); } finally { setIsTyping(false); } }, [projectId, baseApiUrl] ); useEffect(() => { if (conversationId && messages.length === 0 && !initialConversationId) { loadConversation(conversationId); } }, [conversationId, loadConversation, messages.length, initialConversationId]); useEffect(() => { if (initialConversationId && messages.length === 0) { loadConversation(initialConversationId); } }, [initialConversationId, loadConversation, messages.length]); useEffect(() => { if (isOpen && messages.length === 0 && greetings.length > 0 && !initialConversationId && !conversationId) { const greetingMessages = greetings.map((greeting) => ({ id: crypto.randomUUID(), text: greeting, isUser: false, timestamp: /* @__PURE__ */ new Date() })); setMessages(greetingMessages); } }, [ isOpen, messages.length, greetings, initialConversationId, conversationId ]); const sendMessage = useCallback( async (text) => { if (!text.trim()) return; const newMessage = { id: crypto.randomUUID(), text: text.trim(), isUser: true, timestamp: /* @__PURE__ */ new Date() }; const updatedMessages = [...messages, newMessage]; setMessages(updatedMessages); if (!humanTakenOver) { setIsTyping(true); } setError(null); try { const requestBody = { projectId, access: "AI_AGENT", messages: updatedMessages.map((m) => ({ role: m.isUser ? "user" : "assistant", content: m.text })) }; if (conversationId) { requestBody.conversationId = conversationId; } else { requestBody.greetingMessages = greetings.length > 0 ? greetings : []; } const response = await fetch(`${baseApiUrl}/api/chatbot`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(requestBody) }); if (!response.ok) throw new Error("Failed to send message"); const data = await response.json(); if (!data.success) { throw new Error(data.message || "AI response failed"); } if (data.conversationId && !conversationId) { setConversationId(data.conversationId); } if (data.humanTakenOver) { setHumanTakenOver(true); lastMessageCountRef.current = updatedMessages.length; if (data.message && data.message.trim()) { const botMessage2 = { id: crypto.randomUUID(), text: data.message, isUser: false, timestamp: /* @__PURE__ */ new Date() }; setMessages((prev) => [...prev, botMessage2]); lastMessageCountRef.current = updatedMessages.length + 1; } return; } const botMessage = { id: crypto.randomUUID(), text: data.message, isUser: false, timestamp: /* @__PURE__ */ new Date() }; setMessages((prev) => [...prev, botMessage]); lastMessageCountRef.current = updatedMessages.length + 1; } catch (err) { setError(err instanceof Error ? err.message : "An error occurred"); } finally { setIsTyping(false); } }, [projectId, messages, conversationId, baseApiUrl, greetings] ); const clearMessages = useCallback(() => { setMessages([]); setConversationId(null); setHumanTakenOver(false); lastMessageCountRef.current = 0; if (pollingIntervalRef.current) { clearInterval(pollingIntervalRef.current); pollingIntervalRef.current = null; } if (typeof window !== "undefined") { localStorage.removeItem("conversationId"); } }, []); const clearError = useCallback(() => { setError(null); }, []); const markAsRead = useCallback(() => { setHasUnreadMessages(false); }, []); return { state: { isOpen, messages, error, isTyping, hasActiveConversation: !!conversationId, conversationId, humanTakenOver, hasUnreadMessages }, actions: { setIsOpen, sendMessage, clearMessages, clearError, loadConversation, markAsRead } }; } // src/components/ChatbotWidget.tsx import { jsx } from "react/jsx-runtime"; var ChatbotWidget = ({ projectId, title = "Chat with us", icon, greetings, apiUrl, isDev, children }) => { const { state, actions } = useChatbot(projectId, { title, greetings, icon, apiUrl, isDev }); const renderProps = { isOpen: state.isOpen, messages: state.messages, error: state.error, isTyping: state.isTyping, hasActiveConversation: state.hasActiveConversation, conversationId: state.conversationId, humanTakenOver: state.humanTakenOver, hasUnreadMessages: state.hasUnreadMessages, onSendMessage: actions.sendMessage, onToggleOpen: () => actions.setIsOpen(!state.isOpen), onClose: () => actions.setIsOpen(false), onClearMessages: actions.clearMessages, onClearError: actions.clearError, onLoadConversation: actions.loadConversation, onMarkAsRead: actions.markAsRead }; return /* @__PURE__ */ jsx(Portal, { children: children(renderProps) }); }; export { ChatbotWidget, useChatbot };