@envive-ai/react-widgets-v3 0.3.68 → 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 (75) hide show
  1. package/dist/CXIntegration/hooks/useUnifiedCXButton.cjs +1 -1
  2. package/dist/CXIntegration/hooks/useUnifiedCXButton.js +1 -1
  3. package/dist/CXIntegration/implementations/useGladlyUnifiedCXButton.cjs +112 -36
  4. package/dist/CXIntegration/implementations/useGladlyUnifiedCXButton.js +112 -36
  5. package/dist/debug/chatEmbed.cjs +3 -3
  6. package/dist/debug/chatEmbed.js +3 -3
  7. package/dist/debug/reportIssue.cjs +18 -6
  8. package/dist/debug/reportIssue.js +19 -7
  9. package/dist/hocs/withBaseWidget/types.d.ts +1 -1
  10. package/dist/hocs/withBaseWidget/withBaseWidget.cjs +2 -2
  11. package/dist/hocs/withBaseWidget/withBaseWidget.d.cts +2 -2
  12. package/dist/hocs/withBaseWidget/withBaseWidget.d.ts +2 -2
  13. package/dist/hocs/withBaseWidget/withBaseWidget.js +2 -2
  14. package/dist/widgets/ChatPreviewComparisonWidget/ChatPreviewComparisonWidget.cjs +8 -8
  15. package/dist/widgets/ChatPreviewComparisonWidget/ChatPreviewComparisonWidget.d.cts +3 -3
  16. package/dist/widgets/ChatPreviewComparisonWidget/ChatPreviewComparisonWidget.d.ts +3 -3
  17. package/dist/widgets/ChatPreviewComparisonWidget/ChatPreviewComparisonWidget.js +8 -8
  18. package/dist/widgets/ChatPreviewLoadingWidget/ChatPreviewLoadingWidget.cjs +3 -3
  19. package/dist/widgets/ChatPreviewLoadingWidget/ChatPreviewLoadingWidget.d.cts +3 -3
  20. package/dist/widgets/ChatPreviewLoadingWidget/ChatPreviewLoadingWidget.d.ts +3 -3
  21. package/dist/widgets/ChatPreviewLoadingWidget/ChatPreviewLoadingWidget.js +3 -3
  22. package/dist/widgets/ChatPreviewWidget/ChatPreviewWidget.cjs +6 -6
  23. package/dist/widgets/ChatPreviewWidget/ChatPreviewWidget.d.cts +3 -3
  24. package/dist/widgets/ChatPreviewWidget/ChatPreviewWidget.d.ts +3 -3
  25. package/dist/widgets/ChatPreviewWidget/ChatPreviewWidget.js +6 -6
  26. package/dist/widgets/FloatingChatWidget/FloatingChatWidget.cjs +7 -4
  27. package/dist/widgets/FloatingChatWidget/FloatingChatWidget.d.cts +2 -2
  28. package/dist/widgets/FloatingChatWidget/FloatingChatWidget.d.ts +2 -2
  29. package/dist/widgets/FloatingChatWidget/FloatingChatWidget.js +7 -4
  30. package/dist/widgets/FloatingChatWidget/hooks/useAutoPopup.cjs +2 -2
  31. package/dist/widgets/FloatingChatWidget/hooks/useAutoPopup.js +2 -2
  32. package/dist/widgets/FloatingChatWidget/hooks/useFloatingButtonVisibility.cjs +3 -3
  33. package/dist/widgets/FloatingChatWidget/hooks/useFloatingButtonVisibility.js +3 -3
  34. package/dist/widgets/FullPageSalesAgentWidget/FullPageSalesAgentWidget.cjs +2 -2
  35. package/dist/widgets/FullPageSalesAgentWidget/FullPageSalesAgentWidget.d.cts +2 -2
  36. package/dist/widgets/FullPageSalesAgentWidget/FullPageSalesAgentWidget.d.ts +2 -2
  37. package/dist/widgets/FullPageSalesAgentWidget/FullPageSalesAgentWidget.js +2 -2
  38. package/dist/widgets/ProductCardWidget/ProductCardWidget.cjs +4 -4
  39. package/dist/widgets/ProductCardWidget/ProductCardWidget.d.cts +2 -2
  40. package/dist/widgets/ProductCardWidget/ProductCardWidget.d.ts +2 -2
  41. package/dist/widgets/ProductCardWidget/ProductCardWidget.js +4 -4
  42. package/dist/widgets/PromptButtonCarouselWithImageWidget/PromptButtonCarouselWithImageWidget.cjs +6 -6
  43. package/dist/widgets/PromptButtonCarouselWithImageWidget/PromptButtonCarouselWithImageWidget.d.cts +3 -3
  44. package/dist/widgets/PromptButtonCarouselWithImageWidget/PromptButtonCarouselWithImageWidget.d.ts +3 -3
  45. package/dist/widgets/PromptButtonCarouselWithImageWidget/PromptButtonCarouselWithImageWidget.js +6 -6
  46. package/dist/widgets/PromptCarouselWidget/PromptCarouselWidget.cjs +3 -3
  47. package/dist/widgets/PromptCarouselWidget/PromptCarouselWidget.d.ts +2 -2
  48. package/dist/widgets/PromptCarouselWidget/PromptCarouselWidget.js +3 -3
  49. package/dist/widgets/SocialProofFlowWidget/SocialProofFlowWidget.cjs +2 -2
  50. package/dist/widgets/SocialProofFlowWidget/SocialProofFlowWidget.d.cts +2 -2
  51. package/dist/widgets/SocialProofFlowWidget/SocialProofFlowWidget.js +2 -2
  52. package/dist/widgets/SocialProofWidget/SocialProofWidget.cjs +8 -8
  53. package/dist/widgets/SocialProofWidget/SocialProofWidget.d.cts +3 -3
  54. package/dist/widgets/SocialProofWidget/SocialProofWidget.d.ts +3 -3
  55. package/dist/widgets/SocialProofWidget/SocialProofWidget.js +8 -8
  56. package/dist/widgets/TitledPromptCarouselWidget/TitledPromptCarouselWidget.cjs +2 -2
  57. package/dist/widgets/TitledPromptCarouselWidget/TitledPromptCarouselWidget.d.cts +2 -2
  58. package/dist/widgets/TitledPromptCarouselWidget/TitledPromptCarouselWidget.d.ts +2 -2
  59. package/dist/widgets/TitledPromptCarouselWidget/TitledPromptCarouselWidget.js +2 -2
  60. package/dist/widgets/TypingAnimationFlowWidget/TypingAnimationFlowWidget.cjs +2 -2
  61. package/dist/widgets/TypingAnimationFlowWidget/TypingAnimationFlowWidget.d.cts +2 -2
  62. package/dist/widgets/TypingAnimationFlowWidget/TypingAnimationFlowWidget.d.ts +2 -2
  63. package/dist/widgets/TypingAnimationFlowWidget/TypingAnimationFlowWidget.js +2 -2
  64. package/dist/widgets/TypingAnimationWidget/TypingAnimationWidget.cjs +4 -4
  65. package/dist/widgets/TypingAnimationWidget/TypingAnimationWidget.d.cts +3 -3
  66. package/dist/widgets/TypingAnimationWidget/TypingAnimationWidget.d.ts +3 -3
  67. package/dist/widgets/TypingAnimationWidget/TypingAnimationWidget.js +4 -4
  68. package/dist/widgets/hooks/useGetWidgetStatus.cjs +1 -1
  69. package/dist/widgets/hooks/useGetWidgetStatus.js +1 -1
  70. package/package.json +1 -1
  71. package/src/CXIntegration/implementations/__tests__/useGladlyUnifiedCXButton.test.ts +233 -0
  72. package/src/CXIntegration/implementations/useGladlyUnifiedCXButton.ts +173 -40
  73. package/src/debug/reportIssue.tsx +44 -4
  74. package/src/widgets/FloatingChatWidget/FloatingChatWidget.tsx +14 -1
  75. package/src/widgets/FloatingChatWidget/__tests__/FloatingChatWidget.cxClose.test.tsx +138 -0
