Playground.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445
  1. /**
  2. * Playground — Test the chatbot in a simulated customer view
  3. * Features: Interactive chatbot preview, flow selection, execution path tracking, reset
  4. */
  5. import { useState, useRef, useEffect, useMemo } from "react";
  6. import { trpc } from "@/lib/trpc";
  7. import { Button } from "@/components/ui/button";
  8. import { toast } from "sonner";
  9. import {
  10. Play, RotateCcw, Sparkles, User, Headphones,
  11. Send, Bot, Loader2, Package, Truck, RotateCw,
  12. XCircle, ChevronRight, Eye, MessageCircle,
  13. } from "lucide-react";
  14. import { Streamdown } from "streamdown";
  15. /* ─── Quick-reply options matching the greeting ─── */
  16. const GREETING_OPTIONS = [
  17. { id: "orders", label: "Orders", icon: <Package className="w-3.5 h-3.5" />, color: "#14532D" },
  18. { id: "shipping", label: "Shipping", icon: <Truck className="w-3.5 h-3.5" />, color: "#0369a1" },
  19. { id: "returning", label: "Returning", icon: <RotateCw className="w-3.5 h-3.5" />, color: "#ca8a04" },
  20. { id: "cancelling", label: "Cancelling", icon: <XCircle className="w-3.5 h-3.5" />, color: "#dc2626" },
  21. ];
  22. interface TestMessage {
  23. id: number;
  24. sender: "visitor" | "bot" | "agent" | "system";
  25. content: string;
  26. timestamp: string;
  27. flowStep?: string;
  28. }
  29. export default function Playground() {
  30. const [sessionId, setSessionId] = useState<string | null>(null);
  31. const [messages, setMessages] = useState<TestMessage[]>([]);
  32. const [inputText, setInputText] = useState("");
  33. const [isTyping, setIsTyping] = useState(false);
  34. const [showGreeting, setShowGreeting] = useState(true);
  35. const [selectedFlow, setSelectedFlow] = useState<string | null>(null);
  36. const [flowPath, setFlowPath] = useState<string[]>([]);
  37. const [testMode, setTestMode] = useState<"interactive" | "flow">("interactive");
  38. const messagesEndRef = useRef<HTMLDivElement>(null);
  39. const startSession = trpc.chat.startSession.useMutation({
  40. onSuccess: (data) => {
  41. setSessionId(data.sessionId);
  42. },
  43. });
  44. const { data: serverMessages } = trpc.chat.getMessages.useQuery(
  45. { sessionId: sessionId || "" },
  46. { enabled: !!sessionId, refetchInterval: 2000 }
  47. );
  48. const sendMessage = trpc.chat.sendMessage.useMutation({
  49. onSuccess: () => setIsTyping(false),
  50. onError: () => setIsTyping(false),
  51. });
  52. const trackEvent = trpc.analytics.track.useMutation();
  53. // Sync server messages
  54. useEffect(() => {
  55. if (serverMessages?.messages) {
  56. const mapped: TestMessage[] = serverMessages.messages.map((m: any) => ({
  57. id: m.id,
  58. sender: m.sender,
  59. content: m.content,
  60. timestamp: m.createdAt?.toString() || new Date().toISOString(),
  61. }));
  62. setMessages(mapped);
  63. if (mapped.length > 0) setShowGreeting(false);
  64. }
  65. }, [serverMessages]);
  66. // Auto-scroll
  67. useEffect(() => {
  68. messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
  69. }, [messages, isTyping]);
  70. const handleStartTest = () => {
  71. setMessages([]);
  72. setShowGreeting(true);
  73. setSelectedFlow(null);
  74. setFlowPath([]);
  75. setSessionId(null);
  76. startSession.mutate({});
  77. };
  78. const handleReset = () => {
  79. setMessages([]);
  80. setShowGreeting(true);
  81. setSelectedFlow(null);
  82. setFlowPath([]);
  83. setSessionId(null);
  84. setInputText("");
  85. setIsTyping(false);
  86. };
  87. const handleOptionClick = (optionId: string) => {
  88. if (!sessionId) return;
  89. setShowGreeting(false);
  90. setSelectedFlow(optionId);
  91. setFlowPath(prev => [...prev, `Greeting → ${optionId}`]);
  92. trackEvent.mutate({
  93. sessionId,
  94. eventType: "button_clicked",
  95. category: optionId,
  96. metadata: { source: "playground", button: optionId },
  97. });
  98. // Send the option as a message
  99. const text = `I need help with ${optionId}`;
  100. setIsTyping(true);
  101. setMessages(prev => [...prev, {
  102. id: Date.now(),
  103. sender: "visitor",
  104. content: text,
  105. timestamp: new Date().toISOString(),
  106. flowStep: `User selected: ${optionId}`,
  107. }]);
  108. sendMessage.mutate({ sessionId, content: text });
  109. };
  110. const handleSend = () => {
  111. if (!inputText.trim() || !sessionId || isTyping) return;
  112. const text = inputText.trim();
  113. setInputText("");
  114. setIsTyping(true);
  115. setShowGreeting(false);
  116. setMessages(prev => [...prev, {
  117. id: Date.now(),
  118. sender: "visitor",
  119. content: text,
  120. timestamp: new Date().toISOString(),
  121. }]);
  122. sendMessage.mutate({ sessionId, content: text });
  123. };
  124. const handleKeyDown = (e: React.KeyboardEvent) => {
  125. if (e.key === "Enter" && !e.shiftKey) {
  126. e.preventDefault();
  127. handleSend();
  128. }
  129. };
  130. return (
  131. <div className="flex flex-col" style={{ height: "calc(100vh - 4rem)" }}>
  132. {/* Top toolbar */}
  133. <div className="border-b px-4 h-12 flex items-center justify-between shrink-0" style={{ background: "#fff", borderColor: "#e7e0d5" }}>
  134. <div className="flex items-center gap-3">
  135. <div className="w-7 h-7 rounded-lg flex items-center justify-center" style={{ background: "#7c3aed" }}>
  136. <Play className="w-3.5 h-3.5 text-white" />
  137. </div>
  138. <span className="text-sm font-bold" style={{ color: "#7c3aed", fontFamily: "'Playfair Display', serif" }}>Playground</span>
  139. {sessionId && (
  140. <span className="text-[10px] px-2 py-0.5 rounded-full font-medium" style={{ background: "#14532D14", color: "#14532D" }}>
  141. Session active
  142. </span>
  143. )}
  144. </div>
  145. <div className="flex items-center gap-2">
  146. <Button onClick={handleReset} variant="outline" size="sm" className="text-xs">
  147. <RotateCcw className="w-3 h-3 mr-1" /> Reset
  148. </Button>
  149. <Button onClick={handleStartTest} size="sm" className="text-xs text-white" style={{ background: "#7c3aed" }}>
  150. <Play className="w-3 h-3 mr-1" /> {sessionId ? "Restart" : "Start Test"}
  151. </Button>
  152. </div>
  153. </div>
  154. <div className="flex-1 flex min-h-0">
  155. {/* Left panel — Flow execution path */}
  156. <div className="w-64 shrink-0 border-r overflow-y-auto p-3 space-y-3" style={{ background: "#fff", borderColor: "#e7e0d5" }}>
  157. <h3 className="text-xs font-bold uppercase tracking-wider" style={{ color: "#78716C" }}>
  158. Test Controls
  159. </h3>
  160. {/* Mode selector */}
  161. <div className="space-y-1">
  162. <span className="text-[10px] font-semibold uppercase tracking-wider" style={{ color: "#a8a29e" }}>Mode</span>
  163. <div className="flex gap-1">
  164. {(["interactive", "flow"] as const).map(mode => (
  165. <button
  166. key={mode}
  167. onClick={() => setTestMode(mode)}
  168. className="flex-1 px-2 py-1.5 rounded-lg text-[11px] font-medium capitalize transition-colors"
  169. style={{
  170. background: testMode === mode ? "#7c3aed14" : "transparent",
  171. color: testMode === mode ? "#7c3aed" : "#a8a29e",
  172. border: testMode === mode ? "1px solid #7c3aed30" : "1px solid transparent",
  173. }}
  174. >
  175. {mode}
  176. </button>
  177. ))}
  178. </div>
  179. </div>
  180. {/* Quick flow test */}
  181. {testMode === "flow" && (
  182. <div className="space-y-1.5">
  183. <span className="text-[10px] font-semibold uppercase tracking-wider" style={{ color: "#a8a29e" }}>Test Flow</span>
  184. {GREETING_OPTIONS.map(opt => (
  185. <button
  186. key={opt.id}
  187. onClick={() => {
  188. if (sessionId) handleOptionClick(opt.id);
  189. else toast.info("Start a test session first");
  190. }}
  191. className="w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-left hover:bg-black/5 transition-colors"
  192. >
  193. <div className="w-6 h-6 rounded-md flex items-center justify-center shrink-0" style={{ background: `${opt.color}14`, color: opt.color }}>
  194. {opt.icon}
  195. </div>
  196. <span className="text-[11px] font-medium" style={{ color: "#292524" }}>{opt.label}</span>
  197. </button>
  198. ))}
  199. </div>
  200. )}
  201. {/* Execution path */}
  202. <div className="space-y-1.5">
  203. <span className="text-[10px] font-semibold uppercase tracking-wider" style={{ color: "#a8a29e" }}>Execution Path</span>
  204. {flowPath.length === 0 ? (
  205. <p className="text-[11px] italic" style={{ color: "#d6d3d1" }}>No flow steps recorded yet</p>
  206. ) : (
  207. <div className="space-y-1">
  208. {flowPath.map((step, i) => (
  209. <div key={i} className="flex items-center gap-1.5">
  210. <div className="w-4 h-4 rounded-full flex items-center justify-center text-[9px] font-bold" style={{ background: "#7c3aed14", color: "#7c3aed" }}>
  211. {i + 1}
  212. </div>
  213. <span className="text-[10px]" style={{ color: "#78716C" }}>{step}</span>
  214. </div>
  215. ))}
  216. </div>
  217. )}
  218. </div>
  219. {/* Session info */}
  220. {sessionId && (
  221. <div className="p-2 rounded-lg" style={{ background: "#f5f0e8" }}>
  222. <span className="text-[10px] font-semibold uppercase tracking-wider" style={{ color: "#a8a29e" }}>Session</span>
  223. <p className="text-[10px] font-mono mt-0.5 truncate" style={{ color: "#78716C" }}>{sessionId}</p>
  224. <p className="text-[10px] mt-1" style={{ color: "#a8a29e" }}>
  225. {messages.length} messages · {selectedFlow ? `Flow: ${selectedFlow}` : "No flow selected"}
  226. </p>
  227. </div>
  228. )}
  229. </div>
  230. {/* Center — Chat preview (simulated customer view) */}
  231. <div className="flex-1 flex items-center justify-center p-6" style={{ background: "#f5f0e8" }}>
  232. <div
  233. className="w-[400px] max-w-full rounded-2xl shadow-2xl overflow-hidden flex flex-col"
  234. style={{
  235. height: "580px",
  236. background: "#FFFBEB",
  237. border: "1px solid #e7e0d5",
  238. boxShadow: "0 20px 60px rgba(0,0,0,0.12)",
  239. }}
  240. >
  241. {/* Chat header */}
  242. <div className="px-4 py-3 flex items-center justify-between" style={{ background: "linear-gradient(135deg, #14532D 0%, #166534 100%)" }}>
  243. <div className="flex items-center gap-2.5">
  244. <div className="w-9 h-9 rounded-full flex items-center justify-center" style={{ background: "rgba(255,255,255,0.15)" }}>
  245. <Sparkles className="w-4.5 h-4.5 text-white" />
  246. </div>
  247. <div>
  248. <div className="text-sm font-semibold text-white" style={{ fontFamily: "'Source Sans 3', sans-serif" }}>
  249. Ellie
  250. </div>
  251. <div className="flex items-center gap-1">
  252. <span className="w-1.5 h-1.5 rounded-full bg-green-400" />
  253. <span className="text-[10px] text-green-200">Always happy to help</span>
  254. </div>
  255. </div>
  256. </div>
  257. <div className="flex items-center gap-1">
  258. <span className="text-[9px] px-2 py-0.5 rounded-full font-medium" style={{ background: "rgba(255,255,255,0.15)", color: "#fff" }}>
  259. TEST MODE
  260. </span>
  261. </div>
  262. </div>
  263. {/* Messages area */}
  264. <div className="flex-1 overflow-y-auto p-3 space-y-3">
  265. {!sessionId ? (
  266. <div className="flex flex-col items-center justify-center h-full text-center">
  267. <div className="w-14 h-14 rounded-full flex items-center justify-center mb-3" style={{ background: "#14532D14" }}>
  268. <MessageCircle className="w-7 h-7" style={{ color: "#14532D" }} />
  269. </div>
  270. <p className="text-sm font-medium" style={{ color: "#292524" }}>Ready to test</p>
  271. <p className="text-xs mt-1" style={{ color: "#a8a29e" }}>Click "Start Test" to begin a chatbot session</p>
  272. </div>
  273. ) : (
  274. <>
  275. {/* Greeting with options */}
  276. {showGreeting && messages.length === 0 && (
  277. <div className="space-y-3">
  278. <div className="flex gap-2">
  279. <div className="w-6 h-6 rounded-full flex items-center justify-center shrink-0 mt-1" style={{ background: "#14532D", color: "#fff" }}>
  280. <Sparkles className="w-3 h-3" />
  281. </div>
  282. <div className="px-3 py-2 rounded-xl text-[13px] leading-relaxed" style={{ background: "#f5f0e8", color: "#292524", borderBottomLeftRadius: "4px" }}>
  283. <span role="img" aria-label="wave">👋</span> What can we help you with today?
  284. </div>
  285. </div>
  286. <div className="flex flex-wrap gap-2 justify-center px-4">
  287. {GREETING_OPTIONS.map(opt => (
  288. <button
  289. key={opt.id}
  290. onClick={() => handleOptionClick(opt.id)}
  291. className="px-4 py-2 rounded-full text-[12px] font-medium transition-all hover:shadow-md"
  292. style={{
  293. background: "#fff",
  294. color: "#0369a1",
  295. border: "1.5px solid #0369a130",
  296. }}
  297. >
  298. {opt.label}
  299. </button>
  300. ))}
  301. </div>
  302. </div>
  303. )}
  304. {/* Message list */}
  305. {messages.map((msg) => {
  306. const isVisitor = msg.sender === "visitor";
  307. const isBot = msg.sender === "bot";
  308. return (
  309. <div key={msg.id} className={`flex gap-2 ${isVisitor ? "justify-end" : "justify-start"}`}>
  310. {!isVisitor && (
  311. <div
  312. className="w-6 h-6 rounded-full flex items-center justify-center shrink-0 mt-1"
  313. style={{ background: isBot ? "#14532D" : "#C2410C", color: "#fff" }}
  314. >
  315. {isBot ? <Sparkles className="w-3 h-3" /> : <Headphones className="w-3 h-3" />}
  316. </div>
  317. )}
  318. <div
  319. className="max-w-[80%] px-3 py-2 rounded-xl text-[13px] leading-relaxed"
  320. style={{
  321. background: isVisitor ? "#14532D" : "#f5f0e8",
  322. color: isVisitor ? "#fff" : "#292524",
  323. fontFamily: "'Source Sans 3', sans-serif",
  324. borderBottomRightRadius: isVisitor ? "4px" : undefined,
  325. borderBottomLeftRadius: !isVisitor ? "4px" : undefined,
  326. }}
  327. >
  328. {isBot || msg.sender === "agent" ? (
  329. <div className="prose prose-sm max-w-none" style={{ color: "inherit" }}>
  330. <Streamdown>{msg.content}</Streamdown>
  331. </div>
  332. ) : (
  333. msg.content
  334. )}
  335. </div>
  336. {isVisitor && (
  337. <div className="w-6 h-6 rounded-full flex items-center justify-center shrink-0 mt-1" style={{ background: "#78716C20", color: "#78716C" }}>
  338. <User className="w-3 h-3" />
  339. </div>
  340. )}
  341. </div>
  342. );
  343. })}
  344. {/* Typing indicator */}
  345. {isTyping && (
  346. <div className="flex gap-2 items-start">
  347. <div className="w-6 h-6 rounded-full flex items-center justify-center shrink-0" style={{ background: "#14532D", color: "#fff" }}>
  348. <Sparkles className="w-3 h-3" />
  349. </div>
  350. <div className="px-3 py-2 rounded-xl" style={{ background: "#f5f0e8" }}>
  351. <div className="flex gap-1">
  352. <span className="w-1.5 h-1.5 rounded-full animate-bounce" style={{ background: "#a8a29e", animationDelay: "0ms" }} />
  353. <span className="w-1.5 h-1.5 rounded-full animate-bounce" style={{ background: "#a8a29e", animationDelay: "150ms" }} />
  354. <span className="w-1.5 h-1.5 rounded-full animate-bounce" style={{ background: "#a8a29e", animationDelay: "300ms" }} />
  355. </div>
  356. </div>
  357. </div>
  358. )}
  359. {/* Greeting options after messages if still in greeting state */}
  360. {showGreeting && messages.length > 0 && (
  361. <div className="flex flex-wrap gap-2 justify-center px-4">
  362. {GREETING_OPTIONS.map(opt => (
  363. <button
  364. key={opt.id}
  365. onClick={() => handleOptionClick(opt.id)}
  366. className="px-4 py-2 rounded-full text-[12px] font-medium transition-all hover:shadow-md"
  367. style={{ background: "#fff", color: "#0369a1", border: "1.5px solid #0369a130" }}
  368. >
  369. {opt.label}
  370. </button>
  371. ))}
  372. </div>
  373. )}
  374. </>
  375. )}
  376. <div ref={messagesEndRef} />
  377. </div>
  378. {/* Input area */}
  379. <div className="p-3 border-t" style={{ borderColor: "#e7e0d5" }}>
  380. {showGreeting && sessionId && messages.length === 0 ? (
  381. <p className="text-center text-[11px] py-1" style={{ color: "#a8a29e" }}>
  382. Hit the buttons to respond
  383. </p>
  384. ) : (
  385. <div className="flex gap-2">
  386. <input
  387. type="text"
  388. value={inputText}
  389. onChange={(e) => setInputText(e.target.value)}
  390. onKeyDown={handleKeyDown}
  391. placeholder="Type your message..."
  392. className="flex-1 px-3 py-2 rounded-xl text-sm border"
  393. style={{ borderColor: "#e7e0d5", fontFamily: "'Source Sans 3', sans-serif", background: "#fff" }}
  394. disabled={isTyping || !sessionId}
  395. />
  396. <button
  397. onClick={handleSend}
  398. disabled={!inputText.trim() || isTyping || !sessionId}
  399. className="w-9 h-9 rounded-xl flex items-center justify-center transition-all disabled:opacity-40"
  400. style={{ background: "#0369a1", color: "#fff" }}
  401. >
  402. <Send className="w-4 h-4" />
  403. </button>
  404. </div>
  405. )}
  406. <div className="text-center mt-2">
  407. <span className="text-[10px]" style={{ color: "#d6d3d1" }}>Powered by Ellie · Homelegance AI</span>
  408. </div>
  409. </div>
  410. </div>
  411. </div>
  412. </div>
  413. </div>
  414. );
  415. }