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