@clivly/chat-widget 0.4.0 → 0.7.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/README.md CHANGED
@@ -68,6 +68,7 @@ It exports:
68
68
  - `mountChatWidgetScript()`
69
69
  - `autoMountChatWidgetScript()`
70
70
  - `MountChatWidgetOptions`
71
+ - `ChatWidgetMount`
71
72
  - `ScriptMountResult`
72
73
 
73
74
  ### `@clivly/chat-widget/script`
@@ -79,6 +80,71 @@ This entry maps to the generated global/browser artifact:
79
80
  Use it for script-tag delivery, CDN-style consumption, or any future server path
80
81
  that serves the bundled widget directly.
81
82
 
83
+ ## Theming
84
+
85
+ The widget ships light and dark palettes and follows the host app by default.
86
+ For integration recipes (Tailwind, next-themes, a custom toggle) see
87
+ [the guide's theming section](../../docs/guides/adding-the-chat-widget.md#theming).
88
+
89
+ ### `theme`
90
+
91
+ | Value | Behaviour |
92
+ | --- | --- |
93
+ | `"auto"` *(default)* | Follows the host: `data-theme="dark"`/`"light"` on `<html>`, then a `dark`/`light` class on `<html>`, then `prefers-color-scheme`. Re-resolves live. |
94
+ | `"light"` / `"dark"` | Pinned. Host signals are ignored and nothing is observed. |
95
+
96
+ Set it as a prop (`<ChatWidget theme="dark" />`), as `data-theme` on the script
97
+ tag, or as `theme` in `mountChatWidget()` options. An unrecognised
98
+ `data-theme` value falls back to `"auto"` rather than throwing.
99
+
100
+ `mountChatWidgetScript()` also returns `setTheme(theme)` for hosts whose toggle
101
+ does not touch `<html>`:
102
+
103
+ ```ts
104
+ const { setTheme } = mountChatWidgetScript(script);
105
+ setTheme("dark"); // pin
106
+ setTheme("auto"); // hand control back to the host
107
+ ```
108
+
109
+ The resolved theme is applied as `data-clivly-theme="light|dark"` on the widget
110
+ root, together with `color-scheme`, so native scrollbars, autofill, and the
111
+ caret follow the panel.
112
+
113
+ ### Palette tokens
114
+
115
+ Every colour reads through a CSS custom property. React hosts can override any
116
+ of them inline — inline declarations beat both palette rules, so no specificity
117
+ work is needed:
118
+
119
+ ```tsx
120
+ <ChatWidget style={{ "--clivly-chat-panel": "#0b0f19" }} widgetId="support" />
121
+ ```
122
+
123
+ | Token | Light | Dark |
124
+ | --- | --- | --- |
125
+ | `--clivly-chat-accent` | `#185adb` | `#4f8cff` |
126
+ | `--clivly-chat-accent-contrast` | `#ffffff` | `#0b1220` |
127
+ | `--clivly-chat-panel` | `#ffffff` | `#111827` |
128
+ | `--clivly-chat-input` | `transparent` | `#0b1220` |
129
+ | `--clivly-chat-border` | `#d6dde8` | `#2a3546` |
130
+ | `--clivly-chat-text` | `#132238` | `#e8edf5` |
131
+ | `--clivly-chat-muted` | `#60738a` | `#93a2b8` |
132
+ | `--clivly-chat-visitor` | `#ecf3ff` | `#1e3a6b` |
133
+ | `--clivly-chat-operator` | `#f5f7fa` | `#1b2534` |
134
+ | `--clivly-chat-shadow` | `rgba(19, 34, 56, 0.18)` | `rgba(0, 0, 0, 0.5)` |
135
+ | `--clivly-chat-error-bg` | `#fff1f1` | `#2c1618` |
136
+ | `--clivly-chat-error-border` | `#f2c2c2` | `#5c2a2e` |
137
+ | `--clivly-chat-error-text` | `#9f2d2d` | `#ffb4b4` |
138
+
139
+ The widget record's `brandColor` overrides `--clivly-chat-accent` in **both**
140
+ themes — the widget will not silently lighten your brand. If your brand colour
141
+ is very dark it will be low-contrast on the dark panel; pick a lighter accent,
142
+ or override the token per theme in a React host.
143
+
144
+ Deep per-token overrides are not available on the script-tag path: it mounts
145
+ into a Shadow DOM, so host CSS cannot reach the widget's internals. Use
146
+ `brandColor` and the two themes there.
147
+
82
148
  ## Session bootstrap shape
83
149
 
84
150
  The widget expects a session bootstrap endpoint that returns a
@@ -123,11 +189,22 @@ The `browser` and `browser-mount` outputs are built as standalone entrypoints.
123
189
 
124
190
  ## Host-side pairing
125
191
 
126
- The intended host-side SDK helper lives in `@clivly/sdk`:
192
+ The host-side session handler is the bridge between a backend holding
193
+ `CLIVLY_API_KEY` and the widget's `sessionUrl`. Import it from `clivly/sdk`,
194
+ which is available from the `clivly` package `clivly init` already installs:
127
195
 
128
196
  ```ts
129
- import { createChatSessionHandler } from "@clivly/sdk";
197
+ import { createChatSessionHandler } from "clivly/sdk";
198
+
199
+ export const POST = createChatSessionHandler({
200
+ apiKey: process.env.CLIVLY_API_KEY,
201
+ });
130
202
  ```
131
203
 
132
- That handler is the expected bridge between a host backend holding
133
- `CLIVLY_API_KEY` and the widget's `sessionUrl`.
204
+ `@clivly/sdk` exports the same handler if you depend on it directly. Don't import
205
+ from `clivly` (the package root) — that entry is the CLI and is Node-only.
206
+
207
+ Use the handler rather than hand-writing the proxy: it puts `origin` in the
208
+ request body (the backend requires it there, and only falls back to the `Origin`
209
+ header), accepts `visitorToken: null` for a first-time visitor, and turns an
210
+ unreachable Clivly into a JSON `502` instead of a thrown `fetch`.
@@ -2,21 +2,162 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  let react_dom_client = require("react-dom/client");
3
3
  let react = require("react");
4
4
  let react_jsx_runtime = require("react/jsx-runtime");
5
+ //#region src/theme.ts
6
+ const DARK_MEDIA_QUERY = "(prefers-color-scheme: dark)";
7
+ /** The attribute set by next-themes, daisyUI, and most `data-*` theme setups. */
8
+ const THEME_ATTRIBUTE = "data-theme";
9
+ /** Attributes worth re-resolving on. Anything else on `<html>` is noise. */
10
+ const OBSERVED_ATTRIBUTES = ["class", THEME_ATTRIBUTE];
11
+ function isResolvedTheme(value) {
12
+ return value === "dark" || value === "light";
13
+ }
14
+ /**
15
+ * The host's explicit signals, in precedence order: a `data-theme` attribute
16
+ * beats a class, because a host that sets both is using the attribute as the
17
+ * source of truth and the class as a styling convenience.
18
+ *
19
+ * Both conventions are checked for `light` as well as `dark`. A host that says
20
+ * `class="light"` on an OS-dark machine means it, and the explicit signal
21
+ * should win over the OS preference below it.
22
+ */
23
+ function readHostTheme(root) {
24
+ const attribute = root.getAttribute(THEME_ATTRIBUTE);
25
+ if (isResolvedTheme(attribute)) return attribute;
26
+ if (root.classList.contains("dark")) return "dark";
27
+ if (root.classList.contains("light")) return "light";
28
+ return null;
29
+ }
30
+ function prefersDark(view) {
31
+ return view.matchMedia?.(DARK_MEDIA_QUERY).matches ?? false;
32
+ }
33
+ function defaultRoot() {
34
+ return typeof document === "undefined" ? null : document.documentElement;
35
+ }
36
+ function defaultView() {
37
+ return typeof window === "undefined" ? null : window;
38
+ }
39
+ /**
40
+ * Resolve a requested theme to the one the widget should actually paint.
41
+ *
42
+ * Falls back to `light` when there is nothing to read — during SSR there is no
43
+ * document, and guessing dark would flash the wrong palette on hydration for
44
+ * the majority of pages.
45
+ */
46
+ function resolveTheme(theme, { root = defaultRoot(), view = defaultView() } = {}) {
47
+ if (theme !== "auto") return theme;
48
+ const hostTheme = root ? readHostTheme(root) : null;
49
+ if (hostTheme) return hostTheme;
50
+ if (view && prefersDark(view)) return "dark";
51
+ return "light";
52
+ }
53
+ /**
54
+ * Watch both host signals and call `onChange` whenever the resolved theme
55
+ * changes. Returns an unsubscribe function.
56
+ *
57
+ * An explicit `light`/`dark` subscribes to nothing — there is no signal that
58
+ * could change the answer — so callers can pass any theme without branching.
59
+ */
60
+ function observeTheme(theme, onChange, { root = defaultRoot(), view = defaultView() } = {}) {
61
+ if (theme !== "auto") return () => {};
62
+ let current = resolveTheme(theme, {
63
+ root,
64
+ view
65
+ });
66
+ const publish = () => {
67
+ const next = resolveTheme(theme, {
68
+ root,
69
+ view
70
+ });
71
+ if (next !== current) {
72
+ current = next;
73
+ onChange(next);
74
+ }
75
+ };
76
+ const teardowns = [];
77
+ if (root && typeof MutationObserver !== "undefined") {
78
+ const observer = new MutationObserver(publish);
79
+ observer.observe(root, {
80
+ attributeFilter: OBSERVED_ATTRIBUTES,
81
+ attributes: true
82
+ });
83
+ teardowns.push(() => {
84
+ observer.disconnect();
85
+ });
86
+ }
87
+ const media = view?.matchMedia?.(DARK_MEDIA_QUERY);
88
+ if (media) {
89
+ if (media.addEventListener) {
90
+ media.addEventListener("change", publish);
91
+ teardowns.push(() => {
92
+ media.removeEventListener("change", publish);
93
+ });
94
+ } else if (media.addListener) {
95
+ media.addListener(publish);
96
+ teardowns.push(() => {
97
+ media.removeListener(publish);
98
+ });
99
+ }
100
+ }
101
+ return () => {
102
+ for (const teardown of teardowns) teardown();
103
+ };
104
+ }
105
+ /**
106
+ * Narrow an untrusted string — a `data-theme` script attribute, a `setTheme`
107
+ * call from plain JS — to a theme, falling back to `auto`.
108
+ */
109
+ function parseTheme(value) {
110
+ if (value === "auto" || value === "dark" || value === "light") return value;
111
+ return "auto";
112
+ }
113
+ //#endregion
5
114
  //#region src/chat-widget.tsx
115
+ const DELIVERY_LABELS = {
116
+ failed: "Failed to send",
117
+ pending: "Sending…",
118
+ sent: null
119
+ };
6
120
  const WIDGET_STYLES = `
7
121
  .clivly-chat-widget, .clivly-chat-widget * { box-sizing: border-box; }
8
122
  .clivly-chat-widget {
9
123
  --clivly-chat-accent: #185adb;
124
+ --clivly-chat-accent-contrast: #ffffff;
10
125
  --clivly-chat-panel: #ffffff;
126
+ --clivly-chat-input: transparent;
11
127
  --clivly-chat-border: #d6dde8;
12
128
  --clivly-chat-text: #132238;
13
129
  --clivly-chat-muted: #60738a;
14
130
  --clivly-chat-visitor: #ecf3ff;
15
131
  --clivly-chat-operator: #f5f7fa;
132
+ --clivly-chat-shadow: rgba(19, 34, 56, 0.18);
133
+ --clivly-chat-error-bg: #fff1f1;
134
+ --clivly-chat-error-border: #f2c2c2;
135
+ --clivly-chat-error-text: #9f2d2d;
16
136
  color: var(--clivly-chat-text);
137
+ color-scheme: light;
17
138
  font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
18
139
  position: relative;
19
140
  }
141
+ /* Dark is a pure token override — every rule below reads through a variable, so
142
+ the palette is the only thing that changes between themes. The color-scheme
143
+ declaration carries the theme to the parts of the UI that CSS cannot reach:
144
+ native scrollbars, autofill backgrounds, and the caret. */
145
+ .clivly-chat-widget[data-clivly-theme="dark"] {
146
+ --clivly-chat-accent: #4f8cff;
147
+ --clivly-chat-accent-contrast: #0b1220;
148
+ --clivly-chat-panel: #111827;
149
+ --clivly-chat-input: #0b1220;
150
+ --clivly-chat-border: #2a3546;
151
+ --clivly-chat-text: #e8edf5;
152
+ --clivly-chat-muted: #93a2b8;
153
+ --clivly-chat-visitor: #1e3a6b;
154
+ --clivly-chat-operator: #1b2534;
155
+ --clivly-chat-shadow: rgba(0, 0, 0, 0.5);
156
+ --clivly-chat-error-bg: #2c1618;
157
+ --clivly-chat-error-border: #5c2a2e;
158
+ --clivly-chat-error-text: #ffb4b4;
159
+ color-scheme: dark;
160
+ }
20
161
  .clivly-chat-widget button,
21
162
  .clivly-chat-widget input,
22
163
  .clivly-chat-widget textarea {
@@ -27,7 +168,7 @@ const WIDGET_STYLES = `
27
168
  background: var(--clivly-chat-accent);
28
169
  border: 0;
29
170
  border-radius: 999px;
30
- color: white;
171
+ color: var(--clivly-chat-accent-contrast);
31
172
  cursor: pointer;
32
173
  display: inline-flex;
33
174
  gap: 0.5rem;
@@ -37,7 +178,7 @@ const WIDGET_STYLES = `
37
178
  background: var(--clivly-chat-panel);
38
179
  border: 1px solid var(--clivly-chat-border);
39
180
  border-radius: 1rem;
40
- box-shadow: 0 24px 80px rgba(19, 34, 56, 0.18);
181
+ box-shadow: 0 24px 80px var(--clivly-chat-shadow);
41
182
  display: grid;
42
183
  grid-template-rows: auto 1fr auto;
43
184
  height: min(44rem, 75vh);
@@ -140,37 +281,54 @@ const WIDGET_STYLES = `
140
281
  }
