@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.
- package/dist/CXIntegration/hooks/useUnifiedCXButton.cjs +1 -1
- package/dist/CXIntegration/hooks/useUnifiedCXButton.js +1 -1
- package/dist/CXIntegration/implementations/useGladlyUnifiedCXButton.cjs +112 -36
- package/dist/CXIntegration/implementations/useGladlyUnifiedCXButton.js +112 -36
- package/dist/debug/chatEmbed.cjs +3 -3
- package/dist/debug/chatEmbed.js +3 -3
- package/dist/debug/reportIssue.cjs +18 -6
- package/dist/debug/reportIssue.js +19 -7
- package/dist/hocs/withBaseWidget/types.d.ts +1 -1
- package/dist/hocs/withBaseWidget/withBaseWidget.cjs +2 -2
- package/dist/hocs/withBaseWidget/withBaseWidget.d.cts +2 -2
- package/dist/hocs/withBaseWidget/withBaseWidget.d.ts +2 -2
- package/dist/hocs/withBaseWidget/withBaseWidget.js +2 -2
- package/dist/widgets/ChatPreviewComparisonWidget/ChatPreviewComparisonWidget.cjs +8 -8
- package/dist/widgets/ChatPreviewComparisonWidget/ChatPreviewComparisonWidget.d.cts +3 -3
- package/dist/widgets/ChatPreviewComparisonWidget/ChatPreviewComparisonWidget.d.ts +3 -3
- package/dist/widgets/ChatPreviewComparisonWidget/ChatPreviewComparisonWidget.js +8 -8
- package/dist/widgets/ChatPreviewLoadingWidget/ChatPreviewLoadingWidget.cjs +3 -3
- package/dist/widgets/ChatPreviewLoadingWidget/ChatPreviewLoadingWidget.d.cts +3 -3
- package/dist/widgets/ChatPreviewLoadingWidget/ChatPreviewLoadingWidget.d.ts +3 -3
- package/dist/widgets/ChatPreviewLoadingWidget/ChatPreviewLoadingWidget.js +3 -3
- package/dist/widgets/ChatPreviewWidget/ChatPreviewWidget.cjs +6 -6
- package/dist/widgets/ChatPreviewWidget/ChatPreviewWidget.d.cts +3 -3
- package/dist/widgets/ChatPreviewWidget/ChatPreviewWidget.d.ts +3 -3
- package/dist/widgets/ChatPreviewWidget/ChatPreviewWidget.js +6 -6
- package/dist/widgets/FloatingChatWidget/FloatingChatWidget.cjs +7 -4
- package/dist/widgets/FloatingChatWidget/FloatingChatWidget.d.cts +2 -2
- package/dist/widgets/FloatingChatWidget/FloatingChatWidget.d.ts +2 -2
- package/dist/widgets/FloatingChatWidget/FloatingChatWidget.js +7 -4
- package/dist/widgets/FloatingChatWidget/hooks/useAutoPopup.cjs +2 -2
- package/dist/widgets/FloatingChatWidget/hooks/useAutoPopup.js +2 -2
- package/dist/widgets/FloatingChatWidget/hooks/useFloatingButtonVisibility.cjs +3 -3
- package/dist/widgets/FloatingChatWidget/hooks/useFloatingButtonVisibility.js +3 -3
- package/dist/widgets/FullPageSalesAgentWidget/FullPageSalesAgentWidget.cjs +2 -2
- package/dist/widgets/FullPageSalesAgentWidget/FullPageSalesAgentWidget.d.cts +2 -2
- package/dist/widgets/FullPageSalesAgentWidget/FullPageSalesAgentWidget.d.ts +2 -2
- package/dist/widgets/FullPageSalesAgentWidget/FullPageSalesAgentWidget.js +2 -2
- package/dist/widgets/ProductCardWidget/ProductCardWidget.cjs +4 -4
- package/dist/widgets/ProductCardWidget/ProductCardWidget.d.cts +2 -2
- package/dist/widgets/ProductCardWidget/ProductCardWidget.d.ts +2 -2
- package/dist/widgets/ProductCardWidget/ProductCardWidget.js +4 -4
- package/dist/widgets/PromptButtonCarouselWithImageWidget/PromptButtonCarouselWithImageWidget.cjs +6 -6
- package/dist/widgets/PromptButtonCarouselWithImageWidget/PromptButtonCarouselWithImageWidget.d.cts +3 -3
- package/dist/widgets/PromptButtonCarouselWithImageWidget/PromptButtonCarouselWithImageWidget.d.ts +3 -3
- package/dist/widgets/PromptButtonCarouselWithImageWidget/PromptButtonCarouselWithImageWidget.js +6 -6
- package/dist/widgets/PromptCarouselWidget/PromptCarouselWidget.cjs +3 -3
- package/dist/widgets/PromptCarouselWidget/PromptCarouselWidget.d.ts +2 -2
- package/dist/widgets/PromptCarouselWidget/PromptCarouselWidget.js +3 -3
- package/dist/widgets/SocialProofFlowWidget/SocialProofFlowWidget.cjs +2 -2
- package/dist/widgets/SocialProofFlowWidget/SocialProofFlowWidget.d.cts +2 -2
- package/dist/widgets/SocialProofFlowWidget/SocialProofFlowWidget.js +2 -2
- package/dist/widgets/SocialProofWidget/SocialProofWidget.cjs +8 -8
- package/dist/widgets/SocialProofWidget/SocialProofWidget.d.cts +3 -3
- package/dist/widgets/SocialProofWidget/SocialProofWidget.d.ts +3 -3
- package/dist/widgets/SocialProofWidget/SocialProofWidget.js +8 -8
- package/dist/widgets/TitledPromptCarouselWidget/TitledPromptCarouselWidget.cjs +2 -2
- package/dist/widgets/TitledPromptCarouselWidget/TitledPromptCarouselWidget.d.cts +2 -2
- package/dist/widgets/TitledPromptCarouselWidget/TitledPromptCarouselWidget.d.ts +2 -2
- package/dist/widgets/TitledPromptCarouselWidget/TitledPromptCarouselWidget.js +2 -2
- package/dist/widgets/TypingAnimationFlowWidget/TypingAnimationFlowWidget.cjs +2 -2
- package/dist/widgets/TypingAnimationFlowWidget/TypingAnimationFlowWidget.d.cts +2 -2
- package/dist/widgets/TypingAnimationFlowWidget/TypingAnimationFlowWidget.d.ts +2 -2
- package/dist/widgets/TypingAnimationFlowWidget/TypingAnimationFlowWidget.js +2 -2
- package/dist/widgets/TypingAnimationWidget/TypingAnimationWidget.cjs +4 -4
- package/dist/widgets/TypingAnimationWidget/TypingAnimationWidget.d.cts +3 -3
- package/dist/widgets/TypingAnimationWidget/TypingAnimationWidget.d.ts +3 -3
- package/dist/widgets/TypingAnimationWidget/TypingAnimationWidget.js +4 -4
- package/dist/widgets/hooks/useGetWidgetStatus.cjs +1 -1
- package/dist/widgets/hooks/useGetWidgetStatus.js +1 -1
- package/package.json +1 -1
- package/src/CXIntegration/implementations/__tests__/useGladlyUnifiedCXButton.test.ts +233 -0
- package/src/CXIntegration/implementations/useGladlyUnifiedCXButton.ts +173 -40
- package/src/debug/reportIssue.tsx +44 -4
- package/src/widgets/FloatingChatWidget/FloatingChatWidget.tsx +14 -1
- 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 = '
|
|
9
|
-
const
|
|
10
|
-
const
|
|
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
|
|
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 (
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
432
|
-
|
|
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
|
|
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={() =>
|
|
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
|
<>
|