@envive-ai/react-widgets-v3 0.3.67 → 0.3.69

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.
Files changed (43) hide show
  1. package/dist/CXIntegration/implementations/useGladlyUnifiedCXButton.cjs +112 -36
  2. package/dist/CXIntegration/implementations/useGladlyUnifiedCXButton.js +112 -36
  3. package/dist/debug/reportIssue.cjs +15 -3
  4. package/dist/debug/reportIssue.js +16 -4
  5. package/dist/hocs/withBaseWidget/withBaseWidget.d.cts +2 -2
  6. package/dist/hocs/withBaseWidget/withBaseWidget.d.ts +2 -2
  7. package/dist/packages/widgets/dist/SearchResults/SearchResultsWidget.d.ts +2 -2
  8. package/dist/widgets/ChatPreviewComparisonWidget/ChatPreviewComparisonWidget.d.cts +3 -3
  9. package/dist/widgets/ChatPreviewLoadingWidget/ChatPreviewLoadingWidget.d.cts +3 -3
  10. package/dist/widgets/ChatPreviewLoadingWidget/ChatPreviewLoadingWidget.d.ts +3 -3
  11. package/dist/widgets/ChatPreviewWidget/ChatPreviewWidget.d.cts +3 -3
  12. package/dist/widgets/ChatPreviewWidget/ChatPreviewWidget.d.ts +3 -3
  13. package/dist/widgets/FloatingChatWidget/FloatingChatWidget.cjs +4 -1
  14. package/dist/widgets/FloatingChatWidget/FloatingChatWidget.d.cts +2 -2
  15. package/dist/widgets/FloatingChatWidget/FloatingChatWidget.d.ts +2 -2
  16. package/dist/widgets/FloatingChatWidget/FloatingChatWidget.js +4 -1
  17. package/dist/widgets/FullPageSalesAgentWidget/FullPageSalesAgentWidget.d.ts +2 -2
  18. package/dist/widgets/ProductCardWidget/ProductCardWidget.cjs +2 -0
  19. package/dist/widgets/ProductCardWidget/ProductCardWidget.d.cts +2 -2
  20. package/dist/widgets/ProductCardWidget/ProductCardWidget.d.ts +2 -2
  21. package/dist/widgets/ProductCardWidget/ProductCardWidget.js +2 -0
  22. package/dist/widgets/PromptButtonCarouselWithImageWidget/PromptButtonCarouselWithImageWidget.d.cts +3 -3
  23. package/dist/widgets/PromptButtonCarouselWithImageWidget/PromptButtonCarouselWithImageWidget.d.ts +3 -3
  24. package/dist/widgets/PromptCarouselWidget/PromptCarouselWidget.d.cts +2 -2
  25. package/dist/widgets/PromptCarouselWidget/PromptCarouselWidget.d.ts +2 -2
  26. package/dist/widgets/SocialProofFlowWidget/SocialProofFlowWidget.d.cts +2 -2
  27. package/dist/widgets/SocialProofFlowWidget/SocialProofFlowWidget.d.ts +2 -2
  28. package/dist/widgets/SocialProofWidget/SocialProofWidget.d.cts +3 -3
  29. package/dist/widgets/SocialProofWidget/SocialProofWidget.d.ts +3 -3
  30. package/dist/widgets/TitledPromptCarouselWidget/TitledPromptCarouselWidget.d.cts +2 -2
  31. package/dist/widgets/TitledPromptCarouselWidget/TitledPromptCarouselWidget.d.ts +2 -2
  32. package/dist/widgets/TypingAnimationFlowWidget/TypingAnimationFlowWidget.d.cts +2 -2
  33. package/dist/widgets/TypingAnimationFlowWidget/TypingAnimationFlowWidget.d.ts +2 -2
  34. package/dist/widgets/TypingAnimationWidget/TypingAnimationWidget.d.cts +3 -3
  35. package/dist/widgets/dist/SearchResults/SearchResultsWidget.d.cts +2 -2
  36. package/package.json +1 -1
  37. package/src/CXIntegration/implementations/__tests__/useGladlyUnifiedCXButton.test.ts +233 -0
  38. package/src/CXIntegration/implementations/useGladlyUnifiedCXButton.ts +173 -40
  39. package/src/debug/reportIssue.tsx +44 -4
  40. package/src/widgets/FloatingChatWidget/FloatingChatWidget.tsx +14 -1
  41. package/src/widgets/FloatingChatWidget/__tests__/FloatingChatWidget.cxClose.test.tsx +138 -0
  42. package/src/widgets/ProductCardWidget/ProductCardWidget.tsx +2 -0
  43. package/src/widgets/ProductCardWidget/__tests__/ProductCardWidget.test.tsx +24 -1
