@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
JavaScript
"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
};