@clivly/chat-widget 0.17.0 → 0.18.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 +30 -13
- package/dist/browser-mount.cjs +415 -69
- package/dist/browser-mount.mjs +415 -69
- package/dist/browser.cjs +415 -69
- package/dist/browser.mjs +415 -69
- package/dist/index.cjs +415 -69
- package/dist/index.mjs +415 -69
- package/dist/widget.global.js +180 -21
- package/package.json +1 -1
package/dist/browser-mount.cjs
CHANGED
|
@@ -154,6 +154,43 @@ function writeVisitorToken(storage, widgetId, email, token) {
|
|
|
154
154
|
storage.removeItem?.(legacyVisitorTokenKey(widgetId));
|
|
155
155
|
} catch {}
|
|
156
156
|
}
|
|
157
|
+
// src/widget-presentation-cache.ts
|
|
158
|
+
function presentationKey(widgetId) {
|
|
159
|
+
return `clivly-chat-widget:${widgetId}:presentation`;
|
|
160
|
+
}
|
|
161
|
+
function isPresentation(value) {
|
|
162
|
+
if (typeof value !== "object" || value === null) return false;
|
|
163
|
+
const candidate = value;
|
|
164
|
+
return typeof candidate.name === "string" && typeof candidate.config === "object" && candidate.config !== null && Array.isArray(candidate.topics);
|
|
165
|
+
}
|
|
166
|
+
function readPresentation(storage, widgetId) {
|
|
167
|
+
if (!storage) return null;
|
|
168
|
+
try {
|
|
169
|
+
const raw = storage.getItem(presentationKey(widgetId));
|
|
170
|
+
if (!raw) return null;
|
|
171
|
+
const parsed = JSON.parse(raw);
|
|
172
|
+
return isPresentation(parsed) ? parsed : null;
|
|
173
|
+
} catch {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function presentationOf(session) {
|
|
178
|
+
return {
|
|
179
|
+
config: {
|
|
180
|
+
brandColor: session.widget.config.brandColor ?? null,
|
|
181
|
+
greeting: session.widget.config.greeting ?? null
|
|
182
|
+
},
|
|
183
|
+
name: session.widget.name,
|
|
184
|
+
supportState: session.support?.state ?? null,
|
|
185
|
+
topics: session.support?.topics ?? []
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
function writePresentation(storage, widgetId, presentation) {
|
|
189
|
+
if (!storage) return;
|
|
190
|
+
try {
|
|
191
|
+
storage.setItem(presentationKey(widgetId), JSON.stringify(presentation));
|
|
192
|
+
} catch {}
|
|
193
|
+
}
|
|
157
194
|
// src/chat-widget.tsx
|
|
158
195
|
const DELIVERY_LABELS = {
|
|
159
196
|
failed: "Failed to send",
|
|
@@ -308,9 +345,9 @@ const WIDGET_STYLES = `
|
|
|
308
345
|
border: 1px solid var(--clivly-chat-border);
|
|
309
346
|
border-radius: 1rem;
|
|
310
347
|
box-shadow: 0 24px 80px var(--clivly-chat-shadow);
|
|
311
|
-
display:
|
|
312
|
-
|
|
313
|
-
height: min(
|
|
348
|
+
display: flex;
|
|
349
|
+
flex-direction: column;
|
|
350
|
+
height: min(40rem, 75vh);
|
|
314
351
|
overflow: hidden;
|
|
315
352
|
position: absolute;
|
|
316
353
|
right: 0;
|
|
@@ -327,20 +364,56 @@ const WIDGET_STYLES = `
|
|
|
327
364
|
@media (width < 48rem) {
|
|
328
365
|
.clivly-chat-widget__panel {
|
|
329
366
|
bottom: 4rem;
|
|
330
|
-
height: min(
|
|
367
|
+
height: min(40rem, calc(100dvh - 6.5rem));
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
/* On a phone a floating card leaves a strip of page above it and puts the
|
|
371
|
+
launcher over its own composer, so the panel becomes a full-screen sheet
|
|
372
|
+
instead — the close button in its header takes over from the launcher,
|
|
373
|
+
which is hidden while it is open. */
|
|
374
|
+
@media (width < 30rem) {
|
|
375
|
+
.clivly-chat-widget__panel {
|
|
376
|
+
border: 0;
|
|
377
|
+
border-radius: 0;
|
|
378
|
+
bottom: 0;
|
|
379
|
+
height: 100dvh;
|
|
380
|
+
left: 0;
|
|
381
|
+
padding-bottom: env(safe-area-inset-bottom);
|
|
382
|
+
padding-top: env(safe-area-inset-top);
|
|
383
|
+
position: fixed;
|
|
384
|
+
right: 0;
|
|
385
|
+
top: 0;
|
|
386
|
+
width: 100vw;
|
|
387
|
+
}
|
|
388
|
+
.clivly-chat-widget[data-clivly-open] .clivly-chat-widget__launcher {
|
|
389
|
+
display: none;
|
|
331
390
|
}
|
|
332
391
|
}
|
|
333
392
|
.clivly-chat-widget__header {
|
|
393
|
+
align-items: center;
|
|
334
394
|
border-bottom: 1px solid var(--clivly-chat-border);
|
|
335
395
|
display: flex;
|
|
396
|
+
flex: 0 0 auto;
|
|
336
397
|
justify-content: space-between;
|
|
337
|
-
gap:
|
|
338
|
-
padding: 1rem;
|
|
398
|
+
gap: 0.75rem;
|
|
399
|
+
padding: 0.75rem 0.75rem 0.75rem 1rem;
|
|
400
|
+
}
|
|
401
|
+
.clivly-chat-widget__heading {
|
|
402
|
+
min-width: 0;
|
|
339
403
|
}
|
|
340
404
|
.clivly-chat-widget__title {
|
|
341
405
|
font-size: 1rem;
|
|
342
406
|
font-weight: 600;
|
|
343
407
|
margin: 0;
|
|
408
|
+
overflow: hidden;
|
|
409
|
+
text-overflow: ellipsis;
|
|
410
|
+
white-space: nowrap;
|
|
411
|
+
}
|
|
412
|
+
.clivly-chat-widget__header .clivly-chat-widget__subtitle {
|
|
413
|
+
margin-top: 0.125rem;
|
|
414
|
+
overflow: hidden;
|
|
415
|
+
text-overflow: ellipsis;
|
|
416
|
+
white-space: nowrap;
|
|
344
417
|
}
|
|
345
418
|
.clivly-chat-widget__subtitle {
|
|
346
419
|
color: var(--clivly-chat-muted);
|
|
@@ -348,19 +421,50 @@ const WIDGET_STYLES = `
|
|
|
348
421
|
margin: 0.25rem 0 0;
|
|
349
422
|
}
|
|
350
423
|
.clivly-chat-widget__close {
|
|
424
|
+
align-items: center;
|
|
351
425
|
background: transparent;
|
|
352
426
|
border: 0;
|
|
427
|
+
border-radius: 999px;
|
|
353
428
|
color: var(--clivly-chat-muted);
|
|
354
429
|
cursor: pointer;
|
|
430
|
+
display: inline-flex;
|
|
431
|
+
flex: 0 0 auto;
|
|
432
|
+
height: 2.25rem;
|
|
433
|
+
justify-content: center;
|
|
355
434
|
padding: 0;
|
|
435
|
+
width: 2.25rem;
|
|
436
|
+
}
|
|
437
|
+
.clivly-chat-widget__close:hover,
|
|
438
|
+
.clivly-chat-widget__close:focus-visible {
|
|
439
|
+
background: var(--clivly-chat-operator);
|
|
440
|
+
color: var(--clivly-chat-text);
|
|
441
|
+
}
|
|
442
|
+
.clivly-chat-widget__close:focus-visible {
|
|
443
|
+
outline: 2px solid var(--clivly-chat-accent);
|
|
444
|
+
outline-offset: 2px;
|
|
445
|
+
}
|
|
446
|
+
.clivly-chat-widget__close svg {
|
|
447
|
+
height: 1.25rem;
|
|
448
|
+
width: 1.25rem;
|
|
356
449
|
}
|
|
357
450
|
.clivly-chat-widget__body {
|
|
358
451
|
display: flex;
|
|
452
|
+
flex: 1 1 auto;
|
|
359
453
|
flex-direction: column;
|
|
360
454
|
gap: 0.75rem;
|
|
455
|
+
min-height: 0;
|
|
361
456
|
overflow-y: auto;
|
|
457
|
+
overscroll-behavior: contain;
|
|
362
458
|
padding: 1rem;
|
|
363
459
|
}
|
|
460
|
+
.clivly-chat-widget__visually-hidden {
|
|
461
|
+
clip-path: inset(50%);
|
|
462
|
+
height: 1px;
|
|
463
|
+
overflow: hidden;
|
|
464
|
+
position: absolute;
|
|
465
|
+
white-space: nowrap;
|
|
466
|
+
width: 1px;
|
|
467
|
+
}
|
|
364
468
|
.clivly-chat-widget__message {
|
|
365
469
|
border-radius: 1rem;
|
|
366
470
|
max-width: 85%;
|
|
@@ -438,6 +542,11 @@ const WIDGET_STYLES = `
|
|
|
438
542
|
.clivly-chat-widget__powered a:focus-visible {
|
|
439
543
|
text-decoration: underline;
|
|
440
544
|
}
|
|
545
|
+
.clivly-chat-widget__powered,
|
|
546
|
+
.clivly-chat-widget__composer,
|
|
547
|
+
.clivly-chat-widget__form {
|
|
548
|
+
flex: 0 0 auto;
|
|
549
|
+
}
|
|
441
550
|
.clivly-chat-widget__composer,
|
|
442
551
|
.clivly-chat-widget__form {
|
|
443
552
|
border-top: 1px solid var(--clivly-chat-border);
|
|
@@ -445,6 +554,62 @@ const WIDGET_STYLES = `
|
|
|
445
554
|
gap: 0.75rem;
|
|
446
555
|
padding: 1rem;
|
|
447
556
|
}
|
|
557
|
+
.clivly-chat-widget__composer {
|
|
558
|
+
gap: 0.375rem;
|
|
559
|
+
padding: 0.75rem;
|
|
560
|
+
}
|
|
561
|
+
/* With topics docked above it, the composer has no border of its own: the
|
|
562
|
+
strip and the field read as one input area. */
|
|
563
|
+
.clivly-chat-widget__topics + .clivly-chat-widget__composer {
|
|
564
|
+
border-top: 0;
|
|
565
|
+
}
|
|
566
|
+
.clivly-chat-widget__composer-row {
|
|
567
|
+
align-items: flex-end;
|
|
568
|
+
display: flex;
|
|
569
|
+
gap: 0.5rem;
|
|
570
|
+
}
|
|
571
|
+
.clivly-chat-widget__composer-field {
|
|
572
|
+
flex: 1 1 auto;
|
|
573
|
+
min-width: 0;
|
|
574
|
+
}
|
|
575
|
+
.clivly-chat-widget__composer .clivly-chat-widget__textarea {
|
|
576
|
+
line-height: 1.4;
|
|
577
|
+
max-height: 8rem;
|
|
578
|
+
min-height: 2.75rem;
|
|
579
|
+
overflow-y: auto;
|
|
580
|
+
padding: 0.625rem 0.75rem;
|
|
581
|
+
resize: none;
|
|
582
|
+
}
|
|
583
|
+
.clivly-chat-widget__send {
|
|
584
|
+
align-items: center;
|
|
585
|
+
border-radius: 999px;
|
|
586
|
+
display: inline-flex;
|
|
587
|
+
height: 2.75rem;
|
|
588
|
+
justify-content: center;
|
|
589
|
+
padding: 0;
|
|
590
|
+
width: 2.75rem;
|
|
591
|
+
}
|
|
592
|
+
.clivly-chat-widget__send svg {
|
|
593
|
+
height: 1.25rem;
|
|
594
|
+
width: 1.25rem;
|
|
595
|
+
}
|
|
596
|
+
.clivly-chat-widget__composer .clivly-chat-widget__actions {
|
|
597
|
+
padding: 0 0.25rem;
|
|
598
|
+
}
|
|
599
|
+
.clivly-chat-widget__composer .clivly-chat-widget__subtitle {
|
|
600
|
+
font-size: 0.75rem;
|
|
601
|
+
margin: 0;
|
|
602
|
+
}
|
|
603
|
+
/* Once the conversation is ready the status has nothing to say to a sighted
|
|
604
|
+
visitor; it stays in the accessibility tree so the change is announced. */
|
|
605
|
+
.clivly-chat-widget__actions[data-ready] {
|
|
606
|
+
clip-path: inset(50%);
|
|
607
|
+
height: 1px;
|
|
608
|
+
overflow: hidden;
|
|
609
|
+
position: absolute;
|
|
610
|
+
white-space: nowrap;
|
|
611
|
+
width: 1px;
|
|
612
|
+
}
|
|
448
613
|
.clivly-chat-widget__field {
|
|
449
614
|
display: grid;
|
|
450
615
|
gap: 0.35rem;
|
|
@@ -525,13 +690,42 @@ const WIDGET_STYLES = `
|
|
|
525
690
|
font-size: 0.6875rem;
|
|
526
691
|
padding: 0 0.4rem;
|
|
527
692
|
}
|
|
693
|
+
/* Docked above the composer. Wide screens wrap up to about two rows and
|
|
694
|
+
scroll beyond that; narrow or touch screens get one sideways-scrolling row
|
|
695
|
+
whose right edge fades to show there is more. */
|
|
528
696
|
.clivly-chat-widget__topics {
|
|
697
|
+
border-top: 1px solid var(--clivly-chat-border);
|
|
529
698
|
display: flex;
|
|
699
|
+
flex: 0 0 auto;
|
|
530
700
|
flex-wrap: wrap;
|
|
531
|
-
gap: 0.
|
|
532
|
-
margin: 0;
|
|
533
|
-
padding: 0;
|
|
701
|
+
gap: 0.375rem;
|
|
534
702
|
list-style: none;
|
|
703
|
+
margin: 0;
|
|
704
|
+
max-height: 5.25rem;
|
|
705
|
+
overflow-y: auto;
|
|
706
|
+
padding: 0.625rem 0.75rem 0;
|
|
707
|
+
scrollbar-width: thin;
|
|
708
|
+
}
|
|
709
|
+
.clivly-chat-widget__topics > li {
|
|
710
|
+
flex: 0 0 auto;
|
|
711
|
+
scroll-snap-align: start;
|
|
712
|
+
}
|
|
713
|
+
@media (width < 48rem), (any-pointer: coarse) {
|
|
714
|
+
.clivly-chat-widget__topics {
|
|
715
|
+
flex-wrap: nowrap;
|
|
716
|
+
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 1.5rem), transparent);
|
|
717
|
+
mask-image: linear-gradient(to right, #000 calc(100% - 1.5rem), transparent);
|
|
718
|
+
max-height: none;
|
|
719
|
+
overflow-x: auto;
|
|
720
|
+
overflow-y: hidden;
|
|
721
|
+
padding-right: 1.5rem;
|
|
722
|
+
scroll-padding-left: 0.75rem;
|
|
723
|
+
scroll-snap-type: x proximity;
|
|
724
|
+
scrollbar-width: none;
|
|
725
|
+
}
|
|
726
|
+
.clivly-chat-widget__topics::-webkit-scrollbar {
|
|
727
|
+
display: none;
|
|
728
|
+
}
|
|
535
729
|
}
|
|
536
730
|
.clivly-chat-widget__topic {
|
|
537
731
|
background: var(--clivly-chat-panel);
|
|
@@ -539,9 +733,11 @@ const WIDGET_STYLES = `
|
|
|
539
733
|
border-radius: 999px;
|
|
540
734
|
color: var(--clivly-chat-text);
|
|
541
735
|
cursor: pointer;
|
|
542
|
-
font-size: 0.
|
|
543
|
-
|
|
544
|
-
|
|
736
|
+
font-size: 0.8125rem;
|
|
737
|
+
line-height: 1.2;
|
|
738
|
+
min-height: 2rem;
|
|
739
|
+
padding: 0.375rem 0.75rem;
|
|
740
|
+
white-space: nowrap;
|
|
545
741
|
}
|
|
546
742
|
.clivly-chat-widget__topic:hover,
|
|
547
743
|
.clivly-chat-widget__topic:focus-visible {
|
|
@@ -617,15 +813,44 @@ function markMessageFailed(messages, clientMessageId) {
|
|
|
617
813
|
function sortMessages(messages) {
|
|
618
814
|
return [...messages].sort((left, right) => left.createdAt.localeCompare(right.createdAt));
|
|
619
815
|
}
|
|
620
|
-
|
|
816
|
+
const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
817
|
+
/** Relative luminance above which white text stops being readable. */
|
|
818
|
+
const LIGHT_ACCENT_LUMINANCE = .45;
|
|
819
|
+
/**
|
|
820
|
+
* Text colour for a button filled with `hex`: white on dark brands, near-black
|
|
821
|
+
* on light ones, so a yellow or pastel brand colour does not leave the
|
|
822
|
+
* launcher's icon and the Send label white-on-white. Null for anything that is
|
|
823
|
+
* not a plain hex colour, which keeps the stylesheet's own contrast colour.
|
|
824
|
+
*/
|
|
825
|
+
function accentContrastFor(hex) {
|
|
826
|
+
if (!HEX_COLOR.test(hex)) return null;
|
|
827
|
+
const digits = hex.length === 4 ? [...hex.slice(1)].map((digit) => digit + digit).join("") : hex.slice(1);
|
|
828
|
+
const [red = 0, green = 0, blue = 0] = [
|
|
829
|
+
0,
|
|
830
|
+
2,
|
|
831
|
+
4
|
|
832
|
+
].map((offset) => {
|
|
833
|
+
const value = Number.parseInt(digits.slice(offset, offset + 2), 16) / 255;
|
|
834
|
+
return value <= .03928 ? value / 12.92 : ((value + .055) / 1.055) ** 2.4;
|
|
835
|
+
});
|
|
836
|
+
return .2126 * red + .7152 * green + .0722 * blue > LIGHT_ACCENT_LUMINANCE ? "#111827" : "#ffffff";
|
|
837
|
+
}
|
|
838
|
+
/** The session's brand colour, or the remembered one until a session lands. */
|
|
839
|
+
function brandColorOf(session, cached) {
|
|
840
|
+
if (session) return session.widget.config.brandColor ?? null;
|
|
841
|
+
return cached?.config.brandColor ?? null;
|
|
842
|
+
}
|
|
843
|
+
function useThemeStyle(brandColor, style) {
|
|
621
844
|
return (0, react.useMemo)(() => {
|
|
622
|
-
const
|
|
623
|
-
if (!brandColor) return style;
|
|
845
|
+
const hostSetsAccent = Boolean(style && "--clivly-chat-accent" in style);
|
|
846
|
+
if (!brandColor || hostSetsAccent) return style;
|
|
847
|
+
const contrast = accentContrastFor(brandColor);
|
|
624
848
|
return {
|
|
625
849
|
["--clivly-chat-accent"]: brandColor,
|
|
850
|
+
...contrast ? { ["--clivly-chat-accent-contrast"]: contrast } : {},
|
|
626
851
|
...style
|
|
627
852
|
};
|
|
628
|
-
}, [
|
|
853
|
+
}, [brandColor, style]);
|
|
629
854
|
}
|
|
630
855
|
/**
|
|
631
856
|
* Track the theme the widget should paint.
|
|
@@ -699,12 +924,21 @@ function automationBadge(message) {
|
|
|
699
924
|
* root's `data-clivly-launcher` (see WIDGET_STYLES); the label is always its
|
|
700
925
|
* accessible name, and `aria-controls` names the panel only while it exists.
|
|
701
926
|
*/
|
|
702
|
-
function Launcher({ label, onToggle, open, panelId }) {
|
|
927
|
+
function Launcher({ label, onIntent, onToggle, open, panelId }) {
|
|
703
928
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
704
929
|
"aria-controls": open ? panelId : void 0,
|
|
705
930
|
"aria-expanded": open,
|
|
706
931
|
className: "clivly-chat-widget__launcher",
|
|
707
932
|
onClick: onToggle,
|
|
933
|
+
onFocus: () => {
|
|
934
|
+
onIntent("likely");
|
|
935
|
+
},
|
|
936
|
+
onPointerDown: () => {
|
|
937
|
+
onIntent("certain");
|
|
938
|
+
},
|
|
939
|
+
onPointerEnter: (event) => {
|
|
940
|
+
if (event.pointerType === "mouse") onIntent("likely");
|
|
941
|
+
},
|
|
708
942
|
type: "button",
|
|
709
943
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LauncherIcon, { open }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
710
944
|
className: "clivly-chat-widget__launcher-label",
|
|
@@ -849,9 +1083,14 @@ function composerStatusText(hasTransport, isReady, reconnecting) {
|
|
|
849
1083
|
return reconnecting ? "Reconnecting…" : "Connecting…";
|
|
850
1084
|
}
|
|
851
1085
|
/**
|
|
852
|
-
* Guided options
|
|
853
|
-
*
|
|
854
|
-
*
|
|
1086
|
+
* Guided options, docked above the composer rather than inside the transcript.
|
|
1087
|
+
* In the transcript they sat below the greeting and the scroll-to-newest
|
|
1088
|
+
* pinned them there, so on a phone six wrapped chips pushed the greeting and
|
|
1089
|
+
* the answer above the fold. Docked, they are one compact strip (scrolling
|
|
1090
|
+
* sideways on narrow screens) and the conversation keeps the space.
|
|
1091
|
+
*
|
|
1092
|
+
* Native buttons in a labelled group, so they are reachable with Tab and
|
|
1093
|
+
* operable with Enter/Space; free text stays available beside them.
|
|
855
1094
|
*/
|
|
856
1095
|
function SupportTopics({ disabled, onSelect, topics }) {
|
|
857
1096
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
|
|
@@ -868,9 +1107,64 @@ function SupportTopics({ disabled, onSelect, topics }) {
|
|
|
868
1107
|
}) }, topic.id))
|
|
869
1108
|
});
|
|
870
1109
|
}
|
|
871
|
-
/**
|
|
872
|
-
|
|
873
|
-
|
|
1110
|
+
/**
|
|
1111
|
+
* Topics are offered only while automation is answering this conversation.
|
|
1112
|
+
* Before the session lands, the remembered presentation stands in so the
|
|
1113
|
+
* strip is there on the first frame; it shows only if this browser last saw
|
|
1114
|
+
* them offered, so a visitor already talking to a person does not see them
|
|
1115
|
+
* flash up and vanish.
|
|
1116
|
+
*/
|
|
1117
|
+
function visibleTopics(session, state, cached) {
|
|
1118
|
+
if (session) return state === "automated" ? session.support?.topics ?? [] : [];
|
|
1119
|
+
return cached?.supportState === "automated" ? cached.topics : [];
|
|
1120
|
+
}
|
|
1121
|
+
/**
|
|
1122
|
+
* What the open panel shows, derived from the session or — before it lands —
|
|
1123
|
+
* from the host's identity and the remembered presentation.
|
|
1124
|
+
*
|
|
1125
|
+
* A host-identified visitor never sees the form: while their session loads the
|
|
1126
|
+
* panel already shows the conversation shell — title, topics and a composer
|
|
1127
|
+
* they can type into — so opening the widget is not a blank wait. A failed
|
|
1128
|
+
* bootstrap sets `error` and falls back to the form, the recoverable state.
|
|
1129
|
+
*/
|
|
1130
|
+
function panelView({ cachedPresentation, defaultVisitor, error, session, supportState }) {
|
|
1131
|
+
const awaitingHostSession = !session && isCompleteVisitor(defaultVisitor) && !error;
|
|
1132
|
+
const showComposer = Boolean(session) || awaitingHostSession;
|
|
1133
|
+
const greeting = session?.widget.config.greeting ?? cachedPresentation?.config.greeting ?? "Tell us a little about yourself and we’ll pick up the conversation from there.";
|
|
1134
|
+
return {
|
|
1135
|
+
awaitingHostSession,
|
|
1136
|
+
greeting,
|
|
1137
|
+
panelTitle: session?.widget.name ?? cachedPresentation?.name ?? "Start a conversation",
|
|
1138
|
+
sessionSummary: awaitingHostSession && defaultVisitor ? `${defaultVisitor.name?.trim()} • ${defaultVisitor.email?.trim()}` : sessionSummaryText(session, greeting),
|
|
1139
|
+
showComposer,
|
|
1140
|
+
topics: showComposer ? visibleTopics(session, supportState, cachedPresentation) : []
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
/**
|
|
1144
|
+
* Who to start a session for ahead of the click, or null to wait for it. Only
|
|
1145
|
+
* a host-identified visitor; and on a mere hover or focus only one with a
|
|
1146
|
+
* stored thread to resume.
|
|
1147
|
+
*/
|
|
1148
|
+
function prefetchIdentity(intent, { defaultVisitor, settled }, storedToken) {
|
|
1149
|
+
if (settled || !isCompleteVisitor(defaultVisitor)) return null;
|
|
1150
|
+
const identity = {
|
|
1151
|
+
email: defaultVisitor.email.trim(),
|
|
1152
|
+
name: defaultVisitor.name.trim()
|
|
1153
|
+
};
|
|
1154
|
+
if (intent === "likely" && !storedToken(identity.email)) return null;
|
|
1155
|
+
return identity;
|
|
1156
|
+
}
|
|
1157
|
+
function startSessionQuietly(startSession, identity) {
|
|
1158
|
+
if (!identity) return;
|
|
1159
|
+
startSession(identity, "host").catch(() => {});
|
|
1160
|
+
}
|
|
1161
|
+
/** The composer grows with its text up to this height, then scrolls. */
|
|
1162
|
+
const COMPOSER_MAX_HEIGHT_PX = 128;
|
|
1163
|
+
/** Size a textarea to its content, between its CSS min-height and the cap. */
|
|
1164
|
+
function fitTextarea(textarea) {
|
|
1165
|
+
if (!textarea) return;
|
|
1166
|
+
textarea.style.height = "auto";
|
|
1167
|
+
textarea.style.height = `${Math.min(textarea.scrollHeight, COMPOSER_MAX_HEIGHT_PX)}px`;
|
|
874
1168
|
}
|
|
875
1169
|
function composerPlaceholder(hasTopics) {
|
|
876
1170
|
return hasTopics ? "Or type your question" : "Write your message";
|
|
@@ -1039,12 +1333,20 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
1039
1333
|
const [session, setSession] = (0, react.useState)(null);
|
|
1040
1334
|
const [supportState, setSupportState] = (0, react.useState)(null);
|
|
1041
1335
|
const [assistantThinking, setAssistantThinking] = (0, react.useState)(false);
|
|
1336
|
+
const [cachedPresentation, setCachedPresentation] = (0, react.useState)(null);
|
|
1337
|
+
(0, react.useEffect)(() => {
|
|
1338
|
+
setCachedPresentation(readPresentation(storage, widgetId));
|
|
1339
|
+
}, [storage, widgetId]);
|
|
1042
1340
|
const [visitor, setVisitor] = (0, react.useState)({
|
|
1043
1341
|
email: defaultVisitor?.email ?? "",
|
|
1044
1342
|
name: defaultVisitor?.name ?? ""
|
|
1045
1343
|
});
|
|
1046
1344
|
const transportRef = (0, react.useRef)(null);
|
|
1047
1345
|
const listRef = (0, react.useRef)(null);
|
|
1346
|
+
const composerRef = (0, react.useRef)(null);
|
|
1347
|
+
(0, react.useEffect)(() => {
|
|
1348
|
+
fitTextarea(composerRef.current);
|
|
1349
|
+
}, [messageDraft]);
|
|
1048
1350
|
const identityRef = (0, react.useRef)(null);
|
|
1049
1351
|
const sessionSourceRef = (0, react.useRef)(null);
|
|
1050
1352
|
const generationRef = (0, react.useRef)(0);
|
|
@@ -1056,7 +1358,7 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
1056
1358
|
console.warn("[clivly] <ChatWidget> was given no createTransport, so messages cannot be sent. Pass createTransport={createClivlyTransport()} — see https://www.npmjs.com/package/@clivly/chat-widget#five-minute-quick-start");
|
|
1057
1359
|
}, []);
|
|
1058
1360
|
const showWaitingNotice = useWaitingNotice(messages);
|
|
1059
|
-
const themeStyle = useThemeStyle(session, style);
|
|
1361
|
+
const themeStyle = useThemeStyle(brandColorOf(session, cachedPresentation), style);
|
|
1060
1362
|
const resolvedTheme = useResolvedTheme(theme);
|
|
1061
1363
|
(0, react.useEffect)(() => () => {
|
|
1062
1364
|
transportRef.current?.close();
|
|
@@ -1085,6 +1387,7 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
1085
1387
|
});
|
|
1086
1388
|
if (generation !== generationRef.current) return;
|
|
1087
1389
|
writeVisitorToken(storage, widgetId, identity.email, next.visitorToken);
|
|
1390
|
+
writePresentation(storage, widgetId, presentationOf(next));
|
|
1088
1391
|
setSupportState(next.support?.state ?? null);
|
|
1089
1392
|
setSession(next);
|
|
1090
1393
|
},
|
|
@@ -1122,6 +1425,7 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
1122
1425
|
});
|
|
1123
1426
|
if (isStale()) return;
|
|
1124
1427
|
writeVisitorToken(storage, widgetId, identity.email, nextSession.visitorToken);
|
|
1428
|
+
writePresentation(storage, widgetId, presentationOf(nextSession));
|
|
1125
1429
|
identityRef.current = identity;
|
|
1126
1430
|
sessionSourceRef.current = source;
|
|
1127
1431
|
setSupportState(nextSession.support?.state ?? null);
|
|
@@ -1175,6 +1479,13 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
1175
1479
|
session,
|
|
1176
1480
|
startSession
|
|
1177
1481
|
]);
|
|
1482
|
+
const prefetchSession = (intent) => {
|
|
1483
|
+
const identity = prefetchIdentity(intent, {
|
|
1484
|
+
defaultVisitor,
|
|
1485
|
+
settled: isOpen || Boolean(session) || isConnecting || Boolean(error)
|
|
1486
|
+
}, (email) => readVisitorToken(storage, widgetId, email));
|
|
1487
|
+
startSessionQuietly(startSession, identity);
|
|
1488
|
+
};
|
|
1178
1489
|
/**
|
|
1179
1490
|
* Show the visitor's message at once and hand it to the transport. A topic
|
|
1180
1491
|
* click goes as `topic.select` when the transport supports it, so the
|
|
@@ -1213,13 +1524,17 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
1213
1524
|
sendVisitorMessage(body);
|
|
1214
1525
|
};
|
|
1215
1526
|
const canSend = Boolean(createTransport) && isReady;
|
|
1216
|
-
const
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1527
|
+
const { awaitingHostSession, greeting, panelTitle, sessionSummary, showComposer, topics } = panelView({
|
|
1528
|
+
cachedPresentation,
|
|
1529
|
+
defaultVisitor,
|
|
1530
|
+
error,
|
|
1531
|
+
session,
|
|
1532
|
+
supportState
|
|
1533
|
+
});
|
|
1220
1534
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1221
1535
|
className: `clivly-chat-widget ${className ?? ""}`.trim(),
|
|
1222
1536
|
"data-clivly-launcher": launcher,
|
|
1537
|
+
"data-clivly-open": isOpen || void 0,
|
|
1223
1538
|
"data-clivly-placement": placement,
|
|
1224
1539
|
"data-clivly-theme": resolvedTheme,
|
|
1225
1540
|
style: themeStyle,
|
|
@@ -1227,6 +1542,7 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
1227
1542
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: WIDGET_STYLES }),
|
|
1228
1543
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Launcher, {
|
|
1229
1544
|
label: launcherLabel,
|
|
1545
|
+
onIntent: prefetchSession,
|
|
1230
1546
|
onToggle: () => {
|
|
1231
1547
|
setIsOpen((open) => !open);
|
|
1232
1548
|
},
|
|
@@ -1240,23 +1556,36 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
1240
1556
|
children: [
|
|
1241
1557
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("header", {
|
|
1242
1558
|
className: "clivly-chat-widget__header",
|
|
1243
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1244
|
-
className: "clivly-chat-
|
|
1245
|
-
children:
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1559
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1560
|
+
className: "clivly-chat-widget__heading",
|
|
1561
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", {
|
|
1562
|
+
className: "clivly-chat-widget__title",
|
|
1563
|
+
children: panelTitle
|
|
1564
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1565
|
+
className: "clivly-chat-widget__subtitle",
|
|
1566
|
+
children: sessionSummary
|
|
1567
|
+
})]
|
|
1568
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1250
1569
|
"aria-label": "Close chat",
|
|
1251
1570
|
className: "clivly-chat-widget__close",
|
|
1252
1571
|
onClick: () => {
|
|
1253
1572
|
setIsOpen(false);
|
|
1254
1573
|
},
|
|
1255
1574
|
type: "button",
|
|
1256
|
-
children: "
|
|
1575
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
|
|
1576
|
+
"aria-hidden": "true",
|
|
1577
|
+
fill: "none",
|
|
1578
|
+
focusable: "false",
|
|
1579
|
+
stroke: "currentColor",
|
|
1580
|
+
strokeLinecap: "round",
|
|
1581
|
+
strokeWidth: 2,
|
|
1582
|
+
viewBox: "0 0 24 24",
|
|
1583
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M18 6 6 18M6 6l12 12" })
|
|
1584
|
+
})
|
|
1257
1585
|
})]
|
|
1258
1586
|
}),
|
|
1259
1587
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1588
|
+
"aria-busy": awaitingHostSession,
|
|
1260
1589
|
"aria-label": "Conversation",
|
|
1261
1590
|
className: "clivly-chat-widget__body",
|
|
1262
1591
|
ref: listRef,
|
|
@@ -1271,13 +1600,6 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
1271
1600
|
hasSession: Boolean(session),
|
|
1272
1601
|
messages
|
|
1273
1602
|
}),
|
|
1274
|
-
topics.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SupportTopics, {
|
|
1275
|
-
disabled: !canSend,
|
|
1276
|
-
onSelect: (topic) => {
|
|
1277
|
-
sendVisitorMessage(topic.label, topic);
|
|
1278
|
-
},
|
|
1279
|
-
topics
|
|
1280
|
-
}) : null,
|
|
1281
1603
|
session ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(StatusNotice, {
|
|
1282
1604
|
assistantThinking,
|
|
1283
1605
|
email: session.contact?.email,
|
|
@@ -1285,39 +1607,63 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
1285
1607
|
}) : null
|
|
1286
1608
|
]
|
|
1287
1609
|
}),
|
|
1288
|
-
|
|
1610
|
+
topics.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SupportTopics, {
|
|
1611
|
+
disabled: !canSend,
|
|
1612
|
+
onSelect: (topic) => {
|
|
1613
|
+
sendVisitorMessage(topic.label, topic);
|
|
1614
|
+
},
|
|
1615
|
+
topics
|
|
1616
|
+
}) : null,
|
|
1617
|
+
showComposer ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("form", {
|
|
1289
1618
|
className: "clivly-chat-widget__composer",
|
|
1290
1619
|
onSubmit: submitMessage,
|
|
1291
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("
|
|
1292
|
-
className: "clivly-chat-
|
|
1293
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.
|
|
1294
|
-
className: "clivly-chat-
|
|
1295
|
-
children: "
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
event
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1620
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1621
|
+
className: "clivly-chat-widget__composer-row",
|
|
1622
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
1623
|
+
className: "clivly-chat-widget__field clivly-chat-widget__composer-field",
|
|
1624
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1625
|
+
className: "clivly-chat-widget__label clivly-chat-widget__visually-hidden",
|
|
1626
|
+
children: "Message"
|
|
1627
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
|
|
1628
|
+
className: "clivly-chat-widget__textarea",
|
|
1629
|
+
onChange: (event) => {
|
|
1630
|
+
setMessageDraft(event.target.value);
|
|
1631
|
+
},
|
|
1632
|
+
onKeyDown: (event) => {
|
|
1633
|
+
if (!isSendKey(event)) return;
|
|
1634
|
+
event.preventDefault();
|
|
1635
|
+
if (messageDraft.trim() && canSend) submitMessage();
|
|
1636
|
+
},
|
|
1637
|
+
placeholder: composerPlaceholder(topics.length > 0),
|
|
1638
|
+
ref: composerRef,
|
|
1639
|
+
rows: 1,
|
|
1640
|
+
value: messageDraft
|
|
1641
|
+
})]
|
|
1642
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1643
|
+
"aria-label": "Send",
|
|
1644
|
+
className: "clivly-chat-widget__button clivly-chat-widget__send",
|
|
1645
|
+
disabled: !(messageDraft.trim() && canSend),
|
|
1646
|
+
type: "submit",
|
|
1647
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
|
|
1648
|
+
"aria-hidden": "true",
|
|
1649
|
+
fill: "none",
|
|
1650
|
+
focusable: "false",
|
|
1651
|
+
stroke: "currentColor",
|
|
1652
|
+
strokeLinecap: "round",
|
|
1653
|
+
strokeLinejoin: "round",
|
|
1654
|
+
strokeWidth: 2,
|
|
1655
|
+
viewBox: "0 0 24 24",
|
|
1656
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M12 19V5M5 12l7-7 7 7" })
|
|
1657
|
+
})
|
|
1308
1658
|
})]
|
|
1309
|
-
}), /* @__PURE__ */ (0, react_jsx_runtime.
|
|
1659
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1310
1660
|
className: "clivly-chat-widget__actions",
|
|
1311
|
-
|
|
1661
|
+
"data-ready": canSend || void 0,
|
|
1662
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1312
1663
|
"aria-live": "polite",
|
|
1313
1664
|
className: "clivly-chat-widget__subtitle",
|
|
1314
1665
|
children: composerStatusText(Boolean(createTransport), isReady, reconnecting)
|
|
1315
|
-
})
|
|
1316
|
-
className: "clivly-chat-widget__button",
|
|
1317
|
-
disabled: !(messageDraft.trim() && canSend),
|
|
1318
|
-
type: "submit",
|
|
1319
|
-
children: "Send"
|
|
1320
|
-
})]
|
|
1666
|
+
})
|
|
1321
1667
|
})]
|
|
1322
1668
|
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("form", {
|
|
1323
1669
|
className: "clivly-chat-widget__form",
|