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