@xapp/chat-widget 1.103.0 → 1.104.1

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.es.js CHANGED
@@ -2,6 +2,7 @@ import React, { useCallback, useState, useEffect, useRef, useLayoutEffect, creat
2
2
  export { default as React } from 'react';
3
3
  import require$$0, { jsx, jsxs, Fragment } from 'react/jsx-runtime';
4
4
  import { useSelector, useDispatch, Provider } from 'react-redux';
5
+ import { createPortal } from 'react-dom';
5
6
 
6
7
  var commonjsGlobal = typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {};
7
8
 
@@ -327,7 +328,7 @@ function buildChatWidgetChannel(props) {
327
328
  // and the text on it are always a matched pair rather than one scraped
328
329
  // value and one guess.
329
330
  const primaryColor = (0, color_1.usableColorOr)(props.primaryColor, DEFAULT_PRIMARY);
330
- return Object.assign({ type: "chat-widget",
331
+ return Object.assign(Object.assign({ type: "chat-widget",
331
332
  // MCP by default. The widget runs on a public page, so homeowner mode
332
333
  // — which is unauthenticated and carries the appId in the url — is the
333
334
  // only one that can work there; business_owner needs a token, and a
@@ -338,7 +339,7 @@ function buildChatWidgetChannel(props) {
338
339
  mcp: {
339
340
  mode: "homeowner"
340
341
  }
341
- }, theme: buildChatTheme(primaryColor, props.secondaryColor), actionBar: buildActionBar({ apiUrl, formWidgetKey, phoneNumber }) }, (businessName ? { botName: businessName } : {}));
342
+ }, theme: buildChatTheme(primaryColor, props.secondaryColor), actionBar: buildActionBar({ apiUrl, formWidgetKey, phoneNumber }) }, (formWidgetKey ? { formWidgetKey } : {})), (businessName ? { botName: businessName } : {}));
342
343
  }
343
344
  /**
344
345
  * The dock of buttons that replaces the plain chat bubble.
@@ -2398,6 +2399,16 @@ function stripChatContinueParam(win) {
2398
2399
  function hasConversation(chats) {
2399
2400
  return !!(chats === null || chats === void 0 ? void 0 : chats.some(function (chat) { return chat.type === "chat.msg"; }));
2400
2401
  }
2402
+ /**
2403
+ * True once the visitor has said something themselves.
2404
+ *
2405
+ * Stricter than `hasConversation`, which counts the greeting the assistant posts before anyone
2406
+ * has engaged. Visitor messages carry a `visitor:` nick, the same marker MessageList uses to
2407
+ * decide which side of the thread a message belongs on.
2408
+ */
2409
+ function hasVisitorMessage(chats) {
2410
+ return !!(chats === null || chats === void 0 ? void 0 : chats.some(function (chat) { var _a, _b; return chat.type === "chat.msg" && !!((_b = (_a = chat.user) === null || _a === void 0 ? void 0 : _a.nick) === null || _b === void 0 ? void 0 : _b.startsWith("visitor:")); }));
2411
+ }
2401
2412
  /**
2402
2413
  * The CTA to show, given whether there is a conversation to go back to.
2403
2414
  *
@@ -37099,6 +37110,22 @@ var StaticMessagesChatWidgetContainer = function (props) {
37099
37110
  * After this delay of inactivity, the "stop typing" event will be sent.
37100
37111
  */
37101
37112
  var TYPING_INDICATOR_DEBOUNCE_MS = 2000;
37113
+ /**
37114
+ * Reparenting has to happen before the browser paints, or the chat is missing for a frame on
37115
+ * every crossing. useLayoutEffect warns when a component is rendered on a server, where there is
37116
+ * nothing to paint and nothing to move, so fall back there.
37117
+ */
37118
+ var useBeforePaintEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
37119
+ /**
37120
+ * Narrowest viewport a pop-out is allowed on by default. Below this a floating window covers
37121
+ * most of the screen, which is worse than letting the chat scroll away with the page.
37122
+ */
37123
+ var DEFAULT_POP_OUT_MIN_WIDTH = 640;
37124
+ /**
37125
+ * How long to keep correcting the message list's scroll after the chat moves. Text rewraps at
37126
+ * the new width, so the content height settles over a frame or two rather than immediately.
37127
+ */
37128
+ var SCROLL_SETTLE_MS = 250;
37102
37129
  /**
37103
37130
  * Check if we're in development mode. Uses typeof to prevent TypeScript
37104
37131
  * errors after Rollup replaces "production" with a literal string.
@@ -37265,12 +37292,25 @@ var ChatWidgetConnected = function (props) {
37265
37292
  }
37266
37293
  return (jsx(ChatConfigContext.Provider, { value: config, children: jsx(ChatServerContext.Provider, { value: chatServer, children: jsx(ChatWidgetUI, __assign$1({}, props, { config: rawConfig })) }) }));
37267
37294
  };
37295
+ var HOST_FORM_WIDGET_CHECK_MS = 500;
37296
+ /** About 15s: long enough for a slow host script, short enough not to poll forever. */
37297
+ var HOST_FORM_WIDGET_MAX_CHECKS = 30;
37298
+ /** The form widget key a Form/Book button carries on its own formConfig, if any. */
37299
+ function formButtonKey(button) {
37300
+ var _a, _b;
37301
+ return opensFormWidget(button)
37302
+ ? ((_b = (_a = button.formConfig) === null || _a === void 0 ? void 0 : _a.api) === null || _b === void 0 ? void 0 : _b.key) || undefined
37303
+ : undefined;
37304
+ }
37305
+ function isHostFormWidgetOnPage() {
37306
+ return isFormWidgetScriptLoaded() || isFormWidgetReady();
37307
+ }
37268
37308
  /**
37269
37309
  * Inner ChatWidget component that renders the actual UI.
37270
37310
  * Exported for use by StaticChatWidgetContainer to avoid infinite loops.
37271
37311
  */
