@personaliai/react-widget 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1919 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect, useRef } from "react";
4
+ import ReactMarkdown, { type Components } from "react-markdown";
5
+ import remarkGfm from "remark-gfm";
6
+ import rehypeKatex from "rehype-katex";
7
+ import remarkMath from "remark-math";
8
+ import { SafeMarkdownLink } from "./safe-markdown-link";
9
+ import { motion, AnimatePresence } from "framer-motion";
10
+ import { QuickEmojiPicker } from "./quick-emoji-picker";
11
+ import { AttachMenu } from "./attach-menu";
12
+ import VoiceCallWidget from "./voice-call-widget";
13
+ import { getOnColor, primaryColorCssVars, buildColorSchemeCss, type WidgetColorScheme } from "./color-contrast";
14
+ import { normalizeWidgetStyle } from "./widget-style";
15
+ // CSS is shipped separately (dist/styles.css, plus katex's own CSS) instead
16
+ // of side-effect-imported here — a library bundling its own CSS import
17
+ // requires the CONSUMER's bundler to understand raw CSS imports the exact
18
+ // way this package's own build does, which isn't a safe assumption across
19
+ // arbitrary React setups (Next.js, CRA, Vite, etc. all differ). See this
20
+ // package's README for the two imports a consumer needs to add once.
21
+ import {
22
+ Send, Loader2, Sparkles, MessageSquare, FileText, Search,
23
+ Paperclip, Smile, Mic, Square, ChevronRight, ArrowLeft, X,
24
+ ArrowUp, ArrowRight, RefreshCw, Bot, Headphones, User, Check, AlertCircle,
25
+ Link2, ThumbsUp, ThumbsDown, Mail, Bell, Phone,
26
+ type LucideIcon,
27
+ } from "lucide-react";
28
+
29
+ // Preset assistant avatar icons (selectable in the customizer).
30
+ const AVATAR_ICONS: Record<string, LucideIcon> = {
31
+ bot: Bot, headset: Headphones, sparkles: Sparkles, message: MessageSquare, user: User,
32
+ };
33
+
34
+ // process.env.NEXT_PUBLIC_BACKEND_URL is a Next.js/webpack-only convention —
35
+ // consumers of this package may be on Vite, CRA, plain esbuild, etc., where
36
+ // `process` isn't defined as a global at all, so a bare `process.env.X`
37
+ // reference throws ReferenceError before this module even finishes loading.
38
+ // The typeof guard makes this safe everywhere; consumers who DO run under
39
+ // Next.js/webpack and set NEXT_PUBLIC_BACKEND_URL still get it honored.
40
+ const BACKEND_URL =
41
+ (typeof process !== "undefined" ? process.env?.NEXT_PUBLIC_BACKEND_URL : undefined) ??
42
+ "https://api.chatty.personaliai.com";
43
+
44
+ const RECORD_BAR_COUNT = 14;
45
+
46
+ // Send-button variants (icon + shape). Keyed by chatty_bots.send_button_style.
47
+ const SEND_BUTTON_STYLES: Record<string, { shape: string; icon: React.ReactNode; label?: string }> = {
48
+ plane: { shape: "size-8 rounded-full", icon: <Send className="size-4" /> },
49
+ arrowUp: { shape: "size-8 rounded-full", icon: <ArrowUp className="size-4" /> },
50
+ arrowRight: { shape: "size-8 rounded-full", icon: <ArrowRight className="size-4" /> },
51
+ square: { shape: "size-8 rounded-lg", icon: <Send className="size-4" /> },
52
+ label: { shape: "h-8 px-3.5 rounded-full gap-1.5", icon: <Send className="size-3.5" />, label: "Send" },
53
+ };
54
+
55
+ // Browsers record audio as webm/opus, which Gemini does NOT accept. Decode and
56
+ // re-encode to 16-bit mono WAV (a Gemini-supported format) client-side.
57
+ async function audioBlobToWav(blob: Blob): Promise<Blob> {
58
+ const AC: typeof AudioContext = (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext);
59
+ const ctx = new AC();
60
+ const audioBuf = await ctx.decodeAudioData(await blob.arrayBuffer());
61
+ ctx.close();
62
+ const len = audioBuf.length;
63
+ // A near-instant tap-to-stop can decode to an AudioBuffer with ~0 samples —
64
+ // that still produces a "valid" (44-byte-header) WAV with no audio content,
65
+ // which Gemini silently treats as empty. Require a minimum of ~150ms.
66
+ if (len < audioBuf.sampleRate * 0.15) {
67
+ throw new Error("Recording too short");
68
+ }
69
+ const rate = audioBuf.sampleRate;
70
+ const numCh = audioBuf.numberOfChannels;
71
+ const mono = new Float32Array(len);
72
+ for (let ch = 0; ch < numCh; ch++) {
73
+ const d = audioBuf.getChannelData(ch);
74
+ for (let i = 0; i < len; i++) mono[i] += d[i] / numCh;
75
+ }
76
+ const view = new DataView(new ArrayBuffer(44 + len * 2));
77
+ const ws = (o: number, s: string) => { for (let i = 0; i < s.length; i++) view.setUint8(o + i, s.charCodeAt(i)); };
78
+ ws(0, "RIFF"); view.setUint32(4, 36 + len * 2, true); ws(8, "WAVE"); ws(12, "fmt ");
79
+ view.setUint32(16, 16, true); view.setUint16(20, 1, true); view.setUint16(22, 1, true);
80
+ view.setUint32(24, rate, true); view.setUint32(28, rate * 2, true); view.setUint16(32, 2, true);
81
+ view.setUint16(34, 16, true); ws(36, "data"); view.setUint32(40, len * 2, true);
82
+ let off = 44;
83
+ for (let i = 0; i < len; i++) { const s = Math.max(-1, Math.min(1, mono[i])); view.setInt16(off, s < 0 ? s * 0x8000 : s * 0x7fff, true); off += 2; }
84
+ return new Blob([view], { type: "audio/wav" });
85
+ }
86
+
87
+ interface Citation { name: string; type: string; url?: string | null; }
88
+ interface Message {
89
+ role: "user" | "assistant";
90
+ content: string;
91
+ fileUrl?: string;
92
+ fileType?: string;
93
+ sources?: Citation[];
94
+ feedback?: "up" | "down";
95
+ // Only set on assistant messages, and only meaningful when the customizer's
96
+ // "show AI / Human tag" setting is on. /api/widget/poll and /api/widget/live
97
+ // only ever return human-agent replies (server-side filtered), so any
98
+ // message arriving through those two paths is unambiguously "human" —
99
+ // everything else assistant-role is a direct AI reply.
100
+ sender?: "ai" | "human";
101
+ }
102
+ interface Source { id: string; name: string; content: string; }
103
+
104
+ // Visual-flow config parsed out of the bot's custom JS (built by the flow
105
+ // builder in the dashboard). Nodes/edges follow React Flow's shape.
106
+ interface FlowNode {
107
+ id: string;
108
+ type?: string;
109
+ data?: { label?: string };
110
+ }
111
+ interface FlowEdge {
112
+ source: string;
113
+ target: string;
114
+ label?: string;
115
+ data?: { label?: string };
116
+ }
117
+ interface FlowConfig {
118
+ status?: string;
119
+ nodes: FlowNode[];
120
+ edges: FlowEdge[];
121
+ }
122
+
123
+ type Tab = "home" | "messages" | "articles" | "search";
124
+
125
+ function CodeBlock({ lang, text }: { lang: string; text: string }) {
126
+ const [copied, setCopied] = useState(false);
127
+ const copy = () => {
128
+ navigator.clipboard.writeText(text).then(() => {
129
+ setCopied(true);
130
+ setTimeout(() => setCopied(false), 2000);
131
+ });
132
+ };
133
+ return (
134
+ <div className="my-2 rounded-lg overflow-hidden border border-neutral-200 dark:border-neutral-700 text-[11px]">
135
+ <div className="flex items-center justify-between px-3 py-1.5 bg-neutral-100 dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700">
136
+ <span className="text-neutral-500 dark:text-neutral-400 font-mono">{lang}</span>
137
+ <button
138
+ onClick={copy}
139
+ className="flex items-center gap-1 text-neutral-500 dark:text-neutral-400 hover:text-neutral-800 dark:hover:text-neutral-100 transition-colors"
140
+ >
141
+ {copied ? <Check className="size-3" /> : <svg className="size-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect width="14" height="14" x="8" y="8" rx="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>}
142
+ <span>{copied ? "Copied!" : "Copy"}</span>
143
+ </button>
144
+ </div>
145
+ <pre className="p-3 overflow-x-auto bg-neutral-50 dark:bg-neutral-900 font-mono leading-relaxed whitespace-pre">
146
+ <code>{text}</code>
147
+ </pre>
148
+ </div>
149
+ );
150
+ }
151
+
152
+ export interface ChatWidgetCoreProps {
153
+ botId: string;
154
+ originToken: string | null;
155
+ isPreview?: boolean;
156
+ paramColor?: string | null;
157
+ paramStyle?: string | null;
158
+ paramName?: string | null;
159
+ paramWelcome?: string | null;
160
+ paramAvatarIcon?: string | null;
161
+ paramAvatarUrl?: string | null;
162
+ paramLogoUrl?: string | null;
163
+ paramLogoBgColor?: string | null;
164
+ paramShowSenderTag?: string | null;
165
+ paramCsatEnabled?: string | null;
166
+ paramColorScheme?: string | null;
167
+ // The following are only ever passed by widget-entry.tsx (the standalone
168
+ // Shadow DOM mount) — EmbedClient.tsx (the Next.js iframe route) never
169
+ // passes them, so every branch below that checks one of these falls back
170
+ // to exactly the postMessage-based behavior this component always had,
171
+ // unchanged, for the iframe path. These bridge props exist for a
172
+ // same-realm host (e.g. a Shadow DOM mount) where direct calls can
173
+ // replace postMessage / window.addEventListener("message").
174
+ onWidgetReady?: () => void;
175
+ onWidgetClose?: () => void;
176
+ onAssistantMessage?: () => void;
177
+ onRequestNotificationPermission?: (botName: string, avatarUrl?: string | null) => void;
178
+ onTriggerNotification?: (botName: string, bodyText: string, avatarUrl?: string | null) => void;
179
+ // Controlled equivalents of the two signals that used to arrive via
180
+ // window.addEventListener("message") from the parent frame
181
+ // (chatty-fullscreen, chatty-notification-status). Left undefined by
182
+ // EmbedClient.tsx, so the existing message listener below still drives
183
+ // them for the iframe path exactly as before.
184
+ forceFullscreen?: boolean;
185
+ notificationGranted?: boolean;
186
+ }
187
+
188
+ export default function ChatWidgetCore({
189
+ botId,
190
+ originToken,
191
+ isPreview = false,
192
+ paramColor = null,
193
+ paramStyle = null,
194
+ paramName = null,
195
+ paramWelcome = null,
196
+ paramAvatarIcon = null,
197
+ paramAvatarUrl = null,
198
+ paramLogoUrl = null,
199
+ paramLogoBgColor = null,
200
+ paramShowSenderTag = null,
201
+ paramCsatEnabled = null,
202
+ paramColorScheme = null,
203
+ onWidgetReady,
204
+ onWidgetClose,
205
+ onAssistantMessage,
206
+ onRequestNotificationPermission,
207
+ onTriggerNotification,
208
+ forceFullscreen,
209
+ notificationGranted,
210
+ }: ChatWidgetCoreProps) {
211
+ const widgetTokenHeader: Record<string, string> = originToken ? { "X-Widget-Token": originToken } : {};
212
+
213
+ // Scope stored session + history per embedding site, so different host sites
214
+ // (and the dashboard playground) don't share one conversation.
215
+ const hostKey = (() => {
216
+ if (typeof window === "undefined") return "direct";
217
+ try {
218
+ const p = new URLSearchParams(window.location.search).get("host");
219
+ if (p) return p;
220
+ if (document.referrer) return new URL(document.referrer).hostname;
221
+ } catch {}
222
+ return "direct";
223
+ })();
224
+
225
+ // Notify the parent widget loader of a new assistant reply (unread badge).
226
+ const notifyParent = () => {
227
+ if (onAssistantMessage) { onAssistantMessage(); return; }
228
+ try { window.parent?.postMessage({ type: "chatty:message", role: "assistant" }, "*"); } catch {}
229
+ };
230
+
231
+ const notifyClose = () => {
232
+ if (onWidgetClose) { onWidgetClose(); return; }
233
+ try { window.parent?.postMessage({ type: "chatty:close" }, "*"); } catch {}
234
+ };
235
+
236
+ const avatarInner = (iconCls: string) => {
237
+ // avatarUrl/logoUrl are bot-owner-uploaded URLs (or arbitrary external URLs
238
+ // via query params in preview mode) not in next/image's domain allowlist.
239
+ if (avatarIcon === "custom" && avatarUrl) return <img src={avatarUrl} alt="" className="size-full object-cover" />; // eslint-disable-line @next/next/no-img-element
240
+ if (avatarIcon && avatarIcon !== "logo" && AVATAR_ICONS[avatarIcon]) {
241
+ const Icon = AVATAR_ICONS[avatarIcon];
242
+ return <Icon className={iconCls} />;
243
+ }
244
+ if (logoUrl) return <img src={logoUrl} alt="" className="size-full object-cover" />; // eslint-disable-line @next/next/no-img-element
245
+ return botName[0]?.toUpperCase();
246
+ };
247
+
248
+ const headerLogoInner = (iconCls: string) => {
249
+ if (logoUrl) return <img src={logoUrl} alt="" className="w-[34px] h-[34px] object-contain rounded-full" />; // eslint-disable-line @next/next/no-img-element
250
+ return avatarInner(iconCls);
251
+ };
252
+
253
+ const clearChat = () => {
254
+ const fresh = `v-${crypto.randomUUID()}`;
255
+ try {
256
+ localStorage.setItem(`chatty_sid_${botId}_${hostKey}`, fresh);
257
+ localStorage.removeItem(`chatty_msgs_${botId}_${hostKey}`);
258
+ } catch {}
259
+ setSessionId(fresh);
260
+ lastPollRef.current = new Date().toISOString();
261
+
262
+ if (flowConfig) {
263
+ const startEdge = flowConfig.edges?.find((e) => e.source === "start");
264
+ if (startEdge) {
265
+ const firstNode = flowConfig.nodes?.find((n) => n.id === startEdge.target);
266
+ if (firstNode) {
267
+ setMessages([]);
268
+ executeFlowNode(firstNode, flowConfig);
269
+ return;
270
+ }
271
+ }
272
+ }
273
+ setMessages([{ role: "assistant", content: welcomeMsg, sender: "ai" }]);
274
+ };
275
+
276
+ const [loading, setLoading] = useState(true);
277
+ const [botName, setBotName] = useState("Chatty Assistant");
278
+ const [welcomeMsg, setWelcomeMsg] = useState("Hello! How can I help you today?");
279
+ const [starters, setStarters] = useState<string[]>([]);
280
+ const [sendStyle, setSendStyle] = useState("plane");
281
+ const [avatarIcon, setAvatarIcon] = useState("logo");
282
+ const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
283
+ const [hideBranding, setHideBranding] = useState(false);
284
+ const [showSenderTag, setShowSenderTag] = useState(false);
285
+ const [csatEnabled, setCsatEnabled] = useState(true);
286
+ const [customCss, setCustomCss] = useState("");
287
+ const [customJs, setCustomJs] = useState("");
288
+ const [primaryColor, setPrimaryColor] = useState("#f97316");
289
+ // Guaranteed-legible text color for anything painted with primaryColor —
290
+ // the business owner picks that color freely, so a hardcoded white/black
291
+ // text class goes invisible the moment they pick the "wrong" half of the
292
+ // lightness spectrum. Computed via WCAG contrast, not assumed.
293
+ const onPrimary = getOnColor(primaryColor);
294
+ const [widgetStyle, setWidgetStyle] = useState("minimal");
295
+ // Per-section colors (header/bot-bubble/user-bubble/input-bar/send-btn) —
296
+ // null until the owner sets at least one in the Customizer, at which
297
+ // point it takes over from the preset's own primaryColor-driven CSS
298
+ // entirely (applied via an injected !important stylesheet below, the
299
+ // only thing that reliably beats globals.css's .style-* !important rules).
300
+ const [colorScheme, setColorScheme] = useState<WidgetColorScheme | null>(null);
301
+ const [logoUrl, setLogoUrl] = useState<string | null>(null);
302
+ const [logoBgColor, setLogoBgColor] = useState("");
303
+ const [voiceEnabled, setVoiceEnabled] = useState(false);
304
+ const [voiceCallOpen, setVoiceCallOpen] = useState(false);
305
+
306
+ const [tab, setTab] = useState<Tab>("messages");
307
+ const [messages, setMessages] = useState<Message[]>([]);
308
+ const [inputValue, setInputValue] = useState("");
309
+ const [isBotResponding, setIsBotResponding] = useState(false);
310
+ const [emojiOpen, setEmojiOpen] = useState(false);
311
+ const [attachOpen, setAttachOpen] = useState(false);
312
+
313
+ // setSources is currently unused: the Articles tab renders from this list but
314
+ // nothing yet populates it from the backend (help-articles feed isn't wired up).
315
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
316
+ const [sources, setSources] = useState<Source[]>([]);
317
+ const [openArticle, setOpenArticle] = useState<Source | null>(null);
318
+
319
+ const [searchQuery, setSearchQuery] = useState("");
320
+ const [searchAnswer, setSearchAnswer] = useState<string | null>(null);
321
+ const [searching, setSearching] = useState(false);
322
+
323
+ // ── CSAT, Offline Ticketing, & Typing States ──
324
+ const [showCsat, setShowCsat] = useState(false);
325
+ const [csatRating, setCsatRating] = useState(0);
326
+ const [csatComment, setCsatComment] = useState("");
327
+ const [csatSubmitted, setCsatSubmitted] = useState(false);
328
+
329
+ const [showOfflineForm, setShowOfflineForm] = useState(false);
330
+ const [offlineEmail, setOfflineEmail] = useState("");
331
+ const [offlineMessage, setOfflineMessage] = useState("");
332
+ const [offlineSubmitted, setOfflineSubmitted] = useState(false);
333
+
334
+ const [agentTyping, setAgentTyping] = useState(false);
335
+ // Told by widget.js (postMessage) whenever it switches the panel between
336
+ // the fixed-size desktop popup and mobile-fullscreen — see the message
337
+ // listener below. Defaults to false (rounded), which is also correct for
338
+ // the dashboard's own preview iframe, which never goes through widget.js
339
+ // and so never sends this message.
340
+ const [internalIsFullscreen, setIsFullscreen] = useState(false);
341
+ const isFullscreen = forceFullscreen !== undefined ? forceFullscreen : internalIsFullscreen;
342
+
343
+ // Browser Push Notifications (OneSignal / Native Web Push)
344
+ // Initial value read lazily (not via an effect + setState) so the browser's
345
+ // existing Notification permission is reflected on the very first render.
346
+ const [internalPushGranted, setPushGranted] = useState(() => {
347
+ if (typeof window === "undefined") return false;
348
+ return "Notification" in window && Notification.permission === "granted";
349
+ });
350
+ const pushGranted = notificationGranted !== undefined ? notificationGranted : internalPushGranted;
351
+
352
+ // Root mount element — background/overflow mutations below target this
353
+ // node's own document only when it's not inside a Shadow DOM tree (see
354
+ // the two "transparent background" effects further down). Today
355
+ // ChatWidgetCore always renders as the sole content of a dedicated
356
+ // /embed/[botId] iframe document, so getRootNode() is always the regular
357
+ // Document and this is a no-op change — it only matters once a future
358
+ // standalone bundle mounts this component into a Shadow Root on a host
359
+ // page, where mutating document.body would corrupt the host page itself.
360
+ const rootRef = useRef<HTMLDivElement>(null);
361
+ const isInShadowDom = () => {
362
+ const root = rootRef.current?.getRootNode();
363
+ return typeof ShadowRoot !== "undefined" && root instanceof ShadowRoot;
364
+ };
365
+
366
+ useEffect(() => {
367
+ // Only the iframe path (EmbedClient.tsx never passes forceFullscreen/
368
+ // notificationGranted) still needs this listener — the standalone
369
+ // Shadow DOM path drives both directly via those props instead.
370
+ if (typeof window === "undefined" || forceFullscreen !== undefined || notificationGranted !== undefined) return;
371
+ const handleMessage = (e: MessageEvent) => {
372
+ // The embed can be hosted on any customer domain, so the parent's
373
+ // origin isn't known ahead of time — restrict to messages that
374
+ // actually came from our own parent frame instead.
375
+ if (e.source !== window.parent) return;
376
+ if (e.data && e.data.type === "chatty-notification-status") {
377
+ setPushGranted(!!e.data.granted);
378
+ }
379
+ if (e.data && e.data.type === "chatty-fullscreen") {
380
+ setIsFullscreen(!!e.data.value);
381
+ }
382
+ };
383
+ window.addEventListener("message", handleMessage);
384
+ return () => window.removeEventListener("message", handleMessage);
385
+ }, [forceFullscreen, notificationGranted]);
386
+
387
+ const requestPushPermission = async () => {
388
+ if (typeof window === "undefined") return;
389
+
390
+ if (onRequestNotificationPermission) {
391
+ onRequestNotificationPermission(botName, avatarUrl);
392
+ } else {
393
+ try {
394
+ if (window.parent && window.parent !== window) {
395
+ window.parent.postMessage({
396
+ type: "chatty-request-notification",
397
+ botName,
398
+ avatarUrl: avatarUrl || undefined,
399
+ }, "*");
400
+ }
401
+ } catch {}
402
+ }
403
+
404
+ if ("Notification" in window) {
405
+ try {
406
+ const perm = await Notification.requestPermission();
407
+ if (perm === "granted") {
408
+ setPushGranted(true);
409
+ try {
410
+ new Notification(botName, {
411
+ body: "Notifications enabled! You'll be alerted when support or AI replies.",
412
+ icon: avatarUrl || undefined,
413
+ });
414
+ } catch {}
415
+ } else if (perm === "denied") {
416
+ setPushGranted(false);
417
+ alert("Notification permission was blocked. Please allow notifications in your browser location bar.");
418
+ }
419
+ } catch (err) {
420
+ console.warn("Notification request delegated to parent window", err);
421
+ }
422
+ } else {
423
+ alert("Browser push notifications are not supported on this browser.");
424
+ }
425
+ };
426
+
427
+ const triggerPushRef = useRef<(bodyText: string) => void>(() => {});
428
+ const triggerPush = (bodyText: string) => {
429
+ if (typeof window === "undefined") return;
430
+ if (onTriggerNotification) {
431
+ onTriggerNotification(botName, bodyText, avatarUrl);
432
+ } else {
433
+ try {
434
+ if (window.parent && window.parent !== window) {
435
+ window.parent.postMessage({
436
+ type: "chatty-trigger-notification",
437
+ botName,
438
+ bodyText,
439
+ avatarUrl: avatarUrl || undefined,
440
+ }, "*");
441
+ }
442
+ } catch {}
443
+ }
444
+
445
+ if ("Notification" in window && Notification.permission === "granted" && document.hidden) {
446
+ try {
447
+ new Notification(botName, {
448
+ body: bodyText,
449
+ icon: avatarUrl || undefined,
450
+ });
451
+ } catch {}
452
+ }
453
+ };
454
+ triggerPushRef.current = triggerPush;
455
+
456
+ const [activeNodeId, setActiveNodeId] = useState<string | null>(null);
457
+ const [flowConfig, setFlowConfig] = useState<FlowConfig | null>(null);
458
+ // Track whether the active node is a question node waiting for user typed input
459
+ const [flowAwaitingInput, setFlowAwaitingInput] = useState(false);
460
+
461
+ const cleanLabel = (label: string = "") => {
462
+ return label
463
+ .replace(/^💬\s*(Message:\s*)?/, "")
464
+ .replace(/^❓\s*(Ask:\s*)?/, "")
465
+ .replace(/^🏷️\s*(Tag session:\s*)?/, "")
466
+ .replace(/^🔔\s*(Escalate to Live Agent\s*)?/, "");
467
+ };
468
+
469
+ const isQuestionNode = (node: FlowNode | null | undefined) => {
470
+ const label = node?.data?.label || "";
471
+ return label.startsWith("❓") || node?.type === "question" || node?.id?.startsWith("q-");
472
+ };
473
+
474
+ const executeFlowNode = (node: FlowNode | null | undefined, currentConfig: FlowConfig | null | undefined) => {
475
+ if (!node || !currentConfig) return;
476
+ const label = node.data?.label || "";
477
+
478
+ // Tag node — run silently, auto-advance
479
+ if (label.startsWith("🏷️") || node.id?.startsWith("tag-")) {
480
+ const tagValue = label.replace(/^🏷️\s*(Tag session:\s*)?/, "").replace(/['",]/g, "").trim();
481
+ fetch(`${BACKEND_URL}/api/widget/chat`, {
482
+ method: "POST",
483
+ headers: { "Content-Type": "application/json" },
484
+ body: JSON.stringify({ bot_id: botId, session_id: sessionId, text: `[Flow tag: ${tagValue}]`, is_private_note: true })
485
+ }).catch(() => {});
486
+ const nextEdge = currentConfig.edges.find((e) => e.source === node.id);
487
+ if (nextEdge) {
488
+ const nextNode = currentConfig.nodes.find((n) => n.id === nextEdge.target);
489
+ if (nextNode) executeFlowNode(nextNode, currentConfig);
490
+ }
491
+ }
492
+ // Escalate node
493
+ else if (label.startsWith("🔔") || node.id?.startsWith("esc-")) {
494
+ setLiveAgent(true);
495
+ setFlowAwaitingInput(false);
496
+ setActiveNodeId(null);
497
+ setMessages((prev) => [...prev, { role: "assistant", content: "Connecting you to a live agent now..." }]);
498
+ fetch(`${BACKEND_URL}/api/widget/chat`, {
499
+ method: "POST",
500
+ headers: { "Content-Type": "application/json" },
501
+ body: JSON.stringify({ bot_id: botId, session_id: sessionId, text: "[Visitor requested live agent via flow]", ai_paused: true })
502
+ }).catch(() => {});
503
+ }
504
+ // Question node — display question, wait for typed user input (no branch buttons)
505
+ else if (isQuestionNode(node)) {
506
+ setActiveNodeId(node.id);
507
+ setFlowAwaitingInput(true);
508
+ setIsBotResponding(false);
509
+ setMessages((prev) => [...prev, { role: "assistant", content: cleanLabel(label), sender: "ai" }]);
510
+ }
511
+ // Message node — display, then auto-advance if single unlabeled edge, or show choice buttons
512
+ else {
513
+ setActiveNodeId(node.id);
514
+ setFlowAwaitingInput(false);
515
+ setIsBotResponding(false);
516
+ setMessages((prev) => [...prev, { role: "assistant", content: cleanLabel(label), sender: "ai" }]);
517
+ const outgoing = currentConfig.edges.filter((e) => e.source === node.id);
518
+ if (outgoing.length === 1 && !outgoing[0].label && !outgoing[0].data?.label) {
519
+ // Linear — auto-advance after short delay
520
+ setTimeout(() => {
521
+ const nextNode = currentConfig.nodes.find((n) => n.id === outgoing[0].target);
522
+ if (nextNode) executeFlowNode(nextNode, currentConfig);
523
+ }, 900);
524
+ }
525
+ // Multiple labeled edges → stay on node, show buttons (handled in render)
526
+ }
527
+ };
528
+
529
+ // React Flow stores edge labels in edge.label OR edge.data?.label — resolve both.
530
+ const getEdgeLabel = (edge: FlowEdge): string => edge.label || edge.data?.label || "";
531
+
532
+ const handleFlowChoice = (edge: FlowEdge) => {
533
+ if (!flowConfig) return;
534
+ const label = getEdgeLabel(edge);
535
+ setMessages((prev) => [...prev, { role: "user", content: label || "Continue" }]);
536
+ const targetNode = flowConfig.nodes.find((n) => n.id === edge.target);
537
+ if (targetNode) {
538
+ executeFlowNode(targetNode, flowConfig);
539
+ } else {
540
+ // Flow ended — hand off to real AI
541
+ setActiveNodeId(null);
542
+ setFlowAwaitingInput(false);
543
+ }
544
+ };
545
+
546
+ const submitCsat = async () => {
547
+ if (csatRating === 0) return;
548
+ try {
549
+ const res = await fetch(`${BACKEND_URL}/api/widget/csat`, {
550
+ method: "POST",
551
+ headers: { "Content-Type": "application/json", ...widgetTokenHeader },
552
+ body: JSON.stringify({
553
+ bot_id: botId,
554
+ session_id: sessionId,
555
+ rating: csatRating,
556
+ comment: csatComment,
557
+ }),
558
+ });
559
+ if (!res.ok) throw new Error("csat submit failed");
560
+ setCsatSubmitted(true);
561
+ showToast("Thank you for your feedback!", "success");
562
+ setTimeout(() => { setShowCsat(false); notifyClose(); }, 1500);
563
+ } catch {
564
+ showToast("Failed to submit feedback.", "error");
565
+ }
566
+ };
567
+
568
+ const submitOfflineMessage = async () => {
569
+ if (!offlineEmail.trim() || !offlineMessage.trim()) return;
570
+ try {
571
+ const res = await fetch(`${BACKEND_URL}/api/widget/chat`, {
572
+ method: "POST",
573
+ headers: { "Content-Type": "application/json", ...widgetTokenHeader },
574
+ body: JSON.stringify({
575
+ bot_id: botId,
576
+ session_id: sessionId,
577
+ text: `[Offline Support Ticket]\nEmail: ${offlineEmail}\nMessage: ${offlineMessage}`,
578
+ visitor_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
579
+ host: getHost(),
580
+ }),
581
+ });
582
+ if (res.ok) {
583
+ setOfflineSubmitted(true);
584
+ showToast("Ticket submitted successfully!", "success");
585
+ setOfflineEmail("");
586
+ setOfflineMessage("");
587
+ setTimeout(() => { setShowOfflineForm(false); setOfflineSubmitted(false); }, 2000);
588
+ } else {
589
+ showToast("Error sending message.", "error");
590
+ }
591
+ } catch {
592
+ showToast("Failed to connect to support.", "error");
593
+ }
594
+ };
595
+
596
+ const handleCloseClick = () => {
597
+ if (csatEnabled && messages.length > 2 && !csatSubmitted) {
598
+ setShowCsat(true);
599
+ } else {
600
+ notifyClose();
601
+ }
602
+ };
603
+
604
+ const [recording, setRecording] = useState(false);
605
+ const [barLevels, setBarLevels] = useState<number[]>(() => Array(RECORD_BAR_COUNT).fill(0));
606
+ const [transcribing, setTranscribing] = useState(false);
607
+ const mediaRecorderRef = useRef<MediaRecorder | null>(null);
608
+ const audioChunksRef = useRef<Blob[]>([]);
609
+ const audioContextRef = useRef<AudioContext | null>(null);
610
+ const animationFrameRef = useRef<number | null>(null);
611
+
612
+ const [liveAgent, setLiveAgent] = useState(false);
613
+ const messagesEndRef = useRef<HTMLDivElement>(null);
614
+ const fileInputRef = useRef<HTMLInputElement>(null);
615
+ const [pendingFiles, setPendingFiles] = useState<{file: File; preview: string}[]>([]);
616
+ const lastPollRef = useRef<string>(new Date().toISOString());
617
+
618
+ // Custom toast state
619
+ const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
620
+ const showToast = (message: string, type: "success" | "error" = "success") => {
621
+ setToast({ message, type });
622
+ };
623
+ useEffect(() => {
624
+ if (toast) {
625
+ const timer = setTimeout(() => setToast(null), 3000);
626
+ return () => clearTimeout(timer);
627
+ }
628
+ }, [toast]);
629
+
630
+ // Cleanup blob URLs for pending file previews on unmount
631
+ useEffect(() => {
632
+ return () => {
633
+ pendingFiles.forEach(pf => { if (pf.preview) URL.revokeObjectURL(pf.preview); });
634
+ };
635
+ // Intentionally runs only on true unmount — revokes whatever files are
636
+ // pending at that point via closure, not meant to re-run per pendingFiles change.
637
+ // eslint-disable-next-line react-hooks/exhaustive-deps
638
+ }, []);
639
+
640
+ // Persistent per-visitor session id (survives reloads, unique per visitor)
641
+ const [sessionId, setSessionId] = useState(() => {
642
+ if (typeof window === "undefined") return `widget-session-${botId}`;
643
+ const k = `chatty_sid_${botId}_${hostKey}`;
644
+ let s = localStorage.getItem(k);
645
+ if (!s) { s = `v-${crypto.randomUUID()}`; localStorage.setItem(k, s); }
646
+ return s;
647
+ });
648
+
649
+ // Restore prior messages from localStorage
650
+ useEffect(() => {
651
+ if (typeof window === "undefined" || !botId) return;
652
+ try {
653
+ const raw = localStorage.getItem(`chatty_msgs_${botId}_${hostKey}`);
654
+ if (raw) {
655
+ const saved = JSON.parse(raw);
656
+ if (Array.isArray(saved) && saved.length) setMessages(saved);
657
+ }
658
+ } catch {}
659
+ // eslint-disable-next-line react-hooks/exhaustive-deps
660
+ }, [botId]);
661
+
662
+ // Reset html and body backgrounds to transparent to prevent white corners in
663
+ // rounded iframe borders. Skipped inside a Shadow DOM mount — there,
664
+ // document.body belongs to the host page, not to us, and must not be touched.
665
+ useEffect(() => {
666
+ if (typeof window !== "undefined" && !isInShadowDom()) {
667
+ document.documentElement.style.setProperty("background-color", "transparent", "important");
668
+ document.body.style.setProperty("background-color", "transparent", "important");
669
+ }
670
+ }, []);
671
+
672
+ // Persist messages (cap to last 100)
673
+ useEffect(() => {
674
+ if (typeof window === "undefined" || !botId || messages.length === 0) return;
675
+ try { localStorage.setItem(`chatty_msgs_${botId}_${hostKey}`, JSON.stringify(messages.slice(-100))); } catch {}
676
+ }, [messages, botId, hostKey]);
677
+
678
+ // Live human-agent replies via SSE (one persistent connection). Falls back
679
+ // to the /poll endpoint if the stream can't be established.
680
+ useEffect(() => {
681
+ if (!botId || !sessionId) return;
682
+ let stopped = false;
683
+ const ctrl = new AbortController();
684
+
685
+ const applyEvent = (payload: { type: string; content?: string; created_at?: string; value?: boolean }) => {
686
+ if (payload.type === "message") {
687
+ if (payload.created_at) lastPollRef.current = payload.created_at;
688
+ const textContent = payload.content || "";
689
+ setMessages((p) => [...p, { role: "assistant" as const, content: textContent, sender: "ai" }]);
690
+ setIsBotResponding(false);
691
+ setAgentTyping(false);
692
+ triggerPushRef.current(textContent);
693
+ notifyParent();
694
+ } else if (payload.type === "ai_paused") {
695
+ setLiveAgent(!!payload.value);
696
+ } else if (payload.type === "typing") {
697
+ setAgentTyping(!!payload.value);
698
+ }
699
+ };
700
+
701
+ const pollOnce = async () => {
702
+ try {
703
+ const url = `${BACKEND_URL}/api/widget/poll?bot_id=${botId}&session_id=${encodeURIComponent(sessionId)}&after=${encodeURIComponent(lastPollRef.current)}`;
704
+ const res = await fetch(url, { signal: ctrl.signal });
705
+ if (!res.ok) return;
706
+ const d = await res.json();
707
+ setLiveAgent(!!d.ai_paused);
708
+ if (Array.isArray(d.messages) && d.messages.length) {
709
+ lastPollRef.current = d.messages[d.messages.length - 1].created_at;
710
+ // /api/widget/poll only ever returns human-agent replies (server-side
711
+ // filtered by sender="human"), so every message here is human.
712
+ const newMsgs = d.messages.map((m: { content: string }) => ({ role: "assistant" as const, content: m.content, sender: "human" as const }));
713
+ setMessages((p) => [...p, ...newMsgs]);
714
+ setIsBotResponding(false);
715
+ setAgentTyping(false);
716
+ if (newMsgs[0]?.content) triggerPushRef.current(newMsgs[0].content);
717
+ notifyParent();
718
+ }
719
+ } catch {}
720
+ };
721
+
722
+ const run = async () => {
723
+ while (!stopped) {
724
+ try {
725
+ const url = `${BACKEND_URL}/api/widget/live?bot_id=${botId}&session_id=${encodeURIComponent(sessionId)}&after=${encodeURIComponent(lastPollRef.current)}`;
726
+ const res = await fetch(url, { signal: ctrl.signal });
727
+ if (!res.ok || !res.body) throw new Error("no stream");
728
+ const reader = res.body.getReader();
729
+ const dec = new TextDecoder();
730
+ let buf = "";
731
+ for (;;) {
732
+ const { done, value } = await reader.read();
733
+ if (done) break;
734
+ buf += dec.decode(value, { stream: true });
735
+ let sep: number;
736
+ while ((sep = buf.indexOf("\n\n")) >= 0) {
737
+ const frame = buf.slice(0, sep); buf = buf.slice(sep + 2);
738
+ const line = frame.split("\n").find((l) => l.startsWith("data:"));
739
+ if (!line) continue;
740
+ try { applyEvent(JSON.parse(line.slice(5).trim())); } catch {}
741
+ }
742
+ }
743
+ // Server closed the stream (~4 min) — loop reconnects immediately.
744
+ } catch {
745
+ if (stopped || ctrl.signal.aborted) return;
746
+ await pollOnce();
747
+ await new Promise((r) => setTimeout(r, 4000));
748
+ }
749
+ }
750
+ };
751
+ run();
752
+ return () => { stopped = true; ctrl.abort(); };
753
+ // notifyParent intentionally excluded — it's a plain function (not
754
+ // memoized) whose identity is only stable because onAssistantMessage
755
+ // itself is stable per mount; including it would restart this
756
+ // long-lived SSE connection any time a caller re-renders with a new
757
+ // (but behaviorally identical) callback reference.
758
+ // eslint-disable-next-line react-hooks/exhaustive-deps
759
+ }, [botId, sessionId]);
760
+
761
+ // One-shot manual refetch of any new messages since the last poll — used
762
+ // right after a voice call ends so the transcript (written server-side by
763
+ // the voice worker) shows up promptly instead of waiting for the next
764
+ // SSE/poll cycle.
765
+ const refetchNow = async () => {
766
+ try {
767
+ const url = `${BACKEND_URL}/api/widget/poll?bot_id=${botId}&session_id=${encodeURIComponent(sessionId)}&after=${encodeURIComponent(lastPollRef.current)}`;
768
+ const res = await fetch(url);
769
+ if (!res.ok) return;
770
+ const d = await res.json();
771
+ setLiveAgent(!!d.ai_paused);
772
+ if (Array.isArray(d.messages) && d.messages.length) {
773
+ lastPollRef.current = d.messages[d.messages.length - 1].created_at;
774
+ // Same endpoint as pollOnce above — human-agent replies only.
775
+ const newMsgs = d.messages.map((m: { content: string }) => ({ role: "assistant" as const, content: m.content, sender: "human" as const }));
776
+ setMessages((p) => [...p, ...newMsgs]);
777
+ notifyParent();
778
+ }
779
+ } catch {}
780
+ };
781
+
782
+ const getHost = (): string => {
783
+ try { if (typeof document !== "undefined" && document.referrer) return new URL(document.referrer).hostname; } catch {}
784
+ try { if (typeof window !== "undefined") return new URLSearchParams(window.location.search).get("host") || ""; } catch {}
785
+ return "";
786
+ };
787
+
788
+ const isOfficialWebsite = (() => {
789
+ if (typeof window === "undefined") return true;
790
+ const host = getHost().toLowerCase();
791
+ return host === "chatty.personaliai.com" || host.endsWith(".chatty.personaliai.com");
792
+ })();
793
+
794
+ useEffect(() => {
795
+ async function loadBot() {
796
+ if (!botId) return;
797
+ try {
798
+ // Load config from the backend (service role) — works inside third-party
799
+ // iframes where the browser Supabase client is blocked by storage partitioning.
800
+ const res = await fetch(`${BACKEND_URL}/api/widget/theme?bot_id=${encodeURIComponent(String(botId))}&t=${Date.now()}`);
801
+ if (res.ok) {
802
+ const bot = await res.json();
803
+ // In preview mode (dashboard playground), query parameters override DB values
804
+ // so the user sees their unsaved changes in real time.
805
+ // In production, DB values take priority so dashboard edits apply automatically.
806
+ setBotName(isPreview ? (paramName || bot.name || "Chatty Assistant") : (bot.name || "Chatty Assistant"));
807
+ const wMsg = isPreview ? (paramWelcome || bot.welcome_message || "Hello! How can I help you today?") : (bot.welcome_message || "Hello! How can I help you today?");
808
+ setWelcomeMsg(wMsg);
809
+ setStarters(Array.isArray(bot.conversation_starters) ? bot.conversation_starters.filter(Boolean) : []);
810
+ setSendStyle(bot.send_button_style || "plane");
811
+ setAvatarIcon(isPreview ? (paramAvatarIcon || bot.avatar_icon || "logo") : (bot.avatar_icon || "logo"));
812
+ setAvatarUrl(isPreview ? (paramAvatarUrl || bot.avatar_url || null) : (bot.avatar_url || null));
813
+ setPrimaryColor(isPreview ? (paramColor || bot.primary_color || "#f97316") : (bot.primary_color || paramColor || "#f97316"));
814
+ const rawStyle = isPreview ? (paramStyle || bot.widget_style || "minimal") : (bot.widget_style || paramStyle || "minimal");
815
+ const [styleName, dbLogoBg] = rawStyle.split(":");
816
+ setWidgetStyle(normalizeWidgetStyle(styleName));
817
+ if (isPreview) {
818
+ setLogoBgColor(paramLogoBgColor ?? dbLogoBg ?? "");
819
+ } else {
820
+ setLogoBgColor(dbLogoBg || "");
821
+ }
822
+ setLogoUrl(isPreview ? (paramLogoUrl || bot.logo_url || null) : (bot.logo_url || null));
823
+ setHideBranding(!!bot.hide_branding);
824
+ setShowSenderTag(isPreview && paramShowSenderTag !== null ? paramShowSenderTag === "true" : !!bot.show_sender_tag);
825
+ setCsatEnabled(isPreview && paramCsatEnabled !== null ? paramCsatEnabled === "true" : bot.csat_enabled !== false);
826
+ setVoiceEnabled(!!bot.voice_enabled);
827
+ try {
828
+ const rawScheme = isPreview ? (paramColorScheme || (bot.color_scheme ? JSON.stringify(bot.color_scheme) : null)) : (bot.color_scheme ? JSON.stringify(bot.color_scheme) : null);
829
+ setColorScheme(rawScheme ? JSON.parse(rawScheme) : null);
830
+ } catch { setColorScheme(null); }
831
+ setCustomCss(bot.custom_css || "");
832
+ setCustomJs(bot.custom_js || "");
833
+ setMessages((prev) => prev.length ? prev : [{ role: "assistant", content: wMsg, sender: "ai" }]);
834
+ }
835
+ } catch (err) {
836
+ console.error("Failed to load bot:", err);
837
+ } finally {
838
+ setLoading(false);
839
+ if (onWidgetReady) onWidgetReady();
840
+ else try { window.parent?.postMessage({ type: "chatty:ready" }, "*"); } catch {}
841
+ }
842
+ }
843
+ loadBot();
844
+ // onWidgetReady intentionally excluded, same reasoning as notifyParent
845
+ // above — it only needs to fire once per successful/failed load, not
846
+ // whenever the caller happens to re-render with a fresh function ref.
847
+ // eslint-disable-next-line react-hooks/exhaustive-deps
848
+ }, [botId, paramColor, paramStyle, isPreview, paramName, paramWelcome, paramAvatarIcon, paramAvatarUrl, paramLogoUrl, paramLogoBgColor, paramShowSenderTag, paramCsatEnabled, paramColorScheme]);
849
+
850
+ // Run the bot owner's custom JS once, after the widget config has loaded. Scoped to
851
+ // this embed iframe only — same trust model as the owner's own custom CSS.
852
+ useEffect(() => {
853
+ if (!customJs) return;
854
+
855
+ // Safe extraction and parsing of the visual flow JSON
856
+ try {
857
+ const match = customJs.match(/\/\* CHATTY_FLOW_DATA([\s\S]*?)CHATTY_FLOW_DATA \*\//);
858
+ if (match && match[1]) {
859
+ const flow = JSON.parse(match[1].trim()) as FlowConfig;
860
+ if (flow && flow.status === "active" && flow.nodes && flow.edges) {
861
+ // Deriving flowConfig from customJs (an external string, not React
862
+ // state) once per load — not a cascading-render risk.
863
+ // eslint-disable-next-line react-hooks/set-state-in-effect
864
+ setFlowConfig(flow);
865
+ const startEdge = flow.edges.find((e) => e.source === "start");
866
+ if (startEdge) {
867
+ const firstNode = flow.nodes.find((n) => n.id === startEdge.target);
868
+ if (firstNode) {
869
+ setMessages((prev) => {
870
+ // If visitor already has chat history in this session, preserve it!
871
+ if (prev.length > 0 && prev.some((m) => m.role === "user")) {
872
+ return prev;
873
+ }
874
+ executeFlowNode(firstNode, flow);
875
+ return [];
876
+ });
877
+ }
878
+ }
879
+ } else {
880
+ // Flow is paused or removed — clear any existing flow state
881
+ setFlowConfig(null);
882
+ setActiveNodeId(null);
883
+ }
884
+ }
885
+ } catch (err) {
886
+ console.error("Failed to parse visual flow data:", err);
887
+ }
888
+
889
+ // Execute any standard custom JS runnable script
890
+ try {
891
+ const runnableJs = customJs.replace(/\/\* CHATTY_FLOW_DATA[\s\S]*?CHATTY_FLOW_DATA \*\//g, "").trim();
892
+ if (runnableJs) {
893
+ const fn = new Function(runnableJs);
894
+ fn();
895
+ }
896
+ } catch (err) {
897
+ console.error("Chatty custom JS execution error:", err);
898
+ }
899
+ // Deliberately scoped to customJs only — flowConfig/messages state derived
900
+ // from this external string, and executeFlowNode is a stable closure over
901
+ // the fresh `flow` parsed above, not the outer flowConfig state.
902
+ // eslint-disable-next-line react-hooks/exhaustive-deps
903
+ }, [customJs]);
904
+
905
+ // Real-time flow sync: re-fetch bot config every 30s so that flow builder
906
+ // changes apply to the widget without requiring a page reload.
907
+ useEffect(() => {
908
+ if (!botId) return;
909
+ const interval = setInterval(async () => {
910
+ try {
911
+ const res = await fetch(`${BACKEND_URL}/api/widget/theme?bot_id=${encodeURIComponent(String(botId))}&t=${Date.now()}`);
912
+ if (res.ok) {
913
+ const bot = await res.json();
914
+ const newJs = bot.custom_js || "";
915
+ setCustomJs((prev) => {
916
+ if (prev !== newJs) return newJs;
917
+ return prev;
918
+ });
919
+ }
920
+ } catch {}
921
+ }, 30000);
922
+ return () => clearInterval(interval);
923
+ }, [botId]);
924
+
925
+ // Force transparent iframe body background to resolve sub-pixel corner
926
+ // bleeding. Same Shadow DOM guard as the effect above.
927
+ useEffect(() => {
928
+ if (typeof document !== "undefined" && !isInShadowDom()) {
929
+ document.documentElement.style.setProperty("background-color", "transparent", "important");
930
+ document.body.style.setProperty("background-color", "transparent", "important");
931
+ document.body.style.setProperty("background", "transparent", "important");
932
+ }
933
+ }, []);
934
+
935
+ useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages, isBotResponding, tab]);
936
+
937
+ // ---- Text message (streamed via SSE) ----
938
+ // Update the most recent assistant bubble's content in place as tokens arrive.
939
+ const setStreamingAssistant = (content: string) => {
940
+ setMessages((p) => {
941
+ const copy = [...p];
942
+ for (let i = copy.length - 1; i >= 0; i--) {
943
+ if (copy[i].role === "assistant") { copy[i] = { ...copy[i], content }; break; }
944
+ }
945
+ return copy;
946
+ });
947
+ };
948
+
949
+ const rateMessage = async (index: number, rating: "up" | "down") => {
950
+ setMessages((p) => { const c = [...p]; if (c[index]) c[index] = { ...c[index], feedback: rating }; return c; });
951
+ try {
952
+ await fetch(`${BACKEND_URL}/api/widget/feedback`, {
953
+ method: "POST", headers: { "Content-Type": "application/json" },
954
+ body: JSON.stringify({ bot_id: botId, session_id: sessionId, rating }),
955
+ });
956
+ } catch { /* best-effort */ }
957
+ };
958
+
959
+ const sendText = async (text: string) => {
960
+ if (!text.trim() || isBotResponding) return;
961
+ setMessages((p) => [...p, { role: "user", content: text }]);
962
+ setInputValue("");
963
+ setEmojiOpen(false);
964
+
965
+ if (flowConfig && activeNodeId) {
966
+ const activeNode = flowConfig.nodes.find((n) => n.id === activeNodeId);
967
+ const outgoingEdges = flowConfig.edges.filter((e) => e.source === activeNodeId);
968
+
969
+ if (flowAwaitingInput && isQuestionNode(activeNode)) {
970
+ // Question node: user typed a real answer. Route flow AND pass to real AI.
971
+ const resolved = outgoingEdges.map((e) => ({ ...e, _label: getEdgeLabel(e) }));
972
+ const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(text.trim());
973
+
974
+ let matchedEdge = resolved.find((e) => e._label.toLowerCase() === text.toLowerCase());
975
+ if (!matchedEdge) {
976
+ if (isEmail) {
977
+ matchedEdge = resolved.find((e) =>
978
+ e._label.toLowerCase().includes("email") &&
979
+ (e._label.toLowerCase().includes("provided") || e._label.toLowerCase().includes("valid") || e._label.toLowerCase().includes("yes"))
980
+ ) || resolved.find((e) => !e._label.toLowerCase().includes("invalid") && !e._label.toLowerCase().includes("no"));
981
+ } else {
982
+ matchedEdge = resolved.find((e) =>
983
+ e._label.toLowerCase().includes("invalid") || e._label.toLowerCase().includes("no")
984
+ );
985
+ }
986
+ }
987
+
988
+ const selectedEdge = matchedEdge || resolved[0];
989
+ if (selectedEdge) {
990
+ const targetNode = flowConfig.nodes.find((n) => n.id === selectedEdge.target);
991
+ if (targetNode) {
992
+ // Silently persist user's answer in background so it's logged in the inbox database
993
+ fetch(`${BACKEND_URL}/api/widget/chat`, {
994
+ method: "POST",
995
+ headers: { "Content-Type": "application/json" },
996
+ body: JSON.stringify({ bot_id: botId, session_id: sessionId, text: text })
997
+ }).catch(() => {});
998
+
999
+ executeFlowNode(targetNode, flowConfig);
1000
+ return; // Stay in flow, do not trigger streaming AI response
1001
+ } else {
1002
+ // Flow done — fall through to AI below
1003
+ setActiveNodeId(null);
1004
+ setFlowAwaitingInput(false);
1005
+ }
1006
+ }
1007
+
1008
+ } else if (!flowAwaitingInput && outgoingEdges.length > 1) {
1009
+ // Message node with labeled choice buttons — don't send to AI, just route
1010
+ const resolved = outgoingEdges.map((e) => ({ ...e, _label: getEdgeLabel(e) }));
1011
+ const matchedEdge = resolved.find((e) => e._label.toLowerCase() === text.toLowerCase()) || resolved[0];
1012
+ const targetNode = flowConfig.nodes.find((n) => n.id === matchedEdge.target);
1013
+ if (targetNode) {
1014
+ executeFlowNode(targetNode, flowConfig);
1015
+ } else {
1016
+ setActiveNodeId(null);
1017
+ setFlowAwaitingInput(false);
1018
+ }
1019
+ return; // Don't send to AI for menu choices
1020
+ } else if (!flowAwaitingInput && outgoingEdges.length === 0) {
1021
+ // Flow is at terminal node — clear flow, hand off to AI
1022
+ setActiveNodeId(null);
1023
+ setFlowAwaitingInput(false);
1024
+ }
1025
+ }
1026
+
1027
+ setIsBotResponding(true);
1028
+
1029
+ let acc = "";
1030
+ // The assistant bubble is created lazily on the first content so the typing
1031
+ // indicator is the ONLY thing shown until then (no duplicate response icon).
1032
+ let created = false;
1033
+ const writeAssistant = (content: string) => {
1034
+ if (!created) {
1035
+ created = true;
1036
+ setIsBotResponding(false);
1037
+ setMessages((p) => [...p, { role: "assistant" as const, content, sender: "ai" }]);
1038
+ } else {
1039
+ setStreamingAssistant(content);
1040
+ }
1041
+ };
1042
+
1043
+ try {
1044
+ const res = await fetch(`${BACKEND_URL}/api/widget/chat/stream`, {
1045
+ method: "POST", headers: { "Content-Type": "application/json", ...widgetTokenHeader },
1046
+ body: JSON.stringify({ bot_id: botId, session_id: sessionId, text, visitor_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, host: getHost() }),
1047
+ });
1048
+
1049
+ if (!res.ok || !res.body) {
1050
+ let detail = "Something went wrong.";
1051
+ try { const b = await res.json(); detail = b.detail || detail; } catch {}
1052
+ writeAssistant(`⚠️ ${detail}`);
1053
+ return;
1054
+ }
1055
+
1056
+ const reader = res.body.getReader();
1057
+ const decoder = new TextDecoder();
1058
+ let buffer = "";
1059
+ for (;;) {
1060
+ const { done, value } = await reader.read();
1061
+ if (done) break;
1062
+ buffer += decoder.decode(value, { stream: true });
1063
+ let sep: number;
1064
+ while ((sep = buffer.indexOf("\n\n")) >= 0) {
1065
+ const frame = buffer.slice(0, sep);
1066
+ buffer = buffer.slice(sep + 2);
1067
+ const dataLine = frame.split("\n").find((l) => l.startsWith("data:"));
1068
+ if (!dataLine) continue;
1069
+ let payload: { type: string; text?: string; reply?: string; detail?: string; sources?: Citation[] };
1070
+ try { payload = JSON.parse(dataLine.slice(5).trim()); } catch { continue; }
1071
+
1072
+ if (payload.type === "token") {
1073
+ acc += payload.text || "";
1074
+ writeAssistant(acc);
1075
+ } else if (payload.type === "done") {
1076
+ if (payload.reply && payload.reply !== acc) { acc = payload.reply; writeAssistant(acc); }
1077
+ else if (!created && payload.reply) { writeAssistant(payload.reply); }
1078
+ if (payload.sources && payload.sources.length && created) {
1079
+ const srcs = payload.sources;
1080
+ setMessages((p) => {
1081
+ const copy = [...p];
1082
+ for (let i = copy.length - 1; i >= 0; i--) {
1083
+ if (copy[i].role === "assistant") { copy[i] = { ...copy[i], sources: srcs }; break; }
1084
+ }
1085
+ return copy;
1086
+ });
1087
+ }
1088
+ notifyParent();
1089
+ } else if (payload.type === "paused") {
1090
+ setLiveAgent(true);
1091
+ lastPollRef.current = new Date(Date.now() - 2000).toISOString();
1092
+ } else if (payload.type === "error") {
1093
+ writeAssistant(`⚠️ ${payload.detail || "Something went wrong."}`);
1094
+ }
1095
+ }
1096
+ }
1097
+ } catch {
1098
+ writeAssistant("Sorry, I can't connect right now.");
1099
+ } finally {
1100
+ setIsBotResponding(false);
1101
+ }
1102
+ };
1103
+
1104
+ // ---- Media message (image / audio / file) ----
1105
+ const sendMedia = async (file: File | Blob, filename: string, caption = "") => {
1106
+ if (isBotResponding) return;
1107
+ const isImage = file.type.startsWith("image/");
1108
+ const isAudio = file.type.startsWith("audio/");
1109
+ const localUrl = URL.createObjectURL(file);
1110
+ setMessages((p) => [...p, { role: "user", content: caption || (isAudio ? "🎤 Voice message" : `📎 ${filename}`), fileUrl: localUrl, fileType: file.type }]);
1111
+ setIsBotResponding(true);
1112
+ try {
1113
+ const fd = new FormData();
1114
+ fd.append("bot_id", String(botId));
1115
+ fd.append("session_id", sessionId);
1116
+ fd.append("text", caption);
1117
+ fd.append("visitor_timezone", Intl.DateTimeFormat().resolvedOptions().timeZone);
1118
+ fd.append("host", getHost());
1119
+ fd.append("file", file, filename);
1120
+ const res = await fetch(`${BACKEND_URL}/api/widget/chat/media`, { method: "POST", headers: widgetTokenHeader, body: fd });
1121
+ const body = await res.json();
1122
+ setMessages((p) => [...p, res.ok
1123
+ ? { role: "assistant", content: body.reply, sender: "ai" }
1124
+ : { role: "assistant", content: `⚠️ ${body.detail || "Couldn't process that file."}` }]);
1125
+ notifyParent();
1126
+ } catch {
1127
+ setMessages((p) => [...p, { role: "assistant", content: "Sorry, I couldn't upload that." }]);
1128
+ } finally {
1129
+ setIsBotResponding(false);
1130
+ }
1131
+ void isImage;
1132
+ };
1133
+
1134
+ const onFilePick = (e: React.ChangeEvent<HTMLInputElement>) => {
1135
+ const files = e.target.files;
1136
+ if (!files || files.length === 0) return;
1137
+ const newFiles: {file: File; preview: string}[] = [];
1138
+ for (let i = 0; i < files.length; i++) {
1139
+ const f = files[i];
1140
+ const preview = f.type.startsWith("image/") ? URL.createObjectURL(f) : "";
1141
+ newFiles.push({ file: f, preview });
1142
+ }
1143
+ setPendingFiles(prev => [...prev, ...newFiles]);
1144
+ if (fileInputRef.current) fileInputRef.current.value = "";
1145
+ };
1146
+
1147
+ const openFilePicker = (kind: "images" | "documents") => {
1148
+ setAttachOpen(false);
1149
+ if (!fileInputRef.current) return;
1150
+ fileInputRef.current.accept = kind === "images" ? "image/*" : ".pdf,.doc,.docx,.txt,application/pdf";
1151
+ fileInputRef.current.click();
1152
+ };
1153
+
1154
+ const shareLocation = () => {
1155
+ setAttachOpen(false);
1156
+ if (!navigator.geolocation) { showToast("Location isn't supported on this device.", "error"); return; }
1157
+ navigator.geolocation.getCurrentPosition(
1158
+ (pos) => {
1159
+ const { latitude, longitude } = pos.coords;
1160
+ const link = `https://www.google.com/maps?q=${latitude},${longitude}`;
1161
+ setInputValue((v) => (v.trim() ? `${v} 📍 ${link}` : `📍 My location: ${link}`));
1162
+ },
1163
+ () => showToast("Couldn't access your location.", "error"),
1164
+ { enableHighAccuracy: true, timeout: 10000 }
1165
+ );
1166
+ };
1167
+
1168
+ const onPaste = (e: React.ClipboardEvent) => {
1169
+ const items = e.clipboardData?.items;
1170
+ if (!items) return;
1171
+ for (let i = 0; i < items.length; i++) {
1172
+ if (items[i].type.startsWith("image/")) {
1173
+ e.preventDefault();
1174
+ const file = items[i].getAsFile();
1175
+ if (file) {
1176
+ const preview = URL.createObjectURL(file);
1177
+ setPendingFiles(prev => [...prev, { file, preview }]);
1178
+ }
1179
+ }
1180
+ }
1181
+ };
1182
+
1183
+ // ---- Audio recording ----
1184
+ // Transcription runs server-side via Gemini (POST /api/widget/transcribe)
1185
+ // rather than the browser's Web Speech API: webkitSpeechRecognition is
1186
+ // well known to be unreliable inside cross-origin iframes (unlike
1187
+ // getUserMedia, which properly honors the iframe allow="microphone"
1188
+ // attribute) — the widget always runs embedded in one, so client-side
1189
+ // live transcription silently failed for most visitors.
1190
+ const toggleRecord = async () => {
1191
+ if (recording) {
1192
+ mediaRecorderRef.current?.stop();
1193
+ return;
1194
+ }
1195
+ try {
1196
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
1197
+
1198
+ // Live amplitude animation while recording — each bar samples a
1199
+ // distinct slice of the real-time frequency spectrum (not one
1200
+ // averaged number replayed across fixed per-bar multipliers), so
1201
+ // they genuinely fluctuate independently with the actual audio.
1202
+ const AC: typeof AudioContext = (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext);
1203
+ const audioCtx = new AC();
1204
+ const source = audioCtx.createMediaStreamSource(stream);
1205
+ const analyser = audioCtx.createAnalyser();
1206
+ analyser.fftSize = 256; // 128 frequency bins
1207
+ analyser.smoothingTimeConstant = 0.6; // real exponential smoothing from the Web Audio engine
1208
+ source.connect(analyser);
1209
+ audioContextRef.current = audioCtx;
1210
+ const freqData = new Uint8Array(analyser.frequencyBinCount);
1211
+ const USABLE_BINS = 64; // lower half of the spectrum — where voice energy actually lives
1212
+ const binsPerBar = Math.max(1, Math.floor(USABLE_BINS / RECORD_BAR_COUNT));
1213
+ const tick = () => {
1214
+ analyser.getByteFrequencyData(freqData);
1215
+ const levels: number[] = new Array(RECORD_BAR_COUNT);
1216
+ for (let i = 0; i < RECORD_BAR_COUNT; i++) {
1217
+ let sum = 0;
1218
+ for (let j = 0; j < binsPerBar; j++) sum += freqData[i * binsPerBar + j];
1219
+ levels[i] = Math.min(1, sum / binsPerBar / 140);
1220
+ }
1221
+ setBarLevels(levels);
1222
+ animationFrameRef.current = requestAnimationFrame(tick);
1223
+ };
1224
+ tick();
1225
+
1226
+ const mr = new MediaRecorder(stream);
1227
+ audioChunksRef.current = [];
1228
+ mr.ondataavailable = (ev) => { if (ev.data.size > 0) audioChunksRef.current.push(ev.data); };
1229
+ mr.onstop = async () => {
1230
+ if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
1231
+ audioContextRef.current?.close();
1232
+ stream.getTracks().forEach((t) => t.stop());
1233
+ setRecording(false);
1234
+ setBarLevels(Array(RECORD_BAR_COUNT).fill(0));
1235
+
1236
+ const blob = new Blob(audioChunksRef.current, { type: mr.mimeType || "audio/webm" });
1237
+ if (blob.size === 0) return;
1238
+ let wav: Blob;
1239
+ try {
1240
+ wav = await audioBlobToWav(blob);
1241
+ } catch {
1242
+ showToast("Couldn't process that recording — try again.", "error");
1243
+ return;
1244
+ }
1245
+
1246
+ setTranscribing(true);
1247
+ // A cold backend instance can take 20-30s+ to spin up — without a
1248
+ // client-side cap, a stalled request left "Transcribing…" spinning
1249
+ // indefinitely with no feedback, indistinguishable from a hang.
1250
+ const timeoutController = new AbortController();
1251
+ const timeoutId = setTimeout(() => timeoutController.abort(), 30000);
1252
+ try {
1253
+ const fd = new FormData();
1254
+ fd.append("bot_id", String(botId));
1255
+ fd.append("file", wav, "voice-message.wav");
1256
+ const res = await fetch(`${BACKEND_URL}/api/widget/transcribe`, {
1257
+ method: "POST", headers: widgetTokenHeader, body: fd, signal: timeoutController.signal,
1258
+ });
1259
+ const body = await res.json().catch(() => ({}));
1260
+ const text = (body.text || "").trim();
1261
+ if (res.ok && text) {
1262
+ // Land the transcript in the input box — the visitor reviews/
1263
+ // edits and presses send themselves, same as typing.
1264
+ setInputValue((v) => (v ? `${v} ${text}` : text));
1265
+ } else {
1266
+ // No speech detected, or transcription failed — fall back to
1267
+ // sending the raw audio so the message isn't just lost.
1268
+ sendMedia(wav, "voice-message.wav");
1269
+ }
1270
+ } catch (err) {
1271
+ if ((err as Error)?.name === "AbortError") {
1272
+ showToast("Transcription is taking longer than usual — sending your voice message instead.", "error");
1273
+ }
1274
+ sendMedia(wav, "voice-message.wav");
1275
+ } finally {
1276
+ clearTimeout(timeoutId);
1277
+ setTranscribing(false);
1278
+ }
1279
+ };
1280
+ mediaRecorderRef.current = mr;
1281
+ mr.start();
1282
+ setRecording(true);
1283
+ } catch {
1284
+ showToast("Microphone access denied.", "error");
1285
+ }
1286
+ };
1287
+
1288
+ // ---- AI search ----
1289
+ const runSearch = async (q: string) => {
1290
+ if (!q.trim() || searching) return;
1291
+ setSearching(true);
1292
+ setSearchAnswer(null);
1293
+ try {
1294
+ const res = await fetch(`${BACKEND_URL}/api/widget/chat`, {
1295
+ method: "POST", headers: { "Content-Type": "application/json", ...widgetTokenHeader },
1296
+ body: JSON.stringify({ bot_id: botId, session_id: `${sessionId}-search`, text: q, visitor_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, host: getHost() }),
1297
+ });
1298
+ const body = await res.json();
1299
+ setSearchAnswer(res.ok ? body.reply : (body.detail || "No answer found."));
1300
+ } catch {
1301
+ setSearchAnswer("Couldn't reach the assistant.");
1302
+ } finally {
1303
+ setSearching(false);
1304
+ }
1305
+ };
1306
+
1307
+ if (loading) {
1308
+ return <div className="flex h-screen items-center justify-center bg-transparent"><Loader2 className="size-6 animate-spin text-neutral-400" /></div>;
1309
+ }
1310
+
1311
+ const mdComponents: Components = {
1312
+ p: ({ children }) => <p className="mb-1 last:mb-0">{children}</p>,
1313
+ ul: ({ children }) => <ul className="list-disc pl-4 mb-2 space-y-1">{children}</ul>,
1314
+ ol: ({ children }) => <ol className="list-decimal pl-4 mb-2 space-y-1">{children}</ol>,
1315
+ a: ({ href, children }) => (
1316
+ <SafeMarkdownLink href={href} className="underline break-all" style={{ color: primaryColor }}>
1317
+ {children}
1318
+ </SafeMarkdownLink>
1319
+ ),
1320
+ code: ({ className, children, ...rest }) => {
1321
+ const isBlock = className?.startsWith("language-");
1322
+ if (!isBlock) return <code className="bg-neutral-200 dark:bg-neutral-800 px-1 py-0.5 rounded text-[10px] font-mono" {...rest}>{children}</code>;
1323
+ const lang = (className ?? "").replace("language-", "") || "code";
1324
+ const text = String(children).replace(/\n$/, "");
1325
+ return <CodeBlock lang={lang} text={text} />;
1326
+ },
1327
+ };
1328
+
1329
+ // Per-section overrides need real !important CSS (a plain inline style
1330
+ // can never beat globals.css's .style-* !important rules), so they're
1331
+ // injected the same way the box-shadow strip above already is. #chatty-root
1332
+ // gives them ID-level specificity so they win regardless of which design
1333
+ // preset is active. buildColorSchemeCss validates hex values before
1334
+ // interpolating them — not a security boundary (custom_css already lets
1335
+ // the bot owner inject arbitrary CSS here), just guarding against a
1336
+ // malformed stored value breaking the whole stylesheet.
1337
+ const colorSchemeCss = buildColorSchemeCss(colorScheme, "#chatty-root");
1338
+
1339
+ return (
1340
+ <div ref={rootRef} id="chatty-root" className={`w-full h-screen flex flex-col overflow-hidden text-neutral-900 dark:text-neutral-100 font-sans style-${widgetStyle} ${isFullscreen ? "" : "rounded-2xl"}`} style={{ backgroundColor: primaryColor, touchAction: "manipulation", ...primaryColorCssVars(primaryColor) } as React.CSSProperties}>
1341
+ <style dangerouslySetInnerHTML={{ __html: `
1342
+ html, body {
1343
+ touch-action: manipulation;
1344
+ background: transparent !important;
1345
+ background-image: none !important;
1346
+ animation: none !important;
1347
+ overflow: hidden !important;
1348
+ /* The root layout's "antialiased" Tailwind class (-webkit-font-smoothing:
1349
+ antialiased) applies globally, including here — it's a Mac-oriented
1350
+ hint that thins glyphs toward macOS's grayscale AA look. On Windows
1351
+ Chrome it overrides the OS's own ClearType subpixel rendering, which
1352
+ is tuned for Windows displays, making small chat text read noticeably
1353
+ softer than the rest of the page. Reverting to "auto" here restores
1354
+ each platform's own native (sharper on Windows) text rendering,
1355
+ scoped to just the widget so it doesn't change how the dashboard or
1356
+ marketing pages render text. */
1357
+ -webkit-font-smoothing: auto !important;
1358
+ -moz-osx-font-smoothing: auto !important;
1359
+ }
1360
+ /* Strip only box-shadow inside the iframe: the container fills the iframe
1361
+ edge-to-edge with zero margin, so any shadow has no room to render and
1362
+ gets hard-clipped by the iframe's own overflow:hidden (ugly) — this is
1363
+ an iframe limitation, not a CSS bug, since content can never bleed past
1364
+ an iframe's own rectangle. Each design's border and border-radius are
1365
+ safe to keep — a border draws flush at the box edge with zero bleed, and
1366
+ the outer host (widget.js, page.tsx) now applies no radius/border/shadow
1367
+ of its own, so there's no double-corner artifact either. This keeps each
1368
+ design's signature frame (e.g. Luxury Editorial's gold border,
1369
+ Neubrutalism's thick black border) visible on the live widget instead of
1370
+ only in previews. Restoring the shadow too would require insetting this
1371
+ panel inside a larger host box to give it room — deliberately not done,
1372
+ to keep the full iframe as usable chat area. */
1373
+ .style-minimal,
1374
+ .style-playful,
1375
+ .style-corporate,
1376
+ .style-dark-sleek,
1377
+ .style-gradient-glow,
1378
+ .style-glassmorphism,
1379
+ .style-ecommerce,
1380
+ .style-healthcare-calm,
1381
+ .style-neubrutalism,
1382
+ .style-luxury-editorial {
1383
+ box-shadow: none !important;
1384
+ }
1385
+ ${colorSchemeCss}
1386
+ ` }} />
1387
+ {customCss && <style dangerouslySetInnerHTML={{ __html: customCss }} />}
1388
+ {/* Header */}
1389
+ <div className="chat-header px-4 pt-3 pb-2 border-b border-neutral-100 dark:border-neutral-850" style={{ background: primaryColor }}>
1390
+ <div className="flex items-center gap-2.5">
1391
+ <div
1392
+ className="size-11 rounded-full flex items-center justify-center font-bold text-base overflow-hidden shrink-0 transition-colors"
1393
+ style={logoBgColor ? { backgroundColor: logoBgColor, color: getOnColor(logoBgColor) } : { backgroundColor: "color-mix(in srgb, currentColor 25%, transparent)" }}
1394
+ >
1395
+ {headerLogoInner("size-6")}
1396
+ </div>
1397
+ <div className="leading-tight">
1398
+ <h4 className="font-semibold text-sm">{botName}</h4>
1399
+ <p className="text-[9px] flex items-center gap-1" style={{ opacity: 0.8 }}><span className="size-1.5 rounded-full bg-green-300 animate-pulse" />{liveAgent ? "Live agent · we're with you" : "Online · replies instantly"}</p>
1400
+ </div>
1401
+ {voiceEnabled && (
1402
+ <motion.button
1403
+ type="button"
1404
+ whileTap={{ scale: 0.85 }}
1405
+ transition={{ duration: 0.32, ease: [0.34, 1.56, 0.64, 1] }}
1406
+ onClick={() => setVoiceCallOpen(true)}
1407
+ className="ml-auto p-1.5 rounded-full hover:opacity-100 transition-colors shrink-0 cursor-pointer"
1408
+ style={{ opacity: 0.8, backgroundColor: "color-mix(in srgb, currentColor 0%, transparent)" }}
1409
+ onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "color-mix(in srgb, currentColor 15%, transparent)")}
1410
+ onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "color-mix(in srgb, currentColor 0%, transparent)")}
1411
+ aria-label="Start voice call"
1412
+ title="Talk to the assistant"
1413
+ >
1414
+ <Phone className="size-4" />
1415
+ </motion.button>
1416
+ )}
1417
+ <button
1418
+ onClick={requestPushPermission}
1419
+ className={`${voiceEnabled ? "" : "ml-auto "}p-1.5 rounded-full hover:opacity-100 transition-colors shrink-0 cursor-pointer`}
1420
+ style={{ opacity: 0.8 }}
1421
+ onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "color-mix(in srgb, currentColor 15%, transparent)")}
1422
+ onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "transparent")}
1423
+ aria-label="Toggle push notifications"
1424
+ title={pushGranted ? "Browser notifications enabled" : "Enable browser notifications"}
1425
+ >
1426
+ {/* "Granted" state shown via a solid fill, not a fixed color — a
1427
+ hardcoded amber here was nearly invisible against presets
1428
+ with a yellow header (e.g. Neubrutalism's #ffde59). Filling
1429
+ with currentColor keeps it legible against every preset. */}
1430
+ <Bell className={`size-4 ${pushGranted ? "fill-current" : ""}`} />
1431
+ </button>
1432
+ <button onClick={clearChat} className="p-1.5 rounded-full hover:opacity-100 transition-colors shrink-0" style={{ opacity: 0.8 }}
1433
+ onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "color-mix(in srgb, currentColor 15%, transparent)")}
1434
+ onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "transparent")}
1435
+ aria-label="Clear conversation" title="Clear conversation">
1436
+ <RefreshCw className="size-4" />
1437
+ </button>
1438
+ <button onClick={handleCloseClick} className="p-1.5 rounded-full hover:opacity-100 transition-colors shrink-0" style={{ opacity: 0.8 }}
1439
+ onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "color-mix(in srgb, currentColor 15%, transparent)")}
1440
+ onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "transparent")}
1441
+ aria-label="Close chat" title="Close">
1442
+ <X className="size-4" />
1443
+ </button>
1444
+ </div>
1445
+ </div>
1446
+
1447
+ {/* Body */}
1448
+ <div className="flex-1 overflow-y-auto scrollbar-thin bg-card flex flex-col">
1449
+ {voiceCallOpen ? (
1450
+ <VoiceCallWidget
1451
+ botId={botId}
1452
+ sessionId={sessionId}
1453
+ backendUrl={BACKEND_URL}
1454
+ originToken={originToken}
1455
+ visitorTimezone={Intl.DateTimeFormat().resolvedOptions().timeZone}
1456
+ primaryColor={primaryColor}
1457
+ onClose={() => { setVoiceCallOpen(false); refetchNow(); }}
1458
+ />
1459
+ ) : showCsat ? (
1460
+ /* CSAT Feedback Modal */
1461
+ <div className="p-5 flex flex-col justify-center h-full space-y-4">
1462
+ <div className="text-center space-y-2">
1463
+ <h3 className="text-sm font-bold text-neutral-800 dark:text-neutral-250">How was your conversation?</h3>
1464
+ <p className="text-[11px] text-neutral-500">Your rating helps us improve support quality.</p>
1465
+ </div>
1466
+ {/* Stars selection */}
1467
+ <div className="flex justify-center gap-1.5 py-2">
1468
+ {[1, 2, 3, 4, 5].map((star) => (
1469
+ <button
1470
+ key={star}
1471
+ type="button"
1472
+ onClick={() => setCsatRating(star)}
1473
+ className={`text-2xl transition-transform hover:scale-110 cursor-pointer ${
1474
+ star <= csatRating ? "text-yellow-400" : "text-neutral-300 dark:text-neutral-700"
1475
+ }`}
1476
+ >
1477
+
1478
+ </button>
1479
+ ))}
1480
+ </div>
1481
+ {/* Comment */}
1482
+ <textarea
1483
+ rows={3}
1484
+ value={csatComment}
1485
+ onChange={(e) => setCsatComment(e.target.value)}
1486
+ placeholder="What went well or could be better? (optional)..."
1487
+ className="w-full bg-neutral-50 dark:bg-neutral-950 border border-neutral-200 dark:border-neutral-850 rounded-xl px-3 py-2 text-xs resize-none focus:outline-none"
1488
+ />
1489
+ {/* Action buttons */}
1490
+ <div className="flex justify-end gap-2 pt-1">
1491
+ <button
1492
+ type="button"
1493
+ onClick={() => { setShowCsat(false); notifyClose(); }}
1494
+ className="px-3 py-1.5 border border-neutral-200 dark:border-neutral-800 rounded-lg text-xs font-semibold hover:bg-neutral-50 dark:hover:bg-neutral-850 cursor-pointer text-neutral-600 dark:text-neutral-350"
1495
+ >
1496
+ Skip
1497
+ </button>
1498
+ <button
1499
+ type="button"
1500
+ onClick={submitCsat}
1501
+ disabled={csatRating === 0 || csatSubmitted}
1502
+ className="px-3 py-1.5 text-xs font-semibold rounded-lg cursor-pointer disabled:opacity-40"
1503
+ style={{ background: primaryColor, color: onPrimary }}
1504
+ >
1505
+ Submit feedback
1506
+ </button>
1507
+ </div>
1508
+ </div>
1509
+ ) : showOfflineForm ? (
1510
+ /* Offline Message Capture Form */
1511
+ <div className="p-5 flex flex-col h-full justify-between gap-4">
1512
+ <div className="space-y-4">
1513
+ <div className="flex items-center gap-2">
1514
+ <button type="button" onClick={() => setShowOfflineForm(false)} className="p-1 rounded-full hover:bg-neutral-100 dark:hover:bg-neutral-800 cursor-pointer">
1515
+ <ArrowLeft className="size-4 text-neutral-500" />
1516
+ </button>
1517
+ <h3 className="text-sm font-bold text-neutral-800 dark:text-neutral-200">Leave a Message</h3>
1518
+ </div>
1519
+ <p className="text-[11px] text-neutral-500 leading-relaxed dark:text-neutral-400">No support agents are currently available to chat. Leave your contact email and description below, and we&apos;ll get back to you soon.</p>
1520
+
1521
+ <div className="space-y-3">
1522
+ <div className="space-y-1">
1523
+ <label className="text-[10px] font-semibold text-neutral-400 uppercase tracking-wider">Your Email</label>
1524
+ <input
1525
+ type="email"
1526
+ value={offlineEmail}
1527
+ onChange={(e) => setOfflineEmail(e.target.value)}
1528
+ placeholder="name@company.com"
1529
+ className="w-full bg-neutral-50 dark:bg-neutral-950 border border-neutral-200 dark:border-neutral-800 rounded-lg px-2.5 py-1.5 text-xs focus:outline-none"
1530
+ />
1531
+ </div>
1532
+ <div className="space-y-1">
1533
+ <label className="text-[10px] font-semibold text-neutral-400 uppercase tracking-wider">How can we help?</label>
1534
+ <textarea
1535
+ rows={4}
1536
+ value={offlineMessage}
1537
+ onChange={(e) => setOfflineMessage(e.target.value)}
1538
+ placeholder="Describe your issue or question in detail..."
1539
+ className="w-full bg-neutral-50 dark:bg-neutral-950 border border-neutral-200 dark:border-neutral-800 rounded-lg px-2.5 py-1.5 text-xs resize-none focus:outline-none"
1540
+ />
1541
+ </div>
1542
+ </div>
1543
+ </div>
1544
+
1545
+ <div className="flex justify-end gap-2 pt-2 border-t border-neutral-100 dark:border-neutral-800 mt-auto">
1546
+ <button
1547
+ type="button"
1548
+ onClick={() => setShowOfflineForm(false)}
1549
+ className="px-3 py-1.5 border border-neutral-200 dark:border-neutral-800 rounded-lg text-xs font-semibold hover:bg-neutral-50 dark:hover:bg-neutral-850 cursor-pointer text-neutral-600 dark:text-neutral-300"
1550
+ >
1551
+ Cancel
1552
+ </button>
1553
+ <button
1554
+ type="button"
1555
+ onClick={submitOfflineMessage}
1556
+ disabled={!offlineEmail.trim() || !offlineMessage.trim() || offlineSubmitted}
1557
+ className="px-3 py-1.5 text-xs font-semibold rounded-lg cursor-pointer disabled:opacity-40"
1558
+ style={{ background: primaryColor, color: onPrimary }}
1559
+ >
1560
+ Send message
1561
+ </button>
1562
+ </div>
1563
+ </div>
1564
+ ) : (
1565
+ <>
1566
+ {/* HOME */}
1567
+ {tab === "home" && (
1568
+ <div className="p-4 space-y-3">
1569
+ <div className="p-4 rounded-2xl bg-neutral-50 dark:bg-neutral-950 border border-neutral-100 dark:border-neutral-850">
1570
+ <h3 className="text-sm font-bold flex items-center gap-1.5"><Sparkles className="size-4" style={{ color: primaryColor }} />Hi there 👋</h3>
1571
+ <p className="text-xs text-neutral-500 mt-1 leading-relaxed">{welcomeMsg}</p>
1572
+ </div>
1573
+ <button onClick={() => setTab("messages")} className="w-full flex items-center justify-between p-3.5 rounded-2xl border border-neutral-200 dark:border-neutral-800 hover:border-neutral-300 transition-colors text-left">
1574
+ <span className="flex items-center gap-2 text-xs font-semibold"><MessageSquare className="size-4" style={{ color: primaryColor }} />Send us a message</span>
1575
+ <ChevronRight className="size-4 text-neutral-400" />
1576
+ </button>
1577
+ <button onClick={() => setShowOfflineForm(true)} className="w-full flex items-center justify-between p-3.5 rounded-2xl border border-neutral-200 dark:border-neutral-800 hover:border-neutral-300 transition-colors text-left">
1578
+ <span className="flex items-center gap-2 text-xs font-semibold"><Mail className="size-4" style={{ color: primaryColor }} />Leave us a message</span>
1579
+ <ChevronRight className="size-4 text-neutral-400" />
1580
+ </button>
1581
+ <button onClick={() => setTab("articles")} className="w-full flex items-center justify-between p-3.5 rounded-2xl border border-neutral-200 dark:border-neutral-800 hover:border-neutral-300 transition-colors text-left">
1582
+ <span className="flex items-center gap-2 text-xs font-semibold"><FileText className="size-4" style={{ color: primaryColor }} />Browse help articles</span>
1583
+ <ChevronRight className="size-4 text-neutral-400" />
1584
+ </button>
1585
+ <button onClick={() => setTab("search")} className="w-full flex items-center justify-between p-3.5 rounded-2xl border border-neutral-200 dark:border-neutral-800 hover:border-neutral-300 transition-colors text-left">
1586
+ <span className="flex items-center gap-2 text-xs font-semibold"><Search className="size-4" style={{ color: primaryColor }} />Search for answers</span>
1587
+ <ChevronRight className="size-4 text-neutral-400" />
1588
+ </button>
1589
+ </div>
1590
+ )}
1591
+
1592
+ {/* MESSAGES */}
1593
+ {tab === "messages" && (
1594
+ <div className="p-4 space-y-4 text-xs">
1595
+ <AnimatePresence initial={false}>
1596
+ {messages.map((msg, i) => (
1597
+ <motion.div key={i} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}
1598
+ className={`flex gap-2 max-w-[88%] ${msg.role === "user" ? "ml-auto flex-row-reverse" : "mr-auto"}`}>
1599
+ {msg.role !== "user" && <div className="size-6 rounded-full flex items-center justify-center text-[9px] font-bold shrink-0 overflow-hidden" style={{ background: primaryColor, color: onPrimary }}>{avatarInner("size-3.5")}</div>}
1600
+ <div className="flex flex-col min-w-0">
1601
+ {msg.role === "assistant" && showSenderTag && msg.sender && (
1602
+ <span className="text-[9px] font-semibold uppercase tracking-wide text-neutral-400 dark:text-neutral-500 px-0.5 mb-0.5">
1603
+ {msg.sender === "human" ? "Human agent" : "AI"}
1604
+ </span>
1605
+ )}
1606
+ {/* .user-bubble's background/color come entirely from the
1607
+ design preset's own CSS (globals.css, !important) — an
1608
+ inline style here computed from primaryColor would be
1609
+ silently overridden for the background but NOT
1610
+ recomputed for the text color, producing the same
1611
+ invisible-text bug the header had. */}
1612
+ <div className={`p-2.5 rounded-2xl leading-relaxed min-w-0 break-words [overflow-wrap:anywhere] ${msg.role === "user" ? "user-bubble rounded-tr-none" : "bot-bubble bg-neutral-100 dark:bg-neutral-800 rounded-tl-none"}`}>
1613
+ {/* msg.fileUrl is a local blob: URL (URL.createObjectURL) or an uploaded-file URL — neither works with next/image's optimizer */}
1614
+ {/* eslint-disable-next-line @next/next/no-img-element */}
1615
+ {msg.fileUrl && msg.fileType?.startsWith("image/") && <img src={msg.fileUrl} alt="attachment" className="rounded-lg mb-1 max-h-40 object-cover" />}
1616
+ {msg.fileUrl && msg.fileType?.startsWith("audio/") && <audio controls src={msg.fileUrl} className="mb-1 max-w-[180px]" />}
1617
+ {msg.role === "assistant"
1618
+ ? <ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeKatex]} components={mdComponents}>{msg.content}</ReactMarkdown>
1619
+ : <span>{msg.content}</span>}
1620
+ {msg.role === "assistant" && msg.content && i === messages.length - 1 && !isBotResponding && (
1621
+ <div className="mt-1.5 flex items-center gap-1">
1622
+ <button onClick={() => rateMessage(i, "up")} aria-label="Helpful"
1623
+ className={`p-1 rounded-md transition-colors ${msg.feedback === "up" ? "text-green-500" : "text-neutral-300 dark:text-neutral-600 hover:text-neutral-500"}`}>
1624
+ <ThumbsUp className="size-3" />
1625
+ </button>
1626
+ <button onClick={() => rateMessage(i, "down")} aria-label="Not helpful"
1627
+ className={`p-1 rounded-md transition-colors ${msg.feedback === "down" ? "text-red-500" : "text-neutral-300 dark:text-neutral-600 hover:text-neutral-500"}`}>
1628
+ <ThumbsDown className="size-3" />
1629
+ </button>
1630
+ </div>
1631
+ )}
1632
+ {msg.role === "assistant" && msg.sources && msg.sources.length > 0 && (
1633
+ <div className="mt-2 pt-2 border-t border-neutral-200 dark:border-neutral-700 flex flex-wrap gap-1">
1634
+ {msg.sources.map((s, si) => {
1635
+ const label = s.url ? (() => { try { return new URL(s.url!).hostname.replace(/^www\./, "") + new URL(s.url!).pathname.replace(/\/$/, ""); } catch { return s.name; } })() : s.name;
1636
+ const cls = "inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 text-neutral-500 max-w-[170px]";
1637
+ return s.url
1638
+ ? <a key={si} href={s.url} target="_blank" rel="noopener noreferrer" title={s.url} className={`${cls} hover:text-neutral-800 dark:hover:text-neutral-200 transition-colors`}><Link2 className="size-2.5 shrink-0" /><span className="truncate">{label}</span></a>
1639
+ : <span key={si} title={s.name} className={cls}><FileText className="size-2.5 shrink-0" /><span className="truncate">{label}</span></span>;
1640
+ })}
1641
+ </div>
1642
+ )}
1643
+ </div>
1644
+ </div>
1645
+ </motion.div>
1646
+ ))}
1647
+ {(isBotResponding || agentTyping) && (
1648
+ <div className="flex gap-2 mr-auto">
1649
+ <div className="size-6 rounded-full flex items-center justify-center text-[9px] font-bold shrink-0 overflow-hidden" style={{ background: primaryColor, color: onPrimary }}>{avatarInner("size-3.5")}</div>
1650
+ <div className="p-3 bg-neutral-100 dark:bg-neutral-800 rounded-2xl rounded-tl-none flex items-center gap-1">
1651
+ <span className="size-1.5 rounded-full bg-neutral-400 animate-bounce" />
1652
+ <span className="size-1.5 rounded-full bg-neutral-400 animate-bounce [animation-delay:150ms]" />
1653
+ <span className="size-1.5 rounded-full bg-neutral-400 animate-bounce [animation-delay:300ms]" />
1654
+ </div>
1655
+ </div>
1656
+ )}
1657
+ </AnimatePresence>
1658
+ {starters.length > 0 && !activeNodeId && !isBotResponding && messages.filter((m) => m.role === "user").length === 0 && (
1659
+ <div className="flex flex-col items-end gap-2 pt-1">
1660
+ {starters.slice(0, 4).map((s, i) => (
1661
+ <button key={i} onClick={() => sendText(s)}
1662
+ className="starter-chip px-3 py-2 rounded-2xl border text-xs font-medium text-right hover:bg-neutral-50 dark:hover:bg-neutral-900 transition-colors"
1663
+ style={{ borderColor: primaryColor, color: primaryColor }}>
1664
+ {s}
1665
+ </button>
1666
+ ))}
1667
+ </div>
1668
+ )}
1669
+ {flowConfig && activeNodeId && !isBotResponding && !flowAwaitingInput && (
1670
+ (() => {
1671
+ const activeNode = flowConfig.nodes.find((n) => n.id === activeNodeId);
1672
+ // Never show buttons on question nodes — user must type their answer
1673
+ if (isQuestionNode(activeNode)) return null;
1674
+ const outgoingEdges = flowConfig.edges.filter((e) => e.source === activeNodeId);
1675
+ const resolvedEdges = outgoingEdges.map((e) => ({ ...e, _label: getEdgeLabel(e) }));
1676
+ // Only show buttons if there are multiple labeled outgoing edges (menu-style)
1677
+ const labeled = resolvedEdges.filter((e) => e._label);
1678
+ if (labeled.length < 2) return null;
1679
+ return (
1680
+ <div className="flex flex-col items-end gap-2 pt-1">
1681
+ {labeled.map((edge, i) => (
1682
+ <button
1683
+ key={i}
1684
+ type="button"
1685
+ onClick={() => handleFlowChoice(edge)}
1686
+ className="px-3 py-2 rounded-2xl border text-xs font-medium text-right hover:bg-neutral-50 dark:hover:bg-neutral-900 transition-colors cursor-pointer"
1687
+ style={{ borderColor: primaryColor, color: primaryColor }}
1688
+ >
1689
+ {edge._label}
1690
+ </button>
1691
+ ))}
1692
+ </div>
1693
+ );
1694
+ })()
1695
+ )}
1696
+ <div ref={messagesEndRef} />
1697
+ </div>
1698
+ )}
1699
+
1700
+ {/* ARTICLES */}
1701
+ {tab === "articles" && (
1702
+ <div className="p-4">
1703
+ {openArticle ? (
1704
+ <div>
1705
+ <button onClick={() => setOpenArticle(null)} className="flex items-center gap-1 text-[11px] font-semibold text-neutral-500 mb-3"><ArrowLeft className="size-3.5" />All articles</button>
1706
+ <h3 className="text-sm font-bold mb-2">{openArticle.name}</h3>
1707
+ <div className="text-xs text-neutral-600 dark:text-neutral-300 leading-relaxed whitespace-pre-wrap">{openArticle.content}</div>
1708
+ </div>
1709
+ ) : sources.length === 0 ? (
1710
+ <div className="text-center py-10"><FileText className="size-8 text-neutral-300 mx-auto" /><p className="text-xs text-neutral-400 mt-2">No articles yet.</p></div>
1711
+ ) : (
1712
+ <div className="space-y-2">
1713
+ <h3 className="text-xs font-bold uppercase tracking-wide text-neutral-400 mb-1">Help articles</h3>
1714
+ {sources.map((s) => (
1715
+ <button key={s.id} onClick={() => setOpenArticle(s)} className="w-full flex items-center justify-between p-3 rounded-xl border border-neutral-200 dark:border-neutral-800 hover:border-neutral-300 text-left">
1716
+ <div className="min-w-0">
1717
+ <p className="text-xs font-semibold truncate">{s.name}</p>
1718
+ <p className="text-[10px] text-neutral-400 truncate">{s.content.slice(0, 60)}</p>
1719
+ </div>
1720
+ <ChevronRight className="size-4 text-neutral-400 shrink-0" />
1721
+ </button>
1722
+ ))}
1723
+ </div>
1724
+ )}
1725
+ </div>
1726
+ )}
1727
+
1728
+ {/* SEARCH */}
1729
+ {tab === "search" && (
1730
+ <div className="p-4">
1731
+ <form onSubmit={(e) => { e.preventDefault(); runSearch(searchQuery); }} className="relative">
1732
+ <Search className="size-4 text-neutral-400 absolute left-3 top-1/2 -translate-y-1/2" />
1733
+ <input value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="Search our help center…"
1734
+ className="w-full bg-neutral-50 dark:bg-neutral-950 border border-neutral-200 dark:border-neutral-800 rounded-xl pl-9 pr-3 py-2.5 text-xs focus:outline-none" />
1735
+ </form>
1736
+ {searching && <div className="flex items-center gap-2 text-xs text-neutral-400 mt-4"><Loader2 className="size-4 animate-spin" />Generating answer…</div>}
1737
+ {searchAnswer && !searching && (
1738
+ <div className="mt-4 p-3.5 rounded-2xl bg-neutral-50 dark:bg-neutral-950 border border-neutral-100 dark:border-neutral-850">
1739
+ <p className="text-[10px] font-bold uppercase tracking-wide flex items-center gap-1.5 mb-1.5" style={{ color: primaryColor }}><Sparkles className="size-3" />AI-generated answer</p>
1740
+ <div className="text-xs text-neutral-700 dark:text-neutral-300 leading-relaxed">
1741
+ <ReactMarkdown remarkPlugins={[remarkGfm]} components={mdComponents}>{searchAnswer}</ReactMarkdown>
1742
+ </div>
1743
+ <button onClick={() => { setTab("messages"); }} className="mt-3 text-[11px] font-semibold px-3 py-1.5 rounded-lg" style={{ background: primaryColor, color: onPrimary }}>Still have questions? Message us</button>
1744
+ </div>
1745
+ )}
1746
+ </div>
1747
+ )}
1748
+ </>
1749
+ )}
1750
+ </div>
1751
+
1752
+ {/* Composer (Messages tab only) */}
1753
+ {tab === "messages" && !voiceCallOpen && (
1754
+ <div className="border-t border-neutral-100 dark:border-neutral-850 p-2.5 relative bg-card">
1755
+ <input type="file" ref={fileInputRef} onChange={onFilePick} accept="image/*,audio/*,application/pdf,.txt,.doc,.docx" className="hidden" multiple />
1756
+ <AnimatePresence>
1757
+ {emojiOpen && (
1758
+ <motion.div
1759
+ initial={{ opacity: 0, y: 20, scale: 0.85, pointerEvents: "none" }}
1760
+ animate={{ opacity: 1, y: 0, scale: 1, pointerEvents: "auto" }}
1761
+ exit={{ opacity: 0, y: 20, scale: 0.85, pointerEvents: "none" }}
1762
+ transition={{ duration: 0.32, ease: [0.34, 1.56, 0.64, 1] }}
1763
+ className="emoji-panel absolute bottom-[84px] left-2.5 right-2.5 z-10 flex flex-col h-[min(64vh,440px)] min-h-[280px] rounded-2xl border border-neutral-200/80 dark:border-neutral-800/80 shadow-[0_12px_40px_-8px_rgba(0,0,0,0.25)] overflow-hidden bg-card backdrop-blur-sm"
1764
+ >
1765
+ <div className="flex items-center justify-between px-3.5 py-2.5 border-b border-neutral-100 dark:border-neutral-850 shrink-0">
1766
+ <span className="text-[11px] font-bold tracking-wide text-neutral-500 dark:text-neutral-400 uppercase">Pick an emoji</span>
1767
+ <button
1768
+ type="button"
1769
+ onClick={() => setEmojiOpen(false)}
1770
+ className="p-1 -m-1 text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-full transition-colors"
1771
+ aria-label="Close emoji picker"
1772
+ >
1773
+ <X className="size-3.5" />
1774
+ </button>
1775
+ </div>
1776
+ <div className="emoji-panel-picker flex-1 min-h-0">
1777
+ <QuickEmojiPicker onSelect={(emoji) => setInputValue((v) => v + emoji)} accentColor={primaryColor} />
1778
+ </div>
1779
+ </motion.div>
1780
+ )}
1781
+ </AnimatePresence>
1782
+ <AnimatePresence>
1783
+ {attachOpen && (
1784
+ <motion.div
1785
+ initial={{ opacity: 0, y: 20, scale: 0.85, pointerEvents: "none" }}
1786
+ animate={{ opacity: 1, y: 0, scale: 1, pointerEvents: "auto" }}
1787
+ exit={{ opacity: 0, y: 20, scale: 0.85, pointerEvents: "none" }}
1788
+ transition={{ duration: 0.32, ease: [0.34, 1.56, 0.64, 1] }}
1789
+ className="absolute bottom-[84px] left-2.5 z-10 w-52 rounded-2xl border border-neutral-200/80 dark:border-neutral-800/80 shadow-[0_12px_40px_-8px_rgba(0,0,0,0.25)] overflow-hidden bg-card backdrop-blur-sm"
1790
+ >
1791
+ <AttachMenu
1792
+ onPickImages={() => openFilePicker("images")}
1793
+ onPickDocuments={() => openFilePicker("documents")}
1794
+ onShareLocation={shareLocation}
1795
+ accentColor={primaryColor}
1796
+ />
1797
+ </motion.div>
1798
+ )}
1799
+ </AnimatePresence>
1800
+ <form onSubmit={async (e) => {
1801
+ e.preventDefault();
1802
+ if (pendingFiles.length > 0) {
1803
+ for (let i = 0; i < pendingFiles.length; i++) {
1804
+ const pf = pendingFiles[i];
1805
+ const caption = i === 0 ? inputValue.trim() : "";
1806
+ await sendMedia(pf.file, pf.file.name, caption);
1807
+ if (pf.preview) URL.revokeObjectURL(pf.preview);
1808
+ }
1809
+ setPendingFiles([]);
1810
+ setInputValue("");
1811
+ return;
1812
+ }
1813
+ sendText(inputValue);
1814
+ }}
1815
+ className="chat-input-bar rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-950 px-3 pt-2.5 pb-1.5 focus-within:border-neutral-300 dark:focus-within:border-neutral-700 transition-colors">
1816
+ {pendingFiles.length > 0 && (
1817
+ <div className="flex gap-1.5 px-0 pt-1 pb-1.5 flex-wrap">
1818
+ {pendingFiles.map((pf, idx) => (
1819
+ <div key={idx} className="relative group">
1820
+ {pf.file.type.startsWith("image/") ? (
1821
+ // pf.preview is a local blob: URL (URL.createObjectURL) — next/image can't optimize it
1822
+ // eslint-disable-next-line @next/next/no-img-element
1823
+ <img src={pf.preview} alt="preview" className="h-14 w-14 rounded-lg object-cover border border-neutral-200 dark:border-neutral-700" />
1824
+ ) : (
1825
+ <div className="h-14 w-14 rounded-lg border border-neutral-200 dark:border-neutral-700 flex items-center justify-center bg-neutral-50 dark:bg-neutral-800">
1826
+ <span className="text-[9px] text-neutral-500 text-center px-0.5 truncate">{pf.file.name.split('.').pop()?.toUpperCase()}</span>
1827
+ </div>
1828
+ )}
1829
+ <button
1830
+ type="button"
1831
+ onClick={() => {
1832
+ if (pf.preview) URL.revokeObjectURL(pf.preview);
1833
+ setPendingFiles(prev => prev.filter((_, i) => i !== idx));
1834
+ }}
1835
+ className="absolute -top-1.5 -right-1.5 bg-red-500 text-white rounded-full w-4 h-4 flex items-center justify-center text-[10px] opacity-0 group-hover:opacity-100 transition-opacity shadow"
1836
+ >
1837
+ ×
1838
+ </button>
1839
+ </div>
1840
+ ))}
1841
+ </div>
1842
+ )}
1843
+ <input value={inputValue} onChange={(e) => setInputValue(e.target.value)} onFocus={() => { setEmojiOpen(false); setAttachOpen(false); }} onPaste={onPaste}
1844
+ placeholder={recording ? "Recording… tap ◼ to stop" : transcribing ? "Transcribing…" : "Compose your message…"} disabled={isBotResponding || recording || transcribing}
1845
+ className="w-full bg-transparent text-xs focus:outline-none disabled:opacity-60 mb-1.5" />
1846
+ <div className="flex items-center justify-between">
1847
+ <div className="flex items-center gap-0.5">
1848
+ <motion.button type="button" whileTap={{ scale: 0.85 }} onClick={() => { setEmojiOpen((o) => !o); setAttachOpen(false); }} className="chat-input-bar-icon p-1.5 text-neutral-500 hover:text-neutral-800 dark:hover:text-neutral-200 rounded-full" aria-label="Emoji"><Smile className="size-4.5" /></motion.button>
1849
+ <motion.button type="button" whileTap={{ scale: 0.85 }} onClick={() => { setAttachOpen((o) => !o); setEmojiOpen(false); }} className="chat-input-bar-icon p-1.5 text-neutral-500 hover:text-neutral-800 dark:hover:text-neutral-200 rounded-full" aria-label="Attach file"><Paperclip className="size-4.5" /></motion.button>
1850
+ <button type="button" onClick={toggleRecord} disabled={transcribing} className={`p-1.5 rounded-full disabled:opacity-50 ${recording ? "text-red-500" : "chat-input-bar-icon text-neutral-500 hover:text-neutral-800 dark:hover:text-neutral-200"}`} aria-label="Record audio">
1851
+ {transcribing ? <Loader2 className="size-4.5 animate-spin" /> : recording ? <Square className="size-4.5 fill-current" /> : <Mic className="size-4.5" />}
1852
+ </button>
1853
+ {recording && (
1854
+ <div className="flex items-center gap-[2px] h-5 px-1" aria-hidden>
1855
+ {barLevels.map((level, i) => (
1856
+ <span
1857
+ key={i}
1858
+ className="w-0.5 bg-red-500 rounded-full transition-[height] duration-[50ms] ease-out"
1859
+ style={{ height: `${Math.max(2, level * 18)}px` }}
1860
+ />
1861
+ ))}
1862
+ </div>
1863
+ )}
1864
+ </div>
1865
+ {(() => {
1866
+ const c = SEND_BUTTON_STYLES[sendStyle] || SEND_BUTTON_STYLES.plane;
1867
+ return (
1868
+ <button type="submit" disabled={isBotResponding || (!inputValue.trim() && pendingFiles.length === 0)} style={{ background: primaryColor, color: onPrimary }}
1869
+ className={`send-btn ${c.shape} flex items-center justify-center hover:opacity-90 disabled:opacity-40 shrink-0 relative`}>
1870
+ {c.icon}{c.label && <span className="text-xs font-semibold">{c.label}</span>}
1871
+ {pendingFiles.length > 0 && (
1872
+ <span className="absolute -top-1 -right-1 bg-red-500 text-white text-[9px] font-bold rounded-full w-4 h-4 flex items-center justify-center shadow">{pendingFiles.length}</span>
1873
+ )}
1874
+ </button>
1875
+ );
1876
+ })()}
1877
+ </div>
1878
+ </form>
1879
+ {!isOfficialWebsite && !hideBranding && (
1880
+ <div className="text-center pt-2 pb-0.5 text-[10px] text-neutral-400 dark:text-neutral-500 font-mono tracking-wide">
1881
+ Powered by{" "}
1882
+ <a
1883
+ href="https://chatty.personaliai.com"
1884
+ target="_blank"
1885
+ rel="noopener noreferrer"
1886
+ className="hover:underline font-bold text-neutral-500 dark:text-neutral-400"
1887
+ >
1888
+ Chatty
1889
+ </a>
1890
+ </div>
1891
+ )}
1892
+ </div>
1893
+ )}
1894
+
1895
+ {/* Toast Notification */}
1896
+ {toast && (
1897
+ <div className="absolute top-4 left-4 right-4 z-[999] flex items-center gap-2.5 bg-neutral-900/95 dark:bg-neutral-950/95 border border-neutral-800 dark:border-neutral-900 rounded-xl px-3 py-2 shadow-2xl text-[11px] font-semibold text-white animate-in slide-in-from-top-4 fade-in duration-300">
1898
+ {toast.type === "success" ? (
1899
+ <span className="flex size-4.5 items-center justify-center rounded-full bg-green-950/40 text-green-400">
1900
+ <Check className="size-3" />
1901
+ </span>
1902
+ ) : (
1903
+ <span className="flex size-4.5 items-center justify-center rounded-full bg-red-950/40 text-red-400">
1904
+ <AlertCircle className="size-3" />
1905
+ </span>
1906
+ )}
1907
+ <span className="flex-1 truncate">{toast.message}</span>
1908
+ <button
1909
+ onClick={() => setToast(null)}
1910
+ className="text-neutral-400 hover:text-neutral-200 cursor-pointer"
1911
+ >
1912
+ <X className="size-3" />
1913
+ </button>
1914
+ </div>
1915
+ )}
1916
+
1917
+ </div>
1918
+ );
1919
+ }