@freewaretools/outercom 0.4.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/src/react.tsx ADDED
@@ -0,0 +1,994 @@
1
+ "use client"
2
+
3
+ /**
4
+ * chatkit widget — a self-contained floating chat box.
5
+ *
6
+ * No CSS framework dependency: styling is inline + a small injected <style> for
7
+ * the keyframes, themeable via the `accentColor` prop. Drop it into any React
8
+ * app and point `apiBase` at the routes created with `chatkit/next`.
9
+ */
10
+ import { useCallback, useEffect, useRef, useState } from "react"
11
+ import type { ChatMessage, ChatStatus } from "./types"
12
+
13
+ export interface ChatWidgetProps {
14
+ /** Base path for the chat API routes. Default "/api/chat". */
15
+ apiBase?: string
16
+ title?: string
17
+ subtitle?: string
18
+ /** Optional first assistant bubble shown before any reply arrives. */
19
+ greeting?: string
20
+ accentColor?: string
21
+ /** Text on the launcher pill (default "Live Chat"). */
22
+ launcherLabel?: string
23
+ /**
24
+ * Launcher pill look: "accent" (filled with accentColor, default) or "light"
25
+ * (white fill, black rim + text) for visibility on busy/dark pages.
26
+ */
27
+ launcherVariant?: "accent" | "light"
28
+ /** Label shown on the AI's chat bubbles (default "Assistant"). Set to your brand. */
29
+ aiLabel?: string
30
+ position?: "bottom-right" | "bottom-left"
31
+ pollIntervalMs?: number
32
+ /** localStorage key for resuming a session across reloads. */
33
+ storageKey?: string
34
+ /**
35
+ * If set, the widget fetches `{ enabled, greeting }` from this URL on mount
36
+ * and only renders when `enabled` is true. Lets the host gate + configure the
37
+ * widget at request time, instead of baking on/off into a static build.
38
+ */
39
+ configUrl?: string
40
+ /**
41
+ * Pre-known visitor (e.g. a logged-in user). When set, the pre-chat form is
42
+ * skipped and a session auto-starts when the panel opens. For trust, also have
43
+ * the server derive identity (see createChatRoutes `resolveVisitor`) — this
44
+ * value is for display/convenience.
45
+ */
46
+ visitor?: { name: string; email: string }
47
+ /** Hide the floating launcher pill — host controls visibility via `open`. */
48
+ hideLauncher?: boolean
49
+ /** Controlled open state. Provide with `onOpenChange` to drive from a menu, etc. */
50
+ open?: boolean
51
+ /** Called when the widget requests open/close (e.g. its header close button). */
52
+ onOpenChange?: (open: boolean) => void
53
+ /**
54
+ * Called when the unread state changes (a reply arrived while the panel was
55
+ * closed, or the panel opened and cleared it). Lets a host surface a badge
56
+ * elsewhere — e.g. on a menu item — when the launcher is hidden.
57
+ */
58
+ onUnreadChange?: (unread: boolean) => void
59
+ }
60
+
61
+ interface StoredSession {
62
+ sessionId: string
63
+ visitor: { name: string; email: string }
64
+ }
65
+
66
+ type Phase = "form" | "chat"
67
+
68
+ export function ChatWidget({
69
+ apiBase = "/api/chat",
70
+ title: titleProp = "Chat with us",
71
+ subtitle: subtitleProp = "We typically reply in a few minutes",
72
+ greeting: greetingProp,
73
+ accentColor: accentColorProp = "#0f172a",
74
+ launcherLabel: launcherLabelProp = "Live Chat",
75
+ launcherVariant = "accent",
76
+ aiLabel: aiLabelProp = "Assistant",
77
+ position: positionProp = "bottom-right",
78
+ pollIntervalMs = 4000,
79
+ storageKey = "chatkit:session",
80
+ configUrl,
81
+ visitor: visitorProp,
82
+ hideLauncher = false,
83
+ open: controlledOpen,
84
+ onOpenChange,
85
+ onUnreadChange,
86
+ }: ChatWidgetProps) {
87
+ // When configUrl is set, stay hidden until the runtime config says enabled.
88
+ const [enabled, setEnabled] = useState<boolean>(!configUrl)
89
+ // Theming/config overrides fetched at runtime from configUrl (take precedence over props).
90
+ const [overrides, setOverrides] = useState<{
91
+ accentColor?: string
92
+ title?: string
93
+ subtitle?: string
94
+ greeting?: string
95
+ launcherLabel?: string
96
+ aiLabel?: string
97
+ position?: "bottom-left" | "bottom-right"
98
+ }>({})
99
+ const [internalOpen, setInternalOpen] = useState(false)
100
+ // Controlled when `open` prop is provided; otherwise self-managed.
101
+ const open = controlledOpen ?? internalOpen
102
+ const setOpen = (v: boolean | ((p: boolean) => boolean)) => {
103
+ const next = typeof v === "function" ? v(open) : v
104
+ onOpenChange?.(next)
105
+ if (controlledOpen === undefined) setInternalOpen(next)
106
+ }
107
+ // Skip the pre-chat form when identity is already known.
108
+ const [phase, setPhase] = useState<Phase>(visitorProp ? "chat" : "form")
109
+ const [name, setName] = useState("")
110
+ const [email, setEmail] = useState("")
111
+ const [topic, setTopic] = useState("")
112
+ const [sessionId, setSessionId] = useState<string | null>(null)
113
+ const [messages, setMessages] = useState<DisplayMessage[]>([])
114
+ const [input, setInput] = useState("")
115
+ const [status, setStatus] = useState<ChatStatus>("ai")
116
+ const [busy, setBusy] = useState(false)
117
+ const [error, setError] = useState<string | null>(null)
118
+ const [muted, setMuted] = useState(false)
119
+ const [unread, setUnread] = useState(false)
120
+ const [emailState, setEmailState] = useState<"idle" | "sending" | "sent" | "error">("idle")
121
+
122
+ const afterRef = useRef(0)
123
+ const seenIds = useRef<Set<string>>(new Set())
124
+ const scrollRef = useRef<HTMLDivElement>(null)
125
+ const audioCtxRef = useRef<AudioContext | null>(null)
126
+ const armedRef = useRef(false) // suppress sound/unread on the first (history) sync
127
+ const startingRef = useRef(false) // guard against double auto-start
128
+ const openRef = useRef(open)
129
+ const mutedRef = useRef(muted)
130
+ const mutedKey = `${storageKey}:muted`
131
+
132
+ useEffect(() => {
133
+ openRef.current = open
134
+ }, [open])
135
+ useEffect(() => {
136
+ mutedRef.current = muted
137
+ }, [muted])
138
+
139
+ // Load mute preference + clear the unread dot when opened.
140
+ useEffect(() => {
141
+ try {
142
+ setMuted(localStorage.getItem(mutedKey) === "1")
143
+ } catch {
144
+ /* ignore */
145
+ }
146
+ }, [mutedKey])
147
+ useEffect(() => {
148
+ if (open) setUnread(false)
149
+ }, [open])
150
+ // Surface unread changes to the host (e.g. to badge a menu item).
151
+ useEffect(() => {
152
+ onUnreadChange?.(unread)
153
+ // eslint-disable-next-line react-hooks/exhaustive-deps
154
+ }, [unread])
155
+
156
+ function toggleMute() {
157
+ setMuted((m) => {
158
+ const next = !m
159
+ try {
160
+ localStorage.setItem(mutedKey, next ? "1" : "0")
161
+ } catch {
162
+ /* ignore */
163
+ }
164
+ return next
165
+ })
166
+ }
167
+
168
+ // Soft two-tone "boop" via Web Audio — no asset to bundle.
169
+ function playBoop() {
170
+ try {
171
+ const Ctx =
172
+ window.AudioContext ||
173
+ (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
174
+ if (!Ctx) return
175
+ if (!audioCtxRef.current) audioCtxRef.current = new Ctx()
176
+ const ctx = audioCtxRef.current
177
+ if (ctx.state === "suspended") void ctx.resume()
178
+ const now = ctx.currentTime
179
+ ;[660, 880].forEach((freq, i) => {
180
+ const osc = ctx.createOscillator()
181
+ const gain = ctx.createGain()
182
+ osc.type = "sine"
183
+ osc.frequency.value = freq
184
+ const t = now + i * 0.11
185
+ gain.gain.setValueAtTime(0.0001, t)
186
+ gain.gain.exponentialRampToValueAtTime(0.14, t + 0.02)
187
+ gain.gain.exponentialRampToValueAtTime(0.0001, t + 0.11)
188
+ osc.connect(gain).connect(ctx.destination)
189
+ osc.start(t)
190
+ osc.stop(t + 0.12)
191
+ })
192
+ } catch {
193
+ /* audio unavailable — ignore */
194
+ }
195
+ }
196
+
197
+ // Resume a prior session from localStorage.
198
+ useEffect(() => {
199
+ try {
200
+ const raw = localStorage.getItem(storageKey)
201
+ if (raw) {
202
+ const s = JSON.parse(raw) as StoredSession
203
+ if (s?.sessionId) {
204
+ setSessionId(s.sessionId)
205
+ setName(s.visitor?.name ?? "")
206
+ setEmail(s.visitor?.email ?? "")
207
+ setPhase("chat")
208
+ }
209
+ }
210
+ } catch {
211
+ /* ignore */
212
+ }
213
+ }, [storageKey])
214
+
215
+ // Resolve enabled + theming at request time (avoids baking into a build).
216
+ useEffect(() => {
217
+ if (!configUrl) return
218
+ let cancelled = false
219
+ fetch(configUrl, { cache: "no-store" })
220
+ .then((r) => (r.ok ? r.json() : null))
221
+ .then(
222
+ (
223
+ cfg: {
224
+ enabled?: boolean
225
+ greeting?: string
226
+ accentColor?: string
227
+ title?: string
228
+ subtitle?: string
229
+ launcherLabel?: string
230
+ aiLabel?: string
231
+ position?: string
232
+ } | null
233
+ ) => {
234
+ if (cancelled || !cfg) return
235
+ setEnabled(!!cfg.enabled)
236
+ const next: typeof overrides = {}
237
+ if (cfg.greeting) next.greeting = cfg.greeting
238
+ if (cfg.accentColor) next.accentColor = cfg.accentColor
239
+ if (cfg.title) next.title = cfg.title
240
+ if (cfg.subtitle) next.subtitle = cfg.subtitle
241
+ if (cfg.launcherLabel) next.launcherLabel = cfg.launcherLabel
242
+ if (cfg.aiLabel) next.aiLabel = cfg.aiLabel
243
+ if (cfg.position === "bottom-left" || cfg.position === "bottom-right") {
244
+ next.position = cfg.position
245
+ }
246
+ setOverrides(next)
247
+ }
248
+ )
249
+ .catch(() => {
250
+ /* leave hidden on error */
251
+ })
252
+ return () => {
253
+ cancelled = true
254
+ }
255
+ }, [configUrl])
256
+
257
+ const mergeMessages = useCallback((incoming: ChatMessage[], notify = false) => {
258
+ const fresh = incoming.filter((m) => !seenIds.current.has(m.id))
259
+ if (fresh.length === 0) return
260
+ let gotIncoming = false
261
+ for (const m of fresh) {
262
+ seenIds.current.add(m.id)
263
+ if (m.createdAt > afterRef.current) afterRef.current = m.createdAt
264
+ if (m.role === "ai" || m.role === "agent") gotIncoming = true
265
+ }
266
+ setMessages((prev) => [...prev, ...fresh].sort((a, b) => a.createdAt - b.createdAt))
267
+ if (notify && gotIncoming) {
268
+ if (!mutedRef.current) playBoop()
269
+ if (!openRef.current) setUnread(true)
270
+ }
271
+ }, [])
272
+
273
+ const poll = useCallback(async () => {
274
+ if (!sessionId) return
275
+ try {
276
+ const res = await fetch(
277
+ `${apiBase}/poll?sessionId=${encodeURIComponent(sessionId)}&after=${afterRef.current}`
278
+ )
279
+ if (!res.ok) return
280
+ const data = (await res.json()) as { status: ChatStatus; messages: ChatMessage[] }
281
+ setStatus(data.status)
282
+ // Don't sound/badge the first sync (it loads history); arm afterwards.
283
+ mergeMessages(data.messages, armedRef.current)
284
+ armedRef.current = true
285
+ } catch {
286
+ /* transient network error — next tick retries */
287
+ }
288
+ }, [apiBase, sessionId, mergeMessages])
289
+
290
+ // Poll while a chat is active. Runs even when the bubble is closed (a slower
291
+ // heartbeat) so the server knows the visitor is still present (last-seen) and
292
+ // can show the unread dot / play a sound on incoming replies. When the tab is
293
+ // truly gone, polling stops → the server can email unseen replies after X min.
294
+ useEffect(() => {
295
+ if (phase !== "chat" || !sessionId || status === "closed") return
296
+ poll()
297
+ const interval = open ? pollIntervalMs : Math.max(pollIntervalMs * 6, 20000)
298
+ const t = setInterval(poll, interval)
299
+ return () => clearInterval(t)
300
+ }, [open, phase, sessionId, status, pollIntervalMs, poll])
301
+
302
+ // Keep scrolled to the latest message.
303
+ useEffect(() => {
304
+ scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })
305
+ }, [messages, open, phase])
306
+
307
+ async function doStart(v: { name: string; email: string; topic?: string }) {
308
+ if (startingRef.current || sessionId) return
309
+ startingRef.current = true
310
+ setBusy(true)
311
+ try {
312
+ const res = await fetch(apiBase, {
313
+ method: "POST",
314
+ headers: { "Content-Type": "application/json" },
315
+ body: JSON.stringify({ action: "start", visitor: v }),
316
+ })
317
+ const data = (await res.json()) as { sessionId?: string; error?: string }
318
+ if (!res.ok || !data.sessionId) {
319
+ setError(data.error ?? "Could not start the chat. Please try again.")
320
+ return
321
+ }
322
+ setSessionId(data.sessionId)
323
+ setStatus("ai")
324
+ try {
325
+ localStorage.setItem(
326
+ storageKey,
327
+ JSON.stringify({ sessionId: data.sessionId, visitor: { name: v.name, email: v.email } } satisfies StoredSession)
328
+ )
329
+ } catch {
330
+ /* ignore */
331
+ }
332
+ // Show the visitor's opening question locally; the AI reply arrives via poll.
333
+ if (v.topic) {
334
+ mergeMessages([localMessage(data.sessionId, "visitor", v.topic)])
335
+ }
336
+ setPhase("chat")
337
+ } catch {
338
+ setError("Could not start the chat. Please try again.")
339
+ } finally {
340
+ setBusy(false)
341
+ startingRef.current = false
342
+ }
343
+ }
344
+
345
+ async function startChat(e: React.FormEvent) {
346
+ e.preventDefault()
347
+ setError(null)
348
+ if (!name.trim() || !isEmail(email)) {
349
+ setError("Please enter your name and a valid email.")
350
+ return
351
+ }
352
+ await doStart({ name: name.trim(), email: email.trim(), topic: topic.trim() || undefined })
353
+ }
354
+
355
+ // Auto-start once the panel opens and identity is already known (logged-in).
356
+ useEffect(() => {
357
+ if (visitorProp && open && !sessionId && !startingRef.current) {
358
+ void doStart({ name: visitorProp.name, email: visitorProp.email })
359
+ }
360
+ // doStart is stable enough for this guard-driven effect.
361
+ // eslint-disable-next-line react-hooks/exhaustive-deps
362
+ }, [visitorProp, open, sessionId])
363
+
364
+ async function sendMessage(e: React.FormEvent) {
365
+ e.preventDefault()
366
+ const text = input.trim()
367
+ if (!text || !sessionId || status === "closed") return
368
+ setInput("")
369
+ mergeMessages([localMessage(sessionId, "visitor", text)])
370
+ setBusy(true)
371
+ try {
372
+ const res = await fetch(apiBase, {
373
+ method: "POST",
374
+ headers: { "Content-Type": "application/json" },
375
+ body: JSON.stringify({ action: "send", sessionId, text }),
376
+ })
377
+ if (res.ok) {
378
+ const data = (await res.json()) as { status: ChatStatus; messages: ChatMessage[] }
379
+ setStatus(data.status)
380
+ mergeMessages(data.messages, true)
381
+ }
382
+ } catch {
383
+ /* poll will reconcile */
384
+ } finally {
385
+ setBusy(false)
386
+ }
387
+ }
388
+
389
+ async function emailTranscript() {
390
+ if (!sessionId || emailState === "sending") return
391
+ setEmailState("sending")
392
+ try {
393
+ const res = await fetch(apiBase, {
394
+ method: "POST",
395
+ headers: { "Content-Type": "application/json" },
396
+ body: JSON.stringify({ action: "email", sessionId }),
397
+ })
398
+ const data = (await res.json().catch(() => ({}))) as { ok?: boolean }
399
+ setEmailState(res.ok && data.ok ? "sent" : "error")
400
+ } catch {
401
+ setEmailState("error")
402
+ }
403
+ }
404
+
405
+ function resetChat() {
406
+ // Tell the server the visitor ended this chat so agents in Telegram know
407
+ // their replies won't land (and the topic gets closed). Fire-and-forget;
408
+ // keepalive lets it finish even as we tear down local state.
409
+ if (sessionId) {
410
+ fetch(apiBase, {
411
+ method: "POST",
412
+ headers: { "Content-Type": "application/json" },
413
+ body: JSON.stringify({ action: "end", sessionId }),
414
+ keepalive: true,
415
+ }).catch(() => {})
416
+ }
417
+ try {
418
+ localStorage.removeItem(storageKey)
419
+ } catch {
420
+ /* ignore */
421
+ }
422
+ seenIds.current = new Set()
423
+ afterRef.current = 0
424
+ armedRef.current = false
425
+ setMessages([])
426
+ setSessionId(null)
427
+ setStatus("ai")
428
+ setTopic("")
429
+ setInput("")
430
+ setEmailState("idle")
431
+ setUnread(false)
432
+ setPhase("form")
433
+ }
434
+
435
+ function handleReset() {
436
+ // Confirm only if there's a conversation to lose.
437
+ if (messages.length > 0 && !window.confirm("Start over? This clears the current conversation.")) {
438
+ return
439
+ }
440
+ resetChat()
441
+ }
442
+
443
+ // Gated off (or config not yet resolved) — render nothing.
444
+ if (!enabled) return null
445
+
446
+ // Effective values: runtime config (overrides) wins over props.
447
+ const accentColor = overrides.accentColor ?? accentColorProp
448
+ const title = overrides.title ?? titleProp
449
+ const subtitle = overrides.subtitle ?? subtitleProp
450
+ const resolvedGreeting = overrides.greeting ?? greetingProp
451
+ const launcherLabel = overrides.launcherLabel ?? launcherLabelProp
452
+ const aiLabel = overrides.aiLabel ?? aiLabelProp
453
+ const position = overrides.position ?? positionProp
454
+
455
+ const side = position === "bottom-left" ? { left: 20 } : { right: 20 }
456
+ const headerSub =
457
+ status === "live"
458
+ ? "You're chatting with our team"
459
+ : status === "closed"
460
+ ? "This chat has ended"
461
+ : subtitle
462
+
463
+ return (
464
+ <div style={{ position: "fixed", bottom: 20, zIndex: 2147483000, ...side }}>
465
+ <style>{KEYFRAMES}</style>
466
+
467
+ {open && (
468
+ <div
469
+ style={{ ...panelStyle(accentColor), bottom: hideLauncher ? 0 : 72 }}
470
+ role="dialog"
471
+ aria-label={title}
472
+ >
473
+ <header style={headerStyle(accentColor)}>
474
+ <div>
475
+ <div style={{ fontWeight: 600, fontSize: 15 }}>{title}</div>
476
+ <div style={{ fontSize: 12, opacity: 0.8 }}>{headerSub}</div>
477
+ </div>
478
+ <div style={{ display: "flex", alignItems: "center", gap: 6, marginRight: -6 }}>
479
+ {phase === "chat" && (
480
+ <>
481
+ <button
482
+ aria-label={muted ? "Unmute notifications" : "Mute notifications"}
483
+ title={muted ? "Unmute" : "Mute"}
484
+ onClick={toggleMute}
485
+ style={iconBtn}
486
+ >
487
+ {muted ? <MuteIcon /> : <SoundIcon />}
488
+ </button>
489
+ <button
490
+ aria-label="Start over"
491
+ title="Start over"
492
+ onClick={handleReset}
493
+ style={iconBtn}
494
+ >
495
+ <RefreshIcon />
496
+ </button>
497
+ </>
498
+ )}
499
+ <button aria-label="Close chat" onClick={() => setOpen(false)} style={iconBtn}>
500
+ <CloseIcon />
501
+ </button>
502
+ </div>
503
+ </header>
504
+
505
+ {phase === "form" ? (
506
+ <form onSubmit={startChat} style={formStyle}>
507
+ <p style={{ margin: "0 0 4px", fontSize: 13, color: "#475569" }}>
508
+ Tell us who you are and we&apos;ll get started.
509
+ </p>
510
+ <input
511
+ className="ck-field"
512
+ style={inputStyle}
513
+ placeholder="Your name"
514
+ value={name}
515
+ onChange={(e) => setName(e.target.value)}
516
+ autoComplete="name"
517
+ required
518
+ />
519
+ <input
520
+ className="ck-field"
521
+ style={inputStyle}
522
+ type="email"
523
+ placeholder="Your email"
524
+ value={email}
525
+ onChange={(e) => setEmail(e.target.value)}
526
+ autoComplete="email"
527
+ required
528
+ />
529
+ <textarea
530
+ className="ck-field"
531
+ style={{ ...inputStyle, minHeight: 64, resize: "vertical" }}
532
+ placeholder="What can we help with? (optional)"
533
+ value={topic}
534
+ onChange={(e) => setTopic(e.target.value)}
535
+ />
536
+ {error && <div style={errorStyle}>{error}</div>}
537
+ <button type="submit" disabled={busy} style={primaryBtn(accentColor)}>
538
+ {busy ? "Starting…" : "Start chat"}
539
+ </button>
540
+ </form>
541
+ ) : (
542
+ <>
543
+ <div ref={scrollRef} style={messagesStyle}>
544
+ {resolvedGreeting && messages.length === 0 && (
545
+ <Bubble role="ai" text={resolvedGreeting} aiLabel={aiLabel} accentColor={accentColor} />
546
+ )}
547
+ {messages.map((m) => (
548
+ <Bubble
549
+ key={m.id}
550
+ role={m.role}
551
+ text={m.text}
552
+ authorName={m.authorName}
553
+ aiLabel={aiLabel}
554
+ accentColor={accentColor}
555
+ />
556
+ ))}
557
+ </div>
558
+ {status === "closed" ? (
559
+ <div
560
+ style={{
561
+ padding: 12,
562
+ borderTop: "1px solid #e2e8f0",
563
+ display: "flex",
564
+ flexDirection: "column",
565
+ gap: 8,
566
+ }}
567
+ >
568
+ {emailState === "sent" ? (
569
+ <span style={{ textAlign: "center", fontSize: 13, color: "#16a34a" }}>
570
+ ✓ Transcript sent to {email}
571
+ </span>
572
+ ) : (
573
+ <button
574
+ onClick={emailTranscript}
575
+ disabled={emailState === "sending"}
576
+ style={secondaryBtn(accentColor)}
577
+ >
578
+ {emailState === "sending" ? "Sending…" : "✉ Email me a copy"}
579
+ </button>
580
+ )}
581
+ <button onClick={resetChat} style={primaryBtn(accentColor)}>
582
+ Start a new chat
583
+ </button>
584
+ </div>
585
+ ) : (
586
+ <>
587
+ {messages.length > 0 && (
588
+ <div style={transcriptRowStyle}>
589
+ {emailState === "sent" ? (
590
+ <span style={{ fontSize: 12.5, color: "#16a34a" }}>
591
+ ✓ Transcript sent to {email}
592
+ </span>
593
+ ) : (
594
+ <button
595
+ type="button"
596
+ onClick={emailTranscript}
597
+ disabled={emailState === "sending"}
598
+ style={linkBtn}
599
+ >
600
+ {emailState === "sending"
601
+ ? "Sending…"
602
+ : emailState === "error"
603
+ ? "Couldn't send — retry"
604
+ : "✉ Email me a copy"}
605
+ </button>
606
+ )}
607
+ </div>
608
+ )}
609
+ <form onSubmit={sendMessage} style={composerStyle}>
610
+ <input
611
+ className="ck-field"
612
+ style={{ ...inputStyle, margin: 0, flex: 1 }}
613
+ placeholder="Type a message…"
614
+ value={input}
615
+ onChange={(e) => setInput(e.target.value)}
616
+ aria-label="Message"
617
+ />
618
+ <button type="submit" disabled={busy || !input.trim() || !sessionId} style={sendBtn(accentColor)}>
619
+
620
+ </button>
621
+ </form>
622
+ </>
623
+ )}
624
+ </>
625
+ )}
626
+ </div>
627
+ )}
628
+
629
+ {!hideLauncher && (
630
+ <button
631
+ aria-label={open ? "Close live chat" : "Open live chat"}
632
+ onClick={() => setOpen((v) => !v)}
633
+ style={launcherStyle(accentColor, launcherVariant)}
634
+ >
635
+ {open ? <CloseIcon /> : <ChatIcon />}
636
+ <span>{open ? "Close" : launcherLabel}</span>
637
+ {unread && !open && <span style={unreadDotStyle} />}
638
+ </button>
639
+ )}
640
+ </div>
641
+ )
642
+ }
643
+
644
+ // --- message rendering -------------------------------------------------------
645
+
646
+ interface DisplayMessage extends ChatMessage {}
647
+
648
+ function Bubble({
649
+ role,
650
+ text,
651
+ authorName,
652
+ aiLabel,
653
+ accentColor,
654
+ }: {
655
+ role: ChatMessage["role"]
656
+ text: string
657
+ authorName?: string
658
+ aiLabel: string
659
+ accentColor: string
660
+ }) {
661
+ if (role === "system") {
662
+ return (
663
+ <div style={{ textAlign: "center", fontSize: 12, color: "#64748b", margin: "6px 0" }}>
664
+ {text}
665
+ </div>
666
+ )
667
+ }
668
+ const mine = role === "visitor"
669
+ const label =
670
+ role === "agent"
671
+ ? authorName || "Support"
672
+ : role === "ai"
673
+ ? aiLabel
674
+ : ""
675
+ return (
676
+ <div style={{ display: "flex", justifyContent: mine ? "flex-end" : "flex-start" }}>
677
+ <div style={{ maxWidth: "80%" }}>
678
+ {label && (
679
+ <div style={{ fontSize: 11, color: "#94a3b8", margin: "0 4px 2px" }}>{label}</div>
680
+ )}
681
+ <div style={bubbleStyle(mine, accentColor)}>{text}</div>
682
+ </div>
683
+ </div>
684
+ )
685
+ }
686
+
687
+ // --- helpers -----------------------------------------------------------------
688
+
689
+ let localCounter = 0
690
+ function localMessage(sessionId: string, role: ChatMessage["role"], text: string): ChatMessage {
691
+ return {
692
+ id: `local-${++localCounter}`,
693
+ sessionId,
694
+ role,
695
+ text,
696
+ createdAt: Date.now(),
697
+ }
698
+ }
699
+
700
+ function isEmail(s: string): boolean {
701
+ return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s.trim())
702
+ }
703
+
704
+ // --- styles ------------------------------------------------------------------
705
+
706
+ const KEYFRAMES = `
707
+ @keyframes chatkit-pop{from{opacity:0;transform:translateY(8px) scale(.98)}to{opacity:1;transform:none}}
708
+ .ck-field{color:#0f172a !important;-webkit-text-fill-color:#0f172a !important;background:#fff !important;font-size:14px;}
709
+ .ck-field::placeholder{color:#94a3b8 !important;opacity:1 !important;-webkit-text-fill-color:#94a3b8 !important;}
710
+ `
711
+
712
+ const FONT =
713
+ "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif"
714
+
715
+ function launcherStyle(accent: string, variant: "accent" | "light"): React.CSSProperties {
716
+ const light = variant === "light"
717
+ return {
718
+ position: "relative",
719
+ display: "inline-flex",
720
+ alignItems: "center",
721
+ gap: 8,
722
+ padding: light ? "11px 18px" : "13px 20px",
723
+ // "light" — white pill, black rim + text — for visibility on busy/dark pages.
724
+ border: light ? "2px solid #000" : "none",
725
+ borderRadius: 999,
726
+ background: light ? "#fff" : accent,
727
+ color: light ? "#000" : "#fff",
728
+ fontSize: 15,
729
+ fontWeight: 600,
730
+ fontFamily: FONT,
731
+ lineHeight: 1,
732
+ cursor: "pointer",
733
+ boxShadow: "0 6px 24px rgba(0,0,0,0.25)",
734
+ }
735
+ }
736
+
737
+ const unreadDotStyle: React.CSSProperties = {
738
+ position: "absolute",
739
+ top: -3,
740
+ right: -3,
741
+ width: 12,
742
+ height: 12,
743
+ borderRadius: "50%",
744
+ background: "#ef4444",
745
+ border: "2px solid #fff",
746
+ }
747
+
748
+ const transcriptRowStyle: React.CSSProperties = {
749
+ padding: "6px 12px",
750
+ borderTop: "1px solid #e2e8f0",
751
+ background: "#fff",
752
+ textAlign: "center",
753
+ }
754
+
755
+ const linkBtn: React.CSSProperties = {
756
+ background: "transparent",
757
+ border: "none",
758
+ color: "#64748b",
759
+ fontSize: 12.5,
760
+ fontFamily: FONT,
761
+ textDecoration: "underline",
762
+ cursor: "pointer",
763
+ padding: 0,
764
+ }
765
+
766
+ function secondaryBtn(accent: string): React.CSSProperties {
767
+ return {
768
+ width: "100%",
769
+ padding: "10px 14px",
770
+ background: "#fff",
771
+ color: accent,
772
+ border: `1px solid ${accent}`,
773
+ borderRadius: 10,
774
+ fontSize: 14,
775
+ fontWeight: 600,
776
+ cursor: "pointer",
777
+ }
778
+ }
779
+
780
+ function ChatIcon() {
781
+ return (
782
+ <svg
783
+ width="20"
784
+ height="20"
785
+ viewBox="0 0 24 24"
786
+ fill="none"
787
+ stroke="currentColor"
788
+ strokeWidth={2}
789
+ strokeLinecap="round"
790
+ strokeLinejoin="round"
791
+ aria-hidden="true"
792
+ >
793
+ <path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
794
+ </svg>
795
+ )
796
+ }
797
+
798
+ function CloseIcon() {
799
+ return (
800
+ <svg
801
+ width="18"
802
+ height="18"
803
+ viewBox="0 0 24 24"
804
+ fill="none"
805
+ stroke="currentColor"
806
+ strokeWidth={2.5}
807
+ strokeLinecap="round"
808
+ strokeLinejoin="round"
809
+ aria-hidden="true"
810
+ >
811
+ <line x1="18" y1="6" x2="6" y2="18" />
812
+ <line x1="6" y1="6" x2="18" y2="18" />
813
+ </svg>
814
+ )
815
+ }
816
+
817
+ function HeaderSvg({ children }: { children: React.ReactNode }) {
818
+ return (
819
+ <svg
820
+ width="18"
821
+ height="18"
822
+ viewBox="0 0 24 24"
823
+ fill="none"
824
+ stroke="currentColor"
825
+ strokeWidth={2}
826
+ strokeLinecap="round"
827
+ strokeLinejoin="round"
828
+ aria-hidden="true"
829
+ >
830
+ {children}
831
+ </svg>
832
+ )
833
+ }
834
+
835
+ function SoundIcon() {
836
+ return (
837
+ <HeaderSvg>
838
+ <path d="M11 5 6 9H2v6h4l5 4z" />
839
+ <path d="M15.5 8.5a5 5 0 0 1 0 7" />
840
+ <path d="M19 5a9 9 0 0 1 0 14" />
841
+ </HeaderSvg>
842
+ )
843
+ }
844
+
845
+ function MuteIcon() {
846
+ return (
847
+ <HeaderSvg>
848
+ <path d="M11 5 6 9H2v6h4l5 4z" />
849
+ <line x1="23" y1="9" x2="17" y2="15" />
850
+ <line x1="17" y1="9" x2="23" y2="15" />
851
+ </HeaderSvg>
852
+ )
853
+ }
854
+
855
+ function RefreshIcon() {
856
+ return (
857
+ <HeaderSvg>
858
+ <polyline points="1 4 1 10 7 10" />
859
+ <path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
860
+ </HeaderSvg>
861
+ )
862
+ }
863
+
864
+ function panelStyle(_accent: string): React.CSSProperties {
865
+ return {
866
+ position: "absolute",
867
+ bottom: 72,
868
+ right: 0,
869
+ width: 360,
870
+ maxWidth: "calc(100vw - 40px)",
871
+ height: 520,
872
+ maxHeight: "calc(100vh - 120px)",
873
+ background: "#fff",
874
+ borderRadius: 16,
875
+ boxShadow: "0 12px 48px rgba(0,0,0,0.28)",
876
+ display: "flex",
877
+ flexDirection: "column",
878
+ overflow: "hidden",
879
+ fontFamily: FONT,
880
+ animation: "chatkit-pop .18s ease-out",
881
+ }
882
+ }
883
+
884
+ function headerStyle(accent: string): React.CSSProperties {
885
+ return {
886
+ background: accent,
887
+ color: "#fff",
888
+ padding: "14px 16px",
889
+ display: "flex",
890
+ alignItems: "center",
891
+ justifyContent: "space-between",
892
+ }
893
+ }
894
+
895
+ const iconBtn: React.CSSProperties = {
896
+ display: "inline-flex",
897
+ alignItems: "center",
898
+ justifyContent: "center",
899
+ width: 36,
900
+ height: 36,
901
+ flex: "0 0 auto",
902
+ background: "transparent",
903
+ border: "none",
904
+ borderRadius: 8,
905
+ color: "#fff",
906
+ cursor: "pointer",
907
+ opacity: 0.85,
908
+ padding: 0,
909
+ }
910
+
911
+ const formStyle: React.CSSProperties = {
912
+ padding: 16,
913
+ display: "flex",
914
+ flexDirection: "column",
915
+ gap: 10,
916
+ }
917
+
918
+ const messagesStyle: React.CSSProperties = {
919
+ flex: 1,
920
+ overflowY: "auto",
921
+ padding: 14,
922
+ display: "flex",
923
+ flexDirection: "column",
924
+ gap: 8,
925
+ background: "#f8fafc",
926
+ }
927
+
928
+ const composerStyle: React.CSSProperties = {
929
+ display: "flex",
930
+ gap: 8,
931
+ padding: 12,
932
+ borderTop: "1px solid #e2e8f0",
933
+ background: "#fff",
934
+ }
935
+
936
+ const inputStyle: React.CSSProperties = {
937
+ width: "100%",
938
+ padding: "10px 12px",
939
+ border: "1px solid #cbd5e1",
940
+ borderRadius: 10,
941
+ fontSize: 14,
942
+ fontFamily: FONT,
943
+ boxSizing: "border-box",
944
+ outline: "none",
945
+ }
946
+
947
+ function bubbleStyle(mine: boolean, accent: string): React.CSSProperties {
948
+ return {
949
+ padding: "9px 12px",
950
+ borderRadius: 14,
951
+ fontSize: 14,
952
+ lineHeight: 1.4,
953
+ whiteSpace: "pre-wrap",
954
+ wordBreak: "break-word",
955
+ background: mine ? accent : "#fff",
956
+ color: mine ? "#fff" : "#0f172a",
957
+ border: mine ? "none" : "1px solid #e2e8f0",
958
+ borderBottomRightRadius: mine ? 4 : 14,
959
+ borderBottomLeftRadius: mine ? 14 : 4,
960
+ }
961
+ }
962
+
963
+ function primaryBtn(accent: string): React.CSSProperties {
964
+ return {
965
+ width: "100%",
966
+ padding: "11px 14px",
967
+ background: accent,
968
+ color: "#fff",
969
+ border: "none",
970
+ borderRadius: 10,
971
+ fontSize: 14,
972
+ fontWeight: 600,
973
+ cursor: "pointer",
974
+ }
975
+ }
976
+
977
+ function sendBtn(accent: string): React.CSSProperties {
978
+ return {
979
+ flex: "0 0 auto",
980
+ width: 42,
981
+ background: accent,
982
+ color: "#fff",
983
+ border: "none",
984
+ borderRadius: 10,
985
+ fontSize: 16,
986
+ cursor: "pointer",
987
+ }
988
+ }
989
+
990
+ const errorStyle: React.CSSProperties = {
991
+ color: "#dc2626",
992
+ fontSize: 12.5,
993
+ margin: "-2px 0 2px",
994
+ }