ChatbotWidgetLive.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  1. /**
  2. * Live Chatbot Widget — Tidio-inspired greeting with quick-reply buttons
  3. * Shows: "What can we help you with today?" + Orders, Shipping, Returning, Cancelling buttons
  4. */
  5. import { useState, useRef, useEffect } from "react";
  6. import { trpc } from "@/lib/trpc";
  7. import { Bot, X, Send, Loader2, User, Headphones, Sparkles, ChevronDown, MoreVertical } from "lucide-react";
  8. import { Streamdown } from "streamdown";
  9. const QUICK_REPLIES = [
  10. { label: "Orders", value: "I need help with my order" },
  11. { label: "Shipping", value: "I have a shipping question" },
  12. { label: "Returning", value: "I want to return an item" },
  13. { label: "Cancelling", value: "I need to cancel my order" },
  14. ];
  15. export default function ChatbotWidgetLive() {
  16. const [isOpen, setIsOpen] = useState(false);
  17. const [inputText, setInputText] = useState("");
  18. const [sessionId, setSessionId] = useState<string | null>(null);
  19. const [localMessages, setLocalMessages] = useState<Array<{ id: number; sender: string; content: string; createdAt: string }>>([]);
  20. const [isTyping, setIsTyping] = useState(false);
  21. const [showQuickReplies, setShowQuickReplies] = useState(true);
  22. const messagesEndRef = useRef<HTMLDivElement>(null);
  23. const startSession = trpc.chat.startSession.useMutation({
  24. onSuccess: (data) => {
  25. setSessionId(data.sessionId);
  26. },
  27. });
  28. const { data: messagesData } = trpc.chat.getMessages.useQuery(
  29. { sessionId: sessionId || "" },
  30. { enabled: !!sessionId, refetchInterval: 3000 }
  31. );
  32. const sendMessage = trpc.chat.sendMessage.useMutation({
  33. onSuccess: () => {
  34. setIsTyping(false);
  35. },
  36. onError: () => {
  37. setIsTyping(false);
  38. },
  39. });
  40. // Sync server messages to local state
  41. useEffect(() => {
  42. if (messagesData?.messages) {
  43. setLocalMessages(messagesData.messages.map((m: any) => ({
  44. id: m.id,
  45. sender: m.sender,
  46. content: m.content,
  47. createdAt: m.createdAt?.toString() || new Date().toISOString(),
  48. })));
  49. }
  50. }, [messagesData]);
  51. // Auto-scroll
  52. useEffect(() => {
  53. messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
  54. }, [localMessages, isTyping]);
  55. const handleOpen = () => {
  56. setIsOpen(true);
  57. if (!sessionId) {
  58. startSession.mutate({});
  59. }
  60. };
  61. const handleSend = (text?: string) => {
  62. const content = text || inputText.trim();
  63. if (!content || !sessionId || isTyping) return;
  64. setInputText("");
  65. setIsTyping(true);
  66. setShowQuickReplies(false);
  67. // Optimistically add user message
  68. setLocalMessages(prev => [...prev, {
  69. id: Date.now(),
  70. sender: "visitor",
  71. content,
  72. createdAt: new Date().toISOString(),
  73. }]);
  74. sendMessage.mutate({ sessionId, content });
  75. };
  76. const handleQuickReply = (reply: typeof QUICK_REPLIES[0]) => {
  77. handleSend(reply.value);
  78. };
  79. const handleKeyDown = (e: React.KeyboardEvent) => {
  80. if (e.key === "Enter" && !e.shiftKey) {
  81. e.preventDefault();
  82. handleSend();
  83. }
  84. };
  85. // Determine if we should show the greeting state (no user messages yet)
  86. const hasUserMessages = localMessages.some(m => m.sender === "visitor");
  87. return (
  88. <>
  89. {/* Chat window */}
  90. {isOpen && (
  91. <div
  92. className="fixed bottom-20 right-4 z-50 w-[380px] max-w-[calc(100vw-2rem)] rounded-2xl shadow-2xl overflow-hidden flex flex-col"
  93. style={{
  94. height: "520px",
  95. background: "#FFFBEB",
  96. border: "1px solid #e7e0d5",
  97. boxShadow: "0 20px 60px rgba(0,0,0,0.15)",
  98. }}
  99. >
  100. {/* Header — Tidio-inspired gradient */}
  101. <div
  102. className="px-4 py-3 flex items-center justify-between relative"
  103. style={{
  104. background: "linear-gradient(135deg, #14532D 0%, #166534 50%, #15803d 100%)",
  105. }}
  106. >
  107. <div className="flex items-center gap-2.5">
  108. <div className="relative">
  109. <div className="w-10 h-10 rounded-full flex items-center justify-center overflow-hidden" style={{ background: "rgba(255,255,255,0.2)" }}>
  110. <Sparkles className="w-5 h-5 text-white" />
  111. </div>
  112. {/* Online indicator */}
  113. <div className="absolute -bottom-0.5 -right-0.5 w-3.5 h-3.5 rounded-full border-2 border-green-800" style={{ background: "#4ade80" }} />
  114. </div>
  115. <div>
  116. <div className="text-sm font-semibold text-white" style={{ fontFamily: "'Source Sans 3', sans-serif" }}>
  117. Ellie
  118. </div>
  119. <div className="flex items-center gap-1">
  120. <div className="w-1.5 h-1.5 rounded-full" style={{ background: "#4ade80" }} />
  121. <span className="text-[10px] text-green-200">
  122. {messagesData?.status === "escalated" ? "Connected to agent" : "Always happy to help"}
  123. </span>
  124. </div>
  125. </div>
  126. </div>
  127. <div className="flex items-center gap-1">
  128. <button className="p-1.5 rounded-lg hover:bg-white/10 transition-colors">
  129. <MoreVertical className="w-4 h-4 text-white/70" />
  130. </button>
  131. <button onClick={() => setIsOpen(false)} className="p-1.5 rounded-lg hover:bg-white/10 transition-colors">
  132. <ChevronDown className="w-4 h-4 text-white/70" />
  133. </button>
  134. </div>
  135. </div>
  136. {/* Messages */}
  137. <div className="flex-1 overflow-y-auto p-3 space-y-3">
  138. {startSession.isPending ? (
  139. <div className="flex items-center justify-center py-8">
  140. <Loader2 className="w-5 h-5 animate-spin" style={{ color: "#14532D" }} />
  141. <span className="ml-2 text-sm" style={{ color: "#78716C" }}>Starting conversation...</span>
  142. </div>
  143. ) : (
  144. <>
  145. {/* Greeting bubble — always shown at top */}
  146. {!hasUserMessages && (
  147. <div className="flex gap-2 justify-start">
  148. <div
  149. className="w-6 h-6 rounded-full flex items-center justify-center shrink-0 mt-1"
  150. style={{ background: "#14532D", color: "#fff" }}
  151. >
  152. <Sparkles className="w-3 h-3" />
  153. </div>
  154. <div
  155. className="max-w-[80%] px-3 py-2.5 rounded-xl text-[13px] leading-relaxed"
  156. style={{
  157. background: "#f5f0e8",
  158. color: "#292524",
  159. fontFamily: "'Source Sans 3', sans-serif",
  160. borderBottomLeftRadius: "4px",
  161. }}
  162. >
  163. <span>👋 What can we help you with today?</span>
  164. </div>
  165. </div>
  166. )}
  167. {/* Quick reply buttons — Tidio-style */}
  168. {showQuickReplies && !hasUserMessages && sessionId && (
  169. <div className="flex flex-wrap gap-2 justify-center py-2">
  170. {QUICK_REPLIES.map((reply) => (
  171. <button
  172. key={reply.label}
  173. onClick={() => handleQuickReply(reply)}
  174. disabled={isTyping}
  175. className="px-4 py-2 rounded-full text-[13px] font-medium transition-all hover:shadow-md active:scale-95 disabled:opacity-50"
  176. style={{
  177. background: "#fff",
  178. color: "#0369a1",
  179. border: "1.5px solid #0369a1",
  180. fontFamily: "'Source Sans 3', sans-serif",
  181. }}
  182. >
  183. {reply.label}
  184. </button>
  185. ))}
  186. </div>
  187. )}
  188. {/* Conversation messages */}
  189. {localMessages.map((msg) => {
  190. const isVisitor = msg.sender === "visitor";
  191. const isBot = msg.sender === "bot";
  192. return (
  193. <div key={msg.id} className={`flex gap-2 ${isVisitor ? "justify-end" : "justify-start"}`}>
  194. {!isVisitor && (
  195. <div
  196. className="w-6 h-6 rounded-full flex items-center justify-center shrink-0 mt-1"
  197. style={{ background: isBot ? "#14532D" : "#C2410C", color: "#fff" }}
  198. >
  199. {isBot ? <Sparkles className="w-3 h-3" /> : <Headphones className="w-3 h-3" />}
  200. </div>
  201. )}
  202. <div
  203. className="max-w-[80%] px-3 py-2 rounded-xl text-[13px] leading-relaxed"
  204. style={{
  205. background: isVisitor ? "#14532D" : "#f5f0e8",
  206. color: isVisitor ? "#fff" : "#292524",
  207. fontFamily: "'Source Sans 3', sans-serif",
  208. borderBottomRightRadius: isVisitor ? "4px" : undefined,
  209. borderBottomLeftRadius: !isVisitor ? "4px" : undefined,
  210. }}
  211. >
  212. {isBot || msg.sender === "agent" ? (
  213. <div className="prose prose-sm max-w-none" style={{ color: "inherit" }}>
  214. <Streamdown>{msg.content}</Streamdown>
  215. </div>
  216. ) : (
  217. msg.content
  218. )}
  219. </div>
  220. {isVisitor && (
  221. <div className="w-6 h-6 rounded-full flex items-center justify-center shrink-0 mt-1" style={{ background: "#78716C20", color: "#78716C" }}>
  222. <User className="w-3 h-3" />
  223. </div>
  224. )}
  225. </div>
  226. );
  227. })}
  228. </>
  229. )}
  230. {isTyping && (
  231. <div className="flex gap-2 items-start">
  232. <div className="w-6 h-6 rounded-full flex items-center justify-center shrink-0" style={{ background: "#14532D", color: "#fff" }}>
  233. <Sparkles className="w-3 h-3" />
  234. </div>
  235. <div className="px-3 py-2 rounded-xl" style={{ background: "#f5f0e8" }}>
  236. <div className="flex gap-1">
  237. <span className="w-1.5 h-1.5 rounded-full animate-bounce" style={{ background: "#a8a29e", animationDelay: "0ms" }} />
  238. <span className="w-1.5 h-1.5 rounded-full animate-bounce" style={{ background: "#a8a29e", animationDelay: "150ms" }} />
  239. <span className="w-1.5 h-1.5 rounded-full animate-bounce" style={{ background: "#a8a29e", animationDelay: "300ms" }} />
  240. </div>
  241. </div>
  242. </div>
  243. )}
  244. <div ref={messagesEndRef} />
  245. </div>
  246. {/* Input area */}
  247. <div className="p-3 border-t" style={{ borderColor: "#e7e0d5" }}>
  248. {showQuickReplies && !hasUserMessages ? (
  249. <div className="text-center py-1">
  250. <span className="text-xs" style={{ color: "#a8a29e", fontFamily: "'Source Sans 3', sans-serif" }}>
  251. Hit the buttons to respond
  252. </span>
  253. </div>
  254. ) : null}
  255. <div className="flex gap-2">
  256. <input
  257. type="text"
  258. value={inputText}
  259. onChange={(e) => setInputText(e.target.value)}
  260. onKeyDown={handleKeyDown}
  261. placeholder="Type your message..."
  262. className="flex-1 px-3 py-2 rounded-xl text-sm border"
  263. style={{ borderColor: "#e7e0d5", fontFamily: "'Source Sans 3', sans-serif", background: "#fff" }}
  264. disabled={isTyping || startSession.isPending}
  265. />
  266. <button
  267. onClick={() => handleSend()}
  268. disabled={!inputText.trim() || isTyping || !sessionId}
  269. className="w-9 h-9 rounded-xl flex items-center justify-center transition-all disabled:opacity-40"
  270. style={{ background: "#14532D", color: "#fff" }}
  271. >
  272. <Send className="w-4 h-4" />
  273. </button>
  274. </div>
  275. <div className="text-center mt-2">
  276. <span className="text-[10px]" style={{ color: "#d6d3d1" }}>Powered by Ellie · Homelegance AI</span>
  277. </div>
  278. </div>
  279. </div>
  280. )}
  281. {/* Floating button */}
  282. <button
  283. onClick={handleOpen}
  284. className="fixed bottom-4 right-4 z-50 w-14 h-14 rounded-full flex items-center justify-center shadow-lg transition-all hover:scale-105"
  285. style={{
  286. background: "#14532D",
  287. color: "#fff",
  288. boxShadow: "0 4px 20px rgba(20, 83, 45, 0.35)",
  289. }}
  290. >
  291. {isOpen ? <X className="w-5 h-5" /> : <Bot className="w-6 h-6" />}
  292. </button>
  293. </>
  294. );
  295. }