@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.
- package/LICENSE +21 -0
- package/README.md +97 -0
- package/dist/chunk-PR4QN5HX.js +43 -0
- package/dist/chunk-PR4QN5HX.js.map +1 -0
- package/dist/emoji-picker-react.esm-JCK7JWXK.js +26071 -0
- package/dist/emoji-picker-react.esm-JCK7JWXK.js.map +1 -0
- package/dist/index.cjs +98050 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +28 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +71952 -0
- package/dist/index.js.map +1 -0
- package/dist/styles.css +259 -0
- package/package.json +57 -0
- package/src/ChatWidgetCore.tsx +1919 -0
- package/src/attach-menu.tsx +60 -0
- package/src/color-contrast.ts +186 -0
- package/src/index.ts +1 -0
- package/src/quick-emoji-picker.tsx +75 -0
- package/src/safe-markdown-link.tsx +44 -0
- package/src/voice-call-widget.tsx +564 -0
- package/src/widget-presets.css +259 -0
- package/src/widget-style.ts +88 -0
|
@@ -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'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
|
+
}
|