| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315 |
- /**
- * Live Chatbot Widget — Tidio-inspired greeting with quick-reply buttons
- * Shows: "What can we help you with today?" + Orders, Shipping, Returning, Cancelling buttons
- */
- import { useState, useRef, useEffect } from "react";
- import { trpc } from "@/lib/trpc";
- import { Bot, X, Send, Loader2, User, Headphones, Sparkles, ChevronDown, MoreVertical } from "lucide-react";
- import { Streamdown } from "streamdown";
- const QUICK_REPLIES = [
- { label: "Orders", value: "I need help with my order" },
- { label: "Shipping", value: "I have a shipping question" },
- { label: "Returning", value: "I want to return an item" },
- { label: "Cancelling", value: "I need to cancel my order" },
- ];
- export default function ChatbotWidgetLive() {
- const [isOpen, setIsOpen] = useState(false);
- const [inputText, setInputText] = useState("");
- const [sessionId, setSessionId] = useState<string | null>(null);
- const [localMessages, setLocalMessages] = useState<Array<{ id: number; sender: string; content: string; createdAt: string }>>([]);
- const [isTyping, setIsTyping] = useState(false);
- const [showQuickReplies, setShowQuickReplies] = useState(true);
- const messagesEndRef = useRef<HTMLDivElement>(null);
- const startSession = trpc.chat.startSession.useMutation({
- onSuccess: (data) => {
- setSessionId(data.sessionId);
- },
- });
- const { data: messagesData } = trpc.chat.getMessages.useQuery(
- { sessionId: sessionId || "" },
- { enabled: !!sessionId, refetchInterval: 3000 }
- );
- const sendMessage = trpc.chat.sendMessage.useMutation({
- onSuccess: () => {
- setIsTyping(false);
- },
- onError: () => {
- setIsTyping(false);
- },
- });
- // Sync server messages to local state
- useEffect(() => {
- if (messagesData?.messages) {
- setLocalMessages(messagesData.messages.map((m: any) => ({
- id: m.id,
- sender: m.sender,
- content: m.content,
- createdAt: m.createdAt?.toString() || new Date().toISOString(),
- })));
- }
- }, [messagesData]);
- // Auto-scroll
- useEffect(() => {
- messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
- }, [localMessages, isTyping]);
- const handleOpen = () => {
- setIsOpen(true);
- if (!sessionId) {
- startSession.mutate({});
- }
- };
- const handleSend = (text?: string) => {
- const content = text || inputText.trim();
- if (!content || !sessionId || isTyping) return;
- setInputText("");
- setIsTyping(true);
- setShowQuickReplies(false);
- // Optimistically add user message
- setLocalMessages(prev => [...prev, {
- id: Date.now(),
- sender: "visitor",
- content,
- createdAt: new Date().toISOString(),
- }]);
- sendMessage.mutate({ sessionId, content });
- };
- const handleQuickReply = (reply: typeof QUICK_REPLIES[0]) => {
- handleSend(reply.value);
- };
- const handleKeyDown = (e: React.KeyboardEvent) => {
- if (e.key === "Enter" && !e.shiftKey) {
- e.preventDefault();
- handleSend();
- }
- };
- // Determine if we should show the greeting state (no user messages yet)
- const hasUserMessages = localMessages.some(m => m.sender === "visitor");
- return (
- <>
- {/* Chat window */}
- {isOpen && (
- <div
- className="fixed bottom-20 right-4 z-50 w-[380px] max-w-[calc(100vw-2rem)] rounded-2xl shadow-2xl overflow-hidden flex flex-col"
- style={{
- height: "520px",
- background: "#FFFBEB",
- border: "1px solid #e7e0d5",
- boxShadow: "0 20px 60px rgba(0,0,0,0.15)",
- }}
- >
- {/* Header — Tidio-inspired gradient */}
- <div
- className="px-4 py-3 flex items-center justify-between relative"
- style={{
- background: "linear-gradient(135deg, #14532D 0%, #166534 50%, #15803d 100%)",
- }}
- >
- <div className="flex items-center gap-2.5">
- <div className="relative">
- <div className="w-10 h-10 rounded-full flex items-center justify-center overflow-hidden" style={{ background: "rgba(255,255,255,0.2)" }}>
- <Sparkles className="w-5 h-5 text-white" />
- </div>
- {/* Online indicator */}
- <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" }} />
- </div>
- <div>
- <div className="text-sm font-semibold text-white" style={{ fontFamily: "'Source Sans 3', sans-serif" }}>
- Ellie
- </div>
- <div className="flex items-center gap-1">
- <div className="w-1.5 h-1.5 rounded-full" style={{ background: "#4ade80" }} />
- <span className="text-[10px] text-green-200">
- {messagesData?.status === "escalated" ? "Connected to agent" : "Always happy to help"}
- </span>
- </div>
- </div>
- </div>
- <div className="flex items-center gap-1">
- <button className="p-1.5 rounded-lg hover:bg-white/10 transition-colors">
- <MoreVertical className="w-4 h-4 text-white/70" />
- </button>
- <button onClick={() => setIsOpen(false)} className="p-1.5 rounded-lg hover:bg-white/10 transition-colors">
- <ChevronDown className="w-4 h-4 text-white/70" />
- </button>
- </div>
- </div>
- {/* Messages */}
- <div className="flex-1 overflow-y-auto p-3 space-y-3">
- {startSession.isPending ? (
- <div className="flex items-center justify-center py-8">
- <Loader2 className="w-5 h-5 animate-spin" style={{ color: "#14532D" }} />
- <span className="ml-2 text-sm" style={{ color: "#78716C" }}>Starting conversation...</span>
- </div>
- ) : (
- <>
- {/* Greeting bubble — always shown at top */}
- {!hasUserMessages && (
- <div className="flex gap-2 justify-start">
- <div
- className="w-6 h-6 rounded-full flex items-center justify-center shrink-0 mt-1"
- style={{ background: "#14532D", color: "#fff" }}
- >
- <Sparkles className="w-3 h-3" />
- </div>
- <div
- className="max-w-[80%] px-3 py-2.5 rounded-xl text-[13px] leading-relaxed"
- style={{
- background: "#f5f0e8",
- color: "#292524",
- fontFamily: "'Source Sans 3', sans-serif",
- borderBottomLeftRadius: "4px",
- }}
- >
- <span>👋 What can we help you with today?</span>
- </div>
- </div>
- )}
- {/* Quick reply buttons — Tidio-style */}
- {showQuickReplies && !hasUserMessages && sessionId && (
- <div className="flex flex-wrap gap-2 justify-center py-2">
- {QUICK_REPLIES.map((reply) => (
- <button
- key={reply.label}
- onClick={() => handleQuickReply(reply)}
- disabled={isTyping}
- className="px-4 py-2 rounded-full text-[13px] font-medium transition-all hover:shadow-md active:scale-95 disabled:opacity-50"
- style={{
- background: "#fff",
- color: "#0369a1",
- border: "1.5px solid #0369a1",
- fontFamily: "'Source Sans 3', sans-serif",
- }}
- >
- {reply.label}
- </button>
- ))}
- </div>
- )}
- {/* Conversation messages */}
- {localMessages.map((msg) => {
- const isVisitor = msg.sender === "visitor";
- const isBot = msg.sender === "bot";
- return (
- <div key={msg.id} className={`flex gap-2 ${isVisitor ? "justify-end" : "justify-start"}`}>
- {!isVisitor && (
- <div
- className="w-6 h-6 rounded-full flex items-center justify-center shrink-0 mt-1"
- style={{ background: isBot ? "#14532D" : "#C2410C", color: "#fff" }}
- >
- {isBot ? <Sparkles className="w-3 h-3" /> : <Headphones className="w-3 h-3" />}
- </div>
- )}
- <div
- className="max-w-[80%] px-3 py-2 rounded-xl text-[13px] leading-relaxed"
- style={{
- background: isVisitor ? "#14532D" : "#f5f0e8",
- color: isVisitor ? "#fff" : "#292524",
- fontFamily: "'Source Sans 3', sans-serif",
- borderBottomRightRadius: isVisitor ? "4px" : undefined,
- borderBottomLeftRadius: !isVisitor ? "4px" : undefined,
- }}
- >
- {isBot || msg.sender === "agent" ? (
- <div className="prose prose-sm max-w-none" style={{ color: "inherit" }}>
- <Streamdown>{msg.content}</Streamdown>
- </div>
- ) : (
- msg.content
- )}
- </div>
- {isVisitor && (
- <div className="w-6 h-6 rounded-full flex items-center justify-center shrink-0 mt-1" style={{ background: "#78716C20", color: "#78716C" }}>
- <User className="w-3 h-3" />
- </div>
- )}
- </div>
- );
- })}
- </>
- )}
- {isTyping && (
- <div className="flex gap-2 items-start">
- <div className="w-6 h-6 rounded-full flex items-center justify-center shrink-0" style={{ background: "#14532D", color: "#fff" }}>
- <Sparkles className="w-3 h-3" />
- </div>
- <div className="px-3 py-2 rounded-xl" style={{ background: "#f5f0e8" }}>
- <div className="flex gap-1">
- <span className="w-1.5 h-1.5 rounded-full animate-bounce" style={{ background: "#a8a29e", animationDelay: "0ms" }} />
- <span className="w-1.5 h-1.5 rounded-full animate-bounce" style={{ background: "#a8a29e", animationDelay: "150ms" }} />
- <span className="w-1.5 h-1.5 rounded-full animate-bounce" style={{ background: "#a8a29e", animationDelay: "300ms" }} />
- </div>
- </div>
- </div>
- )}
- <div ref={messagesEndRef} />
- </div>
- {/* Input area */}
- <div className="p-3 border-t" style={{ borderColor: "#e7e0d5" }}>
- {showQuickReplies && !hasUserMessages ? (
- <div className="text-center py-1">
- <span className="text-xs" style={{ color: "#a8a29e", fontFamily: "'Source Sans 3', sans-serif" }}>
- Hit the buttons to respond
- </span>
- </div>
- ) : null}
- <div className="flex gap-2">
- <input
- type="text"
- value={inputText}
- onChange={(e) => setInputText(e.target.value)}
- onKeyDown={handleKeyDown}
- placeholder="Type your message..."
- className="flex-1 px-3 py-2 rounded-xl text-sm border"
- style={{ borderColor: "#e7e0d5", fontFamily: "'Source Sans 3', sans-serif", background: "#fff" }}
- disabled={isTyping || startSession.isPending}
- />
- <button
- onClick={() => handleSend()}
- disabled={!inputText.trim() || isTyping || !sessionId}
- className="w-9 h-9 rounded-xl flex items-center justify-center transition-all disabled:opacity-40"
- style={{ background: "#14532D", color: "#fff" }}
- >
- <Send className="w-4 h-4" />
- </button>
- </div>
- <div className="text-center mt-2">
- <span className="text-[10px]" style={{ color: "#d6d3d1" }}>Powered by Ellie · Homelegance AI</span>
- </div>
- </div>
- </div>
- )}
- {/* Floating button */}
- <button
- onClick={handleOpen}
- 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"
- style={{
- background: "#14532D",
- color: "#fff",
- boxShadow: "0 4px 20px rgba(20, 83, 45, 0.35)",
- }}
- >
- {isOpen ? <X className="w-5 h-5" /> : <Bot className="w-6 h-6" />}
- </button>
- </>
- );
- }
|