@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.
- package/dist/CXIntegration/implementations/useGladlyUnifiedCXButton.cjs +112 -36
- package/dist/CXIntegration/implementations/useGladlyUnifiedCXButton.js +112 -36
- package/dist/debug/reportIssue.cjs +15 -3
- package/dist/debug/reportIssue.js +16 -4
- package/dist/hocs/withBaseWidget/withBaseWidget.d.cts +2 -2
- package/dist/hocs/withBaseWidget/withBaseWidget.d.ts +2 -2
- package/dist/packages/widgets/dist/SearchResults/SearchResultsWidget.d.ts +2 -2
- package/dist/widgets/ChatPreviewComparisonWidget/ChatPreviewComparisonWidget.d.cts +3 -3
- package/dist/widgets/ChatPreviewLoadingWidget/ChatPreviewLoadingWidget.d.cts +3 -3
- package/dist/widgets/ChatPreviewLoadingWidget/ChatPreviewLoadingWidget.d.ts +3 -3
- package/dist/widgets/ChatPreviewWidget/ChatPreviewWidget.d.cts +3 -3
- package/dist/widgets/ChatPreviewWidget/ChatPreviewWidget.d.ts +3 -3
- package/dist/widgets/FloatingChatWidget/FloatingChatWidget.cjs +4 -1
- package/dist/widgets/FloatingChatWidget/FloatingChatWidget.d.cts +2 -2
- package/dist/widgets/FloatingChatWidget/FloatingChatWidget.d.ts +2 -2
- package/dist/widgets/FloatingChatWidget/FloatingChatWidget.js +4 -1
- package/dist/widgets/FullPageSalesAgentWidget/FullPageSalesAgentWidget.d.ts +2 -2
- package/dist/widgets/ProductCardWidget/ProductCardWidget.cjs +2 -0
- package/dist/widgets/ProductCardWidget/ProductCardWidget.d.cts +2 -2
- package/dist/widgets/ProductCardWidget/ProductCardWidget.d.ts +2 -2
- package/dist/widgets/ProductCardWidget/ProductCardWidget.js +2 -0
- package/dist/widgets/PromptButtonCarouselWithImageWidget/PromptButtonCarouselWithImageWidget.d.cts +3 -3
- package/dist/widgets/PromptButtonCarouselWithImageWidget/PromptButtonCarouselWithImageWidget.d.ts +3 -3
- package/dist/widgets/PromptCarouselWidget/PromptCarouselWidget.d.cts +2 -2
- package/dist/widgets/PromptCarouselWidget/PromptCarouselWidget.d.ts +2 -2
- package/dist/widgets/SocialProofFlowWidget/SocialProofFlowWidget.d.cts +2 -2
- package/dist/widgets/SocialProofFlowWidget/SocialProofFlowWidget.d.ts +2 -2
- package/dist/widgets/SocialProofWidget/SocialProofWidget.d.cts +3 -3
- package/dist/widgets/SocialProofWidget/SocialProofWidget.d.ts +3 -3
- package/dist/widgets/TitledPromptCarouselWidget/TitledPromptCarouselWidget.d.cts +2 -2
- package/dist/widgets/TitledPromptCarouselWidget/TitledPromptCarouselWidget.d.ts +2 -2
- package/dist/widgets/TypingAnimationFlowWidget/TypingAnimationFlowWidget.d.cts +2 -2
- package/dist/widgets/TypingAnimationFlowWidget/TypingAnimationFlowWidget.d.ts +2 -2
- package/dist/widgets/TypingAnimationWidget/TypingAnimationWidget.d.cts +3 -3
- package/dist/widgets/dist/SearchResults/SearchResultsWidget.d.cts +2 -2
- 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
- package/src/widgets/ProductCardWidget/ProductCardWidget.tsx +2 -0
- 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 = '
|
|
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
|
<>
|
|
@@ -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:
|
|
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 = {
|