141
282
  .clivly-chat-widget__input,
142
283
  .clivly-chat-widget__textarea {
284
+ background: var(--clivly-chat-input);
143
285
  border: 1px solid var(--clivly-chat-border);
144
286
  border-radius: 0.8rem;
287
+ color: inherit;
145
288
  min-width: 0;
146
289
  padding: 0.75rem 0.85rem;
147
290
  }
291
+ .clivly-chat-widget__input::placeholder,
292
+ .clivly-chat-widget__textarea::placeholder {
293
+ color: var(--clivly-chat-muted);
294
+ }
148
295
  .clivly-chat-widget__textarea {
149
296
  min-height: 5.5rem;
150
297
  resize: vertical;
151
298
  }
152
299
  .clivly-chat-widget__actions {
300
+ align-items: center;
153
301
  display: flex;
154
302
  gap: 0.75rem;
155
303
  justify-content: space-between;
156
304
  }
305
+ /* The subtitle is the only part that may reflow; without min-width the flex
306
+ default (min-content) lets it push the button below its intrinsic width. */
307
+ .clivly-chat-widget__actions .clivly-chat-widget__subtitle {
308
+ min-width: 0;
309
+ }
157
310
  .clivly-chat-widget__button {
158
311
  background: var(--clivly-chat-accent);
159
312
  border: 0;
160
313
  border-radius: 0.8rem;
161
- color: white;
314
+ color: var(--clivly-chat-accent-contrast);
162
315
  cursor: pointer;
316
+ flex: 0 0 auto;
317
+ font: inherit;
318
+ font-weight: 600;
319
+ line-height: 1.2;
163
320
  padding: 0.75rem 0.95rem;
321
+ white-space: nowrap;
164
322
  }
