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