@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 CHANGED
@@ -46,16 +46,34 @@ npm install @clivly/chat-widget
46
46
  ```
47
47
 
48
48
  ```tsx
49
- import { ChatWidget, createSessionFetcher } from "@clivly/chat-widget";
49
+ import {
50
+ ChatWidget,
51
+ createClivlyTransport,
52
+ createSessionFetcher,
53
+ } from "@clivly/chat-widget";
54
+
55
+ // Both at module scope, not inline in the element. `createTransport` is a
56
+ // dependency of the effect that opens the socket, so a new function identity on
57
+ // every render tears the connection down and reopens it in a loop.
58
+ const createTransport = createClivlyTransport();
59
+ const getSession = createSessionFetcher({
60
+ sessionUrl: "/api/clivly/widget/session",
61
+ });
50
62
 
51
63
  <ChatWidget
52
64
  widgetId="support"
53
- getSession={createSessionFetcher({
54
- sessionUrl: "/api/clivly/widget/session",
55
- })}
65
+ createTransport={createTransport}
66
+ getSession={getSession}
56
67
  />;
57
68
  ```
58
69
 
70
+ `createTransport` is optional in the type signature but **required to send
71
+ messages** — it is what opens the socket. Without it the widget mints a session
72
+ and renders the composer, but Send stays disabled and the status line reads
73
+ "Chat is unavailable". (The script-tag mount defaults it for you; the React
74
+ component does not, so that the same component can take
75
+ `createLoopbackTransport()` in tests.)
76
+
59
77
  `widgetId` is the **public slug** you set on the widget record — it is not a
60
78
  credential. `sessionUrl` must be the path where you mounted the session route on
61
79
  your own origin; `/api/clivly/widget/session` is what `clivly add widget`
@@ -128,6 +146,71 @@ This entry maps to the generated global/browser artifact:
128
146
  Use it for script-tag delivery, CDN-style consumption, or any future server path
129
147
  that serves the bundled widget directly.
130
148
 
149
+ ## Skipping the pre-chat form for a signed-in visitor
150
+
151
+ By default the widget asks for a name and email before opening a conversation.
152
+ When your app already knows who the visitor is, seed that identity and the
153
+ visitor goes straight to the composer.
154
+
155
+ React:
156
+
157
+ ```tsx
158
+ <ChatWidget
159
+ defaultVisitor={{ name: user.name, email: user.email }}
160
+ createTransport={createTransport}
161
+ getSession={getSession}
162
+ widgetId="support"
163
+ />
164
+ ```
165
+
166
+ Script tag — template the values into the tag when you render the page:
167
+
168
+ ```html
169
+ <script
170
+ src="https://cdn.example.com/widget.global.js"
171
+ data-widget-id="support"
172
+ data-session-url="/api/clivly/widget/session"
173
+ data-visitor-name="Jane Example"
174
+ data-visitor-email="jane@example.com"
175
+ defer
176
+ ></script>
177
+ ```
178
+
179
+ `mountChatWidget()` takes the same `defaultVisitor` option as the React prop.
180
+
181
+ **Both fields are required to skip the form.** The session endpoint rejects a
182
+ blank name, so a half-known identity is treated as a prefill: the form still
183
+ renders with the known field filled in. Blank or whitespace-only values count as
184
+ no identity at all.
185
+
186
+ The session is created when the visitor opens the launcher, not on page load —
187
+ otherwise every visitor who merely loaded a page would create an empty thread in
188
+ your inbox.
189
+
190
+ ### This is not authentication
191
+
192
+ A seeded identity is self-reported: anyone can edit a prop in React devtools or
193
+ a `data-` attribute in the element inspector. It removes the typing, nothing
194
+ more.
195
+
196
+ To make the identity trustworthy, pass `resolveUser` to
197
+ `createChatSessionHandler` on your backend. It resolves the caller's real
198
+ session behind your API key, server-side, and that is what marks the resulting
199
+ contact verified:
200
+
201
+ ```ts
202
+ createChatSessionHandler({
203
+ apiKey: process.env.CLIVLY_API_KEY,
204
+ resolveUser: async (request) => {
205
+ const session = await auth.getSession(request);
206
+ return session ? { id: session.user.id, email: session.user.email, name: session.user.name } : null;
207
+ },
208
+ });
209
+ ```
210
+
211
+ Use both together for a signed-in user: `defaultVisitor` removes the form,
212
+ `resolveUser` is what makes it trustworthy.
213
+
131
214
  ## Theming
132
215
 
133
216
  The widget ships light and dark palettes and follows the host app by default.
@@ -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
- color: inherit;
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
- function ChatWidget({ className, createTransport, getSession, initiallyOpen = false, launcherLabel = "Chat with us", storage = DEFAULT_STORAGE, style, theme = "auto", widgetId }) {
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 submitVisitor = async (event) => {
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: existingVisitorToken,
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() && isReady) submitMessage();
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: isReady || !createTransport ? "Conversation ready" : "Connecting…"
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() && isReady),
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(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),
@@ -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;
@@ -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;
@@ -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
  })]
@@ -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(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),