@clivly/chat-widget 0.10.9 → 0.11.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,10 +256,42 @@ const WIDGET_STYLES = `
256
256
  padding: 0.5rem 1rem;
257
257
  text-align: center;
258
258
  }
259
+ /* Only the name carries the gradient. "Powered by" is ordinary running text
260
+ and inherits the muted colour of the line, so the mark reads as a signature
261
+ rather than the whole sentence being branded. */
259
262
  .clivly-chat-widget__powered a {
260
263
  color: inherit;
261
264
  text-decoration: none;
262
265
  }
266
+ /* The attribution is Clivly's mark, not the host's, so it uses the stops from
267
+ the logo itself rather than --clivly-chat-accent: the accent is driven by
268
+ each customer's own brandColor, and a signature that changed colour per
269
+ customer would stop reading as a signature.
270
+
271
+ The color declaration is the fallback and must stay a real colour:
272
+ background-clip is painted over it, so anything that drops the background —
273
+ forced-colors, an older engine, a copied-out snippet — degrades to solid
274
+ coral rather than to invisible text. */
275
+ .clivly-chat-widget__powered-mark {
276
+ background-image: linear-gradient(90deg, #ff5a4a 0%, #c0231a 100%);
277
+ -webkit-background-clip: text;
278
+ background-clip: text;
279
+ color: #c0231a;
280
+ -webkit-text-fill-color: transparent;
281
+ }
282
+ /* Forced-colors mode strips background-image outright, which would leave the
283
+ text painted in a transparent fill. Hand the colour back to the UA so the
284
+ mark inherits the link colour the UA picked rather than re-pinning coral. */
285
+ @media (forced-colors: active) {
286
+ .clivly-chat-widget__powered a {
287
+ color: LinkText;
288
+ }
289
+ .clivly-chat-widget__powered-mark {
290
+ background-image: none;
291
+ color: currentColor;
292
+ -webkit-text-fill-color: currentColor;
293
+ }
294
+ }
263
295
  .clivly-chat-widget__powered a:hover,
264
296
  .clivly-chat-widget__powered a:focus-visible {
265
297
  text-decoration: underline;
@@ -483,7 +515,27 @@ function MessageList({ greeting, hasSession, messages }) {
483
515
  });
484
516
  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
517
  }
486
- function ChatWidget({ className, createTransport, getSession, initiallyOpen = false, launcherLabel = "Chat with us", storage = DEFAULT_STORAGE, style, theme = "auto", widgetId }) {
518
+ /**
519
+ * Whether a seeded visitor is complete enough to skip the form.
520
+ *
521
+ * Both fields have to be present and non-blank: the server requires each, so
522
+ * auto-starting on a half-filled identity would trade a form the visitor can
523
+ * complete for an error they cannot.
524
+ */
525
+ function isCompleteVisitor(visitor) {
526
+ return Boolean(visitor?.name?.trim() && visitor?.email?.trim());
527
+ }
528
+ /**
529
+ * What the composer says about itself. "Connecting…" claims a socket is in
530
+ * flight, so it is as wrong as "Conversation ready" when no transport exists —
531
+ * the visitor is told to wait for something that will never arrive. The third
532
+ * string keeps the status honest in all three states.
533
+ */
534
+ function composerStatusText(hasTransport, isReady) {
535
+ if (!hasTransport) return "Chat is unavailable";
536
+ return isReady ? "Conversation ready" : "Connecting…";
537
+ }
538
+ function ChatWidget({ className, createTransport, defaultVisitor, getSession, initiallyOpen = false, launcherLabel = "Chat with us", storage = DEFAULT_STORAGE, style, theme = "auto", widgetId }) {
487
539
  const [isOpen, setIsOpen] = (0, react.useState)(initiallyOpen);
488
540
  const [error, setError] = (0, react.useState)(null);
489
541
  const [isConnecting, setIsConnecting] = (0, react.useState)(false);
@@ -492,11 +544,15 @@ function ChatWidget({ className, createTransport, getSession, initiallyOpen = fa
492
544
  const [messages, setMessages] = (0, react.useState)([]);
493
545
  const [session, setSession] = (0, react.useState)(null);
494
546
  const [visitor, setVisitor] = (0, react.useState)({
495
- email: "",
496
- name: ""
547
+ email: defaultVisitor?.email ?? "",
548
+ name: defaultVisitor?.name ?? ""
497
549
  });
498
550
  const transportRef = (0, react.useRef)(null);
499
551
  const listRef = (0, react.useRef)(null);
552
+ (0, react.useEffect)(() => {
553
+ if (createTransport) return;
554
+ 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");
555
+ }, []);
500
556
  const themeStyle = useThemeStyle(session, style);
501
557
  const resolvedTheme = useResolvedTheme(theme);
502
558
  (0, react.useEffect)(() => () => {
@@ -539,15 +595,13 @@ function ChatWidget({ className, createTransport, getSession, initiallyOpen = fa
539
595
  if (transportRef.current === connection) transportRef.current = null;
540
596
  };
541
597
  }, [createTransport, session]);
542
- const submitVisitor = async (event) => {
543
- event.preventDefault();
598
+ const startSession = (0, react.useCallback)(async (identity) => {
544
599
  setError(null);
545
600
  setIsConnecting(true);
546
601
  try {
547
- const existingVisitorToken = storage?.getItem(createStorageKey(widgetId)) ?? null;
548
602
  const nextSession = await getSession({
549
- visitor,
550
- visitorToken: existingVisitorToken,
603
+ visitor: identity,
604
+ visitorToken: storage?.getItem(createStorageKey(widgetId)) ?? null,
551
605
  widgetId
552
606
  });
553
607
  storage?.setItem(createStorageKey(widgetId), nextSession.visitorToken);
@@ -559,7 +613,30 @@ function ChatWidget({ className, createTransport, getSession, initiallyOpen = fa
559
613
  } finally {
560
614
  setIsConnecting(false);
561
615
  }
616
+ }, [
617
+ getSession,
618
+ storage,
619
+ widgetId
620
+ ]);
621
+ const submitVisitor = async (event) => {
622
+ event.preventDefault();
623
+ await startSession(visitor);
562
624
  };
625
+ (0, react.useEffect)(() => {
626
+ if (!(isOpen && isCompleteVisitor(defaultVisitor))) return;
627
+ if (session || isConnecting || error) return;
628
+ startSession({
629
+ email: defaultVisitor.email.trim(),
630
+ name: defaultVisitor.name.trim()
631
+ }).catch(() => {});
632
+ }, [
633
+ defaultVisitor,
634
+ error,
635
+ isConnecting,
636
+ isOpen,
637
+ session,
638
+ startSession
639
+ ]);
563
640
  const submitMessage = (event) => {
564
641
  event?.preventDefault();
565
642
  if (!(session && transportRef.current)) {
@@ -591,6 +668,7 @@ function ChatWidget({ className, createTransport, getSession, initiallyOpen = fa
591
668
  setError(resolved.message);
592
669
  }
593
670
  };
671
+ const canSend = Boolean(createTransport) && isReady;
594
672
  const greeting = session?.widget.config.greeting ?? "Tell us a little about yourself and we’ll pick up the conversation from there.";
595
673
  const panelTitle = session?.widget.name ?? "Start a conversation";
596
674
  const sessionSummary = sessionSummaryText(session, greeting);
@@ -658,7 +736,7 @@ function ChatWidget({ className, createTransport, getSession, initiallyOpen = fa
658
736
  onKeyDown: (event) => {
659
737
  if (!isSendKey(event)) return;
660
738
  event.preventDefault();
661
- if (messageDraft.trim() && isReady) submitMessage();
739
+ if (messageDraft.trim() && canSend) submitMessage();
662
740
  },
663
741
  placeholder: "Write your message",
664
742
  value: messageDraft
@@ -667,10 +745,10 @@ function ChatWidget({ className, createTransport, getSession, initiallyOpen = fa
667
745
  className: "clivly-chat-widget__actions",
668
746
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
669
747
  className: "clivly-chat-widget__subtitle",
670
- children: isReady || !createTransport ? "Conversation ready" : "Connecting…"
748
+ children: composerStatusText(Boolean(createTransport), isReady)
671
749
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
672
750
  className: "clivly-chat-widget__button",
673
- disabled: !(messageDraft.trim() && isReady),
751
+ disabled: !(messageDraft.trim() && canSend),
674
752
  type: "submit",
675
753
  children: "Send"
676
754
  })]
@@ -732,11 +810,18 @@ function ChatWidget({ className, createTransport, getSession, initiallyOpen = fa
732
810
  }),
733
811
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
734
812
  className: "clivly-chat-widget__powered",
735
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
813
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("a", {
736
814
  href: "https://clivly.com",
737
815
  rel: "noopener",
738
816
  target: "_blank",
739
- children: "Powered by Clivly"
817
+ children: [
818
+ "Powered by",
819
+ " ",
820
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
821
+ className: "clivly-chat-widget__powered-mark",
822
+ children: "Clivly"
823
+ })
824
+ ]
740
825
  })
741
826
  })
742
827
  ]
@@ -827,6 +912,33 @@ function createSessionFetcher({ fetchImpl = fetch, sessionUrl }) {
827
912
  //#endregion
828
913
  //#region src/browser-mount.tsx
829
914
  const MOUNTED_DATA_ATTR = "clivlyMounted";
915
+ /**
916
+ * The identity a script-tag host already knows, read off the tag.
917
+ *
918
+ * The React component takes `defaultVisitor` as a prop; a script tag has only
919
+ * strings, so `data-visitor-name` / `data-visitor-email` are the same seam. A
920
+ * host that server-renders its page can template the signed-in user's details
921
+ * straight into the tag and the visitor never sees the pre-chat form.
922
+ *
923
+ * Returns `undefined` rather than an empty object when neither attribute is
924
+ * present, so the component's own "no identity" path stays untouched. Partial
925
+ * values are passed through deliberately: `ChatWidget` treats one field as a
926
+ * prefill and still renders the form, which is the right outcome for a host
927
+ * that knows an email but not a name.
928
+ *
929
+ * This is self-reported identity — anyone can edit the attribute in devtools.
930
+ * `resolveUser` on the host backend is what makes it trustworthy; this only
931
+ * removes the typing.
932
+ */
933
+ function readScriptVisitor(dataset) {
934
+ const email = dataset.visitorEmail?.trim();
935
+ const name = dataset.visitorName?.trim();
936
+ if (!(email || name)) return;
937
+ return {
938
+ ...email ? { email } : {},
939
+ ...name ? { name } : {}
940
+ };
941
+ }
830
942
  function resolveSocketUrl(socketUrlTemplate, conversationId, token) {
831
943
  return socketUrlTemplate.replaceAll("{conversationId}", encodeURIComponent(conversationId)).replaceAll("{token}", encodeURIComponent(token));
832
944
  }
@@ -871,6 +983,7 @@ function mountChatWidgetScript(script = document.currentScript) {
871
983
  shadowRoot.append(container);
872
984
  const { root, setTheme } = createWidgetMount({
873
985
  container,
986
+ defaultVisitor: readScriptVisitor(script.dataset),
874
987
  sessionUrl,
875
988
  socketUrlTemplate: script.dataset.socketUrlTemplate,
876
989
  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;
@@ -130,13 +148,13 @@ interface ChatWidgetProps {
130
148
  }
131
149
  //#endregion
132
150
  //#region src/browser-mount.d.ts
133
- interface MountChatWidgetOptions extends Omit<ChatWidgetProps, "getSession" | "widgetId"> {
151
+ export interface MountChatWidgetOptions extends Omit<ChatWidgetProps, "getSession" | "widgetId"> {
134
152
  container: Element | ShadowRoot;
135
153
  sessionUrl: string;
136
154
  socketUrlTemplate?: string;
137
155
  widgetId: string;
138
156
  }
139
- interface ChatWidgetMount {
157
+ export interface ChatWidgetMount {
140
158
  root: Root;
141
159
  /**
142
160
  * Repaint the widget in a different theme. `"auto"` resumes following the
@@ -148,11 +166,10 @@ interface ChatWidgetMount {
148
166
  */
149
167
  setTheme: (theme: ChatWidgetTheme) => void;
150
168
  }
151
- declare function mountChatWidget(options: MountChatWidgetOptions): Root;
152
- interface ScriptMountResult extends ChatWidgetMount {
169
+ export declare function mountChatWidget(options: MountChatWidgetOptions): Root;
170
+ export interface ScriptMountResult extends ChatWidgetMount {
153
171
  shadowRoot: ShadowRoot;
154
172
  }
155
- declare function mountChatWidgetScript(script?: HTMLScriptElement): ScriptMountResult;
156
- declare function autoMountChatWidgetScript(script?: HTMLScriptElement | null): ScriptMountResult | null;
157
- //#endregion
158
- export { ChatWidgetMount, MountChatWidgetOptions, ScriptMountResult, autoMountChatWidgetScript, mountChatWidget, mountChatWidgetScript };
173
+ export declare function mountChatWidgetScript(script?: HTMLScriptElement): ScriptMountResult;
174
+ export declare function autoMountChatWidgetScript(script?: HTMLScriptElement | null): ScriptMountResult | null;
175
+ //#endregion
@@ -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;
@@ -130,13 +148,13 @@ interface ChatWidgetProps {
130
148
  }
131
149
  //#endregion
132
150
  //#region src/browser-mount.d.ts
133
- interface MountChatWidgetOptions extends Omit<ChatWidgetProps, "getSession" | "widgetId"> {
151
+ export interface MountChatWidgetOptions extends Omit<ChatWidgetProps, "getSession" | "widgetId"> {
134
152
  container: Element | ShadowRoot;
135
153
  sessionUrl: string;
136
154
  socketUrlTemplate?: string;
137
155
  widgetId: string;
138
156
  }
139
- interface ChatWidgetMount {
157
+ export interface ChatWidgetMount {
140
158
  root: Root;
141
159
  /**
142
160
  * Repaint the widget in a different theme. `"auto"` resumes following the
@@ -148,11 +166,10 @@ interface ChatWidgetMount {
148
166
  */
149
167
  setTheme: (theme: ChatWidgetTheme) => void;
150
168
  }
151
- declare function mountChatWidget(options: MountChatWidgetOptions): Root;
152
- interface ScriptMountResult extends ChatWidgetMount {
169
+ export declare function mountChatWidget(options: MountChatWidgetOptions): Root;
170
+ export interface ScriptMountResult extends ChatWidgetMount {
153
171
  shadowRoot: ShadowRoot;
154
172
  }
155
- declare function mountChatWidgetScript(script?: HTMLScriptElement): ScriptMountResult;
156
- declare function autoMountChatWidgetScript(script?: HTMLScriptElement | null): ScriptMountResult | null;
157
- //#endregion
158
- export { ChatWidgetMount, MountChatWidgetOptions, ScriptMountResult, autoMountChatWidgetScript, mountChatWidget, mountChatWidgetScript };
173
+ export declare function mountChatWidgetScript(script?: HTMLScriptElement): ScriptMountResult;
174
+ export declare function autoMountChatWidgetScript(script?: HTMLScriptElement | null): ScriptMountResult | null;
175
+ //#endregion