@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/LICENSE +21 -0
- package/README.md +153 -0
- package/dist/ai.d.ts +31 -0
- package/dist/ai.js +42 -0
- package/dist/engine.d.ts +96 -0
- package/dist/engine.js +282 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +23 -0
- package/dist/local.d.ts +38 -0
- package/dist/local.js +44 -0
- package/dist/next.d.ts +46 -0
- package/dist/next.js +155 -0
- package/dist/prompt.d.ts +24 -0
- package/dist/prompt.js +105 -0
- package/dist/react.d.ts +51 -0
- package/dist/react.js +618 -0
- package/dist/storage.d.ts +54 -0
- package/dist/storage.js +72 -0
- package/dist/telegram.d.ts +40 -0
- package/dist/telegram.js +91 -0
- package/dist/transcript.d.ts +22 -0
- package/dist/transcript.js +73 -0
- package/dist/transport.d.ts +23 -0
- package/dist/transport.js +1 -0
- package/dist/types.d.ts +75 -0
- package/dist/types.js +8 -0
- package/package.json +48 -0
- package/src/ai.ts +72 -0
- package/src/engine.ts +363 -0
- package/src/index.ts +24 -0
- package/src/local.ts +77 -0
- package/src/next.ts +189 -0
- package/src/prompt.ts +131 -0
- package/src/react.tsx +994 -0
- package/src/storage.ts +111 -0
- package/src/telegram.ts +135 -0
- package/src/transcript.ts +116 -0
- package/src/transport.ts +24 -0
- package/src/types.ts +83 -0
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'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
|
+
}
|