@@ -0,0 +1,233 @@
1
+ import { renderHook, waitFor } from '@testing-library/react';
2
+ import { DOMObserver } from '@envive-ai/react-hooks/application/utils';
3
+ import { useGladlyUnifiedCXButton } from '../useGladlyUnifiedCXButton';
4
+
5
+ const GLADLY_CONTAINER_ID = 'gladlyChat_container';
6
+ const HIDE_STYLE_ID = 'envive-gladly-pill-hide';
7
+ const ENVIVE_BUTTON_HIDE_STYLE_ID = 'envive-floating-button-hide';
8
+
9
+ /**
10
+ * Gladly renders into an OPEN shadow root attached to `#gladlyChat_container`
11
+ * (confirmed via live DOM inspection — see PR history). The host's own
12
+ * attributes/size never change between open and closed, and it has zero
13
+ * light-DOM children, so open/close can only be read from inside the shadow
14
+ * tree. Closed state: a small launcher icon (~50x50). Open state: a chat
15
+ * panel comfortably larger than the 200px threshold in both dimensions.
16
+ */
17
+ describe('useGladlyUnifiedCXButton', () => {
18
+ beforeEach(() => {
19
+ document.body.innerHTML = '';
20
+ document.head.innerHTML = '';
21
+ });
22
+
23
+ afterEach(() => {
24
+ DOMObserver.destroy();
25
+ });
26
+
27
+ const flush = (ms = 0) =>
28
+ new Promise<void>(resolve => {
29
+ setTimeout(resolve, ms);
30
+ });
31
+
32
+ // jsdom has no layout engine — getBoundingClientRect() always returns all
33
+ // zeroes — so the sizes our production code reads from the real DOM have
34
+ // to be stubbed per element to exercise that branch here.
35
+ const stubRect = (el: HTMLElement, width: number, height: number) => {
36
+ Object.defineProperty(el, 'getBoundingClientRect', {
37
+ configurable: true,
38
+ value: () => ({ width, height, top: 0, left: 0, right: width, bottom: height }) as DOMRect,
39
+ });
40
+ };
41
+
42
+ const createHostWithShadowRoot = () => {
43
+ const host = document.createElement('div');
44
+ host.id = GLADLY_CONTAINER_ID;
45
+ document.body.appendChild(host);
46
+ const shadowRoot = host.attachShadow({ mode: 'open' });
47
+ return shadowRoot;
48
+ };
49
+
50
+ const renderClosedIcon = (shadowRoot: ShadowRoot) => {
51
+ const icon = document.createElement('button');
52
+ icon.className = '_chatIconButton_z80r8_17';
53
+ stubRect(icon, 50, 50);
54
+ shadowRoot.appendChild(icon);
55
+ return icon;
56
+ };
57
+
58
+ const renderOpenPanel = (shadowRoot: ShadowRoot) => {
59
+ const panel = document.createElement('div');
60
+ panel.className = '_baseWidgetContainer_1mosp_1 _panel_1mosp_21';
61
+ stubRect(panel, 364, 577);
62
+ shadowRoot.appendChild(panel);
63
+ return panel;
64
+ };
65
+
66
+ it('opens, then closes when the panel is replaced by the closed-state launcher icon', async () => {
67
+ const onSwitchToAgent = vi.fn();
68
+ const onCXClose = vi.fn();
69
+
70
+ // Host + shadow root already exist before the hook mounts, so its
71
+ // mount-time attach() finds them synchronously (no need to wait out the
72
+ // retry interval here — that path is covered by the next test).
73
+ const shadowRoot = createHostWithShadowRoot();
74
+ const icon = renderClosedIcon(shadowRoot);
75
+
76
+ const { result } = renderHook(() =>
77
+ useGladlyUnifiedCXButton({ onSwitchToAgent, onCXClose, suppressMerchantButton: true }),
78
+ );
79
+ await flush();
80
+
81
+ result.current.toggle();
82
+ expect(onSwitchToAgent).toHaveBeenCalledTimes(1);
83
+ expect(document.getElementById(ENVIVE_BUTTON_HIDE_STYLE_ID)).not.toBeNull();
84
+
85
+ // Gladly swaps the closed-state icon for the open chat panel.
86
+ icon.remove();
87
+ renderOpenPanel(shadowRoot);
88
+ await flush();
89
+
90
+ expect(onCXClose).not.toHaveBeenCalled();
91
+ expect(document.getElementById(ENVIVE_BUTTON_HIDE_STYLE_ID)).not.toBeNull();
92
+
93
+ // Real close: panel replaced by the small launcher icon again.
94
+ shadowRoot.querySelector('div')?.remove();
95
+ renderClosedIcon(shadowRoot);
96
+ await flush();
97
+
98
+ await waitFor(() => expect(onCXClose).toHaveBeenCalledTimes(1));
99
+ expect(document.getElementById(ENVIVE_BUTTON_HIDE_STYLE_ID)).toBeNull();
100
+ // The closed-state launcher icon lives inside the shadow root, out of
101
+ // reach of the `#custom-gladly-pill` document-level suppression rule —
102
+ // the whole host must be force-hidden again so it doesn't reappear.
103
+ expect(document.getElementById(GLADLY_CONTAINER_ID)?.style.display).toBe('none');
104
+ });
105
+
106
+ it('attaches to the shadow root even when it is not available until after the host node is created', async () => {
107
+ const onSwitchToAgent = vi.fn();
108
+ const onCXClose = vi.fn();
109
+
110
+ renderHook(() =>
111
+ useGladlyUnifiedCXButton({ onSwitchToAgent, onCXClose, suppressMerchantButton: true }),
112
+ ).result.current.toggle();
113
+
114
+ // Host appears first (e.g. Gladly SDK init), shadow root + content a tick
115
+ // later — after the hook's first attach() attempt already ran and found
116
+ // nothing, so this exercises the retry interval actually catching up.
117
+ const host = document.createElement('div');
118
+ host.id = GLADLY_CONTAINER_ID;
119
+ document.body.appendChild(host);
120
+ await flush();
121
+
122
+ const shadowRoot = host.attachShadow({ mode: 'open' });
123
+ renderOpenPanel(shadowRoot);
124
+ await flush(350);
125
+
126
+ expect(onCXClose).not.toHaveBeenCalled();
127
+
128
+ shadowRoot.querySelector('div')?.remove();
129
+ renderClosedIcon(shadowRoot);
130
+ await flush();
131
+
132
+ await waitFor(() => expect(onCXClose).toHaveBeenCalledTimes(1));
133
+ });
134
+
135
+ it('does not treat a transient small render during the opening animation as a close', async () => {
136
+ // toggle() sets wasOpenRef optimistically before the panel has actually
137
+ // grown to its open size. Without the hasConfirmedOpenRef gate, reading
138
+ // the panel here (still mid-animation) would be misread as "closed" and
139
+ // immediately flip the Envive button back — the panel flashing open then
140
+ // shut that an earlier version of this fix regressed on.
141
+ const onSwitchToAgent = vi.fn();
142
+ const onCXClose = vi.fn();
143
+
144
+ const shadowRoot = createHostWithShadowRoot();
145
+ const panel = document.createElement('div');
146
+ panel.className = '_baseWidgetContainer_1mosp_1 _panel_1mosp_21';
147
+ stubRect(panel, 40, 40); // still animating in, hasn't reached open size yet
148
+ shadowRoot.appendChild(panel);
149
+
150
+ renderHook(() =>
151
+ useGladlyUnifiedCXButton({ onSwitchToAgent, onCXClose, suppressMerchantButton: true }),
152
+ ).result.current.toggle();
153
+ await flush();
154
+
155
+ expect(onCXClose).not.toHaveBeenCalled();
156
+ expect(document.getElementById(ENVIVE_BUTTON_HIDE_STYLE_ID)).not.toBeNull();
157
+ });
158
+
159
+ it('confirms the open state via polling when the panel grows without an accompanying DOM mutation', async () => {
160
+ // MutationObserver never fires for a size change that isn't driven by a
161
+ // DOM mutation (e.g. a CSS transition finishing). Only the polling
162
+ // fallback can pick this up and set hasConfirmedOpenRef, which the later
163
+ // real (mutation-based) close then depends on to fire at all.
164
+ const onSwitchToAgent = vi.fn();
165
+ const onCXClose = vi.fn();
166
+
167
+ const shadowRoot = createHostWithShadowRoot();
168
+ const panel = document.createElement('div');
169
+ panel.className = '_baseWidgetContainer_1mosp_1 _panel_1mosp_21';
170
+ stubRect(panel, 40, 40);
171
+ shadowRoot.appendChild(panel);
172
+
173
+ renderHook(() =>
174
+ useGladlyUnifiedCXButton({ onSwitchToAgent, onCXClose, suppressMerchantButton: true }),
175
+ ).result.current.toggle();
176
+ await flush();
177
+
178
+ // Panel reaches its open size purely via layout/animation — no DOM
179
+ // mutation accompanies it.
180
+ stubRect(panel, 364, 577);
181
+ await flush(300); // > SHADOW_POLL_MS, no further mutation triggered
182
+
183
+ panel.remove();
184
+ renderClosedIcon(shadowRoot);
185
+ await flush();
186
+
187
+ await waitFor(() => expect(onCXClose).toHaveBeenCalledTimes(1));
188
+ });
189
+
190
+ it('removes the suppression rule and restores the host when suppressMerchantButton turns off', async () => {
191
+ const onSwitchToAgent = vi.fn();
192
+ const onCXClose = vi.fn();
193
+
194
+ const { rerender } = renderHook(
195
+ ({ suppressMerchantButton }) =>
196
+ useGladlyUnifiedCXButton({ onSwitchToAgent, onCXClose, suppressMerchantButton }),
197
+ { initialProps: { suppressMerchantButton: true } },
198
+ );
199
+
200
+ expect(document.getElementById(HIDE_STYLE_ID)).not.toBeNull();
201
+
202
+ const host = document.createElement('div');
203
+ host.id = GLADLY_CONTAINER_ID;
204
+ host.style.display = 'none';
205
+ document.body.appendChild(host);
206
+
207
+ rerender({ suppressMerchantButton: false });
208
+
209
+ expect(document.getElementById(HIDE_STYLE_ID)).toBeNull();
210
+ expect(document.getElementById(GLADLY_CONTAINER_ID)?.style.display).toBe('');
211
+ });
212
+
213
+ it('cleans up the suppression rule and host on unmount, so switching providers does not leave Gladly hidden', () => {
214
+ const onSwitchToAgent = vi.fn();
215
+ const onCXClose = vi.fn();
216
+
217
+ const host = document.createElement('div');
218
+ host.id = GLADLY_CONTAINER_ID;
219
+ document.body.appendChild(host);
220
+
221
+ const { unmount } = renderHook(() =>
222
+ useGladlyUnifiedCXButton({ onSwitchToAgent, onCXClose, suppressMerchantButton: true }),
223
+ );
224
+ expect(document.getElementById(HIDE_STYLE_ID)).not.toBeNull();
225
+
226
+ // FloatingChatWidget remounts this provider (key={cxProvider}) when the
227
+ // configured provider changes away from Gladly.
228
+ unmount();
229
+
230
+ expect(document.getElementById(HIDE_STYLE_ID)).toBeNull();
231
+ expect(document.getElementById(GLADLY_CONTAINER_ID)?.style.display).toBe('');
232
+ });
233
+ });
@@ -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
  <>