| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445 |
- /**
- * Playground — Test the chatbot in a simulated customer view
- * Features: Interactive chatbot preview, flow selection, execution path tracking, reset
- */
- import { useState, useRef, useEffect, useMemo } from "react";
- import { trpc } from "@/lib/trpc";
- import { Button } from "@/components/ui/button";
- import { toast } from "sonner";
- import {
- Play, RotateCcw, Sparkles, User, Headphones,
- Send, Bot, Loader2, Package, Truck, RotateCw,
- XCircle, ChevronRight, Eye, MessageCircle,
- } from "lucide-react";
- import { Streamdown } from "streamdown";
- /* ─── Quick-reply options matching the greeting ─── */
- const GREETING_OPTIONS = [
- { id: "orders", label: "Orders", icon: <Package className="w-3.5 h-3.5" />, color: "#14532D" },
- { id: "shipping", label: "Shipping", icon: <Truck className="w-3.5 h-3.5" />, color: "#0369a1" },
- { id: "returning", label: "Returning", icon: <RotateCw className="w-3.5 h-3.5" />, color: "#ca8a04" },
- { id: "cancelling", label: "Cancelling", icon: <XCircle className="w-3.5 h-3.5" />, color: "#dc2626" },
- ];
- interface TestMessage {
- id: number;
- sender: "visitor" | "bot" | "agent" | "system";
- content: string;
- timestamp: string;
- flowStep?: string;
- }
- export default function Playground() {
- const [sessionId, setSessionId] = useState<string | null>(null);
- const [messages, setMessages] = useState<TestMessage[]>([]);
- const [inputText, setInputText] = useState("");
- const [isTyping, setIsTyping] = useState(false);
- const [showGreeting, setShowGreeting] = useState(true);
- const [selectedFlow, setSelectedFlow] = useState<string | null>(null);
- const [flowPath, setFlowPath] = useState<string[]>([]);
- const [testMode, setTestMode] = useState<"interactive" | "flow">("interactive");
- const messagesEndRef = useRef<HTMLDivElement>(null);
- const startSession = trpc.chat.startSession.useMutation({
- onSuccess: (data) => {
- setSessionId(data.sessionId);
- },
- });
- const { data: serverMessages } = trpc.chat.getMessages.useQuery(
- { sessionId: sessionId || "" },
- { enabled: !!sessionId, refetchInterval: 2000 }
- );
- const sendMessage = trpc.chat.sendMessage.useMutation({
- onSuccess: () => setIsTyping(false),
- onError: () => setIsTyping(false),
- });
- const trackEvent = trpc.analytics.track.useMutation();
- // Sync server messages
- useEffect(() => {
- if (serverMessages?.messages) {
- const mapped: TestMessage[] = serverMessages.messages.map((m: any) => ({
- id: m.id,
- sender: m.sender,
- content: m.content,
- timestamp: m.createdAt?.toString() || new Date().toISOString(),
- }));
- setMessages(mapped);
- if (mapped.length > 0) setShowGreeting(false);
- }
- }, [serverMessages]);
- // Auto-scroll
- useEffect(() => {
- messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
- }, [messages, isTyping]);
- const handleStartTest = () => {
- setMessages([]);
- setShowGreeting(true);
- setSelectedFlow(null);
- setFlowPath([]);
- setSessionId(null);
- startSession.mutate({});
- };
- const handleReset = () => {
- setMessages([]);
- setShowGreeting(true);
- setSelectedFlow(null);
- setFlowPath([]);
- setSessionId(null);
- setInputText("");
- setIsTyping(false);
- };
- const handleOptionClick = (optionId: string) => {
- if (!sessionId) return;
- setShowGreeting(false);
- setSelectedFlow(optionId);
- setFlowPath(prev => [...prev, `Greeting → ${optionId}`]);
- trackEvent.mutate({
- sessionId,
- eventType: "button_clicked",
- category: optionId,
- metadata: { source: "playground", button: optionId },
- });
- // Send the option as a message
- const text = `I need help with ${optionId}`;
- setIsTyping(true);
- setMessages(prev => [...prev, {
- id: Date.now(),
- sender: "visitor",
- content: text,
- timestamp: new Date().toISOString(),
- flowStep: `User selected: ${optionId}`,
- }]);
- sendMessage.mutate({ sessionId, content: text });
- };
- const handleSend = () => {
- if (!inputText.trim() || !sessionId || isTyping) return;
- const text = inputText.trim();
- setInputText("");
- setIsTyping(true);
- setShowGreeting(false);
- setMessages(prev => [...prev, {
- id: Date.now(),
- sender: "visitor",
- content: text,
- timestamp: new Date().toISOString(),
- }]);
- sendMessage.mutate({ sessionId, content: text });
- };
- const handleKeyDown = (e: React.KeyboardEvent) => {
- if (e.key === "Enter" && !e.shiftKey) {
- e.preventDefault();
- handleSend();
- }
- };
- return (
- <div className="flex flex-col" style={{ height: "calc(100vh - 4rem)" }}>
- {/* Top toolbar */}
- <div className="border-b px-4 h-12 flex items-center justify-between shrink-0" style={{ background: "#fff", borderColor: "#e7e0d5" }}>
- <div className="flex items-center gap-3">
- <div className="w-7 h-7 rounded-lg flex items-center justify-center" style={{ background: "#7c3aed" }}>
- <Play className="w-3.5 h-3.5 text-white" />
- </div>
- <span className="text-sm font-bold" style={{ color: "#7c3aed", fontFamily: "'Playfair Display', serif" }}>Playground</span>
- {sessionId && (
- <span className="text-[10px] px-2 py-0.5 rounded-full font-medium" style={{ background: "#14532D14", color: "#14532D" }}>
- Session active
- </span>
- )}
- </div>
- <div className="flex items-center gap-2">
- <Button onClick={handleReset} variant="outline" size="sm" className="text-xs">
- <RotateCcw className="w-3 h-3 mr-1" /> Reset
- </Button>
- <Button onClick={handleStartTest} size="sm" className="text-xs text-white" style={{ background: "#7c3aed" }}>
- <Play className="w-3 h-3 mr-1" /> {sessionId ? "Restart" : "Start Test"}
- </Button>
- </div>
- </div>
- <div className="flex-1 flex min-h-0">
- {/* Left panel — Flow execution path */}
- <div className="w-64 shrink-0 border-r overflow-y-auto p-3 space-y-3" style={{ background: "#fff", borderColor: "#e7e0d5" }}>
- <h3 className="text-xs font-bold uppercase tracking-wider" style={{ color: "#78716C" }}>
- Test Controls
- </h3>
- {/* Mode selector */}
- <div className="space-y-1">
- <span className="text-[10px] font-semibold uppercase tracking-wider" style={{ color: "#a8a29e" }}>Mode</span>
- <div className="flex gap-1">
- {(["interactive", "flow"] as const).map(mode => (
- <button
- key={mode}
- onClick={() => setTestMode(mode)}
- className="flex-1 px-2 py-1.5 rounded-lg text-[11px] font-medium capitalize transition-colors"
- style={{
- background: testMode === mode ? "#7c3aed14" : "transparent",
- color: testMode === mode ? "#7c3aed" : "#a8a29e",
- border: testMode === mode ? "1px solid #7c3aed30" : "1px solid transparent",
- }}
- >
- {mode}
- </button>
- ))}
- </div>
- </div>
- {/* Quick flow test */}
- {testMode === "flow" && (
- <div className="space-y-1.5">
- <span className="text-[10px] font-semibold uppercase tracking-wider" style={{ color: "#a8a29e" }}>Test Flow</span>
- {GREETING_OPTIONS.map(opt => (
- <button
- key={opt.id}
- onClick={() => {
- if (sessionId) handleOptionClick(opt.id);
- else toast.info("Start a test session first");
- }}
- className="w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-left hover:bg-black/5 transition-colors"
- >
- <div className="w-6 h-6 rounded-md flex items-center justify-center shrink-0" style={{ background: `${opt.color}14`, color: opt.color }}>
- {opt.icon}
- </div>
- <span className="text-[11px] font-medium" style={{ color: "#292524" }}>{opt.label}</span>
- </button>
- ))}
- </div>
- )}
- {/* Execution path */}
- <div className="space-y-1.5">
- <span className="text-[10px] font-semibold uppercase tracking-wider" style={{ color: "#a8a29e" }}>Execution Path</span>
- {flowPath.length === 0 ? (
- <p className="text-[11px] italic" style={{ color: "#d6d3d1" }}>No flow steps recorded yet</p>
- ) : (
- <div className="space-y-1">
- {flowPath.map((step, i) => (
- <div key={i} className="flex items-center gap-1.5">
- <div className="w-4 h-4 rounded-full flex items-center justify-center text-[9px] font-bold" style={{ background: "#7c3aed14", color: "#7c3aed" }}>
- {i + 1}
- </div>
- <span className="text-[10px]" style={{ color: "#78716C" }}>{step}</span>
- </div>
- ))}
- </div>
- )}
- </div>
- {/* Session info */}
- {sessionId && (
- <div className="p-2 rounded-lg" style={{ background: "#f5f0e8" }}>
- <span className="text-[10px] font-semibold uppercase tracking-wider" style={{ color: "#a8a29e" }}>Session</span>
- <p className="text-[10px] font-mono mt-0.5 truncate" style={{ color: "#78716C" }}>{sessionId}</p>
- <p className="text-[10px] mt-1" style={{ color: "#a8a29e" }}>
- {messages.length} messages · {selectedFlow ? `Flow: ${selectedFlow}` : "No flow selected"}
- </p>
- </div>
- )}
- </div>
- {/* Center — Chat preview (simulated customer view) */}
- <div className="flex-1 flex items-center justify-center p-6" style={{ background: "#f5f0e8" }}>
- <div
- className="w-[400px] max-w-full rounded-2xl shadow-2xl overflow-hidden flex flex-col"
- style={{
- height: "580px",
- background: "#FFFBEB",
- border: "1px solid #e7e0d5",
- boxShadow: "0 20px 60px rgba(0,0,0,0.12)",
- }}
- >
- {/* Chat header */}
- <div className="px-4 py-3 flex items-center justify-between" style={{ background: "linear-gradient(135deg, #14532D 0%, #166534 100%)" }}>
- <div className="flex items-center gap-2.5">
- <div className="w-9 h-9 rounded-full flex items-center justify-center" style={{ background: "rgba(255,255,255,0.15)" }}>
- <Sparkles className="w-4.5 h-4.5 text-white" />
- </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">
- <span className="w-1.5 h-1.5 rounded-full bg-green-400" />
- <span className="text-[10px] text-green-200">Always happy to help</span>
- </div>
- </div>
- </div>
- <div className="flex items-center gap-1">
- <span className="text-[9px] px-2 py-0.5 rounded-full font-medium" style={{ background: "rgba(255,255,255,0.15)", color: "#fff" }}>
- TEST MODE
- </span>
- </div>
- </div>
- {/* Messages area */}
- <div className="flex-1 overflow-y-auto p-3 space-y-3">
- {!sessionId ? (
- <div className="flex flex-col items-center justify-center h-full text-center">
- <div className="w-14 h-14 rounded-full flex items-center justify-center mb-3" style={{ background: "#14532D14" }}>
- <MessageCircle className="w-7 h-7" style={{ color: "#14532D" }} />
- </div>
- <p className="text-sm font-medium" style={{ color: "#292524" }}>Ready to test</p>
- <p className="text-xs mt-1" style={{ color: "#a8a29e" }}>Click "Start Test" to begin a chatbot session</p>
- </div>
- ) : (
- <>
- {/* Greeting with options */}
- {showGreeting && messages.length === 0 && (
- <div className="space-y-3">
- <div className="flex gap-2">
- <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="px-3 py-2 rounded-xl text-[13px] leading-relaxed" style={{ background: "#f5f0e8", color: "#292524", borderBottomLeftRadius: "4px" }}>
- <span role="img" aria-label="wave">👋</span> What can we help you with today?
- </div>
- </div>
- <div className="flex flex-wrap gap-2 justify-center px-4">
- {GREETING_OPTIONS.map(opt => (
- <button
- key={opt.id}
- onClick={() => handleOptionClick(opt.id)}
- className="px-4 py-2 rounded-full text-[12px] font-medium transition-all hover:shadow-md"
- style={{
- background: "#fff",
- color: "#0369a1",
- border: "1.5px solid #0369a130",
- }}
- >
- {opt.label}
- </button>
- ))}
- </div>
- </div>
- )}
- {/* Message list */}
- {messages.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>
- );
- })}
- {/* Typing indicator */}
- {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>
- )}
- {/* Greeting options after messages if still in greeting state */}
- {showGreeting && messages.length > 0 && (
- <div className="flex flex-wrap gap-2 justify-center px-4">
- {GREETING_OPTIONS.map(opt => (
- <button
- key={opt.id}
- onClick={() => handleOptionClick(opt.id)}
- className="px-4 py-2 rounded-full text-[12px] font-medium transition-all hover:shadow-md"
- style={{ background: "#fff", color: "#0369a1", border: "1.5px solid #0369a130" }}
- >
- {opt.label}
- </button>
- ))}
- </div>
- )}
- </>
- )}
- <div ref={messagesEndRef} />
- </div>
- {/* Input area */}
- <div className="p-3 border-t" style={{ borderColor: "#e7e0d5" }}>
- {showGreeting && sessionId && messages.length === 0 ? (
- <p className="text-center text-[11px] py-1" style={{ color: "#a8a29e" }}>
- Hit the buttons to respond
- </p>
- ) : (
- <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 || !sessionId}
- />
- <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: "#0369a1", 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>
- </div>
- </div>
- </div>
- );
- }
|