@clivly/chat-widget 0.14.1 → 0.16.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 +61 -1
- package/dist/browser-mount.cjs +470 -58
- package/dist/browser-mount.d.cts +73 -1
- package/dist/browser-mount.d.mts +73 -1
- package/dist/browser-mount.mjs +471 -59
- package/dist/browser.cjs +470 -58
- package/dist/browser.d.cts +73 -1
- package/dist/browser.d.mts +73 -1
- package/dist/browser.mjs +471 -59
- package/dist/index.cjs +470 -58
- package/dist/index.d.cts +76 -4
- package/dist/index.d.mts +76 -4
- package/dist/index.mjs +471 -59
- package/dist/widget.global.js +151 -299
- package/package.json +1 -1
package/dist/browser.cjs
CHANGED
|
@@ -163,16 +163,102 @@ const WIDGET_STYLES = `
|
|
|
163
163
|
.clivly-chat-widget textarea {
|
|
164
164
|
font: inherit;
|
|
165
165
|
}
|
|
166
|
+
/* The standalone embed does not load the app's shared stylesheet. */
|
|
167
|
+
@media (width < 48rem), (any-pointer: coarse) {
|
|
168
|
+
.clivly-chat-widget input,
|
|
169
|
+
.clivly-chat-widget textarea {
|
|
170
|
+
font-size: max(16px, 1em);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/* Floating placements. The insets keep the launcher off a phone's home
|
|
174
|
+
indicator and rounded corners; --clivly-chat-z-index lets a host slot it
|
|
175
|
+
beneath its own dialogs. */
|
|
176
|
+
.clivly-chat-widget[data-clivly-placement="bottom-right"],
|
|
177
|
+
.clivly-chat-widget[data-clivly-placement="bottom-left"] {
|
|
178
|
+
bottom: max(1.25rem, env(safe-area-inset-bottom));
|
|
179
|
+
position: fixed;
|
|
180
|
+
z-index: var(--clivly-chat-z-index, 2147483000);
|
|
181
|
+
}
|
|
182
|
+
.clivly-chat-widget[data-clivly-placement="bottom-right"] {
|
|
183
|
+
right: max(1.25rem, env(safe-area-inset-right));
|
|
184
|
+
}
|
|
185
|
+
.clivly-chat-widget[data-clivly-placement="bottom-left"] {
|
|
186
|
+
left: max(1.25rem, env(safe-area-inset-left));
|
|
187
|
+
}
|
|
166
188
|
.clivly-chat-widget__launcher {
|
|
167
189
|
align-items: center;
|
|
168
190
|
background: var(--clivly-chat-accent);
|
|
169
191
|
border: 0;
|
|
170
192
|
border-radius: 999px;
|
|
193
|
+
box-shadow:
|
|
194
|
+
0 2px 6px rgba(0, 0, 0, 0.18),
|
|
195
|
+
0 10px 28px -10px var(--clivly-chat-accent);
|
|
171
196
|
color: var(--clivly-chat-accent-contrast);
|
|
172
197
|
cursor: pointer;
|
|
173
198
|
display: inline-flex;
|
|
199
|
+
font-weight: 500;
|
|
174
200
|
gap: 0.5rem;
|
|
175
|
-
|
|
201
|
+
justify-content: center;
|
|
202
|
+
min-height: 3rem;
|
|
203
|
+
padding: 0 1.15rem;
|
|
204
|
+
transition: transform 150ms ease-out, filter 150ms ease-out;
|
|
205
|
+
}
|
|
206
|
+
.clivly-chat-widget__launcher:hover {
|
|
207
|
+
filter: brightness(1.06);
|
|
208
|
+
transform: translateY(-1px);
|
|
209
|
+
}
|
|
210
|
+
.clivly-chat-widget__launcher:active {
|
|
211
|
+
transform: scale(0.96);
|
|
212
|
+
}
|
|
213
|
+
.clivly-chat-widget__launcher:focus-visible {
|
|
214
|
+
outline: 2px solid var(--clivly-chat-accent);
|
|
215
|
+
outline-offset: 3px;
|
|
216
|
+
}
|
|
217
|
+
.clivly-chat-widget__launcher-icon {
|
|
218
|
+
flex-shrink: 0;
|
|
219
|
+
height: 1.25rem;
|
|
220
|
+
width: 1.25rem;
|
|
221
|
+
}
|
|
222
|
+
/* Icon-only: a 48px circle — a comfortable touch target that does not cover
|
|
223
|
+
the host's pagination, toolbars or form controls the way a labelled pill
|
|
224
|
+
does on a phone. The label is clipped, not removed, so it stays the
|
|
225
|
+
button's accessible name. */
|
|
226
|
+
.clivly-chat-widget[data-clivly-launcher="icon"] .clivly-chat-widget__launcher {
|
|
227
|
+
height: 3rem;
|
|
228
|
+
padding: 0;
|
|
229
|
+
width: 3rem;
|
|
230
|
+
}
|
|
231
|
+
.clivly-chat-widget[data-clivly-launcher="icon"] .clivly-chat-widget__launcher-label {
|
|
232
|
+
clip-path: inset(50%);
|
|
233
|
+
height: 1px;
|
|
234
|
+
overflow: hidden;
|
|
235
|
+
position: absolute;
|
|
236
|
+
white-space: nowrap;
|
|
237
|
+
width: 1px;
|
|
238
|
+
}
|
|
239
|
+
@media (width < 48rem) {
|
|
240
|
+
.clivly-chat-widget[data-clivly-launcher="auto"] .clivly-chat-widget__launcher {
|
|
241
|
+
height: 3rem;
|
|
242
|
+
padding: 0;
|
|
243
|
+
width: 3rem;
|
|
244
|
+
}
|
|
245
|
+
.clivly-chat-widget[data-clivly-launcher="auto"] .clivly-chat-widget__launcher-label {
|
|
246
|
+
clip-path: inset(50%);
|
|
247
|
+
height: 1px;
|
|
248
|
+
overflow: hidden;
|
|
249
|
+
position: absolute;
|
|
250
|
+
white-space: nowrap;
|
|
251
|
+
width: 1px;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
@media (prefers-reduced-motion: reduce) {
|
|
255
|
+
.clivly-chat-widget__launcher {
|
|
256
|
+
transition: none;
|
|
257
|
+
}
|
|
258
|
+
.clivly-chat-widget__launcher:hover,
|
|
259
|
+
.clivly-chat-widget__launcher:active {
|
|
260
|
+
transform: none;
|
|
261
|
+
}
|
|
176
262
|
}
|
|
177
263
|
.clivly-chat-widget__panel {
|
|
178
264
|
background: var(--clivly-chat-panel);
|
|
@@ -188,6 +274,19 @@ const WIDGET_STYLES = `
|
|
|
188
274
|
bottom: 4.5rem;
|
|
189
275
|
width: min(24rem, calc(100vw - 2rem));
|
|
190
276
|
}
|
|
277
|
+
/* Opens toward the page from whichever corner the launcher is in. */
|
|
278
|
+
.clivly-chat-widget[data-clivly-placement="bottom-left"] .clivly-chat-widget__panel {
|
|
279
|
+
left: 0;
|
|
280
|
+
right: auto;
|
|
281
|
+
}
|
|
282
|
+
/* dvh: on a phone vh includes the collapsed URL bar, so a 75vh panel's top
|
|
283
|
+
edge could slide under it. The 48px launcher sits lower than the pill. */
|
|
284
|
+
@media (width < 48rem) {
|
|
285
|
+
.clivly-chat-widget__panel {
|
|
286
|
+
bottom: 4rem;
|
|
287
|
+
height: min(44rem, calc(100dvh - 6.5rem));
|
|
288
|
+
}
|
|
289
|
+
}
|
|
191
290
|
.clivly-chat-widget__header {
|
|
192
291
|
border-bottom: 1px solid var(--clivly-chat-border);
|
|
193
292
|
display: flex;
|
|
@@ -372,6 +471,47 @@ const WIDGET_STYLES = `
|
|
|
372
471
|
margin: 0;
|
|
373
472
|
padding: 0.625rem 0.75rem;
|
|
374
473
|
}
|
|
474
|
+
/* Automated and AI messages are marked as such, never styled as a teammate. */
|
|
475
|
+
.clivly-chat-widget__message[data-automation] {
|
|
476
|
+
background: transparent;
|
|
477
|
+
border: 1px dashed var(--clivly-chat-border);
|
|
478
|
+
}
|
|
479
|
+
.clivly-chat-widget__badge {
|
|
480
|
+
border: 1px solid var(--clivly-chat-border);
|
|
481
|
+
border-radius: 999px;
|
|
482
|
+
font-size: 0.6875rem;
|
|
483
|
+
padding: 0 0.4rem;
|
|
484
|
+
}
|
|
485
|
+
.clivly-chat-widget__topics {
|
|
486
|
+
display: flex;
|
|
487
|
+
flex-wrap: wrap;
|
|
488
|
+
gap: 0.5rem;
|
|
489
|
+
margin: 0;
|
|
490
|
+
padding: 0;
|
|
491
|
+
list-style: none;
|
|
492
|
+
}
|
|
493
|
+
.clivly-chat-widget__topic {
|
|
494
|
+
background: var(--clivly-chat-panel);
|
|
495
|
+
border: 1px solid var(--clivly-chat-accent);
|
|
496
|
+
border-radius: 999px;
|
|
497
|
+
color: var(--clivly-chat-text);
|
|
498
|
+
cursor: pointer;
|
|
499
|
+
font-size: 0.875rem;
|
|
500
|
+
min-height: 2.25rem;
|
|
501
|
+
padding: 0.4rem 0.85rem;
|
|
502
|
+
}
|
|
503
|
+
.clivly-chat-widget__topic:hover,
|
|
504
|
+
.clivly-chat-widget__topic:focus-visible {
|
|
505
|
+
background: var(--clivly-chat-visitor);
|
|
506
|
+
}
|
|
507
|
+
.clivly-chat-widget__topic:focus-visible {
|
|
508
|
+
outline: 2px solid var(--clivly-chat-accent);
|
|
509
|
+
outline-offset: 2px;
|
|
510
|
+
}
|
|
511
|
+
.clivly-chat-widget__topic[disabled] {
|
|
512
|
+
cursor: not-allowed;
|
|
513
|
+
opacity: 0.55;
|
|
514
|
+
}
|
|
375
515
|
.clivly-chat-widget__empty {
|
|
376
516
|
color: var(--clivly-chat-muted);
|
|
377
517
|
font-size: 0.875rem;
|
|
@@ -468,8 +608,15 @@ function useResolvedTheme(theme) {
|
|
|
468
608
|
* protocol reads as a single unit, and so `ChatWidget` stays inside the
|
|
469
609
|
* cognitive-complexity budget.
|
|
470
610
|
*/
|
|
471
|
-
function applyServerEvent(event,
|
|
611
|
+
function applyServerEvent(event, actions) {
|
|
612
|
+
const { setError, setIsReady, setMessages } = actions;
|
|
472
613
|
switch (event.type) {
|
|
614
|
+
case "assistant.status":
|
|
615
|
+
actions.setAssistantThinking(event.status === "thinking");
|
|
616
|
+
return;
|
|
617
|
+
case "support.state":
|
|
618
|
+
actions.setSupportState(event.state);
|
|
619
|
+
return;
|
|
473
620
|
case "conversation.snapshot":
|
|
474
621
|
setMessages(sortMessages(event.messages.map((message) => createLocalMessage(message))));
|
|
475
622
|
return;
|
|
@@ -490,14 +637,80 @@ function applyServerEvent(event, { setError, setIsReady, setMessages }) {
|
|
|
490
637
|
default: return;
|
|
491
638
|
}
|
|
492
639
|
}
|
|
640
|
+
/**
|
|
641
|
+
* What a message from the support assistant is called. Automated
|
|
642
|
+
* acknowledgements, AI answers and people are never presented alike, so a
|
|
643
|
+
* visitor is never led to think a person has read their message.
|
|
644
|
+
*/
|
|
645
|
+
const AUTOMATION_BADGES = {
|
|
646
|
+
ai: "AI assistant",
|
|
647
|
+
ai_clarify: "AI assistant",
|
|
648
|
+
greeting: "Automated",
|
|
649
|
+
handoff: "Automated",
|
|
650
|
+
topic: "Automated"
|
|
651
|
+
};
|
|
652
|
+
const AUTHOR_LABEL_SUFFIX = / · (?:Automated|AI assistant)$/;
|
|
653
|
+
function automationBadge(message) {
|
|
654
|
+
if (message.senderType !== "system" || !message.automation) return null;
|
|
655
|
+
return AUTOMATION_BADGES[message.automation] ?? "Automated";
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* The button that opens and closes the panel. Its size and shape come from the
|
|
659
|
+
* root's `data-clivly-launcher` (see WIDGET_STYLES); the label is always its
|
|
660
|
+
* accessible name, and `aria-controls` names the panel only while it exists.
|
|
661
|
+
*/
|
|
662
|
+
function Launcher({ label, onToggle, open, panelId }) {
|
|
663
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
664
|
+
"aria-controls": open ? panelId : void 0,
|
|
665
|
+
"aria-expanded": open,
|
|
666
|
+
className: "clivly-chat-widget__launcher",
|
|
667
|
+
onClick: onToggle,
|
|
668
|
+
type: "button",
|
|
669
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LauncherIcon, { open }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
670
|
+
className: "clivly-chat-widget__launcher-label",
|
|
671
|
+
children: label
|
|
672
|
+
})]
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* A chat bubble while closed, a cross while open — so the icon-only launcher
|
|
677
|
+
* still tells a sighted visitor what tapping it will do. Decorative: the
|
|
678
|
+
* label and `aria-expanded` carry the meaning for assistive technology.
|
|
679
|
+
* Inline SVG because the script-tag build renders inside a shadow root with no
|
|
680
|
+
* icon font or stylesheet of the host's to borrow.
|
|
681
|
+
*/
|
|
682
|
+
function LauncherIcon({ open }) {
|
|
683
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
|
|
684
|
+
"aria-hidden": "true",
|
|
685
|
+
className: "clivly-chat-widget__launcher-icon",
|
|
686
|
+
fill: "none",
|
|
687
|
+
focusable: "false",
|
|
688
|
+
stroke: "currentColor",
|
|
689
|
+
strokeLinecap: "round",
|
|
690
|
+
strokeLinejoin: "round",
|
|
691
|
+
strokeWidth: 2,
|
|
692
|
+
viewBox: "0 0 24 24",
|
|
693
|
+
children: open ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M18 6 6 18M6 6l12 12" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M7.9 20A9 9 0 1 0 4 16.1L2 22Z" })
|
|
694
|
+
});
|
|
695
|
+
}
|
|
493
696
|
function MessageBubble({ message }) {
|
|
697
|
+
const badge = automationBadge(message);
|
|
698
|
+
const author = badge ? (message.author ?? "").replace(AUTHOR_LABEL_SUFFIX, "") : message.author;
|
|
494
699
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("article", {
|
|
495
700
|
className: "clivly-chat-widget__message",
|
|
701
|
+
"data-automation": badge ? message.automation : void 0,
|
|
496
702
|
"data-sender": message.senderType,
|
|
497
703
|
children: [
|
|
498
704
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
499
705
|
className: "clivly-chat-widget__message-meta",
|
|
500
|
-
children: [
|
|
706
|
+
children: [
|
|
707
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: author || "Clivly" }),
|
|
708
|
+
badge ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
709
|
+
className: "clivly-chat-widget__badge",
|
|
710
|
+
children: badge
|
|
711
|
+
}) : null,
|
|
712
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: formatTimestamp(message.createdAt) })
|
|
713
|
+
]
|
|
501
714
|
}),
|
|
502
715
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: message.body }),
|
|
503
716
|
message.delivery === "sent" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
@@ -540,6 +753,7 @@ function awaitingReplySince(messages) {
|
|
|
540
753
|
for (let index = messages.length - 1; index >= 0; index--) {
|
|
541
754
|
const message = messages[index];
|
|
542
755
|
if (!message || message.delivery === "failed") continue;
|
|
756
|
+
if (message.senderType === "system" || message.automation === "topic_choice") continue;
|
|
543
757
|
if (message.senderType !== "visitor") return null;
|
|
544
758
|
return new Date(message.createdAt).getTime();
|
|
545
759
|
}
|
|
@@ -589,60 +803,148 @@ function isCompleteVisitor(visitor) {
|
|
|
589
803
|
* the visitor is told to wait for something that will never arrive. The third
|
|
590
804
|
* string keeps the status honest in all three states.
|
|
591
805
|
*/
|
|
592
|
-
function composerStatusText(hasTransport, isReady) {
|
|
806
|
+
function composerStatusText(hasTransport, isReady, reconnecting) {
|
|
593
807
|
if (!hasTransport) return "Chat is unavailable";
|
|
594
|
-
|
|
808
|
+
if (isReady) return "Conversation ready";
|
|
809
|
+
return reconnecting ? "Reconnecting…" : "Connecting…";
|
|
595
810
|
}
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
811
|
+
/**
|
|
812
|
+
* Guided options under the greeting. Native buttons in a labelled group, so
|
|
813
|
+
* they are reachable with Tab and operable with Enter/Space; free text stays
|
|
814
|
+
* available beside them the whole time.
|
|
815
|
+
*/
|
|
816
|
+
function SupportTopics({ disabled, onSelect, topics }) {
|
|
817
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
|
|
818
|
+
"aria-label": "Suggested topics",
|
|
819
|
+
className: "clivly-chat-widget__topics",
|
|
820
|
+
children: topics.map((topic) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
821
|
+
className: "clivly-chat-widget__topic",
|
|
822
|
+
disabled,
|
|
823
|
+
onClick: () => {
|
|
824
|
+
onSelect(topic);
|
|
825
|
+
},
|
|
826
|
+
type: "button",
|
|
827
|
+
children: topic.label
|
|
828
|
+
}) }, topic.id))
|
|
607
829
|
});
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
830
|
+
}
|
|
831
|
+
/** Topics are offered only while automation is answering this conversation. */
|
|
832
|
+
function visibleTopics(session, state) {
|
|
833
|
+
return state === "automated" ? session?.support?.topics ?? [] : [];
|
|
834
|
+
}
|
|
835
|
+
function composerPlaceholder(hasTopics) {
|
|
836
|
+
return hasTopics ? "Or type your question" : "Write your message";
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Hand one visitor message to the transport: a topic click as `topic.select`
|
|
840
|
+
* when the transport supports it (the server resolves the answer), otherwise
|
|
841
|
+
* as the label, typed. Throws what the transport throws.
|
|
842
|
+
*/
|
|
843
|
+
function dispatchVisitorMessage(transport, clientMessageId, body, topic) {
|
|
844
|
+
if (topic && transport.selectTopic) {
|
|
845
|
+
transport.selectTopic({
|
|
846
|
+
id: clientMessageId,
|
|
847
|
+
topicId: topic.id,
|
|
848
|
+
type: "topic.select"
|
|
849
|
+
});
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
transport.sendMessage({
|
|
853
|
+
body,
|
|
854
|
+
id: clientMessageId,
|
|
855
|
+
type: "message.create"
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
/**
|
|
859
|
+
* One honest status line: the AI is working on an answer, or — once the
|
|
860
|
+
* visitor has waited — that the team has been told. Never both.
|
|
861
|
+
*/
|
|
862
|
+
function StatusNotice({ assistantThinking, email, showWaitingNotice }) {
|
|
863
|
+
if (assistantThinking) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
864
|
+
className: "clivly-chat-widget__notice",
|
|
865
|
+
role: "status",
|
|
866
|
+
children: "The AI assistant is looking for an answer…"
|
|
867
|
+
});
|
|
868
|
+
if (!showWaitingNotice) return null;
|
|
869
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
870
|
+
className: "clivly-chat-widget__notice",
|
|
871
|
+
role: "status",
|
|
872
|
+
children: waitingNoticeText(email)
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
const RECONNECT_BASE_MS = 1e3;
|
|
876
|
+
const RECONNECT_MAX_MS = 3e4;
|
|
877
|
+
/**
|
|
878
|
+
* How long to wait before reconnect attempt `attempt` (0-based): doubling from
|
|
879
|
+
* one second to a 30-second ceiling, ±25% jitter so a fleet of widgets dropped
|
|
880
|
+
* by the same server restart doesn't reconnect in lockstep.
|
|
881
|
+
*/
|
|
882
|
+
function reconnectDelayMs(attempt, random = Math.random) {
|
|
883
|
+
const base = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** attempt);
|
|
884
|
+
return Math.round(base * (.75 + random() * .5));
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* Keep the socket for `session` open, and reconnect when it drops — a laptop
|
|
888
|
+
* waking, a network change, a server deploy — instead of leaving the visitor at
|
|
889
|
+
* "Connecting…" until they reload. A reconnect refreshes the session (same
|
|
890
|
+
* conversation, fresh token) and the server's snapshot on connect brings back
|
|
891
|
+
* anything that arrived meanwhile. Returns whether a reconnect is pending.
|
|
892
|
+
*/
|
|
893
|
+
function useLiveConnection({ actions, createTransport, refreshSession, session, setIsReady, transportRef }) {
|
|
894
|
+
const [reconnecting, setReconnecting] = (0, react.useState)(false);
|
|
895
|
+
const attemptRef = (0, react.useRef)(0);
|
|
896
|
+
const refreshRef = (0, react.useRef)(refreshSession);
|
|
897
|
+
const actionsRef = (0, react.useRef)(actions);
|
|
621
898
|
(0, react.useEffect)(() => {
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
}
|
|
899
|
+
refreshRef.current = refreshSession;
|
|
900
|
+
actionsRef.current = actions;
|
|
901
|
+
});
|
|
625
902
|
(0, react.useEffect)(() => {
|
|
626
903
|
transportRef.current?.close();
|
|
627
904
|
transportRef.current = null;
|
|
628
905
|
setIsReady(false);
|
|
629
906
|
if (!(session && createTransport)) return;
|
|
907
|
+
let disposed = false;
|
|
908
|
+
let timer;
|
|
909
|
+
const retry = () => {
|
|
910
|
+
timer = void 0;
|
|
911
|
+
refreshRef.current(session).catch(() => {
|
|
912
|
+
scheduleReconnect();
|
|
913
|
+
});
|
|
914
|
+
};
|
|
915
|
+
const scheduleReconnect = () => {
|
|
916
|
+
if (disposed || timer !== void 0) return;
|
|
917
|
+
setReconnecting(true);
|
|
918
|
+
timer = setTimeout(retry, reconnectDelayMs(attemptRef.current));
|
|
919
|
+
attemptRef.current += 1;
|
|
920
|
+
};
|
|
921
|
+
const retryNow = () => {
|
|
922
|
+
if (timer !== void 0) {
|
|
923
|
+
clearTimeout(timer);
|
|
924
|
+
retry();
|
|
925
|
+
}
|
|
926
|
+
};
|
|
927
|
+
window.addEventListener("online", retryNow);
|
|
630
928
|
const connection = createTransport({
|
|
631
929
|
handlers: {
|
|
632
930
|
onClose: () => {
|
|
633
931
|
setIsReady(false);
|
|
932
|
+
actionsRef.current.setAssistantThinking(false);
|
|
933
|
+
scheduleReconnect();
|
|
634
934
|
},
|
|
635
935
|
onError: (transportError) => {
|
|
636
|
-
setError(transportError.message);
|
|
936
|
+
if (attemptRef.current === 0) actionsRef.current.setError(transportError.message);
|
|
637
937
|
},
|
|
638
938
|
onEvent: (event) => {
|
|
639
939
|
applyServerEvent(event, {
|
|
640
|
-
|
|
641
|
-
setIsReady
|
|
642
|
-
setMessages
|
|
940
|
+
...actionsRef.current,
|
|
941
|
+
setIsReady
|
|
643
942
|
});
|
|
644
943
|
},
|
|
645
944
|
onOpen: () => {
|
|
945
|
+
if (attemptRef.current > 0) actionsRef.current.setError(null);
|
|
946
|
+
attemptRef.current = 0;
|
|
947
|
+
setReconnecting(false);
|
|
646
948
|
setIsReady(true);
|
|
647
949
|
}
|
|
648
950
|
},
|
|
@@ -650,10 +952,72 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
650
952
|
});
|
|
651
953
|
transportRef.current = connection;
|
|
652
954
|
return () => {
|
|
955
|
+
disposed = true;
|
|
956
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
957
|
+
window.removeEventListener("online", retryNow);
|
|
653
958
|
connection.close();
|
|
654
959
|
if (transportRef.current === connection) transportRef.current = null;
|
|
655
960
|
};
|
|
656
961
|
}, [createTransport, session]);
|
|
962
|
+
return reconnecting;
|
|
963
|
+
}
|
|
964
|
+
function ChatWidget({ className, createTransport, defaultVisitor, getSession, initiallyOpen = false, launcher = "auto", launcherLabel = "Chat with us", placement = "bottom-right", storage = DEFAULT_STORAGE, style, theme = "auto", widgetId }) {
|
|
965
|
+
const [isOpen, setIsOpen] = (0, react.useState)(initiallyOpen);
|
|
966
|
+
const panelId = (0, react.useId)();
|
|
967
|
+
const [error, setError] = (0, react.useState)(null);
|
|
968
|
+
const [isConnecting, setIsConnecting] = (0, react.useState)(false);
|
|
969
|
+
const [isReady, setIsReady] = (0, react.useState)(false);
|
|
970
|
+
const [messageDraft, setMessageDraft] = (0, react.useState)("");
|
|
971
|
+
const [messages, setMessages] = (0, react.useState)([]);
|
|
972
|
+
const [session, setSession] = (0, react.useState)(null);
|
|
973
|
+
const [supportState, setSupportState] = (0, react.useState)(null);
|
|
974
|
+
const [assistantThinking, setAssistantThinking] = (0, react.useState)(false);
|
|
975
|
+
const [visitor, setVisitor] = (0, react.useState)({
|
|
976
|
+
email: defaultVisitor?.email ?? "",
|
|
977
|
+
name: defaultVisitor?.name ?? ""
|
|
978
|
+
});
|
|
979
|
+
const transportRef = (0, react.useRef)(null);
|
|
980
|
+
const listRef = (0, react.useRef)(null);
|
|
981
|
+
const identityRef = (0, react.useRef)(null);
|
|
982
|
+
(0, react.useEffect)(() => {
|
|
983
|
+
if (createTransport) return;
|
|
984
|
+
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");
|
|
985
|
+
}, []);
|
|
986
|
+
const showWaitingNotice = useWaitingNotice(messages);
|
|
987
|
+
const themeStyle = useThemeStyle(session, style);
|
|
988
|
+
const resolvedTheme = useResolvedTheme(theme);
|
|
989
|
+
(0, react.useEffect)(() => () => {
|
|
990
|
+
transportRef.current?.close();
|
|
991
|
+
transportRef.current = null;
|
|
992
|
+
}, []);
|
|
993
|
+
(0, react.useEffect)(() => {
|
|
994
|
+
if (!listRef.current) return;
|
|
995
|
+
listRef.current.scrollTop = listRef.current.scrollHeight;
|
|
996
|
+
}, [messages]);
|
|
997
|
+
const reconnecting = useLiveConnection({
|
|
998
|
+
actions: {
|
|
999
|
+
setAssistantThinking,
|
|
1000
|
+
setError,
|
|
1001
|
+
setMessages,
|
|
1002
|
+
setSupportState
|
|
1003
|
+
},
|
|
1004
|
+
createTransport,
|
|
1005
|
+
refreshSession: async (current) => {
|
|
1006
|
+
const identity = identityRef.current;
|
|
1007
|
+
if (!identity) throw new Error("No visitor to reconnect as.");
|
|
1008
|
+
const next = await getSession({
|
|
1009
|
+
visitor: identity,
|
|
1010
|
+
visitorToken: current.visitorToken,
|
|
1011
|
+
widgetId
|
|
1012
|
+
});
|
|
1013
|
+
storage?.setItem(createStorageKey(widgetId), next.visitorToken);
|
|
1014
|
+
setSupportState(next.support?.state ?? null);
|
|
1015
|
+
setSession(next);
|
|
1016
|
+
},
|
|
1017
|
+
session,
|
|
1018
|
+
setIsReady,
|
|
1019
|
+
transportRef
|
|
1020
|
+
});
|
|
657
1021
|
(0, react.useEffect)(() => {
|
|
658
1022
|
if (!(isOpen && isReady)) return;
|
|
659
1023
|
const markReadIfVisible = () => {
|
|
@@ -681,6 +1045,8 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
681
1045
|
widgetId
|
|
682
1046
|
});
|
|
683
1047
|
storage?.setItem(createStorageKey(widgetId), nextSession.visitorToken);
|
|
1048
|
+
identityRef.current = identity;
|
|
1049
|
+
setSupportState(nextSession.support?.state ?? null);
|
|
684
1050
|
setSession(nextSession);
|
|
685
1051
|
setMessages(sortMessages(nextSession.messages.map((message) => createLocalMessage(message))));
|
|
686
1052
|
} catch (sessionError) {
|
|
@@ -713,58 +1079,68 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
713
1079
|
session,
|
|
714
1080
|
startSession
|
|
715
1081
|
]);
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
1082
|
+
/**
|
|
1083
|
+
* Show the visitor's message at once and hand it to the transport. A topic
|
|
1084
|
+
* click goes as `topic.select` when the transport supports it, so the
|
|
1085
|
+
* server resolves the answer; otherwise as the label, typed.
|
|
1086
|
+
*/
|
|
1087
|
+
const sendVisitorMessage = (body, topic) => {
|
|
1088
|
+
const transport = transportRef.current;
|
|
1089
|
+
if (!(session && transport)) {
|
|
719
1090
|
setError("Chat is not connected yet.");
|
|
720
1091
|
return;
|
|
721
1092
|
}
|
|
722
|
-
const body = messageDraft.trim();
|
|
723
|
-
if (!body) return;
|
|
724
1093
|
const clientMessageId = generateId();
|
|
725
1094
|
const optimisticMessage = {
|
|
726
1095
|
author: sessionDisplayName(session),
|
|
1096
|
+
automation: topic && transport.selectTopic ? "topic_choice" : null,
|
|
727
1097
|
body,
|
|
728
1098
|
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
729
1099
|
id: clientMessageId,
|
|
730
1100
|
senderType: "visitor"
|
|
731
1101
|
};
|
|
732
1102
|
setMessages((currentMessages) => sortMessages([...currentMessages, createLocalMessage(optimisticMessage, "pending")]));
|
|
733
|
-
setMessageDraft("");
|
|
734
1103
|
setError(null);
|
|
735
1104
|
try {
|
|
736
|
-
|
|
737
|
-
body,
|
|
738
|
-
id: clientMessageId,
|
|
739
|
-
type: "message.create"
|
|
740
|
-
});
|
|
1105
|
+
dispatchVisitorMessage(transport, clientMessageId, body, topic);
|
|
741
1106
|
} catch (transportError) {
|
|
742
1107
|
const resolved = transportError instanceof Error ? transportError : /* @__PURE__ */ new Error("Failed to send your message.");
|
|
743
1108
|
setMessages((currentMessages) => markMessageFailed(currentMessages, clientMessageId));
|
|
744
1109
|
setError(resolved.message);
|
|
745
1110
|
}
|
|
746
1111
|
};
|
|
1112
|
+
const submitMessage = (event) => {
|
|
1113
|
+
event?.preventDefault();
|
|
1114
|
+
const body = messageDraft.trim();
|
|
1115
|
+
if (!body) return;
|
|
1116
|
+
setMessageDraft("");
|
|
1117
|
+
sendVisitorMessage(body);
|
|
1118
|
+
};
|
|
747
1119
|
const canSend = Boolean(createTransport) && isReady;
|
|
748
1120
|
const greeting = session?.widget.config.greeting ?? "Tell us a little about yourself and we’ll pick up the conversation from there.";
|
|
749
1121
|
const panelTitle = session?.widget.name ?? "Start a conversation";
|
|
750
1122
|
const sessionSummary = sessionSummaryText(session, greeting);
|
|
1123
|
+
const topics = visibleTopics(session, supportState);
|
|
751
1124
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
752
1125
|
className: `clivly-chat-widget ${className ?? ""}`.trim(),
|
|
1126
|
+
"data-clivly-launcher": launcher,
|
|
1127
|
+
"data-clivly-placement": placement,
|
|
753
1128
|
"data-clivly-theme": resolvedTheme,
|
|
754
1129
|
style: themeStyle,
|
|
755
1130
|
children: [
|
|
756
1131
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: WIDGET_STYLES }),
|
|
757
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(
|
|
758
|
-
|
|
759
|
-
|
|
1132
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Launcher, {
|
|
1133
|
+
label: launcherLabel,
|
|
1134
|
+
onToggle: () => {
|
|
760
1135
|
setIsOpen((open) => !open);
|
|
761
1136
|
},
|
|
762
|
-
|
|
763
|
-
|
|
1137
|
+
open: isOpen,
|
|
1138
|
+
panelId
|
|
764
1139
|
}),
|
|
765
1140
|
isOpen ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
|
|
766
1141
|
"aria-label": panelTitle,
|
|
767
1142
|
className: "clivly-chat-widget__panel",
|
|
1143
|
+
id: panelId,
|
|
768
1144
|
children: [
|
|
769
1145
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("header", {
|
|
770
1146
|
className: "clivly-chat-widget__header",
|
|
@@ -785,8 +1161,10 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
785
1161
|
})]
|
|
786
1162
|
}),
|
|
787
1163
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1164
|
+
"aria-label": "Conversation",
|
|
788
1165
|
className: "clivly-chat-widget__body",
|
|
789
1166
|
ref: listRef,
|
|
1167
|
+
role: "log",
|
|
790
1168
|
children: [
|
|
791
1169
|
error ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
792
1170
|
className: "clivly-chat-widget__error",
|
|
@@ -797,10 +1175,17 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
797
1175
|
hasSession: Boolean(session),
|
|
798
1176
|
messages
|
|
799
1177
|
}),
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
1178
|
+
topics.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SupportTopics, {
|
|
1179
|
+
disabled: !canSend,
|
|
1180
|
+
onSelect: (topic) => {
|
|
1181
|
+
sendVisitorMessage(topic.label, topic);
|
|
1182
|
+
},
|
|
1183
|
+
topics
|
|
1184
|
+
}) : null,
|
|
1185
|
+
session ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(StatusNotice, {
|
|
1186
|
+
assistantThinking,
|
|
1187
|
+
email: session.contact?.email,
|
|
1188
|
+
showWaitingNotice
|
|
804
1189
|
}) : null
|
|
805
1190
|
]
|
|
806
1191
|
}),
|
|
@@ -822,14 +1207,15 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
822
1207
|
event.preventDefault();
|
|
823
1208
|
if (messageDraft.trim() && canSend) submitMessage();
|
|
824
1209
|
},
|
|
825
|
-
placeholder:
|
|
1210
|
+
placeholder: composerPlaceholder(topics.length > 0),
|
|
826
1211
|
value: messageDraft
|
|
827
1212
|
})]
|
|
828
1213
|
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
829
1214
|
className: "clivly-chat-widget__actions",
|
|
830
1215
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1216
|
+
"aria-live": "polite",
|
|
831
1217
|
className: "clivly-chat-widget__subtitle",
|
|
832
|
-
children: composerStatusText(Boolean(createTransport), isReady)
|
|
1218
|
+
children: composerStatusText(Boolean(createTransport), isReady, reconnecting)
|
|
833
1219
|
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
834
1220
|
className: "clivly-chat-widget__button",
|
|
835
1221
|
disabled: !(messageDraft.trim() && canSend),
|
|
@@ -948,6 +1334,10 @@ function createWebSocketTransport({ createSocket = (url, protocols) => new WebSo
|
|
|
948
1334
|
markRead: () => {
|
|
949
1335
|
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: "conversation.read" }));
|
|
950
1336
|
},
|
|
1337
|
+
selectTopic: (event) => {
|
|
1338
|
+
if (socket.readyState !== WebSocket.OPEN) throw new Error("Chat socket is not connected.");
|
|
1339
|
+
socket.send(JSON.stringify(event));
|
|
1340
|
+
},
|
|
951
1341
|
sendMessage: (message) => {
|
|
952
1342
|
if (socket.readyState !== WebSocket.OPEN) throw new Error("Chat socket is not connected.");
|
|
953
1343
|
socket.send(JSON.stringify(message));
|
|
@@ -974,6 +1364,26 @@ function createClivlyTransport(options = {}) {
|
|
|
974
1364
|
});
|
|
975
1365
|
}
|
|
976
1366
|
//#endregion
|
|
1367
|
+
//#region src/layout.ts
|
|
1368
|
+
const PLACEMENTS = [
|
|
1369
|
+
"bottom-left",
|
|
1370
|
+
"bottom-right",
|
|
1371
|
+
"inline"
|
|
1372
|
+
];
|
|
1373
|
+
const LAUNCHERS = [
|
|
1374
|
+
"auto",
|
|
1375
|
+
"icon",
|
|
1376
|
+
"pill"
|
|
1377
|
+
];
|
|
1378
|
+
/** A script tag's `data-placement`; anything unrecognised floats bottom-right. */
|
|
1379
|
+
function parsePlacement(value) {
|
|
1380
|
+
return PLACEMENTS.find((placement) => placement === value) ?? "bottom-right";
|
|
1381
|
+
}
|
|
1382
|
+
/** A script tag's `data-launcher`; anything unrecognised is `"auto"`. */
|
|
1383
|
+
function parseLauncher(value) {
|
|
1384
|
+
return LAUNCHERS.find((launcher) => launcher === value) ?? "auto";
|
|
1385
|
+
}
|
|
1386
|
+
//#endregion
|
|
977
1387
|
//#region src/session.ts
|
|
978
1388
|
function isWidgetSessionPayload(value) {
|
|
979
1389
|
return typeof value === "object" && value !== null;
|
|
@@ -1071,6 +1481,8 @@ function mountChatWidgetScript$1(script = document.currentScript) {
|
|
|
1071
1481
|
const { root, setTheme } = createWidgetMount({
|
|
1072
1482
|
container,
|
|
1073
1483
|
defaultVisitor: readScriptVisitor(script.dataset),
|
|
1484
|
+
launcher: parseLauncher(script.dataset.launcher),
|
|
1485
|
+
placement: parsePlacement(script.dataset.placement),
|
|
1074
1486
|
sessionUrl,
|
|
1075
1487
|
socketUrlTemplate: script.dataset.socketUrlTemplate,
|
|
1076
1488
|
theme: parseTheme(script.dataset.theme),
|