@clivly/chat-widget 0.10.8 → 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/dist/index.d.cts CHANGED
@@ -137,6 +137,24 @@ declare function createLoopbackTransport({ operatorName, operatorReply, replyDel
137
137
  interface ChatWidgetProps {
138
138
  className?: string;
139
139
  createTransport?: CreateChatTransport;
140
+ /**
141
+ * Identity the host already knows, used to skip the pre-chat form.
142
+ *
143
+ * When both `name` and `email` are non-empty the widget opens straight into
144
+ * the conversation and bootstraps the session itself, instead of asking a
145
+ * signed-in user to retype what the host app already displays in its own
146
+ * header. A partial value (one field, or blanks) is treated as a prefill:
147
+ * the form still renders, with the known fields filled in.
148
+ *
149
+ * This seeds the *form*, and a form value is self-reported no matter who
150
+ * typed it. It is not, on its own, proof of identity — anyone can pass any
151
+ * address here. Verification is a separate, server-side mechanism: pass
152
+ * `resolveUser` to `createChatSessionHandler` on the host backend and it
153
+ * resolves the caller's real session behind the API key, which is what marks
154
+ * the resulting contact verified. Use both together for a signed-in user:
155
+ * this prop removes the typing, `resolveUser` is what makes it trustworthy.
156
+ */
157
+ defaultVisitor?: Partial<ChatWidgetVisitor>;
140
158
  getSession: GetSession;
141
159
  initiallyOpen?: boolean;
142
160
  launcherLabel?: string;
@@ -155,7 +173,7 @@ interface ChatWidgetProps {
155
173
  theme?: ChatWidgetTheme;
156
174
  widgetId: string;
157
175
  }
158
- declare function ChatWidget({ className, createTransport, getSession, initiallyOpen, launcherLabel, storage, style, theme, widgetId }: ChatWidgetProps): ReactElement;
176
+ declare function ChatWidget({ className, createTransport, defaultVisitor, getSession, initiallyOpen, launcherLabel, storage, style, theme, widgetId }: ChatWidgetProps): ReactElement;
159
177
  declare function ChatLauncher(): null;
160
178
  declare function ChatPanel(): null;
161
179
  declare function MessageComposer(): null;
package/dist/index.d.mts CHANGED
@@ -137,6 +137,24 @@ declare function createLoopbackTransport({ operatorName, operatorReply, replyDel
137
137
  interface ChatWidgetProps {
138
138
  className?: string;
139
139
  createTransport?: CreateChatTransport;
140
+ /**
141
+ * Identity the host already knows, used to skip the pre-chat form.
142
+ *
143
+ * When both `name` and `email` are non-empty the widget opens straight into
144
+ * the conversation and bootstraps the session itself, instead of asking a
145
+ * signed-in user to retype what the host app already displays in its own
146
+ * header. A partial value (one field, or blanks) is treated as a prefill:
147
+ * the form still renders, with the known fields filled in.
148
+ *
149
+ * This seeds the *form*, and a form value is self-reported no matter who
150
+ * typed it. It is not, on its own, proof of identity — anyone can pass any
151
+ * address here. Verification is a separate, server-side mechanism: pass
152
+ * `resolveUser` to `createChatSessionHandler` on the host backend and it
153
+ * resolves the caller's real session behind the API key, which is what marks
154
+ * the resulting contact verified. Use both together for a signed-in user:
155
+ * this prop removes the typing, `resolveUser` is what makes it trustworthy.
156
+ */
157
+ defaultVisitor?: Partial<ChatWidgetVisitor>;
140
158
  getSession: GetSession;
141
159
  initiallyOpen?: boolean;
142
160
  launcherLabel?: string;
@@ -155,7 +173,7 @@ interface ChatWidgetProps {
155
173
  theme?: ChatWidgetTheme;
156
174
  widgetId: string;
157
175
  }
158
- declare function ChatWidget({ className, createTransport, getSession, initiallyOpen, launcherLabel, storage, style, theme, widgetId }: ChatWidgetProps): ReactElement;
176
+ declare function ChatWidget({ className, createTransport, defaultVisitor, getSession, initiallyOpen, launcherLabel, storage, style, theme, widgetId }: ChatWidgetProps): ReactElement;
159
177
  declare function ChatLauncher(): null;
160
178
  declare function ChatPanel(): null;
161
179
  declare function MessageComposer(): null;
package/dist/index.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
- color: inherit;
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
- function ChatWidget({ className, createTransport, getSession, initiallyOpen = false, launcherLabel = "Chat with us", storage = DEFAULT_STORAGE, style, theme = "auto", widgetId }) {
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 submitVisitor = async (event) => {
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: existingVisitorToken,
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() && isReady) submitMessage();
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: isReady || !createTransport ? "Conversation ready" : "Connecting…"
737
+ children: composerStatusText(Boolean(createTransport), isReady)
670
738
  }), /* @__PURE__ */ jsx("button", {
671
739
  className: "clivly-chat-widget__button",
672
- disabled: !(messageDraft.trim() && isReady),
740
+ disabled: !(messageDraft.trim() && canSend),
673
741
  type: "submit",
674
742
  children: "Send"
675
743
  })]
@@ -898,6 +966,33 @@ function createSessionFetcher({ fetchImpl = fetch, sessionUrl }) {
898
966
  //#endregion
899
967
  //#region src/browser-mount.tsx
900
968
  const MOUNTED_DATA_ATTR = "clivlyMounted";
969
+ /**
970
+ * The identity a script-tag host already knows, read off the tag.
971
+ *
972
+ * The React component takes `defaultVisitor` as a prop; a script tag has only
973
+ * strings, so `data-visitor-name` / `data-visitor-email` are the same seam. A
974
+ * host that server-renders its page can template the signed-in user's details
975
+ * straight into the tag and the visitor never sees the pre-chat form.
976
+ *
977
+ * Returns `undefined` rather than an empty object when neither attribute is
978
+ * present, so the component's own "no identity" path stays untouched. Partial
979
+ * values are passed through deliberately: `ChatWidget` treats one field as a
980
+ * prefill and still renders the form, which is the right outcome for a host
981
+ * that knows an email but not a name.
982
+ *
983
+ * This is self-reported identity — anyone can edit the attribute in devtools.
984
+ * `resolveUser` on the host backend is what makes it trustworthy; this only
985
+ * removes the typing.
986
+ */
987
+ function readScriptVisitor(dataset) {
988
+ const email = dataset.visitorEmail?.trim();
989
+ const name = dataset.visitorName?.trim();
990
+ if (!(email || name)) return;
991
+ return {
992
+ ...email ? { email } : {},
993
+ ...name ? { name } : {}
994
+ };
995
+ }
901
996
  function resolveSocketUrl(socketUrlTemplate, conversationId, token) {
902
997
  return socketUrlTemplate.replaceAll("{conversationId}", encodeURIComponent(conversationId)).replaceAll("{token}", encodeURIComponent(token));
903
998
  }
@@ -942,6 +1037,7 @@ function mountChatWidgetScript$1(script = document.currentScript) {
942
1037
  shadowRoot.append(container);
943
1038
  const { root, setTheme } = createWidgetMount({
944
1039
  container,
1040
+ defaultVisitor: readScriptVisitor(script.dataset),
945
1041
  sessionUrl,
946
1042
  socketUrlTemplate: script.dataset.socketUrlTemplate,
947
1043
  theme: parseTheme(script.dataset.theme),