@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 +81 -4
- package/dist/browser-mount.cjs +297 -71
- package/dist/browser-mount.d.cts +40 -11
- package/dist/browser-mount.d.mts +40 -11
- package/dist/browser-mount.mjs +298 -72
- package/dist/browser.cjs +297 -71
- package/dist/browser.d.cts +41 -11
- package/dist/browser.d.mts +41 -11
- package/dist/browser.mjs +298 -72
- package/dist/index.cjs +299 -72
- package/dist/index.d.cts +59 -36
- package/dist/index.d.mts +59 -36
- package/dist/index.mjs +300 -74
- package/dist/widget.global.js +228 -84
- package/package.json +7 -3
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
|
|
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 "
|
|
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
|
-
|
|
133
|
-
`
|
|
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`.
|
package/dist/browser-mount.cjs
CHANGED
|
@@ -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:
|
|
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
|
|
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:
|
|
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:
|
|
171
|
-
border: 1px solid
|
|
328
|
+
background: var(--clivly-chat-error-bg);
|
|
329
|
+
border: 1px solid var(--clivly-chat-error-border);
|
|
172
330
|
border-radius: 0.8rem;
|
|
173
|
-
color:
|
|
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 {
|
|
240
|
-
|
|
403
|
+
if (!brandColor) return style;
|
|
404
|
+
return {
|
|
405
|
+
["--clivly-chat-accent"]: brandColor,
|
|
406
|
+
...style
|
|
407
|
+
};
|
|
408
|
+
}, [session?.widget.config.brandColor, style]);
|
|
241
409
|
}
|
|
242
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
|
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
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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 =
|
|
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
|
}
|