@@ -5,9 +5,24 @@ import { FLOATING_BUTTON_ID } from '../../widgets/FloatingChatWidget/constants';
5
5
  import { CustomerServiceImplProps, UnifiedCXButton } from '../types';
6
6
 
7
7
  const GLADLY_CONTAINER_ID = 'gladlyChat_container';
8
- const GLADLY_BUTTON_SELECTOR = '[data-aid*="gladlyChatChatButton"]';
9
- const GLADLY_OPEN_SIZE_THRESHOLD = 200;
10
- const POLL_INTERVAL_MS = 400;
8
+ const GLADLY_BUTTON_SELECTOR = '#custom-gladly-pill';
9
+ const HIDE_STYLE_ID = 'envive-gladly-pill-hide';
10
+ const ENVIVE_BUTTON_HIDE_STYLE_ID = 'envive-floating-button-hide';
11
+ // Gladly renders its actual UI (launcher icon and chat panel alike) inside an
12
+ // open shadow root attached to `#gladlyChat_container` — confirmed via live
13
+ // inspection: the host's own attributes/size/display never change between
14
+ // open and closed, and the host has zero light-DOM children, so nothing
15
+ // outside the shadow root reflects open/closed state. The open chat panel is
16
+ // comfortably larger than this in both dimensions; the closed-state launcher
17
+ // icon and tooltips are not.
18
+ const GLADLY_PANEL_OPEN_SIZE_THRESHOLD = 200;
19
+ const SHADOW_ATTACH_RETRY_MS = 300;
20
+ // MutationObserver only fires on DOM mutations (nodes/attributes added,
21
+ // removed, or changed) — a size change driven purely by a CSS
22
+ // animation/transition with no accompanying DOM change would never be seen,
23
+ // leaving `hasConfirmedOpenRef` stuck false and the Envive button hidden
24
+ // forever. This polls the shadow root as a safety net alongside it.
25
+ const SHADOW_POLL_MS = 250;
11
26
 