165
323
  .clivly-chat-widget__button[disabled] {
166
324
  cursor: not-allowed;
167
325
  opacity: 0.55;
168
326
  }
169
327
  .clivly-chat-widget__error {
170
- background: #fff1f1;
171
- border: 1px solid #f2c2c2;
328
+ background: var(--clivly-chat-error-bg);
329
+ border: 1px solid var(--clivly-chat-error-border);
172
330
  border-radius: 0.8rem;
173
- color: #9f2d2d;
331
+ color: var(--clivly-chat-error-text);
174
332
  font-size: 0.875rem;
175
333
  padding: 0.75rem;
176
334
  }
@@ -182,6 +340,13 @@ const WIDGET_STYLES = `
182
340
  }
183
341
  `;
184
342
  const DEFAULT_STORAGE = typeof window === "undefined" ? void 0 : window.localStorage;
343
+ function sessionDisplayName(session) {
344
+ return session.contact?.name ?? "You";
345
+ }
346
+ function sessionSummaryText(session, fallback) {
347
+ if (!session) return fallback;
348
+ return session.contact ? `${session.contact.name} • ${session.contact.email}` : "Verified visitor";
349
+ }
185
350
  function createLocalMessage(message, delivery = "sent") {
186
351
  return {
187
352
  ...message,
@@ -232,14 +397,93 @@ function markMessageFailed(messages, clientMessageId) {
232
397
  function sortMessages(messages) {
233
398
  return [...messages].sort((left, right) => left.createdAt.localeCompare(right.createdAt));
234
399
  }
235
- function useThemeStyle(session) {
400
+ function useThemeStyle(session, style) {
236
401
  return (0, react.useMemo)(() => {
237
402
  const brandColor = session?.widget.config.brandColor ?? null;
238
- if (!brandColor) return;
239
- return { ["--clivly-chat-accent"]: brandColor };
240
- }, [session?.widget.config.brandColor]);
403
+ if (!brandColor) return style;
404
+ return {
405
+ ["--clivly-chat-accent"]: brandColor,
406
+ ...style
407
+ };
408
+ }, [session?.widget.config.brandColor, style]);
241
409
  }
242
- function ChatWidget({ className, createTransport, getSession, initiallyOpen = false, launcherLabel = "Chat with us", storage = DEFAULT_STORAGE, widgetId }) {
410
+ /**
411
+ * Track the theme the widget should paint.
412
+ *
413
+ * The initial value is resolved synchronously so the first paint is already
414
+ * correct — deferring it to an effect would flash light on a dark page.
415
+ */
416
+ function useResolvedTheme(theme) {
417
+ const [resolved, setResolved] = (0, react.useState)(() => resolveTheme(theme));
418
+ (0, react.useEffect)(() => {
419
+ setResolved(resolveTheme(theme));
420
+ return observeTheme(theme, setResolved);
421
+ }, [theme]);
422
+ return resolved;
423
+ }
424
+ /**
425
+ * Reduce one socket event into widget state.
426
+ *
427
+ * Lives at module scope rather than inside the connect effect so the wire
428
+ * protocol reads as a single unit, and so `ChatWidget` stays inside the
429
+ * cognitive-complexity budget.
430
+ */
431
+ function applyServerEvent(event, { setError, setIsReady, setMessages }) {
432
+ switch (event.type) {
433
+ case "conversation.snapshot":
434
+ setMessages(sortMessages(event.messages.map((message) => createLocalMessage(message))));
435
+ return;
436
+ case "error":
437
+ setError(event.message);
438
+ return;
439
+ case "message.ack":
440
+ setMessages((currentMessages) => sortMessages(replacePendingMessage(currentMessages, event.clientMessageId, event.message)));
441
+ return;
442
+ case "message.created": {
443
+ const clientMessageId = event.clientMessageId;
444
+ setMessages((currentMessages) => sortMessages(clientMessageId ? replacePendingMessage(currentMessages, clientMessageId, event.message) : mergeMessage(currentMessages, event.message)));
445
+ return;
446
+ }
447
+ case "session.ready":
448
+ setIsReady(true);
449
+ return;
450
+ default: return;
451
+ }
452
+ }
453
+ function MessageBubble({ message }) {
454
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("article", {
455
+ className: "clivly-chat-widget__message",
456
+ "data-sender": message.senderType,
457
+ children: [
458
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
459
+ className: "clivly-chat-widget__message-meta",
460
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: message.author ?? "Clivly" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: formatTimestamp(message.createdAt) })]
461
+ }),
462
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: message.body }),
463
+ message.delivery === "sent" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
464
+ className: "clivly-chat-widget__status",
465
+ children: DELIVERY_LABELS[message.delivery]
466
+ })
467
+ ]
468
+ });
469
+ }
470
+ /**
471
+ * The scrollable transcript. Split out of `ChatWidget` because the three states
472
+ * (no session, empty thread, messages) were a nested ternary in the render tree
473
+ * — early returns make them readable and keep the parent's complexity down.
474
+ */
475
+ function MessageList({ greeting, hasSession, messages }) {
476
+ if (!hasSession) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
477
+ className: "clivly-chat-widget__empty",
478
+ children: greeting
479
+ });
480
+ if (messages.length === 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
481
+ className: "clivly-chat-widget__empty",
482
+ children: "No messages yet. Say hello to get the conversation started."
483
+ });
484
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_jsx_runtime.Fragment, { children: messages.map((message) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MessageBubble, { message }, message.id)) });
485
+ }
486
+ function ChatWidget({ className, createTransport, getSession, initiallyOpen = false, launcherLabel = "Chat with us", storage = DEFAULT_STORAGE, style, theme = "auto", widgetId }) {
243
487
  const [isOpen, setIsOpen] = (0, react.useState)(initiallyOpen);
244
488
  const [error, setError] = (0, react.useState)(null);
245
489
  const [isConnecting, setIsConnecting] = (0, react.useState)(false);
@@ -253,7 +497,8 @@ function ChatWidget({ className, createTransport, getSession, initiallyOpen = fa
253
497
  });
254
498
  const transportRef = (0, react.useRef)(null);
255
499
  const listRef = (0, react.useRef)(null);
256
- const themeStyle = useThemeStyle(session);
500
+ const themeStyle = useThemeStyle(session, style);
501
+ const resolvedTheme = useResolvedTheme(theme);
257
502
  (0, react.useEffect)(() => () => {
258
503
  transportRef.current?.close();
259
504
  transportRef.current = null;
@@ -267,31 +512,6 @@ function ChatWidget({ className, createTransport, getSession, initiallyOpen = fa
267
512
  transportRef.current = null;
268
513
  setIsReady(false);
269
514
  if (!(session && createTransport)) return;
270
- const handleEvent = (event) => {
271
- switch (event.type) {
272
- case "conversation.snapshot":
273
- setMessages(sortMessages(event.messages.map((message) => createLocalMessage(message))));
274
- return;
275
- case "error":
276
- setError(event.message);
277
- return;
278
- case "message.ack":
279
- setMessages((currentMessages) => sortMessages(replacePendingMessage(currentMessages, event.clientMessageId, event.message)));
280
- return;
281
- case "message.created": {
282
- const clientMessageId = event.clientMessageId;
283
- if (clientMessageId) {
284
- setMessages((currentMessages) => sortMessages(replacePendingMessage(currentMessages, clientMessageId, event.message)));
285
- return;
286
- }
287
- setMessages((currentMessages) => sortMessages(mergeMessage(currentMessages, event.message)));
288
- return;
289
- }
290
- case "session.ready":
291
- setIsReady(true);
292
- return;
293
- }
294
- };
295
515
  const connection = createTransport({
296
516
  handlers: {
297
517
  onClose: () => {
@@ -300,7 +520,13 @@ function ChatWidget({ className, createTransport, getSession, initiallyOpen = fa
300
520
  onError: (transportError) => {
301
521
  setError(transportError.message);
302
522
  },
303
- onEvent: handleEvent,
523
+ onEvent: (event) => {
524
+ applyServerEvent(event, {
525
+ setError,
526
+ setIsReady,
527
+ setMessages
528
+ });
529
+ },
304
530
  onOpen: () => {
305
531
  setIsReady(true);
306
532
  }
@@ -318,16 +544,18 @@ function ChatWidget({ className, createTransport, getSession, initiallyOpen = fa
318
544
  setError(null);
319
545
  setIsConnecting(true);
320
546
  try {
547
+ const existingVisitorToken = storage?.getItem(createStorageKey(widgetId)) ?? null;
321
548
  const nextSession = await getSession({
322
549
  visitor,
323
- visitorToken: storage?.getItem(createStorageKey(widgetId)) ?? null,
550
+ visitorToken: existingVisitorToken,
324
551
  widgetId
325
552
  });
326
553
  storage?.setItem(createStorageKey(widgetId), nextSession.visitorToken);
327
554
  setSession(nextSession);
328
555
  setMessages(sortMessages(nextSession.messages.map((message) => createLocalMessage(message))));
329
556
  } catch (sessionError) {
330
- setError((sessionError instanceof Error ? sessionError : /* @__PURE__ */ new Error("Failed to start the conversation.")).message);
557
+ const resolved = sessionError instanceof Error ? sessionError : /* @__PURE__ */ new Error("Failed to start the conversation.");
558
+ setError(resolved.message);
331
559
  } finally {
332
560
  setIsConnecting(false);
333
561
  }
@@ -342,7 +570,7 @@ function ChatWidget({ className, createTransport, getSession, initiallyOpen = fa
342
570
  if (!body) return;
343
571
  const clientMessageId = generateId();
344
572
  const optimisticMessage = {
345
- author: session.contact.name,
573
+ author: sessionDisplayName(session),
346
574
  body,
347
575
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
348
576
  id: clientMessageId,
@@ -365,9 +593,10 @@ function ChatWidget({ className, createTransport, getSession, initiallyOpen = fa
365
593
  };
366
594
  const greeting = session?.widget.config.greeting ?? "Tell us a little about yourself and we’ll pick up the conversation from there.";
367
595
  const panelTitle = session?.widget.name ?? "Start a conversation";
368
- const sessionSummary = session ? `${session.contact.name} • ${session.contact.email}` : greeting;
596
+ const sessionSummary = sessionSummaryText(session, greeting);
369
597
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
370
598
  className: `clivly-chat-widget ${className ?? ""}`.trim(),
599
+ "data-clivly-theme": resolvedTheme,
371
600
  style: themeStyle,
372
601
  children: [
373
602
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: WIDGET_STYLES }),
@@ -407,26 +636,10 @@ function ChatWidget({ className, createTransport, getSession, initiallyOpen = fa
407
636
  children: [error ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
408
637
  className: "clivly-chat-widget__error",
409
638
  children: error
410
- }) : null, session ? messages.length > 0 ? messages.map((message) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("article", {
411
- className: "clivly-chat-widget__message",
412
- "data-sender": message.senderType,
413
- children: [
414
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
415
- className: "clivly-chat-widget__message-meta",
416
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: message.author ?? "Clivly" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: formatTimestamp(message.createdAt) })]
417
- }),
418
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: message.body }),
419
- message.delivery === "sent" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
420
- className: "clivly-chat-widget__status",
421
- children: message.delivery === "pending" ? "Sending…" : "Failed to send"
422
- })
423
- ]
424
- }, message.id)) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
425
- className: "clivly-chat-widget__empty",
426
- children: "No messages yet. Say hello to get the conversation started."
427
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
428
- className: "clivly-chat-widget__empty",
429
- children: greeting
639
+ }) : null, /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MessageList, {
640
+ greeting,
641
+ hasSession: Boolean(session),
642
+ messages
430
643
  })]
431
644
  }),
432
645
  session ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("form", {
@@ -617,16 +830,27 @@ const MOUNTED_DATA_ATTR = "clivlyMounted";
617
830
  function resolveSocketUrl(socketUrlTemplate, conversationId, token) {
618
831
  return socketUrlTemplate.replaceAll("{conversationId}", encodeURIComponent(conversationId)).replaceAll("{token}", encodeURIComponent(token));
619
832
  }
620
- function mountChatWidget({ container, createTransport, sessionUrl, socketUrlTemplate, widgetId, ...props }) {
833
+ function createWidgetMount({ container, createTransport, sessionUrl, socketUrlTemplate, theme = "auto", widgetId, ...props }) {
621
834
  const transport = createTransport ?? (socketUrlTemplate ? createWebSocketTransport({ resolveUrl: (session) => session.socketUrl ?? resolveSocketUrl(socketUrlTemplate, session.conversation.id, session.token) }) : createClivlyTransport());
835
+ const getSession = createSessionFetcher({ sessionUrl });
622
836
  const root = (0, react_dom_client.createRoot)(container);
623
- root.render(/* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChatWidget, {
624
- ...props,
625
- createTransport: transport,
626
- getSession: createSessionFetcher({ sessionUrl }),
627
- widgetId
628
- }));
629
- return root;
837
+ const render = (nextTheme) => {
838
+ root.render(/* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChatWidget, {
839
+ ...props,
840
+ createTransport: transport,
841
+ getSession,
842
+ theme: nextTheme,
843
+ widgetId
844
+ }));
845
+ };
846
+ render(theme);
847
+ return {
848
+ root,
849
+ setTheme: render
850
+ };
851
+ }
852
+ function mountChatWidget(options) {
853
+ return createWidgetMount(options).root;
630
854
  }
631
855
  function markScriptMounted(script) {
632
856
  script.dataset[MOUNTED_DATA_ATTR] = "true";
@@ -645,15 +869,17 @@ function mountChatWidgetScript(script = document.currentScript) {
645
869
  const shadowRoot = host.attachShadow({ mode: "open" });
646
870
  const container = document.createElement("div");
647
871
  shadowRoot.append(container);
648
- const root = mountChatWidget({
872
+ const { root, setTheme } = createWidgetMount({
649
873
  container,
650
874
  sessionUrl,
651
875
  socketUrlTemplate: script.dataset.socketUrlTemplate,
876
+ theme: parseTheme(script.dataset.theme),
652
877
  widgetId
653
878
  });
654
879
  markScriptMounted(script);
655
880
  return {
656
881
  root,
882
+ setTheme,
657
883
  shadowRoot
658
884
  };
659
885
  }