@clivly/chat-widget 0.10.9 → 0.11.0-next.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 +87 -4
- package/dist/browser-mount.cjs +108 -12
- package/dist/browser-mount.d.cts +18 -0
- package/dist/browser-mount.d.mts +18 -0
- package/dist/browser-mount.mjs +109 -13
- package/dist/browser.cjs +108 -12
- package/dist/browser.d.cts +18 -0
- package/dist/browser.d.mts +18 -0
- package/dist/browser.mjs +109 -13
- package/dist/index.cjs +108 -12
- package/dist/index.d.cts +19 -1
- package/dist/index.d.mts +19 -1
- package/dist/index.mjs +109 -13
- package/dist/widget.global.js +105 -83
- package/package.json +2 -2
package/dist/browser.cjs
CHANGED
|
@@ -256,9 +256,31 @@ const WIDGET_STYLES = `
|
|
|
256
256
|
padding: 0.5rem 1rem;
|
|
257
257
|
text-align: center;
|
|
258
258
|
}
|
|
259
|
+
/* The attribution is Clivly's mark, not the host's, so it uses the stops from
|
|
260
|
+
the logo itself rather than --clivly-chat-accent: the accent is driven by
|
|
261
|
+
each customer's own brandColor, and a signature that changed colour per
|
|
262
|
+
customer would stop reading as a signature.
|
|
263
|
+
|
|
264
|
+
The color declaration is the fallback and must stay a real colour:
|
|
265
|
+
background-clip is painted over it, so anything that drops the background —
|
|
266
|
+
forced-colors, an older engine, a copied-out snippet — degrades to solid
|
|
267
|
+
coral rather than to invisible text. */
|
|
259
268
|
.clivly-chat-widget__powered a {
|
|
260
|
-
|
|
269
|
+
background-image: linear-gradient(90deg, #ff5a4a 0%, #c0231a 100%);
|
|
270
|
+
-webkit-background-clip: text;
|
|
271
|
+
background-clip: text;
|
|
272
|
+
color: #c0231a;
|
|
261
273
|
text-decoration: none;
|
|
274
|
+
-webkit-text-fill-color: transparent;
|
|
275
|
+
}
|
|
276
|
+
/* Forced-colors mode strips background-image outright, which would leave the
|
|
277
|
+
text painted in a transparent fill. Hand the colour back to the UA. */
|
|
278
|
+
@media (forced-colors: active) {
|
|
279
|
+
.clivly-chat-widget__powered a {
|
|
280
|
+
background-image: none;
|
|
281
|
+
color: LinkText;
|
|
282
|
+
-webkit-text-fill-color: currentColor;
|
|
283
|
+
}
|
|
262
284
|
}
|
|
263
285
|
.clivly-chat-widget__powered a:hover,
|
|
264
286
|
.clivly-chat-widget__powered a:focus-visible {
|
|
@@ -483,7 +505,27 @@ function MessageList({ greeting, hasSession, messages }) {
|
|
|
483
505
|
});
|
|
484
506
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_jsx_runtime.Fragment, { children: messages.map((message) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MessageBubble, { message }, message.id)) });
|
|
485
507
|
}
|
|
486
|
-
|
|
508
|
+
/**
|
|
509
|
+
* Whether a seeded visitor is complete enough to skip the form.
|
|
510
|
+
*
|
|
511
|
+
* Both fields have to be present and non-blank: the server requires each, so
|
|
512
|
+
* auto-starting on a half-filled identity would trade a form the visitor can
|
|
513
|
+
* complete for an error they cannot.
|
|
514
|
+
*/
|
|
515
|
+
function isCompleteVisitor(visitor) {
|
|
516
|
+
return Boolean(visitor?.name?.trim() && visitor?.email?.trim());
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* What the composer says about itself. "Connecting…" claims a socket is in
|
|
520
|
+
* flight, so it is as wrong as "Conversation ready" when no transport exists —
|
|
521
|
+
* the visitor is told to wait for something that will never arrive. The third
|
|
522
|
+
* string keeps the status honest in all three states.
|
|
523
|
+
*/
|
|
524
|
+
function composerStatusText(hasTransport, isReady) {
|
|
525
|
+
if (!hasTransport) return "Chat is unavailable";
|
|
526
|
+
return isReady ? "Conversation ready" : "Connecting…";
|
|
527
|
+
}
|
|
528
|
+
function ChatWidget({ className, createTransport, defaultVisitor, getSession, initiallyOpen = false, launcherLabel = "Chat with us", storage = DEFAULT_STORAGE, style, theme = "auto", widgetId }) {
|
|
487
529
|
const [isOpen, setIsOpen] = (0, react.useState)(initiallyOpen);
|
|
488
530
|
const [error, setError] = (0, react.useState)(null);
|
|
489
531
|
const [isConnecting, setIsConnecting] = (0, react.useState)(false);
|
|
@@ -492,11 +534,15 @@ function ChatWidget({ className, createTransport, getSession, initiallyOpen = fa
|
|
|
492
534
|
const [messages, setMessages] = (0, react.useState)([]);
|
|
493
535
|
const [session, setSession] = (0, react.useState)(null);
|
|
494
536
|
const [visitor, setVisitor] = (0, react.useState)({
|
|
495
|
-
email: "",
|
|
496
|
-
name: ""
|
|
537
|
+
email: defaultVisitor?.email ?? "",
|
|
538
|
+
name: defaultVisitor?.name ?? ""
|
|
497
539
|
});
|
|
498
540
|
const transportRef = (0, react.useRef)(null);
|
|
499
541
|
const listRef = (0, react.useRef)(null);
|
|
542
|
+
(0, react.useEffect)(() => {
|
|
543
|
+
if (createTransport) return;
|
|
544
|
+
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");
|
|
545
|
+
}, []);
|
|
500
546
|
const themeStyle = useThemeStyle(session, style);
|
|
501
547
|
const resolvedTheme = useResolvedTheme(theme);
|
|
502
548
|
(0, react.useEffect)(() => () => {
|
|
@@ -539,15 +585,13 @@ function ChatWidget({ className, createTransport, getSession, initiallyOpen = fa
|
|
|
539
585
|
if (transportRef.current === connection) transportRef.current = null;
|
|
540
586
|
};
|
|
541
587
|
}, [createTransport, session]);
|
|
542
|
-
const
|
|
543
|
-
event.preventDefault();
|
|
588
|
+
const startSession = (0, react.useCallback)(async (identity) => {
|
|
544
589
|
setError(null);
|
|
545
590
|
setIsConnecting(true);
|
|
546
591
|
try {
|
|
547
|
-
const existingVisitorToken = storage?.getItem(createStorageKey(widgetId)) ?? null;
|
|
548
592
|
const nextSession = await getSession({
|
|
549
|
-
visitor,
|
|
550
|
-
visitorToken:
|
|
593
|
+
visitor: identity,
|
|
594
|
+
visitorToken: storage?.getItem(createStorageKey(widgetId)) ?? null,
|
|
551
595
|
widgetId
|
|
552
596
|
});
|
|
553
597
|
storage?.setItem(createStorageKey(widgetId), nextSession.visitorToken);
|
|
@@ -559,7 +603,30 @@ function ChatWidget({ className, createTransport, getSession, initiallyOpen = fa
|
|
|
559
603
|
} finally {
|
|
560
604
|
setIsConnecting(false);
|
|
561
605
|
}
|
|
606
|
+
}, [
|
|
607
|
+
getSession,
|
|
608
|
+
storage,
|
|
609
|
+
widgetId
|
|
610
|
+
]);
|
|
611
|
+
const submitVisitor = async (event) => {
|
|
612
|
+
event.preventDefault();
|
|
613
|
+
await startSession(visitor);
|
|
562
614
|
};
|
|
615
|
+
(0, react.useEffect)(() => {
|
|
616
|
+
if (!(isOpen && isCompleteVisitor(defaultVisitor))) return;
|
|
617
|
+
if (session || isConnecting || error) return;
|
|
618
|
+
startSession({
|
|
619
|
+
email: defaultVisitor.email.trim(),
|
|
620
|
+
name: defaultVisitor.name.trim()
|
|
621
|
+
}).catch(() => {});
|
|
622
|
+
}, [
|
|
623
|
+
defaultVisitor,
|
|
624
|
+
error,
|
|
625
|
+
isConnecting,
|
|
626
|
+
isOpen,
|
|
627
|
+
session,
|
|
628
|
+
startSession
|
|
629
|
+
]);
|
|
563
630
|
const submitMessage = (event) => {
|
|
564
631
|
event?.preventDefault();
|
|
565
632
|
if (!(session && transportRef.current)) {
|
|
@@ -591,6 +658,7 @@ function ChatWidget({ className, createTransport, getSession, initiallyOpen = fa
|
|
|
591
658
|
setError(resolved.message);
|
|
592
659
|
}
|
|
593
660
|
};
|
|
661
|
+
const canSend = Boolean(createTransport) && isReady;
|
|
594
662
|
const greeting = session?.widget.config.greeting ?? "Tell us a little about yourself and we’ll pick up the conversation from there.";
|
|
595
663
|
const panelTitle = session?.widget.name ?? "Start a conversation";
|
|
596
664
|
const sessionSummary = sessionSummaryText(session, greeting);
|
|
@@ -658,7 +726,7 @@ function ChatWidget({ className, createTransport, getSession, initiallyOpen = fa
|
|
|
658
726
|
onKeyDown: (event) => {
|
|
659
727
|
if (!isSendKey(event)) return;
|
|
660
728
|
event.preventDefault();
|
|
661
|
-
if (messageDraft.trim() &&
|
|
729
|
+
if (messageDraft.trim() && canSend) submitMessage();
|
|
662
730
|
},
|
|
663
731
|
placeholder: "Write your message",
|
|
664
732
|
value: messageDraft
|
|
@@ -667,10 +735,10 @@ function ChatWidget({ className, createTransport, getSession, initiallyOpen = fa
|
|
|
667
735
|
className: "clivly-chat-widget__actions",
|
|
668
736
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
669
737
|
className: "clivly-chat-widget__subtitle",
|
|
670
|
-
children:
|
|
738
|
+
children: composerStatusText(Boolean(createTransport), isReady)
|
|
671
739
|
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
672
740
|
className: "clivly-chat-widget__button",
|
|
673
|
-
disabled: !(messageDraft.trim() &&
|
|
741
|
+
disabled: !(messageDraft.trim() && canSend),
|
|
674
742
|
type: "submit",
|
|
675
743
|
children: "Send"
|
|
676
744
|
})]
|
|
@@ -827,6 +895,33 @@ function createSessionFetcher({ fetchImpl = fetch, sessionUrl }) {
|
|
|
827
895
|
//#endregion
|
|
828
896
|
//#region src/browser-mount.tsx
|
|
829
897
|
const MOUNTED_DATA_ATTR = "clivlyMounted";
|
|
898
|
+
/**
|
|
899
|
+
* The identity a script-tag host already knows, read off the tag.
|
|
900
|
+
*
|
|
901
|
+
* The React component takes `defaultVisitor` as a prop; a script tag has only
|
|
902
|
+
* strings, so `data-visitor-name` / `data-visitor-email` are the same seam. A
|
|
903
|
+
* host that server-renders its page can template the signed-in user's details
|
|
904
|
+
* straight into the tag and the visitor never sees the pre-chat form.
|
|
905
|
+
*
|
|
906
|
+
* Returns `undefined` rather than an empty object when neither attribute is
|
|
907
|
+
* present, so the component's own "no identity" path stays untouched. Partial
|
|
908
|
+
* values are passed through deliberately: `ChatWidget` treats one field as a
|
|
909
|
+
* prefill and still renders the form, which is the right outcome for a host
|
|
910
|
+
* that knows an email but not a name.
|
|
911
|
+
*
|
|
912
|
+
* This is self-reported identity — anyone can edit the attribute in devtools.
|
|
913
|
+
* `resolveUser` on the host backend is what makes it trustworthy; this only
|
|
914
|
+
* removes the typing.
|
|
915
|
+
*/
|
|
916
|
+
function readScriptVisitor(dataset) {
|
|
917
|
+
const email = dataset.visitorEmail?.trim();
|
|
918
|
+
const name = dataset.visitorName?.trim();
|
|
919
|
+
if (!(email || name)) return;
|
|
920
|
+
return {
|
|
921
|
+
...email ? { email } : {},
|
|
922
|
+
...name ? { name } : {}
|
|
923
|
+
};
|
|
924
|
+
}
|
|
830
925
|
function resolveSocketUrl(socketUrlTemplate, conversationId, token) {
|
|
831
926
|
return socketUrlTemplate.replaceAll("{conversationId}", encodeURIComponent(conversationId)).replaceAll("{token}", encodeURIComponent(token));
|
|
832
927
|
}
|
|
@@ -871,6 +966,7 @@ function mountChatWidgetScript$1(script = document.currentScript) {
|
|
|
871
966
|
shadowRoot.append(container);
|
|
872
967
|
const { root, setTheme } = createWidgetMount({
|
|
873
968
|
container,
|
|
969
|
+
defaultVisitor: readScriptVisitor(script.dataset),
|
|
874
970
|
sessionUrl,
|
|
875
971
|
socketUrlTemplate: script.dataset.socketUrlTemplate,
|
|
876
972
|
theme: parseTheme(script.dataset.theme),
|
package/dist/browser.d.cts
CHANGED
|
@@ -110,6 +110,24 @@ type CreateChatTransport = (options: CreateChatTransportOptions) => ChatTranspor
|
|
|
110
110
|
interface ChatWidgetProps {
|
|
111
111
|
className?: string;
|
|
112
112
|
createTransport?: CreateChatTransport;
|
|
113
|
+
/**
|
|
114
|
+
* Identity the host already knows, used to skip the pre-chat form.
|
|
115
|
+
*
|
|
116
|
+
* When both `name` and `email` are non-empty the widget opens straight into
|
|
117
|
+
* the conversation and bootstraps the session itself, instead of asking a
|
|
118
|
+
* signed-in user to retype what the host app already displays in its own
|
|
119
|
+
* header. A partial value (one field, or blanks) is treated as a prefill:
|
|
120
|
+
* the form still renders, with the known fields filled in.
|
|
121
|
+
*
|
|
122
|
+
* This seeds the *form*, and a form value is self-reported no matter who
|
|
123
|
+
* typed it. It is not, on its own, proof of identity — anyone can pass any
|
|
124
|
+
* address here. Verification is a separate, server-side mechanism: pass
|
|
125
|
+
* `resolveUser` to `createChatSessionHandler` on the host backend and it
|
|
126
|
+
* resolves the caller's real session behind the API key, which is what marks
|
|
127
|
+
* the resulting contact verified. Use both together for a signed-in user:
|
|
128
|
+
* this prop removes the typing, `resolveUser` is what makes it trustworthy.
|
|
129
|
+
*/
|
|
130
|
+
defaultVisitor?: Partial<ChatWidgetVisitor>;
|
|
113
131
|
getSession: GetSession;
|
|
114
132
|
initiallyOpen?: boolean;
|
|
115
133
|
launcherLabel?: string;
|
package/dist/browser.d.mts
CHANGED
|
@@ -110,6 +110,24 @@ type CreateChatTransport = (options: CreateChatTransportOptions) => ChatTranspor
|
|
|
110
110
|
interface ChatWidgetProps {
|
|
111
111
|
className?: string;
|
|
112
112
|
createTransport?: CreateChatTransport;
|
|
113
|
+
/**
|
|
114
|
+
* Identity the host already knows, used to skip the pre-chat form.
|
|
115
|
+
*
|
|
116
|
+
* When both `name` and `email` are non-empty the widget opens straight into
|
|
117
|
+
* the conversation and bootstraps the session itself, instead of asking a
|
|
118
|
+
* signed-in user to retype what the host app already displays in its own
|
|
119
|
+
* header. A partial value (one field, or blanks) is treated as a prefill:
|
|
120
|
+
* the form still renders, with the known fields filled in.
|
|
121
|
+
*
|
|
122
|
+
* This seeds the *form*, and a form value is self-reported no matter who
|
|
123
|
+
* typed it. It is not, on its own, proof of identity — anyone can pass any
|
|
124
|
+
* address here. Verification is a separate, server-side mechanism: pass
|
|
125
|
+
* `resolveUser` to `createChatSessionHandler` on the host backend and it
|
|
126
|
+
* resolves the caller's real session behind the API key, which is what marks
|
|
127
|
+
* the resulting contact verified. Use both together for a signed-in user:
|
|
128
|
+
* this prop removes the typing, `resolveUser` is what makes it trustworthy.
|
|
129
|
+
*/
|
|
130
|
+
defaultVisitor?: Partial<ChatWidgetVisitor>;
|
|
113
131
|
getSession: GetSession;
|
|
114
132
|
initiallyOpen?: boolean;
|
|
115
133
|
launcherLabel?: string;
|
package/dist/browser.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createRoot } from "react-dom/client";
|
|
2
|
-
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { useCallback, useEffect, 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)";
|
|
@@ -255,9 +255,31 @@ const WIDGET_STYLES = `
|
|
|
255
255
|
padding: 0.5rem 1rem;
|
|
256
256
|
text-align: center;
|
|
257
257
|
}
|
|
258
|
+
/* The attribution is Clivly's mark, not the host's, so it uses the stops from
|
|
259
|
+
the logo itself rather than --clivly-chat-accent: the accent is driven by
|
|
260
|
+
each customer's own brandColor, and a signature that changed colour per
|
|
261
|
+
customer would stop reading as a signature.
|
|
262
|
+
|
|
263
|
+
The color declaration is the fallback and must stay a real colour:
|
|
264
|
+
background-clip is painted over it, so anything that drops the background —
|
|
265
|
+
forced-colors, an older engine, a copied-out snippet — degrades to solid
|
|
266
|
+
coral rather than to invisible text. */
|
|
258
267
|
.clivly-chat-widget__powered a {
|
|
259
|
-
|
|
268
|
+
background-image: linear-gradient(90deg, #ff5a4a 0%, #c0231a 100%);
|
|
269
|
+
-webkit-background-clip: text;
|
|
270
|
+
background-clip: text;
|
|
271
|
+
color: #c0231a;
|
|
260
272
|
text-decoration: none;
|
|
273
|
+
-webkit-text-fill-color: transparent;
|
|
274
|
+
}
|
|
275
|
+
/* Forced-colors mode strips background-image outright, which would leave the
|
|
276
|
+
text painted in a transparent fill. Hand the colour back to the UA. */
|
|
277
|
+
@media (forced-colors: active) {
|
|
278
|
+
.clivly-chat-widget__powered a {
|
|
279
|
+
background-image: none;
|
|
280
|
+
color: LinkText;
|
|
281
|
+
-webkit-text-fill-color: currentColor;
|
|
282
|
+
}
|
|
261
283
|
}
|
|
262
284
|
.clivly-chat-widget__powered a:hover,
|
|
263
285
|
.clivly-chat-widget__powered a:focus-visible {
|
|
@@ -482,7 +504,27 @@ function MessageList({ greeting, hasSession, messages }) {
|
|
|
482
504
|
});
|
|
483
505
|
return /* @__PURE__ */ jsx(Fragment, { children: messages.map((message) => /* @__PURE__ */ jsx(MessageBubble, { message }, message.id)) });
|
|
484
506
|
}
|
|
485
|
-
|
|
507
|
+
/**
|
|
508
|
+
* Whether a seeded visitor is complete enough to skip the form.
|
|
509
|
+
*
|
|
510
|
+
* Both fields have to be present and non-blank: the server requires each, so
|
|
511
|
+
* auto-starting on a half-filled identity would trade a form the visitor can
|
|
512
|
+
* complete for an error they cannot.
|
|
513
|
+
*/
|
|
514
|
+
function isCompleteVisitor(visitor) {
|
|
515
|
+
return Boolean(visitor?.name?.trim() && visitor?.email?.trim());
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* What the composer says about itself. "Connecting…" claims a socket is in
|
|
519
|
+
* flight, so it is as wrong as "Conversation ready" when no transport exists —
|
|
520
|
+
* the visitor is told to wait for something that will never arrive. The third
|
|
521
|
+
* string keeps the status honest in all three states.
|
|
522
|
+
*/
|
|
523
|
+
function composerStatusText(hasTransport, isReady) {
|
|
524
|
+
if (!hasTransport) return "Chat is unavailable";
|
|
525
|
+
return isReady ? "Conversation ready" : "Connecting…";
|
|
526
|
+
}
|
|
527
|
+
function ChatWidget({ className, createTransport, defaultVisitor, getSession, initiallyOpen = false, launcherLabel = "Chat with us", storage = DEFAULT_STORAGE, style, theme = "auto", widgetId }) {
|
|
486
528
|
const [isOpen, setIsOpen] = useState(initiallyOpen);
|
|
487
529
|
const [error, setError] = useState(null);
|
|
488
530
|
const [isConnecting, setIsConnecting] = useState(false);
|
|
@@ -491,11 +533,15 @@ function ChatWidget({ className, createTransport, getSession, initiallyOpen = fa
|
|
|
491
533
|
const [messages, setMessages] = useState([]);
|
|
492
534
|
const [session, setSession] = useState(null);
|
|
493
535
|
const [visitor, setVisitor] = useState({
|
|
494
|
-
email: "",
|
|
495
|
-
name: ""
|
|
536
|
+
email: defaultVisitor?.email ?? "",
|
|
537
|
+
name: defaultVisitor?.name ?? ""
|
|
496
538
|
});
|
|
497
539
|
const transportRef = useRef(null);
|
|
498
540
|
const listRef = useRef(null);
|
|
541
|
+
useEffect(() => {
|
|
542
|
+
if (createTransport) return;
|
|
543
|
+
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");
|
|
544
|
+
}, []);
|
|
499
545
|
const themeStyle = useThemeStyle(session, style);
|
|
500
546
|
const resolvedTheme = useResolvedTheme(theme);
|
|
501
547
|
useEffect(() => () => {
|
|
@@ -538,15 +584,13 @@ function ChatWidget({ className, createTransport, getSession, initiallyOpen = fa
|
|
|
538
584
|
if (transportRef.current === connection) transportRef.current = null;
|
|
539
585
|
};
|
|
540
586
|
}, [createTransport, session]);
|
|
541
|
-
const
|
|
542
|
-
event.preventDefault();
|
|
587
|
+
const startSession = useCallback(async (identity) => {
|
|
543
588
|
setError(null);
|
|
544
589
|
setIsConnecting(true);
|
|
545
590
|
try {
|
|
546
|
-
const existingVisitorToken = storage?.getItem(createStorageKey(widgetId)) ?? null;
|
|
547
591
|
const nextSession = await getSession({
|
|
548
|
-
visitor,
|
|
549
|
-
visitorToken:
|
|
592
|
+
visitor: identity,
|
|
593
|
+
visitorToken: storage?.getItem(createStorageKey(widgetId)) ?? null,
|
|
550
594
|
widgetId
|
|
551
595
|
});
|
|
552
596
|
storage?.setItem(createStorageKey(widgetId), nextSession.visitorToken);
|
|
@@ -558,7 +602,30 @@ function ChatWidget({ className, createTransport, getSession, initiallyOpen = fa
|
|
|
558
602
|
} finally {
|
|
559
603
|
setIsConnecting(false);
|
|
560
604
|
}
|
|
605
|
+
}, [
|
|
606
|
+
getSession,
|
|
607
|
+
storage,
|
|
608
|
+
widgetId
|
|
609
|
+
]);
|
|
610
|
+
const submitVisitor = async (event) => {
|
|
611
|
+
event.preventDefault();
|
|
612
|
+
await startSession(visitor);
|
|
561
613
|
};
|
|
614
|
+
useEffect(() => {
|
|
615
|
+
if (!(isOpen && isCompleteVisitor(defaultVisitor))) return;
|
|
616
|
+
if (session || isConnecting || error) return;
|
|
617
|
+
startSession({
|
|
618
|
+
email: defaultVisitor.email.trim(),
|
|
619
|
+
name: defaultVisitor.name.trim()
|
|
620
|
+
}).catch(() => {});
|
|
621
|
+
}, [
|
|
622
|
+
defaultVisitor,
|
|
623
|
+
error,
|
|
624
|
+
isConnecting,
|
|
625
|
+
isOpen,
|
|
626
|
+
session,
|
|
627
|
+
startSession
|
|
628
|
+
]);
|
|
562
629
|
const submitMessage = (event) => {
|
|
563
630
|
event?.preventDefault();
|
|
564
631
|
if (!(session && transportRef.current)) {
|
|
@@ -590,6 +657,7 @@ function ChatWidget({ className, createTransport, getSession, initiallyOpen = fa
|
|
|
590
657
|
setError(resolved.message);
|
|
591
658
|
}
|
|
592
659
|
};
|
|
660
|
+
const canSend = Boolean(createTransport) && isReady;
|
|
593
661
|
const greeting = session?.widget.config.greeting ?? "Tell us a little about yourself and we’ll pick up the conversation from there.";
|
|
594
662
|
const panelTitle = session?.widget.name ?? "Start a conversation";
|
|
595
663
|
const sessionSummary = sessionSummaryText(session, greeting);
|
|
@@ -657,7 +725,7 @@ function ChatWidget({ className, createTransport, getSession, initiallyOpen = fa
|
|
|
657
725
|
onKeyDown: (event) => {
|
|
658
726
|
if (!isSendKey(event)) return;
|
|
659
727
|
event.preventDefault();
|
|
660
|
-
if (messageDraft.trim() &&
|
|
728
|
+
if (messageDraft.trim() && canSend) submitMessage();
|
|
661
729
|
},
|
|
662
730
|
placeholder: "Write your message",
|
|
663
731
|
value: messageDraft
|
|
@@ -666,10 +734,10 @@ function ChatWidget({ className, createTransport, getSession, initiallyOpen = fa
|
|
|
666
734
|
className: "clivly-chat-widget__actions",
|
|
667
735
|
children: [/* @__PURE__ */ jsx("span", {
|
|
668
736
|
className: "clivly-chat-widget__subtitle",
|
|
669
|
-
children:
|
|
737
|
+
children: composerStatusText(Boolean(createTransport), isReady)
|
|
670
738
|
}), /* @__PURE__ */ jsx("button", {
|
|
671
739
|
className: "clivly-chat-widget__button",
|
|
672
|
-
disabled: !(messageDraft.trim() &&
|
|
740
|
+
disabled: !(messageDraft.trim() && canSend),
|
|
673
741
|
type: "submit",
|
|
674
742
|
children: "Send"
|
|
675
743
|
})]
|
|
@@ -826,6 +894,33 @@ function createSessionFetcher({ fetchImpl = fetch, sessionUrl }) {
|
|
|
826
894
|
//#endregion
|
|
827
895
|
//#region src/browser-mount.tsx
|
|
828
896
|
const MOUNTED_DATA_ATTR = "clivlyMounted";
|
|
897
|
+
/**
|
|
898
|
+
* The identity a script-tag host already knows, read off the tag.
|
|
899
|
+
*
|
|
900
|
+
* The React component takes `defaultVisitor` as a prop; a script tag has only
|
|
901
|
+
* strings, so `data-visitor-name` / `data-visitor-email` are the same seam. A
|
|
902
|
+
* host that server-renders its page can template the signed-in user's details
|
|
903
|
+
* straight into the tag and the visitor never sees the pre-chat form.
|
|
904
|
+
*
|
|
905
|
+
* Returns `undefined` rather than an empty object when neither attribute is
|
|
906
|
+
* present, so the component's own "no identity" path stays untouched. Partial
|
|
907
|
+
* values are passed through deliberately: `ChatWidget` treats one field as a
|
|
908
|
+
* prefill and still renders the form, which is the right outcome for a host
|
|
909
|
+
* that knows an email but not a name.
|
|
910
|
+
*
|
|
911
|
+
* This is self-reported identity — anyone can edit the attribute in devtools.
|
|
912
|
+
* `resolveUser` on the host backend is what makes it trustworthy; this only
|
|
913
|
+
* removes the typing.
|
|
914
|
+
*/
|
|
915
|
+
function readScriptVisitor(dataset) {
|
|
916
|
+
const email = dataset.visitorEmail?.trim();
|
|
917
|
+
const name = dataset.visitorName?.trim();
|
|
918
|
+
if (!(email || name)) return;
|
|
919
|
+
return {
|
|
920
|
+
...email ? { email } : {},
|
|
921
|
+
...name ? { name } : {}
|
|
922
|
+
};
|
|
923
|
+
}
|
|
829
924
|
function resolveSocketUrl(socketUrlTemplate, conversationId, token) {
|
|
830
925
|
return socketUrlTemplate.replaceAll("{conversationId}", encodeURIComponent(conversationId)).replaceAll("{token}", encodeURIComponent(token));
|
|
831
926
|
}
|
|
@@ -870,6 +965,7 @@ function mountChatWidgetScript$1(script = document.currentScript) {
|
|
|
870
965
|
shadowRoot.append(container);
|
|
871
966
|
const { root, setTheme } = createWidgetMount({
|
|
872
967
|
container,
|
|
968
|
+
defaultVisitor: readScriptVisitor(script.dataset),
|
|
873
969
|
sessionUrl,
|
|
874
970
|
socketUrlTemplate: script.dataset.socketUrlTemplate,
|
|
875
971
|
theme: parseTheme(script.dataset.theme),
|
package/dist/index.cjs
CHANGED
|
@@ -256,9 +256,31 @@ const WIDGET_STYLES = `
|
|
|
256
256
|
padding: 0.5rem 1rem;
|
|
257
257
|
text-align: center;
|
|
258
258
|
}
|
|
259
|
+
/* The attribution is Clivly's mark, not the host's, so it uses the stops from
|
|
260
|
+
the logo itself rather than --clivly-chat-accent: the accent is driven by
|
|
261
|
+
each customer's own brandColor, and a signature that changed colour per
|
|
262
|
+
customer would stop reading as a signature.
|
|
263
|
+
|
|
264
|
+
The color declaration is the fallback and must stay a real colour:
|
|
265
|
+
background-clip is painted over it, so anything that drops the background —
|
|
266
|
+
forced-colors, an older engine, a copied-out snippet — degrades to solid
|
|
267
|
+
coral rather than to invisible text. */
|
|
259
268
|
.clivly-chat-widget__powered a {
|
|
260
|
-
|
|
269
|
+
background-image: linear-gradient(90deg, #ff5a4a 0%, #c0231a 100%);
|
|
270
|
+
-webkit-background-clip: text;
|
|
271
|
+
background-clip: text;
|
|
272
|
+
color: #c0231a;
|
|
261
273
|
text-decoration: none;
|
|
274
|
+
-webkit-text-fill-color: transparent;
|
|
275
|
+
}
|
|
276
|
+
/* Forced-colors mode strips background-image outright, which would leave the
|
|
277
|
+
text painted in a transparent fill. Hand the colour back to the UA. */
|
|
278
|
+
@media (forced-colors: active) {
|
|
279
|
+
.clivly-chat-widget__powered a {
|
|
280
|
+
background-image: none;
|
|
281
|
+
color: LinkText;
|
|
282
|
+
-webkit-text-fill-color: currentColor;
|
|
283
|
+
}
|
|
262
284
|
}
|
|
263
285
|
.clivly-chat-widget__powered a:hover,
|
|
264
286
|
.clivly-chat-widget__powered a:focus-visible {
|
|
@@ -483,7 +505,27 @@ function MessageList({ greeting, hasSession, messages }) {
|
|
|
483
505
|
});
|
|
484
506
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_jsx_runtime.Fragment, { children: messages.map((message) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MessageBubble, { message }, message.id)) });
|
|
485
507
|
}
|
|
486
|
-
|
|
508
|
+
/**
|
|
509
|
+
* Whether a seeded visitor is complete enough to skip the form.
|
|
510
|
+
*
|
|
511
|
+
* Both fields have to be present and non-blank: the server requires each, so
|
|
512
|
+
* auto-starting on a half-filled identity would trade a form the visitor can
|
|
513
|
+
* complete for an error they cannot.
|
|
514
|
+
*/
|
|
515
|
+
function isCompleteVisitor(visitor) {
|
|
516
|
+
return Boolean(visitor?.name?.trim() && visitor?.email?.trim());
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* What the composer says about itself. "Connecting…" claims a socket is in
|
|
520
|
+
* flight, so it is as wrong as "Conversation ready" when no transport exists —
|
|
521
|
+
* the visitor is told to wait for something that will never arrive. The third
|
|
522
|
+
* string keeps the status honest in all three states.
|
|
523
|
+
*/
|
|
524
|
+
function composerStatusText(hasTransport, isReady) {
|
|
525
|
+
if (!hasTransport) return "Chat is unavailable";
|
|
526
|
+
return isReady ? "Conversation ready" : "Connecting…";
|
|
527
|
+
}
|
|
528
|
+
function ChatWidget({ className, createTransport, defaultVisitor, getSession, initiallyOpen = false, launcherLabel = "Chat with us", storage = DEFAULT_STORAGE, style, theme = "auto", widgetId }) {
|
|
487
529
|
const [isOpen, setIsOpen] = (0, react.useState)(initiallyOpen);
|
|
488
530
|
const [error, setError] = (0, react.useState)(null);
|
|
489
531
|
const [isConnecting, setIsConnecting] = (0, react.useState)(false);
|
|
@@ -492,11 +534,15 @@ function ChatWidget({ className, createTransport, getSession, initiallyOpen = fa
|
|
|
492
534
|
const [messages, setMessages] = (0, react.useState)([]);
|
|
493
535
|
const [session, setSession] = (0, react.useState)(null);
|
|
494
536
|
const [visitor, setVisitor] = (0, react.useState)({
|
|
495
|
-
email: "",
|
|
496
|
-
name: ""
|
|
537
|
+
email: defaultVisitor?.email ?? "",
|
|
538
|
+
name: defaultVisitor?.name ?? ""
|
|
497
539
|
});
|
|
498
540
|
const transportRef = (0, react.useRef)(null);
|
|
499
541
|
const listRef = (0, react.useRef)(null);
|
|
542
|
+
(0, react.useEffect)(() => {
|
|
543
|
+
if (createTransport) return;
|
|
544
|
+
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");
|
|
545
|
+
}, []);
|
|
500
546
|
const themeStyle = useThemeStyle(session, style);
|
|
501
547
|
const resolvedTheme = useResolvedTheme(theme);
|
|
502
548
|
(0, react.useEffect)(() => () => {
|
|
@@ -539,15 +585,13 @@ function ChatWidget({ className, createTransport, getSession, initiallyOpen = fa
|
|
|
539
585
|
if (transportRef.current === connection) transportRef.current = null;
|
|
540
586
|
};
|
|
541
587
|
}, [createTransport, session]);
|
|
542
|
-
const
|
|
543
|
-
event.preventDefault();
|
|
588
|
+
const startSession = (0, react.useCallback)(async (identity) => {
|
|
544
589
|
setError(null);
|
|
545
590
|
setIsConnecting(true);
|
|
546
591
|
try {
|
|
547
|
-
const existingVisitorToken = storage?.getItem(createStorageKey(widgetId)) ?? null;
|
|
548
592
|
const nextSession = await getSession({
|
|
549
|
-
visitor,
|
|
550
|
-
visitorToken:
|
|
593
|
+
visitor: identity,
|
|
594
|
+
visitorToken: storage?.getItem(createStorageKey(widgetId)) ?? null,
|
|
551
595
|
widgetId
|
|
552
596
|
});
|
|
553
597
|
storage?.setItem(createStorageKey(widgetId), nextSession.visitorToken);
|
|
@@ -559,7 +603,30 @@ function ChatWidget({ className, createTransport, getSession, initiallyOpen = fa
|
|
|
559
603
|
} finally {
|
|
560
604
|
setIsConnecting(false);
|
|
561
605
|
}
|
|
606
|
+
}, [
|
|
607
|
+
getSession,
|
|
608
|
+
storage,
|
|
609
|
+
widgetId
|
|
610
|
+
]);
|
|
611
|
+
const submitVisitor = async (event) => {
|
|
612
|
+
event.preventDefault();
|
|
613
|
+
await startSession(visitor);
|
|
562
614
|
};
|
|
615
|
+
(0, react.useEffect)(() => {
|
|
616
|
+
if (!(isOpen && isCompleteVisitor(defaultVisitor))) return;
|
|
617
|
+
if (session || isConnecting || error) return;
|
|
618
|
+
startSession({
|
|
619
|
+
email: defaultVisitor.email.trim(),
|
|
620
|
+
name: defaultVisitor.name.trim()
|
|
621
|
+
}).catch(() => {});
|
|
622
|
+
}, [
|
|
623
|
+
defaultVisitor,
|
|
624
|
+
error,
|
|
625
|
+
isConnecting,
|
|
626
|
+
isOpen,
|
|
627
|
+
session,
|
|
628
|
+
startSession
|
|
629
|
+
]);
|
|
563
630
|
const submitMessage = (event) => {
|
|
564
631
|
event?.preventDefault();
|
|
565
632
|
if (!(session && transportRef.current)) {
|
|
@@ -591,6 +658,7 @@ function ChatWidget({ className, createTransport, getSession, initiallyOpen = fa
|
|
|
591
658
|
setError(resolved.message);
|
|
592
659
|
}
|
|
593
660
|
};
|
|
661
|
+
const canSend = Boolean(createTransport) && isReady;
|
|
594
662
|
const greeting = session?.widget.config.greeting ?? "Tell us a little about yourself and we’ll pick up the conversation from there.";
|
|
595
663
|
const panelTitle = session?.widget.name ?? "Start a conversation";
|
|
596
664
|
const sessionSummary = sessionSummaryText(session, greeting);
|
|
@@ -658,7 +726,7 @@ function ChatWidget({ className, createTransport, getSession, initiallyOpen = fa
|
|
|
658
726
|
onKeyDown: (event) => {
|
|
659
727
|
if (!isSendKey(event)) return;
|
|
660
728
|
event.preventDefault();
|
|
661
|
-
if (messageDraft.trim() &&
|
|
729
|
+
if (messageDraft.trim() && canSend) submitMessage();
|
|
662
730
|
},
|
|
663
731
|
placeholder: "Write your message",
|
|
664
732
|
value: messageDraft
|
|
@@ -667,10 +735,10 @@ function ChatWidget({ className, createTransport, getSession, initiallyOpen = fa
|
|
|
667
735
|
className: "clivly-chat-widget__actions",
|
|
668
736
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
669
737
|
className: "clivly-chat-widget__subtitle",
|
|
670
|
-
children:
|
|
738
|
+
children: composerStatusText(Boolean(createTransport), isReady)
|
|
671
739
|
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
672
740
|
className: "clivly-chat-widget__button",
|
|
673
|
-
disabled: !(messageDraft.trim() &&
|
|
741
|
+
disabled: !(messageDraft.trim() && canSend),
|
|
674
742
|
type: "submit",
|
|
675
743
|
children: "Send"
|
|
676
744
|
})]
|
|
@@ -899,6 +967,33 @@ function createSessionFetcher({ fetchImpl = fetch, sessionUrl }) {
|
|
|
899
967
|
//#endregion
|
|
900
968
|
//#region src/browser-mount.tsx
|
|
901
969
|
const MOUNTED_DATA_ATTR = "clivlyMounted";
|
|
970
|
+
/**
|
|
971
|
+
* The identity a script-tag host already knows, read off the tag.
|
|
972
|
+
*
|
|
973
|
+
* The React component takes `defaultVisitor` as a prop; a script tag has only
|
|
974
|
+
* strings, so `data-visitor-name` / `data-visitor-email` are the same seam. A
|
|
975
|
+
* host that server-renders its page can template the signed-in user's details
|
|
976
|
+
* straight into the tag and the visitor never sees the pre-chat form.
|
|
977
|
+
*
|
|
978
|
+
* Returns `undefined` rather than an empty object when neither attribute is
|
|
979
|
+
* present, so the component's own "no identity" path stays untouched. Partial
|
|
980
|
+
* values are passed through deliberately: `ChatWidget` treats one field as a
|
|
981
|
+
* prefill and still renders the form, which is the right outcome for a host
|
|
982
|
+
* that knows an email but not a name.
|
|
983
|
+
*
|
|
984
|
+
* This is self-reported identity — anyone can edit the attribute in devtools.
|
|
985
|
+
* `resolveUser` on the host backend is what makes it trustworthy; this only
|
|
986
|
+
* removes the typing.
|
|
987
|
+
*/
|
|
988
|
+
function readScriptVisitor(dataset) {
|
|
989
|
+
const email = dataset.visitorEmail?.trim();
|
|
990
|
+
const name = dataset.visitorName?.trim();
|
|
991
|
+
if (!(email || name)) return;
|
|
992
|
+
return {
|
|
993
|
+
...email ? { email } : {},
|
|
994
|
+
...name ? { name } : {}
|
|
995
|
+
};
|
|
996
|
+
}
|
|
902
997
|
function resolveSocketUrl(socketUrlTemplate, conversationId, token) {
|
|
903
998
|
return socketUrlTemplate.replaceAll("{conversationId}", encodeURIComponent(conversationId)).replaceAll("{token}", encodeURIComponent(token));
|
|
904
999
|
}
|
|
@@ -943,6 +1038,7 @@ function mountChatWidgetScript$1(script = document.currentScript) {
|
|
|
943
1038
|
shadowRoot.append(container);
|
|
944
1039
|
const { root, setTheme } = createWidgetMount({
|
|
945
1040
|
container,
|
|
1041
|
+
defaultVisitor: readScriptVisitor(script.dataset),
|
|
946
1042
|
sessionUrl,
|
|
947
1043
|
socketUrlTemplate: script.dataset.socketUrlTemplate,
|
|
948
1044
|
theme: parseTheme(script.dataset.theme),
|