12
27
  type GladlySdk = {
13
28
  show?: () => void;
@@ -25,6 +40,56 @@ const restoreContainer = (node: HTMLElement | null) => {
25
40
  node?.style.removeProperty('display');
26
41
  };
27
42
 
43
+ /**
44
+ * Hide the merchant's floating pill with an injected CSS rule instead of
45
+ * toggling `display` on a specific element reference. Gladly can re-create
46
+ * the pill node after our reference is taken (re-renders, SDK re-init), so a
47
+ * rule keyed by selector keeps applying no matter how many times the node is
48
+ * swapped out, whereas a stale/detached node reference would silently no-op
49
+ * and let the merchant pill reappear next to ours.
50
+ */
51
+ const hideMerchantButton = () => {
52
+ if (document.getElementById(HIDE_STYLE_ID)) return;
53
+ const style = document.createElement('style');
54
+ style.id = HIDE_STYLE_ID;
55
+ style.textContent = `${GLADLY_BUTTON_SELECTOR} { display: none !important; }`;
56
+ document.head.appendChild(style);
57
+ };
58
+
59
+ const showMerchantButton = () => {
60
+ document.getElementById(HIDE_STYLE_ID)?.remove();
61
+ };
62
+
63
+ /**
64
+ * Hide/show the Envive floating button via an injected CSS rule rather than
65
+ * `enviveFloatingButton.hide()/.show()`, which toggles inline `display` on a
66
+ * cached node reference. `FloatingChatWidget` unmounts and remounts that
67
+ * button in response to `isCXOpen` (see `buttonShouldRender`), so the cached
68
+ * reference can point at a node React has already removed — the toggle then
69
+ * silently no-ops and the button stays invisible after Gladly closes. A rule
70
+ * keyed by id keeps applying across remounts instead.
71
+ */
72
+ const hideEnviveButton = () => {
73
+ if (document.getElementById(ENVIVE_BUTTON_HIDE_STYLE_ID)) return;
74
+ const style = document.createElement('style');
75
+ style.id = ENVIVE_BUTTON_HIDE_STYLE_ID;
76
+ style.textContent = `#${FLOATING_BUTTON_ID} { display: none !important; }`;
77
+ document.head.appendChild(style);
78
+ };
79
+
80
+ const showEnviveButton = () => {
81
+ document.getElementById(ENVIVE_BUTTON_HIDE_STYLE_ID)?.remove();
82
+ };
83
+
84
+ const isPanelOpenInShadowRoot = (shadowRoot: ShadowRoot): boolean =>
85
+ Array.from(shadowRoot.querySelectorAll('*')).some(el => {
86
+ const rect = el.getBoundingClientRect();
87
+ return (
88
+ rect.width > GLADLY_PANEL_OPEN_SIZE_THRESHOLD &&
89
+ rect.height > GLADLY_PANEL_OPEN_SIZE_THRESHOLD
90
+ );
91
+ });
92
+
28
93
  interface UseGladlyUnifiedCXButtonProps extends CustomerServiceImplProps {}
29
94
 
30
95
  export const useGladlyUnifiedCXButton = ({
@@ -33,10 +98,53 @@ export const useGladlyUnifiedCXButton = ({
33
98
  suppressMerchantButton,
34
99
  }: UseGladlyUnifiedCXButtonProps): UnifiedCXButton => {
35
100
  const gladlyContainer = useElementObserver(SelectorFactory.id(GLADLY_CONTAINER_ID));
36
- const enviveFloatingButton = useElementObserver(SelectorFactory.id(FLOATING_BUTTON_ID));
37
101
  const wasOpenRef = useRef(false);
102
+ // `toggle()` sets `wasOpenRef` optimistically before Gladly has actually
103
+ // opened the panel. Confirmed-open gates the close check below so a
104
+ // transient intermediate state during opening can't be misread as a close.
105
+ const hasConfirmedOpenRef = useRef(false);
106
+
107
+ const isSwitchEnabled = () => {
108
+ const gladly = getGladly();
109
+ const hasGladlySdk = !!gladly && typeof gladly.show === 'function';
110
+ const hasGladlyButton = !!document.querySelector(GLADLY_BUTTON_SELECTOR);
111
+ const hasGladlyContainer = !!document.getElementById(GLADLY_CONTAINER_ID);
112
+
113
+ return hasGladlySdk || hasGladlyButton || hasGladlyContainer;
114
+ };
38
115
 
39
- const isSwitchEnabled = () => !!document.getElementById(GLADLY_CONTAINER_ID);
116
+ const handleOpen = () => {
117
+ wasOpenRef.current = true;
118
+ hideEnviveButton();
119
+ };
120
+
121
+ const handleClose = () => {
122
+ wasOpenRef.current = false;
123
+ hasConfirmedOpenRef.current = false;
124
+ onCXClose?.();
125
+ // The merchant-visible launcher Gladly falls back to on close lives
126
+ // inside the shadow root, out of reach of the `#custom-gladly-pill`
127
+ // document-level suppression rule below — hiding the whole host is the
128
+ // only way to keep it suppressed once the panel closes.
129
+ if (suppressMerchantButton) {
130
+ forceHideContainer(document.getElementById(GLADLY_CONTAINER_ID));
131
+ }
132
+ showEnviveButton();
133
+ };
134
+
135
+ // Keep the latest handleOpen/handleClose (and the suppressMerchantButton
136
+ // auto-open guard) reachable from the shadow-root MutationObserver set up
137
+ // once below, without re-subscribing that observer on every render.
138
+ const checkShadowOpenStateRef = useRef<(shadowRoot: ShadowRoot) => void>(() => {});
139
+ checkShadowOpenStateRef.current = (shadowRoot: ShadowRoot) => {
140
+ const open = isPanelOpenInShadowRoot(shadowRoot);
141
+ if (open) {
142
+ hasConfirmedOpenRef.current = true;
143
+ if (!wasOpenRef.current) handleOpen();
144
+ } else if (wasOpenRef.current && hasConfirmedOpenRef.current) {
145
+ handleClose();
146
+ }
147
+ };
40
148
 
41
149
  gladlyContainer.onAdd(el => {
42
150
  if (suppressMerchantButton && el && !wasOpenRef.current) {
@@ -51,55 +159,80 @@ export const useGladlyUnifiedCXButton = ({
51
159
  }
52
160
  });
53
161
 
162
+ // Defensive fallback in case Gladly unmounts the container in some
163
+ // configuration we haven't observed.
164
+ gladlyContainer.onRemove(() => {
165
+ if (wasOpenRef.current) handleClose();
166
+ });
167
+
54
168
  useEffect(() => {
55
- if (!suppressMerchantButton) {
169
+ if (suppressMerchantButton) {
170
+ hideMerchantButton();
171
+ } else {
172
+ showMerchantButton();
56
173
  restoreContainer(document.getElementById(GLADLY_CONTAINER_ID));
57
174
  }
175
+ // `FloatingChatWidget` remounts this provider when the configured
176
+ // provider changes (`key={cxProvider}`), so without a cleanup, switching
177
+ // away from Gladly would leave the injected suppression rule (and
178
+ // potentially the host's forced display:none) behind even though this
179
+ // integration is no longer active.
180
+ return () => {
181
+ showMerchantButton();
182
+ restoreContainer(document.getElementById(GLADLY_CONTAINER_ID));
183
+ };
58
184
  }, [suppressMerchantButton]);
59
185
 
186
+ // `#gladlyChat_container` is an open shadow host — its own light-DOM
187
+ // attributes/size never change between Gladly's open and closed states, so
188
+ // open/close has to be read from inside the shadow tree instead. The
189
+ // shadow root may attach asynchronously after the host node itself exists,
190
+ // so this retries briefly until it's there, then observes it directly
191
+ // (regular MutationObserver subtree watching doesn't cross shadow
192
+ // boundaries, which is why nothing here used to fire on open/close).
60
193
  useEffect(() => {
61
- const handleOpen = () => {
62
- wasOpenRef.current = true;
63
- enviveFloatingButton.hide();
64
- };
65
-
66
- const handleClose = () => {
67
- wasOpenRef.current = false;
68
- onCXClose?.();
69
- const node = document.getElementById(GLADLY_CONTAINER_ID);
70
- if (suppressMerchantButton) {
71
- forceHideContainer(node);
194
+ let shadowObserver: MutationObserver | null = null;
195
+ let retryIntervalId: number | undefined;
196
+ let pollIntervalId: number | undefined;
197
+
198
+ const attach = () => {
199
+ const host = document.getElementById(GLADLY_CONTAINER_ID);
200
+ const shadowRoot = host?.shadowRoot;
201
+ if (!shadowRoot || shadowObserver) return;
202
+
203
+ shadowObserver = new MutationObserver(() => checkShadowOpenStateRef.current(shadowRoot));
204
+ shadowObserver.observe(shadowRoot, { childList: true, subtree: true, attributes: true });
205
+ checkShadowOpenStateRef.current(shadowRoot);
206
+ pollIntervalId = window.setInterval(
207
+ () => checkShadowOpenStateRef.current(shadowRoot),
208
+ SHADOW_POLL_MS,
209
+ );
210
+
211
+ if (retryIntervalId) {
212
+ window.clearInterval(retryIntervalId);
213
+ retryIntervalId = undefined;
72
214
  }
73
- enviveFloatingButton.show();
74
215
  };
75
216
 
76
- const checkState = () => {
77
- const node = document.getElementById(GLADLY_CONTAINER_ID);
78
- if (!node) return;
79
-
80
- const styles = window.getComputedStyle(node);
81
- const isHidden = styles.display === 'none' || styles.visibility === 'hidden';
82
- if (isHidden) {
83
- if (wasOpenRef.current) handleClose();
84
- return;
85
- }
86
-
87
- const rect = node.getBoundingClientRect();
88
- const isOpen =
89
- rect.width > GLADLY_OPEN_SIZE_THRESHOLD && rect.height > GLADLY_OPEN_SIZE_THRESHOLD;
90
-
91
- if (isOpen && !wasOpenRef.current) handleOpen();
92
- else if (!isOpen && wasOpenRef.current) handleClose();
217
+ // Started before the synchronous attach() attempt so a successful
218
+ // same-tick attachment (shadow root already present) can clear it
219
+ // immediately, instead of leaving it running for the component's entire
220
+ // lifetime because it was created after attach() already returned.
221
+ retryIntervalId = window.setInterval(attach, SHADOW_ATTACH_RETRY_MS);
222
+ attach();
223
+
224
+ return () => {
225
+ shadowObserver?.disconnect();
226
+ if (retryIntervalId) window.clearInterval(retryIntervalId);
227
+ if (pollIntervalId) window.clearInterval(pollIntervalId);
93
228
  };
94
-
95
- const intervalId = window.setInterval(checkState, POLL_INTERVAL_MS);
96
- return () => window.clearInterval(intervalId);
97
- }, [suppressMerchantButton, enviveFloatingButton, onCXClose]);
229
+ }, []);
98
230
 
99
231
  const toggle = () => {
100
232
  onSwitchToAgent();
101
- enviveFloatingButton.hide();
233
+ hideEnviveButton();
102
234
  wasOpenRef.current = true;
235
+ hasConfirmedOpenRef.current = false;
103
236
 
104
237
  restoreContainer(document.getElementById(GLADLY_CONTAINER_ID));
105
238
 
@@ -5,7 +5,8 @@ import {
5
5
  Suggestion,
6
6
  TurnInfo,
7
7
  } from '@spiffy-ai/commerce-api-client';
8
- import { ChangeEvent, useCallback, useEffect, useState } from 'react';
8
+ import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react';
9
+ import { createPortal } from 'react-dom';
9
10
  import { useAtomValue } from 'jotai';
10
11
 
11
12
  import { Message, MessageType } from '@envive-ai/react-hooks/application/models';
@@ -418,18 +419,49 @@ interface DrawerProps {
418
419
  onClose: () => void;
419
420
  children: React.ReactNode;
420
421
  width?: string;
422
+ // An element inside the same root (shadow root, or document for tests/Storybook)
423
+ // the drawer should portal into — see the comment on getPortalTarget below.
424
+ anchorNode: Element | null;
421
425
  }
422
426
 
427
+ // The drawer is `position: fixed`, but it's rendered wherever ReportIssue
428
+ // happens to be mounted — currently inside the chat footer, which framer-motion
429
+ // animates via `layoutId` (FullPageSalesAgent.tsx). That layout animation keeps
430
+ // a `transform`/`will-change: transform` on the footer, which (per the CSS
431
+ // spec) makes it the containing block for any `position: fixed` descendant,
432
+ // trapping the drawer inside the footer's own bounds instead of the viewport.
433
+ // Portaling to the root of the current shadow tree (or document.body outside
434
+ // one, e.g. Storybook/tests) sidesteps that regardless of where ReportIssue
435
+ // ends up nested in the future — CSS custom properties and the Tailwind
436
+ // utility classes it relies on still apply, since both work off the whole
437
+ // shadow tree rather than DOM-portal structure.
438
+ const getPortalTarget = (node: Element | null): Element | ShadowRoot | null => {
439
+ const root = node?.getRootNode();
440
+ if (root instanceof ShadowRoot) {
441
+ return root;
442
+ }
443
+ if (root instanceof Document) {
444
+ return root.body;
445
+ }
446
+ return null;
447
+ };
448
+
423
449
  const Drawer = ({
424
450
  isOpen,
425
451
  onClose,
426
452
  children,
427
453
  width = 'md:envive-tw-w-[80%] lg:envive-tw-w-[67%]',
454
+ anchorNode,
428
455
  }: DrawerProps) => {
429
456
  if (!isOpen) return null;
430
457
 
431
- return (
432
- <div className="envive-tw-fixed envive-tw-right-[0] envive-tw-top-[0] envive-tw-z-[9999] envive-tw-flex envive-tw-w-full envive-tw-flex-row envive-tw-justify-end">
458
+ const content = (
459
+ // Even portaled to the shadow root, this still competes for stacking order
460
+ // against the merchant's own page content (not just our widget tree) —
461
+ // some themes put their own chrome (nav, sticky headers) at the
462
+ // practical max z-index (2147483647, the largest value z-index accepts),
463
+ // which would otherwise still paint over a merely-high value like 9999.
464
+ <div className="envive-tw-fixed envive-tw-right-[0] envive-tw-top-[0] envive-tw-z-[2147483647] envive-tw-flex envive-tw-w-full envive-tw-flex-row envive-tw-justify-end">
433
465
  <div
434
466
  className="envive-tw-bg envive-tw-flex-grow envive-tw-bg-black/40"
435
467
  onClick={onClose}
@@ -441,9 +473,13 @@ const Drawer = ({
441
473
  </div>
442
474
  </div>
443
475
  );
476
+
477
+ const portalTarget = getPortalTarget(anchorNode);
478
+ return portalTarget ? createPortal(content, portalTarget) : content;
444
479
  };
445
480
 
446
481
  export const ReportIssue = () => {
482
+ const anchorRef = useRef<HTMLDivElement>(null);
447
483
  const { messages, suggestions, userEvents } = useAtomValue(chatAtom);
448
484
  const lastMessages = useAtomValue(lastAssistantMessageAtom);
449
485
  const [lastMessagesText, setLastMessagesText] = useState<string>('');
@@ -606,7 +642,10 @@ export const ReportIssue = () => {
606
642
  });
607
643
 
608
644
  return (
609
- <div className="envive-tw-mx-[16px] envive-tw-block envive-tw-h-[24px] envive-tw-w-full">
645
+ <div
646
+ ref={anchorRef}
647
+ className="envive-tw-mx-[16px] envive-tw-block envive-tw-h-[24px] envive-tw-w-full"
648
+ >
610
649
  <button
611
650
  type="button"
612
651
  onClick={e => {
@@ -620,6 +659,7 @@ export const ReportIssue = () => {
620
659
  <Drawer
621
660
  isOpen={isOpen}
622
661
  onClose={onClose}
662
+ anchorNode={anchorRef.current}
623
663
  >
624
664
  <ReportHeader onClose={onClose} />
625
665
  <div className="envive-tw-min-h-0 envive-tw-p-0 envive-tw-flex envive-tw-flex-[1_1_auto] envive-tw-flex-col envive-tw-overflow-y-auto">
@@ -156,7 +156,20 @@ const FloatingChatWidgetHandler = (props: FloatingChatWidgetHandlerProps) => {
156
156
  enabled={customerServiceIntegration?.enabled ?? false}
157
157
  suppressMerchantButton={customerServiceIntegration?.suppressMerchantButton ?? false}
158
158
  onSwitchToAgent={() => setIsCXOpen(true)}
159
- onCXClose={() => setIsCXOpen(false)}
159
+ onCXClose={() => {
160
+ setIsCXOpen(false);
161
+ // Closing the CX provider's chat (e.g. Gladly) should collapse the
162
+ // whole widget back to the floating button, not just drop back to
163
+ // the Envive chat underneath — the floating button only renders
164
+ // when both `isCXOpen` and the overlay's own `isOpen` are false.
165
+ if (!previewChatAlwaysOpen) {
166
+ closeChat(
167
+ ChatElementDisplayLocationV3.FLOATING_CHAT_CLOSE_BUTTON,
168
+ undefined,
169
+ 'close_button',
170
+ );
171
+ }
172
+ }}
160
173
  >
161
174
  {({ isSwitchEnabled, toggle }) => (
162
175
  <>
@@ -0,0 +1,138 @@
1
+ import { useState } from 'react';
2
+ import { act, render, screen } from '@testing-library/react';
3
+ import { FloatingChatWidget } from '../FloatingChatWidget';
4
+
5
+ /**
6
+ * Reproduces, without a browser, the exact state composition behind "the
7
+ * Envive floating button doesn't come back after closing Gladly": the button
8
+ * only renders when BOTH `isCXOpen` (owned by this widget) AND the Envive
9
+ * chat overlay's own `isOpen` (owned by `useChatToggle`, a separate jotai
10
+ * atom) are false. `onCXClose` must flip both.
11
+ *
12
+ * `useChatToggle` is mocked here with the same shape as the real hook
13
+ * (including the `if (isOpen) toggle()` closure-captured gate in `closeChat`)
14
+ * so this also catches a stale-closure regression, not just a logic typo.
15
+ */
16
+ let capturedOnSwitchToAgent: (() => void) | undefined;
17
+ let capturedOnCXClose: (() => void) | undefined;
18
+
19
+ vi.mock('../../../hocs/withBaseWidget', () => ({
20
+ withBaseWidget: (Widget: React.ComponentType<Record<string, unknown>>) => {
21
+ const Wrapped = (props: Record<string, unknown>) => (
22
+ <Widget
23
+ {...props}
24
+ uiConfig={{
25
+ customerServiceIntegration: {
26
+ enabled: true,
27
+ provider: 'gladly',
28
+ suppressMerchantButton: true,
29
+ },
30
+ floatingButton: { showOption: 'always' },
31
+ floatingChat: {},
32
+ lookAndFeel: {},
33
+ }}
34
+ isUiConfigLoading={false}
35
+ hardcopyContent={undefined}
36
+ />
37
+ );
38
+ return Wrapped;
39
+ },
40
+ }));
41
+
42
+ vi.mock('@envive-ai/react-toolkit-v3/FloatingButton', () => ({
43
+ FloatingButton: () => <div data-testid="floating-button">Floating Button</div>,
44
+ }));
45
+
46
+ vi.mock('@envive-ai/react-hooks/contexts/amplitudeContext', () => ({
47
+ useAmplitude: () => ({ trackEvent: vi.fn(), isReady: true }),
48
+ }));
49
+
50
+ vi.mock('@envive-ai/react-hooks/hooks/TrackComponentVisibleEvent', () => ({
51
+ useTrackComponentVisibleEvent: () => ({ isVisible: true }),
52
+ }));
53
+
54
+ vi.mock('@envive-ai/react-hooks/contexts/salesAgentContext', () => ({
55
+ useSalesAgent: () => ({}),
56
+ }));
57
+
58
+ vi.mock('@envive-ai/react-hooks/contexts/featureFlagServiceContext', () => ({
59
+ useFeatureFlagService: () => ({
60
+ featureFlagService: { isFeatureGateEnabled: vi.fn().mockReturnValue(false) },
61
+ }),
62
+ }));
63
+
64
+ vi.mock('../../hooks/useGetWidgetStatus', () => ({
65
+ default: () => ({ userHasInteractedValue: false }),
66
+ }));
67
+
68
+ vi.mock('../hooks/useFloatingButtonVisibility', () => ({
69
+ useFloatingButtonVisibility: () => ({ shouldShowFloatingButton: true }),
70
+ }));
71
+
72
+ vi.mock('../hooks/useAutoPopup', () => ({
73
+ useAutoPopup: () => {},
74
+ }));
75
+
76
+ vi.mock('src/debug/debugBar', () => ({
77
+ DebugBar: () => null,
78
+ }));
79
+
80
+ vi.mock('../FloatingChatOverlay', () => ({
81
+ FloatingChatOverlay: () => null,
82
+ }));
83
+
84
+ // Mirrors the real `useChatToggle` (packages/hooks/src/hooks/ChatToggle/useChatToggle.ts):
85
+ // `closeChat`/`openChat` only call `toggle` when the closure's own `isOpen` says it's
86
+ // necessary, exactly like the real hook's `if (isOpen) toggle(...)` guards.
87
+ vi.mock('@envive-ai/react-hooks/hooks/ChatToggle', () => ({
88
+ useChatToggle: () => {
89
+ const [isOpen, setIsOpen] = useState(true); // Envive chat starts open
90
+ const toggle = () => setIsOpen(prev => !prev);
91
+ return {
92
+ isOpen,
93
+ openChat: () => {
94
+ if (!isOpen) toggle();
95
+ },
96
+ closeChat: () => {
97
+ if (isOpen) toggle();
98
+ },
99
+ onHover: () => {},
100
+ };
101
+ },
102
+ }));
103
+
104
+ vi.mock('../../../CXIntegration/hooks/useUnifiedCXButton', () => ({
105
+ useUnifiedCXButton: (props: { onSwitchToAgent: () => void; onCXClose: () => void }) => {
106
+ capturedOnSwitchToAgent = props.onSwitchToAgent;
107
+ capturedOnCXClose = props.onCXClose;
108
+ return { isSwitchEnabled: () => true, toggle: () => {} };
109
+ },
110
+ }));
111
+
112
+ describe('FloatingChatWidget — CX close restores the floating button', () => {
113
+ beforeEach(() => {
114
+ capturedOnSwitchToAgent = undefined;
115
+ capturedOnCXClose = undefined;
116
+ });
117
+
118
+ it('does not render the floating button while the CX chat (Gladly) is open', () => {
119
+ render(<FloatingChatWidget />);
120
+
121
+ expect(capturedOnSwitchToAgent).toBeDefined();
122
+ act(() => capturedOnSwitchToAgent?.());
123
+
124
+ expect(screen.queryByTestId('floating-button')).toBeNull();
125
+ });
126
+
127
+ it('renders the floating button again once the CX chat closes, even though the Envive overlay was still open the whole time', () => {
128
+ render(<FloatingChatWidget />);
129
+
130
+ act(() => capturedOnSwitchToAgent?.());
131
+ expect(screen.queryByTestId('floating-button')).toBeNull();
132
+
133
+ expect(capturedOnCXClose).toBeDefined();
134
+ act(() => capturedOnCXClose?.());
135
+
136
+ expect(screen.getByTestId('floating-button')).toBeInTheDocument();
137
+ });
138
+ });
@@ -50,6 +50,7 @@ const ProductCardWidgetHandler = (props: BaseWidgetProps) => {
50
50
  const carouselId = productCardWidgetConfig?.contentId || '';
51
51
  const promptButtonType =
52
52
  (productCardWidgetConfig?.promptButtonType as PromptButtonVariant) || PromptButtonVariant.LIGHT;
53
+ const promptButtonLayout = productCardWidgetConfig?.promptButtonLayout;
53
54
  const italicizeHeadline = productCardWidgetConfig?.italicizeHeadline;
54
55
  const fallbackColor = productCardWidgetConfig?.fallbackColor;
55
56
  const imageSrc = productCardWidgetConfig?.imageSrc || '';
@@ -143,6 +144,7 @@ const ProductCardWidgetHandler = (props: BaseWidgetProps) => {
143
144
  animatedText={animatedText}
144
145
  prompts={prompts}
145
146
  promptButtonType={promptButtonType}
147
+ promptButtonLayout={promptButtonLayout}
146
148
  carouselId={carouselId}
147
149
  placeholder={placeholder}
148
150
  voiceInputEnabled={isVoiceInputEnabled}
@@ -6,6 +6,7 @@ import { ProductCardWidget } from '../ProductCardWidget';
6
6
 
7
7
  const mockTrackEvent = vi.fn();
8
8
  const mockGetHardcopy = vi.fn();
9
+ const mockGetWidgetConfig = vi.fn();
9
10
  const mockUseTrackComponentVisibleEvent = vi.fn(() => ({ isVisible: true }));
10
11
 
11
12
  vi.mock('@envive-ai/react-toolkit-v3/ProductCard', () => ({
@@ -16,14 +17,17 @@ vi.mock('@envive-ai/react-toolkit-v3/ProductCard', () => ({
16
17
  prompts,
17
18
  onSelect,
18
19
  isLoading,
20
+ promptButtonLayout,
19
21
  }: {
20
22
  prompts: string[];
21
23
  onSelect: (text: string) => void;
22
24
  isLoading?: boolean;
25
+ promptButtonLayout?: string;
23
26
  }) => (
24
27
  <div
25
28
  data-testid="product-card-mock"
26
29
  data-loading={String(Boolean(isLoading))}
30
+ data-prompt-button-layout={promptButtonLayout}
27
31
  >
28
32
  {prompts.map(text => (
29
33
  <button
@@ -84,7 +88,7 @@ vi.mock('@envive-ai/react-hooks/contexts/pageContext', () => ({
84
88
 
85
89
  vi.mock('@envive-ai/react-hooks/contexts/widgetConfigContext', () => ({
86
90
  useWidgetConfig: () => ({
87
- getWidgetConfig: vi.fn().mockResolvedValue({}),
91
+ getWidgetConfig: mockGetWidgetConfig,
88
92
  }),
89
93
  }));
90
94
 
@@ -124,6 +128,24 @@ describe('ProductCardWidget analytics', () => {
124
128
  beforeEach(() => {
125
129
  vi.clearAllMocks();
126
130
  mockGetHardcopy.mockResolvedValue(defaultHardcopy);
131
+ mockGetWidgetConfig.mockResolvedValue({});
132
+ });
133
+
134
+ it('passes promptButtonLayout from the widget config through to the ProductCard', async () => {
135
+ mockGetWidgetConfig.mockResolvedValue({
136
+ widgetConfigId: 'layout-1',
137
+ type: WidgetTypeV3.ProductCardV3,
138
+ promptButtonLayout: 'stacked',
139
+ });
140
+
141
+ render(<ProductCardWidget widgetConfigId="layout-1" />);
142
+
143
+ await waitFor(() =>
144
+ expect(screen.getByTestId('product-card-mock')).toHaveAttribute(
145
+ 'data-prompt-button-layout',
146
+ 'stacked',
147
+ ),
148
+ );
127
149
  });
128
150
 
129
151
  it('should track ChatComponentVisible when widget mounts', async () => {
@@ -174,6 +196,7 @@ describe('ProductCardWidget analytics', () => {
174
196
  describe('ProductCardWidget loading state', () => {
175
197
  beforeEach(() => {
176
198
  vi.clearAllMocks();
199
+ mockGetWidgetConfig.mockResolvedValue({});
177
200
  });
178
201
 
179
202
  const hardcopy = {