37272
37312
  var ChatWidgetUI = function (props) {
37273
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x, _y, _z, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14;
37313
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x, _y, _z, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16, _17, _18, _19, _20, _21, _22, _23;
37274
37314
  var innerDispatch = useChatDispatch();
37275
37315
  var dispatch = useChatServerDispatch();
37276
37316
  // Publish the visual viewport's geometry so the panel can size around the soft keyboard.
@@ -37295,23 +37335,176 @@ var ChatWidgetUI = function (props) {
37295
37335
  var arrivedViaChatLink = useArrivedViaChatLink(previewMode);
37296
37336
  var conversationOnScreen = hasConversation(chatState.chats);
37297
37337
  var chatButtonCta = useMemo(function () { var _a; return resolveCta((_a = props.config) === null || _a === void 0 ? void 0 : _a.cta, { hasConversation: conversationOnScreen, arrivedViaChatLink: arrivedViaChatLink }); }, [(_b = props.config) === null || _b === void 0 ? void 0 : _b.cta, conversationOnScreen, arrivedViaChatLink]);
37338
+ // Pop-out: an embedded chat follows the visitor down the page rather than scrolling away
37339
+ // mid-conversation. Same component, same store - it is moved, not reopened - so the
37340
+ // conversation continues instead of restarting in a second widget.
37341
+ var popOutEnabled = dockedMode && ((_c = props.config.popOut) === null || _c === void 0 ? void 0 : _c.enabled) === true;
37342
+ var popOutMinWidth = (_e = (_d = props.config.popOut) === null || _d === void 0 ? void 0 : _d.minWidth) !== null && _e !== void 0 ? _e : DEFAULT_POP_OUT_MIN_WIDTH;
37343
+ var anchorRef = useRef(null);
37344
+ var _24 = useState(false), anchorOffScreen = _24[0], setAnchorOffScreen = _24[1];
37345
+ // A returning visitor's chat often sits below the fold at load. Following them before they
37346
+ // have even reached it would put a floating window in front of someone who never scrolled
37347
+ // past anything, so the chat has to have been on screen once first.
37348
+ var _25 = useState(false), anchorSeen = _25[0], setAnchorSeen = _25[1];
37349
+ // Set when the visitor puts the floating window away: it stays put until their own
37350
+ // scrolling brings the chat's place on the page back into view.
37351
+ var _26 = useState(false), popOutDismissed = _26[0], setPopOutDismissed = _26[1];
37352
+ // On a phone the chat offers its entry point rather than covering the page; this is set when
37353
+ // the visitor taps that and asks for the conversation.
37354
+ var _27 = useState(false), mobileOpened = _27[0], setMobileOpened = _27[1];
37355
+ // Tracked rather than read at render: a visitor who narrows the window or rotates a tablet
37356
+ // while the chat is popped out would otherwise keep a floating window over most of a phone
37357
+ // screen until some unrelated re-render happened along.
37358
+ var _28 = useState(typeof window === "undefined" ? 0 : window.innerWidth), viewportWidth = _28[0], setViewportWidth = _28[1];
37359
+ useEffect(function () {
37360
+ if (!popOutEnabled || typeof window === "undefined") {
37361
+ return undefined;
37362
+ }
37363
+ var handleResize = function () { return setViewportWidth(window.innerWidth); };
37364
+ handleResize();
37365
+ window.addEventListener("resize", handleResize);
37366
+ return function () { return window.removeEventListener("resize", handleResize); };
37367
+ }, [popOutEnabled]);
37368
+ useEffect(function () {
37369
+ if (!popOutEnabled || typeof IntersectionObserver === "undefined" || !anchorRef.current) {
37370
+ return undefined;
37371
+ }
37372
+ var observer = new IntersectionObserver(function (entries) {
37373
+ var entry = entries[entries.length - 1];
37374
+ if (!entry) {
37375
+ return;
37376
+ }
37377
+ setAnchorOffScreen(!entry.isIntersecting);
37378
+ if (entry.isIntersecting) {
37379
+ setAnchorSeen(true);
37380
+ setPopOutDismissed(false);
37381
+ // Back at the chat itself, so the phone entry point starts over.
37382
+ setMobileOpened(false);
37383
+ }
37384
+ });
37385
+ observer.observe(anchorRef.current);
37386
+ return function () { return observer.disconnect(); };
37387
+ }, [popOutEnabled]);
37388
+ // With `after: "message"` the chat only follows a visitor who has engaged - the assistant's
37389
+ // greeting arrives before anyone types, so it does not count. A floating window also covers
37390
+ // most of a phone screen, so narrow screens stay on the page either way.
37391
+ var engaged = ((_f = props.config.popOut) === null || _f === void 0 ? void 0 : _f.after) !== "message" || hasVisitorMessage(chatState.chats);
37392
+ var following = popOutEnabled && anchorSeen && anchorOffScreen && !popOutDismissed && engaged;
37393
+ var wideEnough = viewportWidth >= popOutMinWidth;
37394
+ var mobileEntry = (_h = (_g = props.config.popOut) === null || _g === void 0 ? void 0 : _g.mobile) !== null && _h !== void 0 ? _h : "auto";
37395
+ /**
37396
+ * Too narrow for a floating window: the chat stays put and offers its entry point instead,
37397
+ * so a visitor reading on still has a way back to the conversation.
37398
+ */
37399
+ var collapsedOnMobile = following && !wideEnough && mobileEntry !== "none" && !mobileOpened;
37400
+ var poppedOut = following && (wideEnough || mobileOpened);
37401
+ function handlePopOutDismiss() {
37402
+ // On a phone, putting the window away goes back to the entry point rather than leaving
37403
+ // the visitor with nothing: the conversation is still off-screen up the page.
37404
+ if (wideEnough) {
37405
+ setPopOutDismissed(true);
37406
+ return;
37407
+ }
37408
+ setMobileOpened(false);
37409
+ }
37410
+ function handleMobileOpen() {
37411
+ setMobileOpened(true);
37412
+ }
37413
+ // The chat always renders into this one node, and the node is what moves between the page
37414
+ // and the corner. Swapping the JSX between an element and a portal instead would hand React
37415
+ // a different fiber type in the same slot, remounting the whole chat - and with it the
37416
+ // visitor's half-typed message and the message list's scroll position - on every crossing.
37417
+ var popOutHost = useMemo(function () { return (popOutEnabled && typeof document !== "undefined" ? document.createElement("div") : null); }, [popOutEnabled]);
37418
+ // Where the visitor is reading, recorded as they scroll. Measuring this at move time does
37419
+ // not work: React has already applied the new size by then, so the height is the new one
37420
+ // while the scroll position is still the old one, and the mix is meaningless.
37421
+ var readingRef = useRef({ fromBottom: 0, latest: true });
37422
+ useEffect(function () {
37423
+ if (!popOutHost) {
37424
+ return undefined;
37425
+ }
37426
+ var onScroll = function (event) {
37427
+ var el = event.target;
37428
+ if (!(el instanceof HTMLElement) || !el.classList.contains("message-list-container")) {
37429
+ return;
37430
+ }
37431
+ var fromBottom = el.scrollHeight - el.clientHeight - el.scrollTop;
37432
+ readingRef.current = {
37433
+ fromBottom: fromBottom,
37434
+ // Within a screenful of the newest message is "following the conversation", and
37435
+ // that is where they go back to. The list also smooth-scrolls after each
37436
+ // message, so the last position it reports can be mid-animation.
37437
+ latest: fromBottom <= el.clientHeight,
37438
+ };
37439
+ };
37440
+ // Scroll events do not bubble, but they do capture.
37441
+ popOutHost.addEventListener("scroll", onScroll, true);
37442
+ return function () { return popOutHost.removeEventListener("scroll", onScroll, true); };
37443
+ }, [popOutHost]);
37444
+ useBeforePaintEffect(function () {
37445
+ if (!popOutHost) {
37446
+ return undefined;
37447
+ }
37448
+ popOutHost.className = "widget-portal";
37449
+ var target = poppedOut ? document.body : anchorRef.current;
37450
+ if (target && popOutHost.parentElement !== target) {
37451
+ // Moving a node blurs whatever was focused inside it, which would drop the visitor
37452
+ // out of the message box mid-sentence, so put the caret back afterwards.
37453
+ var focused = document.activeElement;
37454
+ var refocus = focused instanceof HTMLElement && popOutHost.contains(focused) ? focused : undefined;
37455
+ // The embedded chat and the floating window are different heights, so a scroll
37456
+ // position kept in pixels lands somewhere else in the thread - usually leaving the
37457
+ // newest message below the fold, which reads as the conversation having been lost.
37458
+ // Distance from the bottom is what the visitor actually chose: at the newest message
37459
+ // they stay there, and reading back stays that far back.
37460
+ var messages_1 = popOutHost.querySelector(".message-list-container");
37461
+ // appendChild moves the node, children and all, without React re-rendering them.
37462
+ target.appendChild(popOutHost);
37463
+ // Read afresh each time rather than closing over the position from before the move:
37464
+ // the delayed corrections below run up to a quarter of a second later, and a visitor
37465
+ // who scrolls back through the thread in that window must not be snapped forward
37466
+ // again. Their scrolling updates the ref, so the later passes simply hold their place.
37467
+ var restoreScroll = function () {
37468
+ if (!messages_1) {
37469
+ return;
37470
+ }
37471
+ var _a = readingRef.current, fromBottom = _a.fromBottom, latest = _a.latest;
37472
+ var bottom = messages_1.scrollHeight - messages_1.clientHeight;
37473
+ messages_1.scrollTop = latest ? bottom : Math.max(0, bottom - fromBottom);
37474
+ };
37475
+ restoreScroll();
37476
+ refocus === null || refocus === void 0 ? void 0 : refocus.focus({ preventScroll: true });
37477
+ // The thread rewraps at the new width, so the content keeps growing for a frame or
37478
+ // two after the move. Restoring only once leaves the newest message under the fold,
37479
+ // which is what a visitor reads as "my conversation is gone".
37480
+ var frame_1 = requestAnimationFrame(restoreScroll);
37481
+ var settled_1 = window.setTimeout(restoreScroll, SCROLL_SETTLE_MS);
37482
+ return function () {
37483
+ cancelAnimationFrame(frame_1);
37484
+ window.clearTimeout(settled_1);
37485
+ };
37486
+ }
37487
+ return undefined;
37488
+ }, [popOutHost, poppedOut]);
37489
+ useEffect(function () { return function () { return popOutHost === null || popOutHost === void 0 ? void 0 : popOutHost.remove(); }; }, [popOutHost]);
37298
37490
  // Only an embedded widget may drop its header. In normal mode the header holds the
37299
37491
  // minimize and close buttons, so hiding it would leave a floating window with no way out.
37300
- var headerHidden = ((_c = props.config.header) === null || _c === void 0 ? void 0 : _c.hidden) === true && (dockedMode || staticMode);
37492
+ // While popped out the header is what lets the visitor put the window away, so it stays.
37493
+ var headerHidden = ((_j = props.config.header) === null || _j === void 0 ? void 0 : _j.hidden) === true && (dockedMode || staticMode) && !poppedOut;
37301
37494
  // The header is the only place a HEADER_LEFT / HEADER_RIGHT menu button renders. Without it
37302
37495
  // the menu would be unreachable, so hand the button to the footer instead.
37303
37496
  var menuConfig = useMemo(function () {
37304
37497
  return headerHidden && props.config.menu && props.config.menu.menuButtonLocation !== "FOOTER"
37305
37498
  ? __assign$1(__assign$1({}, props.config.menu), { menuButtonLocation: "FOOTER" }) : props.config.menu;
37306
37499
  }, [headerHidden, props.config.menu]);
37307
- var canRefresh = (_e = (_d = props.config.header) === null || _d === void 0 ? void 0 : _d.actions) === null || _e === void 0 ? void 0 : _e.refresh;
37500
+ var canRefresh = (_l = (_k = props.config.header) === null || _k === void 0 ? void 0 : _k.actions) === null || _l === void 0 ? void 0 : _l.refresh;
37308
37501
  // can't minimize in docked mode, static mode, or preview mode.
37309
- var canMinimize = !dockedMode && !staticMode && !previewMode && ((_g = (_f = props.config.header) === null || _f === void 0 ? void 0 : _f.actions) === null || _g === void 0 ? void 0 : _g.minimize);
37310
- log("docked: ".concat(dockedMode, " static: ").concat(staticMode, " preview: ").concat(previewMode, " minimized: ").concat((_j = (_h = props.config.header) === null || _h === void 0 ? void 0 : _h.actions) === null || _j === void 0 ? void 0 : _j.minimize));
37502
+ var canMinimize = !dockedMode && !staticMode && !previewMode && ((_o = (_m = props.config.header) === null || _m === void 0 ? void 0 : _m.actions) === null || _o === void 0 ? void 0 : _o.minimize);
37503
+ log("docked: ".concat(dockedMode, " static: ").concat(staticMode, " preview: ").concat(previewMode, " minimized: ").concat((_q = (_p = props.config.header) === null || _p === void 0 ? void 0 : _p.actions) === null || _q === void 0 ? void 0 : _q.minimize));
37311
37504
  var canCancel;
37312
37505
  // To preserve legacy behavior, cancel needs a little more checks
37313
- if (typeof ((_l = (_k = props.config.header) === null || _k === void 0 ? void 0 : _k.actions) === null || _l === void 0 ? void 0 : _l.cancel) === "boolean") {
37314
- canCancel = !dockedMode && !staticMode && !previewMode && ((_o = (_m = props.config.header) === null || _m === void 0 ? void 0 : _m.actions) === null || _o === void 0 ? void 0 : _o.cancel);
37506
+ if (typeof ((_s = (_r = props.config.header) === null || _r === void 0 ? void 0 : _r.actions) === null || _s === void 0 ? void 0 : _s.cancel) === "boolean") {
37507
+ canCancel = !dockedMode && !staticMode && !previewMode && ((_u = (_t = props.config.header) === null || _t === void 0 ? void 0 : _t.actions) === null || _u === void 0 ? void 0 : _u.cancel);
37315
37508
  }
37316
37509
  else {
37317
37510
  canCancel = !dockedMode && !staticMode && !previewMode;
@@ -37322,16 +37515,16 @@ var ChatWidgetUI = function (props) {
37322
37515
  }
37323
37516
  // Our state - pull from storage
37324
37517
  // Parse autoOpenOnWidth once for use in both initial state and useEffect
37325
- var autoOpenOnWidthValue = ((_p = props.config) === null || _p === void 0 ? void 0 : _p.autoOpenOnWidth)
37518
+ var autoOpenOnWidthValue = ((_v = props.config) === null || _v === void 0 ? void 0 : _v.autoOpenOnWidth)
37326
37519
  ? parseInt(props.config.autoOpenOnWidth, 10)
37327
37520
  : NaN;
37328
37521
  var autoOpenOnWidthMatch = !isNaN(autoOpenOnWidthValue) && window.innerWidth >= autoOpenOnWidthValue;
37329
37522
  // When autoOpenOnWidth is configured, use width check instead of persisted visible state
37330
37523
  // to prevent greeting requests on viewports below the threshold
37331
- var _15 = useState((!canMinimize && !canCancel) ||
37524
+ var _29 = useState((!canMinimize && !canCancel) ||
37332
37525
  // !!get("visible") ||
37333
- (!isNaN(autoOpenOnWidthValue) ? autoOpenOnWidthMatch : chatState.visuals.visible)), visible = _15[0], setVisibleState = _15[1];
37334
- var _16 = useState(false); _16[0]; var setTypingState = _16[1]; // false initially - state kept for potential external observers
37526
+ (!isNaN(autoOpenOnWidthValue) ? autoOpenOnWidthMatch : chatState.visuals.visible)), visible = _29[0], setVisibleState = _29[1];
37527
+ var _30 = useState(false); _30[0]; var setTypingState = _30[1]; // false initially - state kept for potential external observers
37335
37528
  // Ref to track typing state for use in timeout callbacks
37336
37529
  var typingRef = useRef(false);
37337
37530
  // Timeout ref for debouncing "stop typing" events
@@ -37339,11 +37532,11 @@ var ChatWidgetUI = function (props) {
37339
37532
  // Track whether autoOpenOnWidth has already fired so it doesn't re-open after user closes
37340
37533
  var autoOpenFiredRef = useRef(autoOpenOnWidthMatch);
37341
37534
  var chatServer = useContext(ChatServerContext);
37342
- var patternsConfig = (_r = (_q = props.config) === null || _q === void 0 ? void 0 : _q.autoOpenOnPattern) === null || _r === void 0 ? void 0 : _r.patterns;
37535
+ var patternsConfig = (_x = (_w = props.config) === null || _w === void 0 ? void 0 : _w.autoOpenOnPattern) === null || _x === void 0 ? void 0 : _x.patterns;
37343
37536
  var currentUrl = window.location.href;
37344
37537
  var patternExist = patternsConfig && patternsConfig.length > 0;
37345
37538
  var patternMatches = patternsConfig === null || patternsConfig === void 0 ? void 0 : patternsConfig.some(function (pattern) { return currentUrl.includes(pattern); });
37346
- var configWidth = (_t = (_s = props.config) === null || _s === void 0 ? void 0 : _s.autoOpenOnPattern) === null || _t === void 0 ? void 0 : _t.minimumWidth;
37539
+ var configWidth = (_z = (_y = props.config) === null || _y === void 0 ? void 0 : _y.autoOpenOnPattern) === null || _z === void 0 ? void 0 : _z.minimumWidth;
37347
37540
  var currentWidth = window.innerWidth;
37348
37541
  var setVisible = useCallback(function (newVisible) {
37349
37542
  if (staticMode || previewMode) {
@@ -37378,7 +37571,7 @@ var ChatWidgetUI = function (props) {
37378
37571
  };
37379
37572
  // eslint-disable-next-line react-hooks/exhaustive-deps
37380
37573
  }, []);
37381
- var _17 = useState(!document.hidden), isTabVisible = _17[0], setIsTabVisible = _17[1];
37574
+ var _31 = useState(!document.hidden), isTabVisible = _31[0], setIsTabVisible = _31[1];
37382
37575
  useEffect(function () {
37383
37576
  var handleVisibilityChange = function () {
37384
37577
  setIsTabVisible(!document.hidden);
@@ -37602,8 +37795,8 @@ var ChatWidgetUI = function (props) {
37602
37795
  }));
37603
37796
  }
37604
37797
  // Action Bar state and handlers
37605
- var _18 = useState(false), formModalOpen = _18[0], setFormModalOpen = _18[1];
37606
- var _19 = useState(null), activeFormConfig = _19[0], setActiveFormConfig = _19[1];
37798
+ var _32 = useState(false), formModalOpen = _32[0], setFormModalOpen = _32[1];
37799
+ var _33 = useState(null), activeFormConfig = _33[0], setActiveFormConfig = _33[1];
37607
37800
  var handleFormButtonClick = useCallback(function (formButton) {
37608
37801
  // If form-widget is configured, use its openForm API
37609
37802
  // The form-widget script should already be pre-loaded
@@ -37670,19 +37863,56 @@ var ChatWidgetUI = function (props) {
37670
37863
  var messages = chatState && chatState.chats;
37671
37864
  log("Rendering - accountStatus: \"".concat(chatState.accountStatus, "\", connectionStatus: \"").concat(chatState.connection.connectionStatus, "\""));
37672
37865
  var config = props.config, onConnectionStatusChange = props.onConnectionStatusChange;
37673
- var actionBarEnabled = ((_u = config === null || config === void 0 ? void 0 : config.actionBar) === null || _u === void 0 ? void 0 : _u.enabled) === true;
37866
+ var actionBarEnabled = ((_0 = config === null || config === void 0 ? void 0 : config.actionBar) === null || _0 === void 0 ? void 0 : _0.enabled) === true;
37674
37867
  // The host page declares this; the widget cannot work it out. On the standalone
37675
37868
  // contact/booking page the form already *is* the page, which makes every button that
37676
37869
  // only opens one redundant. XappMedia/chat-widget#1626.
37677
37870
  var isFormPage = useIsFormPage();
37871
+ // Studio can save Form/Book buttons with no form widget key anywhere. They can only open the
37872
+ // FormModal fallback, which then says "Form widget key not configured" - so drop them, unless
37873
+ // the host page installed the form widget itself. One key anywhere is enough: a page has one
37874
+ // form widget and every Form/Book button opens it through the same global.
37875
+ var configuredButtons = (_2 = (_1 = config === null || config === void 0 ? void 0 : config.actionBar) === null || _1 === void 0 ? void 0 : _1.buttons) !== null && _2 !== void 0 ? _2 : [];
37876
+ var hasKeylessFormButton = !(config === null || config === void 0 ? void 0 : config.formWidgetKey) &&
37877
+ configuredButtons.some(opensFormWidget) &&
37878
+ !configuredButtons.some(formButtonKey);
37879
+ var _34 = useState(function () { return hasKeylessFormButton && isHostFormWidgetOnPage(); }), hostFormWidget = _34[0], setHostFormWidget = _34[1];
37880
+ // The host's form-widget script loads independently of ours and can arrive after the first
37881
+ // render, so keep looking for a while rather than deciding once. This only runs when there is
37882
+ // no key anywhere, and with no key we never preload - so the script this finds by its fixed id
37883
+ // cannot be our own.
37884
+ useEffect(function () {
37885
+ if (!hasKeylessFormButton || hostFormWidget) {
37886
+ return undefined;
37887
+ }
37888
+ var checks = 0;
37889
+ var interval = setInterval(function () {
37890
+ checks++;
37891
+ if (isHostFormWidgetOnPage()) {
37892
+ setHostFormWidget(true);
37893
+ clearInterval(interval);
37894
+ }
37895
+ else if (checks >= HOST_FORM_WIDGET_MAX_CHECKS) {
37896
+ clearInterval(interval);
37897
+ }
37898
+ }, HOST_FORM_WIDGET_CHECK_MS);
37899
+ return function () { return clearInterval(interval); };
37900
+ }, [hasKeylessFormButton, hostFormWidget]);
37901
+ var actionBarConfig = useMemo(function () {
37902
+ var actionBar = config === null || config === void 0 ? void 0 : config.actionBar;
37903
+ if (!actionBar || !hasKeylessFormButton || hostFormWidget) {
37904
+ return actionBar;
37905
+ }
37906
+ return __assign$1(__assign$1({}, actionBar), { buttons: actionBar.buttons.filter(function (button) { return !opensFormWidget(button); }) });
37907
+ }, [config === null || config === void 0 ? void 0 : config.actionBar, hasKeylessFormButton, hostFormWidget]);
37678
37908
  var actionBarButtons = useMemo(function () {
37679
- return actionBarEnabled && (config === null || config === void 0 ? void 0 : config.actionBar)
37680
- ? visibleActionBarButtons(config.actionBar.buttons, {
37909
+ return actionBarEnabled && actionBarConfig
37910
+ ? visibleActionBarButtons(actionBarConfig.buttons, {
37681
37911
  chatDisabled: config.disabled,
37682
37912
  formDisabled: isFormPage,
37683
37913
  })
37684
37914
  : [];
37685
- }, [actionBarEnabled, config === null || config === void 0 ? void 0 : config.actionBar, config === null || config === void 0 ? void 0 : config.disabled, isFormPage]);
37915
+ }, [actionBarEnabled, actionBarConfig, config === null || config === void 0 ? void 0 : config.disabled, isFormPage]);
37686
37916
  // A bar that filters down to nothing is a bare strip of chrome. Treat it as if the bar
37687
37917
  // were off, so the visitor still gets the plain chat button rather than an empty shell.
37688
37918
  var actionBarVisible = actionBarEnabled && actionBarButtons.length > 0;
@@ -37694,7 +37924,7 @@ var ChatWidgetUI = function (props) {
37694
37924
  onConnectionStatusChange(connectionStatus);
37695
37925
  }
37696
37926
  }, [connectionStatus, onConnectionStatusChange]);
37697
- useExternalScript((_v = props.config) === null || _v === void 0 ? void 0 : _v.middlewareUrl);
37927
+ useExternalScript((_3 = props.config) === null || _3 === void 0 ? void 0 : _3.middlewareUrl);
37698
37928
  // Only reach for form.xapp.ai when something on this page can still open the form.
37699
37929
  //
37700
37930
  // Loading it is not free: the script mounts a second form-widget instance whose wrapper
@@ -37704,11 +37934,16 @@ var ChatWidgetUI = function (props) {
37704
37934
  //
37705
37935
  // With no bar configured the side button IS the entry point, so that case still loads.
37706
37936
  var formWidgetNeeded = !isFormPage && (!actionBarEnabled || actionBarButtons.some(opensFormWidget));
37937
+ // The top-level key is the documented place for it, but an App can carry the key only on
37938
+ // a Form/Book button's formConfig - buildChatWidgetChannel did exactly that. Reading only
37939
+ // the top-level key left the script unloaded, so every click waited out
37940
+ // openFormWidgetWhenReady before the modal fallback found the button's key.
37941
+ var formWidgetKey = (_4 = config === null || config === void 0 ? void 0 : config.formWidgetKey) !== null && _4 !== void 0 ? _4 : actionBarButtons.map(formButtonKey).find(Boolean);
37707
37942
  useEffect(function () {
37708
- if ((config === null || config === void 0 ? void 0 : config.formWidgetKey) && formWidgetNeeded) {
37709
- preloadFormWidgetScript(config.formWidgetKey);
37943
+ if (formWidgetKey && formWidgetNeeded) {
37944
+ preloadFormWidgetScript(formWidgetKey);
37710
37945
  }
37711
- }, [config === null || config === void 0 ? void 0 : config.formWidgetKey, formWidgetNeeded]);
37946
+ }, [formWidgetKey, formWidgetNeeded]);
37712
37947
  // Set body attribute when actionBar is enabled (used by CSS to hide form-widget side button on mobile)
37713
37948
  // Uses reference counting to handle multiple widget instances
37714
37949
  useEffect(function () {
@@ -37734,18 +37969,23 @@ var ChatWidgetUI = function (props) {
37734
37969
  };
37735
37970
  }, [actionBarVisible]);
37736
37971
  // This is a pseudo agent. It represent's the widget (shown in the header avatar for instance)
37737
- var widgetAgent = ((_w = chatState.agents["agent:robot"]) === null || _w === void 0 ? void 0 : _w.user) ||
37972
+ var widgetAgent = ((_5 = chatState.agents["agent:robot"]) === null || _5 === void 0 ? void 0 : _5.user) ||
37738
37973
  (config === null || config === void 0 ? void 0 : config.agent) || {
37739
37974
  nick: "agent:robot",
37740
37975
  avatarPath: config.avatarUrl,
37741
37976
  display_name: "Agent",
37742
37977
  };
37743
- return (jsxs(Fragment, { children: [jsxs("div", { className: "widget-container ".concat(modeClass, " ").concat(getVisibilityClass()).concat(actionBarVisible ? " widget-container--with-action-bar" : ""), children: [jsx(WidgetStylesheet, { theme: config === null || config === void 0 ? void 0 : config.theme }), !headerHidden && (jsx(ChatHeader, { accountStatus: chatState.accountStatus, refreshOnClick: handleRestartClick, minimizeOnClick: handleMinimizeClick, cancelOnClick: handleCancelClick, agent: widgetAgent, canRefresh: canRefresh, canMinimize: canMinimize, canCancel: canCancel, config: config === null || config === void 0 ? void 0 : config.header, menuConfig: config.menu, debugMode: (_z = (_y = (_x = props.config) === null || _x === void 0 ? void 0 : _x.connection) === null || _y === void 0 ? void 0 : _y.mcp) === null || _z === void 0 ? void 0 : _z.debugMode, onSubmit: handleOnSubmit })), jsx(MessageList, { visible: visible, queuePosition: chatState.queuePosition, isChatting: chatState.isChatting, isOffline: isOffline, messages: messages, agents: chatState.agents, agent: config === null || config === void 0 ? void 0 : config.agent, lastRatingRequestTimestamp: chatState.lastRatingRequestTimestamp, hasRating: chatState.hasRating, visitorId: chatState.visitorId, hasWsButton: !!chatState.wsButton, messageMiddleware: props.messageMiddleware, textTypingStatusEnabled: (_1 = (_0 = props.config) === null || _0 === void 0 ? void 0 : _0.typingStatus) === null || _1 === void 0 ? void 0 : _1.textTypingStatusEnabled, disableAutoScroll: props.disableAutoScroll, onSend: handleSendMessage, onWrite: handleWriteMessage, onOpenUrl: handleOpenUrl, minimizeOnClick: handleMinimizeClick }), jsx("div", { className: "xa-spinner-container ".concat(visible && connectionStatus === "pending" ? "visible" : ""), children: jsx("div", { className: "xa-spinner" }) }), connectionStatus === "offline" && jsx(ServerOffline, {}), chatState.wsButton && visible && (jsx(WsButton, { button: chatState.wsButton, onPress: handleWsButtonPress })), jsx(ChatFooter, { isAdmin: config === null || config === void 0 ? void 0 : config.isAdmin, isChatting: chatState.isChatting, placeholder: (_2 = config === null || config === void 0 ? void 0 : config.input) === null || _2 === void 0 ? void 0 : _2.placeholder, sendButtonIcon: (_4 = (_3 = config === null || config === void 0 ? void 0 : config.footer) === null || _3 === void 0 ? void 0 : _3.sendButton) === null || _4 === void 0 ? void 0 : _4.icon, sendButtonIconHover: (_6 = (_5 = config === null || config === void 0 ? void 0 : config.footer) === null || _5 === void 0 ? void 0 : _5.sendButton) === null || _6 === void 0 ? void 0 : _6.iconHover, sendButtonIconDisabled: (_8 = (_7 = config === null || config === void 0 ? void 0 : config.footer) === null || _7 === void 0 ? void 0 : _7.sendButton) === null || _8 === void 0 ? void 0 : _8.iconDisabled, visible: visible, hasWsButton: !!chatState.wsButton, menuConfig: menuConfig, footerConfig: (_9 = props.config) === null || _9 === void 0 ? void 0 : _9.footer, inputConfig: (_10 = props.config) === null || _10 === void 0 ? void 0 : _10.input, disabled: previewMode, onChange: handleOnChange, onSubmit: handleOnSubmit, onFileUpload: handleFileUpload }), jsx("div", { className: "restartModal", ref: modalRef, onClick: handleRestartModalCloseClick, children: jsx(ModalContent, { onClose: handleRestartModalCloseClick, onReset: handleReset }) })] }), actionBarVisible && config.actionBar ? (jsxs(Fragment, { children: [jsx(ActionBar, { config: __assign$1(__assign$1({}, config.actionBar), {
37978
+ var widgetContainer = (jsxs("div", { className: "widget-container ".concat(modeClass, " ").concat(getVisibilityClass()).concat(actionBarVisible ? " widget-container--with-action-bar" : "").concat(poppedOut ? " widget-container--popped" : "").concat(collapsedOnMobile ? " widget-container--collapsed" : ""), children: [jsx(WidgetStylesheet, { theme: config === null || config === void 0 ? void 0 : config.theme }), !headerHidden && (jsx(ChatHeader, { accountStatus: chatState.accountStatus, refreshOnClick: handleRestartClick, minimizeOnClick: poppedOut ? handlePopOutDismiss : handleMinimizeClick, cancelOnClick: handleCancelClick, agent: widgetAgent, canRefresh: canRefresh, canMinimize: poppedOut || canMinimize, canCancel: canCancel, config: config === null || config === void 0 ? void 0 : config.header, menuConfig: config.menu, debugMode: (_8 = (_7 = (_6 = props.config) === null || _6 === void 0 ? void 0 : _6.connection) === null || _7 === void 0 ? void 0 : _7.mcp) === null || _8 === void 0 ? void 0 : _8.debugMode, onSubmit: handleOnSubmit })), jsx(MessageList, { visible: visible, queuePosition: chatState.queuePosition, isChatting: chatState.isChatting, isOffline: isOffline, messages: messages, agents: chatState.agents, agent: config === null || config === void 0 ? void 0 : config.agent, lastRatingRequestTimestamp: chatState.lastRatingRequestTimestamp, hasRating: chatState.hasRating, visitorId: chatState.visitorId, hasWsButton: !!chatState.wsButton, messageMiddleware: props.messageMiddleware, textTypingStatusEnabled: (_10 = (_9 = props.config) === null || _9 === void 0 ? void 0 : _9.typingStatus) === null || _10 === void 0 ? void 0 : _10.textTypingStatusEnabled, disableAutoScroll: props.disableAutoScroll, onSend: handleSendMessage, onWrite: handleWriteMessage, onOpenUrl: handleOpenUrl, minimizeOnClick: handleMinimizeClick }), jsx("div", { className: "xa-spinner-container ".concat(visible && connectionStatus === "pending" ? "visible" : ""), children: jsx("div", { className: "xa-spinner" }) }), connectionStatus === "offline" && jsx(ServerOffline, {}), chatState.wsButton && visible && (jsx(WsButton, { button: chatState.wsButton, onPress: handleWsButtonPress })), jsx(ChatFooter, { isAdmin: config === null || config === void 0 ? void 0 : config.isAdmin, isChatting: chatState.isChatting, placeholder: (_11 = config === null || config === void 0 ? void 0 : config.input) === null || _11 === void 0 ? void 0 : _11.placeholder, sendButtonIcon: (_13 = (_12 = config === null || config === void 0 ? void 0 : config.footer) === null || _12 === void 0 ? void 0 : _12.sendButton) === null || _13 === void 0 ? void 0 : _13.icon, sendButtonIconHover: (_15 = (_14 = config === null || config === void 0 ? void 0 : config.footer) === null || _14 === void 0 ? void 0 : _14.sendButton) === null || _15 === void 0 ? void 0 : _15.iconHover, sendButtonIconDisabled: (_17 = (_16 = config === null || config === void 0 ? void 0 : config.footer) === null || _16 === void 0 ? void 0 : _16.sendButton) === null || _17 === void 0 ? void 0 : _17.iconDisabled, visible: visible, hasWsButton: !!chatState.wsButton, menuConfig: menuConfig, footerConfig: (_18 = props.config) === null || _18 === void 0 ? void 0 : _18.footer, inputConfig: (_19 = props.config) === null || _19 === void 0 ? void 0 : _19.input, disabled: previewMode, onChange: handleOnChange, onSubmit: handleOnSubmit, onFileUpload: handleFileUpload }), jsx("div", { className: "restartModal", ref: modalRef, onClick: handleRestartModalCloseClick, children: jsx(ModalContent, { onClose: handleRestartModalCloseClick, onReset: handleReset }) })] }));
37979
+ return (jsxs(Fragment, { children: [popOutEnabled ? (
37980
+ // The anchor keeps the chat's place in the page, so the layout does not jump when
37981
+ // the chat pops out. The chat itself lives in popOutHost, which the effect above
37982
+ // moves between this anchor and the body.
37983
+ jsx("div", { className: "widget-anchor", ref: anchorRef, children: popOutHost && createPortal(widgetContainer, popOutHost) })) : (widgetContainer), actionBarVisible && actionBarConfig && !(collapsedOnMobile && mobileEntry === "button") ? (jsxs(Fragment, { children: [jsx(ActionBar, { config: __assign$1(__assign$1({}, actionBarConfig), {
37744
37984
  // Use actionBar.cta if set, otherwise fall back to widget-level cta
37745
- cta: resolveCta((_11 = config.actionBar.cta) !== null && _11 !== void 0 ? _11 : config.cta, {
37985
+ cta: resolveCta((_20 = actionBarConfig.cta) !== null && _20 !== void 0 ? _20 : config.cta, {
37746
37986
  hasConversation: conversationOnScreen,
37747
37987
  arrivedViaChatLink: arrivedViaChatLink,
37748
- }) }), visible: visible, chatDisabled: config.disabled, formDisabled: isFormPage, hasUserInteracted: (_12 = chatState.visuals) === null || _12 === void 0 ? void 0 : _12.hasInteracted, onChatClick: chatButtonOnClick, onChatMinimize: handleMinimizeClick, onFormClick: handleFormButtonClick, onCtaDismiss: handleCtaDismiss }), formModalOpen && activeFormConfig && (jsx(FormModal, { config: activeFormConfig, widgetEnv: config, onClose: handleFormModalClose }))] })) : (jsx(ChatButton, { addClass: visible ? "visible" : "", onClick: chatButtonOnClick, config: chatButtonCta, imageUrl: (_13 = config === null || config === void 0 ? void 0 : config.chatButton) === null || _13 === void 0 ? void 0 : _13.imageUrl, visible: visible, hasInteracted: (_14 = chatState.visuals) === null || _14 === void 0 ? void 0 : _14.hasInteracted, onCtaDismiss: handleCtaDismiss })), jsx(ErrorOverlay, { enableErrorOverlay: config === null || config === void 0 ? void 0 : config.enableErrorOverlay })] }));
37988
+ }) }), visible: collapsedOnMobile ? false : visible, chatDisabled: config.disabled, formDisabled: isFormPage, hasUserInteracted: (_21 = chatState.visuals) === null || _21 === void 0 ? void 0 : _21.hasInteracted, onChatClick: collapsedOnMobile ? handleMobileOpen : chatButtonOnClick, onChatMinimize: handleMinimizeClick, onFormClick: handleFormButtonClick, onCtaDismiss: handleCtaDismiss }), formModalOpen && activeFormConfig && (jsx(FormModal, { config: activeFormConfig, widgetEnv: config, onClose: handleFormModalClose }))] })) : (jsx(ChatButton, { addClass: visible && !collapsedOnMobile ? "visible" : "", onClick: collapsedOnMobile ? handleMobileOpen : chatButtonOnClick, config: chatButtonCta, imageUrl: (_22 = config === null || config === void 0 ? void 0 : config.chatButton) === null || _22 === void 0 ? void 0 : _22.imageUrl, visible: collapsedOnMobile ? false : visible, hasInteracted: (_23 = chatState.visuals) === null || _23 === void 0 ? void 0 : _23.hasInteracted, onCtaDismiss: handleCtaDismiss })), jsx(ErrorOverlay, { enableErrorOverlay: config === null || config === void 0 ? void 0 : config.enableErrorOverlay })] }));
37749
37989
  };
37750
37990
  /**
37751
37991
  * Top-level wrapper that dispatches between preview mode and connected mode.