@envive-ai/react-hooks 0.3.55 → 0.3.57
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/application/commerce-api.cjs +11 -12
- package/dist/application/commerce-api.js +11 -12
- package/dist/application/logging/index.cjs +13 -0
- package/dist/application/logging/index.d.cts +3 -0
- package/dist/application/logging/index.d.ts +3 -0
- package/dist/application/logging/index.js +4 -0
- package/dist/application/logging/logLevel.cjs +123 -0
- package/dist/application/logging/logLevel.d.cts +47 -0
- package/dist/application/logging/logLevel.d.ts +47 -0
- package/dist/application/logging/logLevel.js +115 -0
- package/dist/application/logging/logger.cjs +6 -1
- package/dist/application/logging/logger.d.cts +13 -0
- package/dist/application/logging/logger.d.ts +13 -0
- package/dist/application/logging/logger.js +7 -1
- package/dist/atoms/app/index.d.cts +7 -7
- package/dist/atoms/app/index.d.ts +7 -7
- package/dist/atoms/app/variant.d.cts +6 -6
- package/dist/atoms/chat/chatState.d.cts +19 -19
- package/dist/atoms/chat/chatState.d.ts +19 -19
- package/dist/atoms/chat/form.d.cts +2 -2
- package/dist/atoms/chat/form.d.ts +2 -2
- package/dist/atoms/chat/index.d.cts +3 -3
- package/dist/atoms/chat/index.d.ts +3 -3
- package/dist/atoms/chat/lastMessage.d.cts +2 -2
- package/dist/atoms/chat/lastMessage.d.ts +2 -2
- package/dist/atoms/chat/messageQueue.d.cts +6 -6
- package/dist/atoms/chat/messageQueue.d.ts +6 -6
- package/dist/atoms/chat/performanceMetrics.d.cts +6 -6
- package/dist/atoms/chat/performanceMetrics.d.ts +6 -6
- package/dist/atoms/chat/renderedWidgetRefs.d.cts +2 -2
- package/dist/atoms/chat/renderedWidgetRefs.d.ts +2 -2
- package/dist/atoms/chat/replies.d.cts +3 -3
- package/dist/atoms/chat/replies.d.ts +3 -3
- package/dist/atoms/chat/suggestions.d.cts +2 -2
- package/dist/atoms/chat/suggestions.d.ts +2 -2
- package/dist/atoms/envive/enviveConfig.d.cts +14 -14
- package/dist/atoms/globalSearch/globalSearch.d.cts +5 -5
- package/dist/atoms/globalSearch/globalSearch.d.ts +5 -5
- package/dist/atoms/org/customerService.d.cts +6 -6
- package/dist/atoms/org/customerService.d.ts +6 -6
- package/dist/atoms/org/graphqlConfig.d.cts +4 -4
- package/dist/atoms/org/newOrgConfigAtom.d.cts +2 -2
- package/dist/atoms/org/orgAnalyticsConfig.d.cts +4 -4
- package/dist/atoms/org/orgAnalyticsConfig.d.ts +4 -4
- package/dist/atoms/search/chatSearch.d.cts +17 -17
- package/dist/atoms/search/chatSearch.d.ts +17 -17
- package/dist/atoms/search/searchAPI.d.cts +13 -13
- package/dist/atoms/search/searchAPI.d.ts +13 -13
- package/dist/atoms/search/types.d.cts +1 -1
- package/dist/atoms/search/utils.d.cts +1 -1
- package/dist/atoms/widget/chatPreviewLoading.d.cts +2 -2
- package/dist/atoms/widget/chatPreviewLoading.d.ts +2 -2
- package/dist/contexts/salesAgentContext/salesAgentService.cjs +2 -2
- package/dist/contexts/salesAgentContext/salesAgentService.js +2 -2
- package/dist/contexts/systemSettingsContext/systemSettingsContext.d.ts +2 -2
- package/dist/contexts/types.d.cts +1 -1
- package/dist/contexts/types.d.ts +1 -1
- package/dist/contexts/typesV3.d.ts +1 -1
- package/dist/hooks/ChatToggle/useChatToggle.cjs +10 -8
- package/dist/hooks/ChatToggle/useChatToggle.d.cts +4 -3
- package/dist/hooks/ChatToggle/useChatToggle.d.ts +4 -3
- package/dist/hooks/ChatToggle/useChatToggle.js +11 -9
- package/dist/hooks/Intersection/useIntersection.cjs +7 -5
- package/dist/hooks/Intersection/useIntersection.js +7 -5
- package/dist/hooks/ShopifyUrlOperations/useShopifyUrlOperations.d.cts +2 -2
- package/dist/hooks/ShopifyUrlOperations/useShopifyUrlOperations.d.ts +2 -2
- package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.cjs +8 -6
- package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.js +8 -6
- package/dist/hooks/WidgetInteraction/types.cjs +40 -1
- package/dist/hooks/WidgetInteraction/types.d.cts +16 -2
- package/dist/hooks/WidgetInteraction/types.d.ts +16 -2
- package/dist/hooks/WidgetInteraction/types.js +41 -2
- package/dist/services/amplitudeService/amplitudeService.cjs +14 -5
- package/dist/services/amplitudeService/amplitudeService.js +14 -5
- package/package.json +6 -2
- package/src/application/__tests__/commerceApiStreaming.test.ts +171 -0
- package/src/application/commerce-api.ts +21 -21
- package/src/application/logging/__tests__/logger.test.ts +159 -0
- package/src/application/logging/index.ts +12 -0
- package/src/application/logging/logLevel.ts +162 -0
- package/src/application/logging/logger.ts +13 -0
- package/src/application/utils/__tests__/widgetTextFromApiWidgetTextResponse.test.ts +55 -0
- package/src/contexts/salesAgentContext/salesAgentService.ts +1 -1
- package/src/contexts/userIdentityContext/__tests__/userIdentityContext.test.tsx +4 -5
- package/src/hooks/ChatToggle/__tests__/useChatToggle.test.ts +273 -0
- package/src/hooks/ChatToggle/useChatToggle.ts +39 -12
- package/src/hooks/Intersection/useIntersection.ts +7 -4
- package/src/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.ts +8 -5
- package/src/hooks/WidgetInteraction/__tests__/chatTriggerWidget.test.ts +51 -0
- package/src/hooks/WidgetInteraction/types.ts +53 -0
- package/src/services/amplitudeService/__tests__/amplitudeService.test.ts +76 -0
- package/src/services/amplitudeService/amplitudeService.ts +37 -5
- package/src/services/hardcopyService/__tests__/hardcopyService.test.ts +9 -0
|
@@ -177,13 +177,12 @@ describe('UserIdentityProvider', () => {
|
|
|
177
177
|
it('should allow components to access context via useUserIdentity hook', async () => {
|
|
178
178
|
renderWithProviders(<MockComponent />);
|
|
179
179
|
|
|
180
|
+
// Verify all context methods are accessible. The user id is populated by an async
|
|
181
|
+
// effect, so the content assertions must wait for it rather than assert right away.
|
|
180
182
|
await waitFor(() => {
|
|
181
|
-
expect(screen.getByTestId('user-id')).
|
|
183
|
+
expect(screen.getByTestId('user-id').textContent).toContain('spiffy-user-id-');
|
|
184
|
+
expect(screen.getByTestId('is-ready').textContent).toBe('true');
|
|
182
185
|
});
|
|
183
|
-
|
|
184
|
-
// Verify all context methods are accessible
|
|
185
|
-
expect(screen.getByTestId('user-id').textContent).toContain('spiffy-user-id-');
|
|
186
|
-
expect(screen.getByTestId('is-ready').textContent).toBe('true');
|
|
187
186
|
});
|
|
188
187
|
|
|
189
188
|
it('should throw error when used outside of UserIdentityProvider', () => {
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { renderHook } from '@testing-library/react';
|
|
2
|
+
import { ChatElementDisplayLocationV3 } from 'src/application/models';
|
|
3
|
+
import { EnviveMetricsEventName } from 'src/contexts/amplitudeContext';
|
|
4
|
+
import {
|
|
5
|
+
WidgetInteraction,
|
|
6
|
+
WidgetInteractionComponent,
|
|
7
|
+
WidgetInteractionType,
|
|
8
|
+
} from '../../WidgetInteraction/types';
|
|
9
|
+
import { useChatToggle } from '../useChatToggle';
|
|
10
|
+
|
|
11
|
+
const mockTrackEvent = vi.fn();
|
|
12
|
+
const mockTrackWidgetInteraction = vi.fn<(props: WidgetInteraction) => void>();
|
|
13
|
+
const mockOnToggle = vi.fn();
|
|
14
|
+
|
|
15
|
+
let isOpen = false;
|
|
16
|
+
|
|
17
|
+
vi.mock('src/contexts/amplitudeContext/amplitudeContext', async () => {
|
|
18
|
+
const actual = await vi.importActual('src/contexts/amplitudeContext/amplitudeContext');
|
|
19
|
+
return {
|
|
20
|
+
...actual,
|
|
21
|
+
useAmplitude: () => ({ trackEvent: mockTrackEvent }),
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
vi.mock('../../WidgetInteraction', () => ({
|
|
26
|
+
useWidgetInteraction: () => ({ trackWidgetInteraction: mockTrackWidgetInteraction }),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
vi.mock('jotai', async () => {
|
|
30
|
+
const actual = await vi.importActual('jotai');
|
|
31
|
+
return {
|
|
32
|
+
...actual,
|
|
33
|
+
useAtomValue: () => ({ isOpen }),
|
|
34
|
+
useSetAtom: () => mockOnToggle,
|
|
35
|
+
};
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const emitted = (): WidgetInteraction[] =>
|
|
39
|
+
(mockTrackWidgetInteraction.mock.calls as unknown as WidgetInteraction[][]).map(
|
|
40
|
+
([interaction]) => interaction,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
/** Every WidgetInteraction emission made during the call, as [widget, interactionType] pairs. */
|
|
44
|
+
const emissions = () =>
|
|
45
|
+
emitted().map(({ trigger }) => [trigger.widget, trigger.widget_interaction]);
|
|
46
|
+
|
|
47
|
+
const emissionsOfType = (type: WidgetInteractionType) =>
|
|
48
|
+
emitted().filter(({ trigger }) => trigger.widget_interaction === type);
|
|
49
|
+
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
vi.clearAllMocks();
|
|
52
|
+
isOpen = false;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('useChatToggle — open', () => {
|
|
56
|
+
it('attributes the click to the widget that was actually clicked', () => {
|
|
57
|
+
const { result } = renderHook(() => useChatToggle());
|
|
58
|
+
|
|
59
|
+
result.current.toggle(ChatElementDisplayLocationV3.SOCIAL_PROOF_PRIMARY_BUTTON);
|
|
60
|
+
|
|
61
|
+
expect(emissions()).toEqual([
|
|
62
|
+
[WidgetInteractionComponent.SOCIAL_PROOF, WidgetInteractionType.WIDGET_CLICKED],
|
|
63
|
+
]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it.each([
|
|
67
|
+
[
|
|
68
|
+
ChatElementDisplayLocationV3.CHAT_PREVIEW_PROMPT_BUTTON,
|
|
69
|
+
WidgetInteractionComponent.CHAT_PREVIEW,
|
|
70
|
+
],
|
|
71
|
+
[ChatElementDisplayLocationV3.TYPING_ANIMATION, WidgetInteractionComponent.EMBEDDED_WIDGET],
|
|
72
|
+
[ChatElementDisplayLocationV3.PROMPT_CAROUSEL, WidgetInteractionComponent.SUGGESTION_BAR],
|
|
73
|
+
[
|
|
74
|
+
ChatElementDisplayLocationV3.PRODUCT_CARD_TEXT_FIELD,
|
|
75
|
+
WidgetInteractionComponent.IMAGE_PROMPT_CARD,
|
|
76
|
+
],
|
|
77
|
+
[ChatElementDisplayLocationV3.FLOATING_BUTTON, WidgetInteractionComponent.FLOATING_BUTTON],
|
|
78
|
+
])('attributes %s to %s', (location, expectedWidget) => {
|
|
79
|
+
const { result } = renderHook(() => useChatToggle());
|
|
80
|
+
|
|
81
|
+
result.current.toggle(location);
|
|
82
|
+
|
|
83
|
+
expect(emissions()).toEqual([[expectedWidget, WidgetInteractionType.WIDGET_CLICKED]]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it.each([
|
|
87
|
+
ChatElementDisplayLocationV3.AUTOMATIC_PROMPT,
|
|
88
|
+
ChatElementDisplayLocationV3.REFERRER_BASED_PROMPT,
|
|
89
|
+
ChatElementDisplayLocationV3.WINDOW_API_CALL,
|
|
90
|
+
])('emits no widget interaction for the system-initiated trigger %s', location => {
|
|
91
|
+
const { result } = renderHook(() => useChatToggle());
|
|
92
|
+
|
|
93
|
+
result.current.toggle(location);
|
|
94
|
+
|
|
95
|
+
expect(mockTrackWidgetInteraction).not.toHaveBeenCalled();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('still emits the v2 expanded event for system-initiated opens', () => {
|
|
99
|
+
// B2 suppresses only the v3 intentional click. The v2 event is unchanged, so merchants
|
|
100
|
+
// still on v2 dashboards see no difference at all.
|
|
101
|
+
const { result } = renderHook(() => useChatToggle());
|
|
102
|
+
|
|
103
|
+
result.current.toggle(ChatElementDisplayLocationV3.AUTOMATIC_PROMPT);
|
|
104
|
+
|
|
105
|
+
expect(mockTrackEvent).toHaveBeenCalledTimes(1);
|
|
106
|
+
expect(mockTrackEvent.mock.calls[0][0].eventProps.message_metadata.trigger_location).toBe(
|
|
107
|
+
ChatElementDisplayLocationV3.AUTOMATIC_PROMPT,
|
|
108
|
+
);
|
|
109
|
+
expect(mockOnToggle).toHaveBeenCalledTimes(1);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('never emits a collapse when opening', () => {
|
|
113
|
+
const { result } = renderHook(() => useChatToggle());
|
|
114
|
+
|
|
115
|
+
result.current.toggle(ChatElementDisplayLocationV3.SOCIAL_PROOF_PRIMARY_BUTTON);
|
|
116
|
+
|
|
117
|
+
expect(emissionsOfType(WidgetInteractionType.WIDGET_COLLAPSED)).toHaveLength(0);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('useChatToggle — close', () => {
|
|
122
|
+
beforeEach(() => {
|
|
123
|
+
isOpen = true;
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('emits exactly one collapse, attributed to the floating chat', () => {
|
|
127
|
+
const { result } = renderHook(() => useChatToggle());
|
|
128
|
+
|
|
129
|
+
result.current.closeChat(
|
|
130
|
+
ChatElementDisplayLocationV3.FLOATING_CHAT_OVERLAY,
|
|
131
|
+
undefined,
|
|
132
|
+
'body_click',
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
expect(emissions()).toEqual([
|
|
136
|
+
[WidgetInteractionComponent.FLOATING_CHAT, WidgetInteractionType.WIDGET_COLLAPSED],
|
|
137
|
+
]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it.each(['body_click', 'swipe', 'close_button'] as const)(
|
|
141
|
+
'carries collapse_source %s',
|
|
142
|
+
source => {
|
|
143
|
+
const { result } = renderHook(() => useChatToggle());
|
|
144
|
+
|
|
145
|
+
result.current.closeChat(
|
|
146
|
+
ChatElementDisplayLocationV3.FLOATING_CHAT_CLOSE_BUTTON,
|
|
147
|
+
undefined,
|
|
148
|
+
source,
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const [collapse] = emissionsOfType(WidgetInteractionType.WIDGET_COLLAPSED);
|
|
152
|
+
expect(collapse.trigger.widget_interaction_data).toEqual({
|
|
153
|
+
widget_collapsed: { collapse_source: source },
|
|
154
|
+
});
|
|
155
|
+
},
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
it('omits widget_interaction_data when no collapse source is given', () => {
|
|
159
|
+
const { result } = renderHook(() => useChatToggle());
|
|
160
|
+
|
|
161
|
+
result.current.closeChat(ChatElementDisplayLocationV3.WINDOW_API_CALL);
|
|
162
|
+
|
|
163
|
+
const [collapse] = emissionsOfType(WidgetInteractionType.WIDGET_COLLAPSED);
|
|
164
|
+
expect(collapse.trigger.widget_interaction_data).toBeUndefined();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('never emits a click when closing', () => {
|
|
168
|
+
const { result } = renderHook(() => useChatToggle());
|
|
169
|
+
|
|
170
|
+
result.current.closeChat(ChatElementDisplayLocationV3.FLOATING_CHAT_CLOSE_BUTTON);
|
|
171
|
+
|
|
172
|
+
expect(emissionsOfType(WidgetInteractionType.WIDGET_CLICKED)).toHaveLength(0);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('useChatToggle — guards', () => {
|
|
177
|
+
it('openChat does nothing when the chat is already open', () => {
|
|
178
|
+
isOpen = true;
|
|
179
|
+
const { result } = renderHook(() => useChatToggle());
|
|
180
|
+
|
|
181
|
+
result.current.openChat(ChatElementDisplayLocationV3.SOCIAL_PROOF_PRIMARY_BUTTON);
|
|
182
|
+
|
|
183
|
+
expect(mockTrackWidgetInteraction).not.toHaveBeenCalled();
|
|
184
|
+
expect(mockOnToggle).not.toHaveBeenCalled();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('closeChat does nothing when the chat is already closed', () => {
|
|
188
|
+
const { result } = renderHook(() => useChatToggle());
|
|
189
|
+
|
|
190
|
+
result.current.closeChat(ChatElementDisplayLocationV3.FLOATING_CHAT_CLOSE_BUTTON);
|
|
191
|
+
|
|
192
|
+
expect(mockTrackWidgetInteraction).not.toHaveBeenCalled();
|
|
193
|
+
expect(mockOnToggle).not.toHaveBeenCalled();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('hover stays attributed to the floating button', () => {
|
|
197
|
+
// onHover is only wired to the floating button itself, so it is intentionally unchanged.
|
|
198
|
+
const { result } = renderHook(() => useChatToggle());
|
|
199
|
+
|
|
200
|
+
result.current.onHover();
|
|
201
|
+
|
|
202
|
+
expect(emissions()).toEqual([
|
|
203
|
+
[WidgetInteractionComponent.FLOATING_BUTTON, WidgetInteractionType.WIDGET_HOVERED],
|
|
204
|
+
]);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe('useChatToggle — adaptive storefront safety', () => {
|
|
209
|
+
it('emits no ASF widget under any trigger location', () => {
|
|
210
|
+
// The ASF dashboard filters on v3_widget in {full_page_sales_agent,
|
|
211
|
+
// full_page_sales_agent_recommendations}. This hook must never produce either value, or the
|
|
212
|
+
// change could move ASF product-surface metrics.
|
|
213
|
+
const asfWidgets: WidgetInteractionComponent[] = [
|
|
214
|
+
WidgetInteractionComponent.FULL_PAGE_SALES_AGENT,
|
|
215
|
+
WidgetInteractionComponent.FULL_PAGE_SALES_AGENT_RECOMMENDATIONS,
|
|
216
|
+
WidgetInteractionComponent.FULL_PAGE_SALES_AGENT_RECOMMENDATIONS_ATC_FALLBACK,
|
|
217
|
+
];
|
|
218
|
+
|
|
219
|
+
for (const location of Object.values(ChatElementDisplayLocationV3)) {
|
|
220
|
+
vi.clearAllMocks();
|
|
221
|
+
|
|
222
|
+
isOpen = false;
|
|
223
|
+
const opened = renderHook(() => useChatToggle());
|
|
224
|
+
opened.result.current.toggle(location);
|
|
225
|
+
|
|
226
|
+
isOpen = true;
|
|
227
|
+
const closed = renderHook(() => useChatToggle());
|
|
228
|
+
closed.result.current.toggle(location);
|
|
229
|
+
|
|
230
|
+
for (const [widget] of emissions()) {
|
|
231
|
+
expect(asfWidgets).not.toContain(widget);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('only ever emits click, collapse or hover — never an ASF-dashboard interaction type', () => {
|
|
237
|
+
// The ASF product funnels filter on product_card_clicked / add_to_cart_clicked, and the
|
|
238
|
+
// engagement funnels on interaction_class=intentional. This hook must not emit any
|
|
239
|
+
// product-related type.
|
|
240
|
+
const forbidden = [
|
|
241
|
+
WidgetInteractionType.PRODUCT_CARD_CLICKED,
|
|
242
|
+
WidgetInteractionType.ADD_TO_CART_CLICKED,
|
|
243
|
+
WidgetInteractionType.MESSAGE_SUBMITTED,
|
|
244
|
+
WidgetInteractionType.SUGGESTION_CLICKED,
|
|
245
|
+
];
|
|
246
|
+
|
|
247
|
+
for (const location of Object.values(ChatElementDisplayLocationV3)) {
|
|
248
|
+
vi.clearAllMocks();
|
|
249
|
+
|
|
250
|
+
isOpen = false;
|
|
251
|
+
renderHook(() => useChatToggle()).result.current.toggle(location);
|
|
252
|
+
|
|
253
|
+
isOpen = true;
|
|
254
|
+
renderHook(() => useChatToggle()).result.current.toggle(location);
|
|
255
|
+
|
|
256
|
+
for (const [, type] of emissions()) {
|
|
257
|
+
expect(forbidden).not.toContain(type);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
describe('useChatToggle — event names', () => {
|
|
264
|
+
it('emits the v3 widget interaction event name', () => {
|
|
265
|
+
const { result } = renderHook(() => useChatToggle());
|
|
266
|
+
|
|
267
|
+
result.current.toggle(ChatElementDisplayLocationV3.SOCIAL_PROOF_PRIMARY_BUTTON);
|
|
268
|
+
|
|
269
|
+
expect(mockTrackWidgetInteraction.mock.calls[0][0].eventName).toBe(
|
|
270
|
+
EnviveMetricsEventName.WidgetInteraction,
|
|
271
|
+
);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
@@ -7,7 +7,12 @@ import {
|
|
|
7
7
|
useAmplitude,
|
|
8
8
|
} from 'src/contexts/amplitudeContext/amplitudeContext';
|
|
9
9
|
import { useWidgetInteraction } from '../WidgetInteraction';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
CHAT_TRIGGER_WIDGET,
|
|
12
|
+
Collapse,
|
|
13
|
+
WidgetInteractionComponent,
|
|
14
|
+
WidgetInteractionType,
|
|
15
|
+
} from '../WidgetInteraction/types';
|
|
11
16
|
|
|
12
17
|
export const useChatToggle = () => {
|
|
13
18
|
const onToggle = useSetAtom(chatOnToggleAtom);
|
|
@@ -15,15 +20,27 @@ export const useChatToggle = () => {
|
|
|
15
20
|
const { trackEvent } = useAmplitude();
|
|
16
21
|
const { trackWidgetInteraction } = useWidgetInteraction();
|
|
17
22
|
|
|
18
|
-
const toggle = (
|
|
23
|
+
const toggle = (
|
|
24
|
+
triggerLocation: ChatElementDisplayLocationV3,
|
|
25
|
+
triggerId?: string,
|
|
26
|
+
collapseSource?: Collapse['collapse_source'],
|
|
27
|
+
) => {
|
|
28
|
+
// null for system-initiated triggers, undefined if a caller passed no location at all.
|
|
29
|
+
const triggerWidget = CHAT_TRIGGER_WIDGET[triggerLocation] ?? null;
|
|
30
|
+
|
|
19
31
|
if (!isOpen) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
32
|
+
// Only a real user click on a real widget counts as an intentional interaction. System
|
|
33
|
+
// triggers (auto-popup, referrer prompt, window API) are still recorded by the floating
|
|
34
|
+
// chat's own WIDGET_EXPANDED event, so nothing is lost by skipping the click here.
|
|
35
|
+
if (triggerWidget) {
|
|
36
|
+
trackWidgetInteraction({
|
|
37
|
+
eventName: EnviveMetricsEventName.WidgetInteraction,
|
|
38
|
+
trigger: {
|
|
39
|
+
widget: triggerWidget,
|
|
40
|
+
widget_interaction: WidgetInteractionType.WIDGET_CLICKED,
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
}
|
|
27
44
|
|
|
28
45
|
trackEvent({
|
|
29
46
|
eventName: SpiffyMetricsEventName.ChatComponentExpanded,
|
|
@@ -35,11 +52,17 @@ export const useChatToggle = () => {
|
|
|
35
52
|
},
|
|
36
53
|
});
|
|
37
54
|
} else {
|
|
55
|
+
// The thing being collapsed is always the floating chat, whatever triggered the close.
|
|
56
|
+
// This is the single emitter for WIDGET_COLLAPSED — callers pass collapseSource rather
|
|
57
|
+
// than firing their own duplicate event.
|
|
38
58
|
trackWidgetInteraction({
|
|
39
59
|
eventName: EnviveMetricsEventName.WidgetInteraction,
|
|
40
60
|
trigger: {
|
|
41
|
-
widget: WidgetInteractionComponent.
|
|
61
|
+
widget: WidgetInteractionComponent.FLOATING_CHAT,
|
|
42
62
|
widget_interaction: WidgetInteractionType.WIDGET_COLLAPSED,
|
|
63
|
+
...(collapseSource
|
|
64
|
+
? { widget_interaction_data: { widget_collapsed: { collapse_source: collapseSource } } }
|
|
65
|
+
: {}),
|
|
43
66
|
},
|
|
44
67
|
});
|
|
45
68
|
|
|
@@ -63,9 +86,13 @@ export const useChatToggle = () => {
|
|
|
63
86
|
}
|
|
64
87
|
};
|
|
65
88
|
|
|
66
|
-
const closeChat = (
|
|
89
|
+
const closeChat = (
|
|
90
|
+
triggerLocation: ChatElementDisplayLocationV3,
|
|
91
|
+
triggerId?: string,
|
|
92
|
+
collapseSource?: Collapse['collapse_source'],
|
|
93
|
+
) => {
|
|
67
94
|
if (isOpen) {
|
|
68
|
-
toggle(triggerLocation, triggerId);
|
|
95
|
+
toggle(triggerLocation, triggerId, collapseSource);
|
|
69
96
|
}
|
|
70
97
|
};
|
|
71
98
|
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { RefObject, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import Logger from 'src/application/logging/logger';
|
|
3
|
+
|
|
4
|
+
const logger = new Logger('useIntersection');
|
|
2
5
|
|
|
3
6
|
// https://rasilbaidar.medium.com/trigger-event-when-element-enters-viewport-the-react-way-168509da2e23
|
|
4
7
|
export const useIntersection = (element: RefObject<HTMLElement>, rootMargin: string) => {
|
|
@@ -10,21 +13,21 @@ export const useIntersection = (element: RefObject<HTMLElement>, rootMargin: str
|
|
|
10
13
|
// Bails immediately once the observer is attached, keeping per-render cost O(1).
|
|
11
14
|
useEffect(() => {
|
|
12
15
|
const current = element?.current;
|
|
13
|
-
|
|
16
|
+
logger.logDebug('effect ran', {
|
|
14
17
|
elementCurrent: current,
|
|
15
18
|
rootMargin,
|
|
16
19
|
alreadyObserving: !!observerRef.current,
|
|
17
20
|
});
|
|
18
21
|
|
|
19
22
|
if (!current) {
|
|
20
|
-
|
|
23
|
+
logger.logDebug('element.current is null — observer NOT attached');
|
|
21
24
|
return;
|
|
22
25
|
}
|
|
23
26
|
if (observerRef.current) return; // already observing
|
|
24
27
|
|
|
25
28
|
const observer = new IntersectionObserver(
|
|
26
29
|
([entry]) => {
|
|
27
|
-
|
|
30
|
+
logger.logDebug('IntersectionObserver fired', {
|
|
28
31
|
isIntersecting: entry.isIntersecting,
|
|
29
32
|
intersectionRatio: entry.intersectionRatio,
|
|
30
33
|
boundingClientRect: entry.boundingClientRect,
|
|
@@ -37,7 +40,7 @@ export const useIntersection = (element: RefObject<HTMLElement>, rootMargin: str
|
|
|
37
40
|
|
|
38
41
|
observer.observe(current);
|
|
39
42
|
observerRef.current = observer;
|
|
40
|
-
|
|
43
|
+
logger.logDebug('observer attached to element', current);
|
|
41
44
|
});
|
|
42
45
|
|
|
43
46
|
// Disconnect on unmount only — intentionally separate from the setup effect above.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useAtomValue } from 'jotai';
|
|
2
2
|
import { RefObject, useEffect, useRef } from 'react';
|
|
3
|
+
import Logger from 'src/application/logging/logger';
|
|
3
4
|
import { pageVariantInfoAtom } from 'src/atoms/app';
|
|
4
5
|
import { useAmplitude } from 'src/contexts/amplitudeContext/amplitudeContext';
|
|
5
6
|
import { PageVariantInfo } from 'src/contexts/pageContext/types';
|
|
@@ -10,6 +11,8 @@ import {
|
|
|
10
11
|
SpiffyMetricsEventName,
|
|
11
12
|
} from 'src/services/amplitudeService/amplitudeService';
|
|
12
13
|
|
|
14
|
+
const logger = new Logger('useTrackComponentVisibleEvent');
|
|
15
|
+
|
|
13
16
|
/**
|
|
14
17
|
* Tracks a component and logs `SpiffyMetricsEventName.ChatComponentVisible` when visible.
|
|
15
18
|
*
|
|
@@ -44,7 +47,7 @@ export const useTrackComponentVisibleEvent = (
|
|
|
44
47
|
const renderOffsetMs = useRef(safeNow()).current;
|
|
45
48
|
|
|
46
49
|
useEffect(() => {
|
|
47
|
-
|
|
50
|
+
logger.logDebug('effect ran', {
|
|
48
51
|
enabled,
|
|
49
52
|
isVisible,
|
|
50
53
|
hasTrackedEvent: hasTrackedEvent.current,
|
|
@@ -52,19 +55,19 @@ export const useTrackComponentVisibleEvent = (
|
|
|
52
55
|
});
|
|
53
56
|
|
|
54
57
|
if (!enabled) {
|
|
55
|
-
|
|
58
|
+
logger.logDebug('skipped — enabled is false');
|
|
56
59
|
return;
|
|
57
60
|
}
|
|
58
61
|
if (hasTrackedEvent.current) {
|
|
59
|
-
|
|
62
|
+
logger.logDebug('skipped — event already tracked');
|
|
60
63
|
return;
|
|
61
64
|
}
|
|
62
65
|
if (!isVisible) {
|
|
63
|
-
|
|
66
|
+
logger.logDebug('skipped — element not visible');
|
|
64
67
|
return;
|
|
65
68
|
}
|
|
66
69
|
|
|
67
|
-
|
|
70
|
+
logger.logDebug('firing tracking events', eventProps);
|
|
68
71
|
trackEvent({
|
|
69
72
|
eventName: SpiffyMetricsEventName.ChatComponentVisible,
|
|
70
73
|
eventProps,
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { ChatElementDisplayLocationV3 } from '../../../application/models';
|
|
2
|
+
import { CHAT_TRIGGER_WIDGET, WidgetInteractionComponent } from '../types';
|
|
3
|
+
|
|
4
|
+
// System-initiated triggers: the chat opens without the shopper clicking a widget. These must not
|
|
5
|
+
// produce an intentional WIDGET_CLICKED, or they inflate every "meaningful engagement" metric.
|
|
6
|
+
const SYSTEM_TRIGGERS = [
|
|
7
|
+
ChatElementDisplayLocationV3.WINDOW_API_CALL,
|
|
8
|
+
ChatElementDisplayLocationV3.REFERRER_BASED_PROMPT,
|
|
9
|
+
ChatElementDisplayLocationV3.AUTOMATIC_PROMPT,
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
describe('CHAT_TRIGGER_WIDGET', () => {
|
|
13
|
+
it('maps every display location', () => {
|
|
14
|
+
for (const location of Object.values(ChatElementDisplayLocationV3)) {
|
|
15
|
+
expect(CHAT_TRIGGER_WIDGET[location]).not.toBeUndefined();
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('attributes system-initiated triggers to no widget', () => {
|
|
20
|
+
for (const location of SYSTEM_TRIGGERS) {
|
|
21
|
+
expect(CHAT_TRIGGER_WIDGET[location]).toBeNull();
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('attributes user-initiated triggers to a widget', () => {
|
|
26
|
+
const userTriggers = Object.values(ChatElementDisplayLocationV3).filter(
|
|
27
|
+
location => !SYSTEM_TRIGGERS.includes(location),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
for (const location of userTriggers) {
|
|
31
|
+
expect(CHAT_TRIGGER_WIDGET[location]).not.toBeNull();
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('attributes a social proof trigger to social_proof, not floating_button', () => {
|
|
36
|
+
// Regression guard: useChatToggle used to hardcode FLOATING_BUTTON for every open,
|
|
37
|
+
// so a suggestion click on the social proof widget was credited to the floating button.
|
|
38
|
+
expect(CHAT_TRIGGER_WIDGET[ChatElementDisplayLocationV3.SOCIAL_PROOF_PRIMARY_BUTTON]).toBe(
|
|
39
|
+
WidgetInteractionComponent.SOCIAL_PROOF,
|
|
40
|
+
);
|
|
41
|
+
expect(CHAT_TRIGGER_WIDGET[ChatElementDisplayLocationV3.SOCIAL_PROOF_TEXT_FIELD]).toBe(
|
|
42
|
+
WidgetInteractionComponent.SOCIAL_PROOF,
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('still attributes the floating button to floating_button', () => {
|
|
47
|
+
expect(CHAT_TRIGGER_WIDGET[ChatElementDisplayLocationV3.FLOATING_BUTTON]).toBe(
|
|
48
|
+
WidgetInteractionComponent.FLOATING_BUTTON,
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ChatElementDisplayLocationV3 } from '../../application/models';
|
|
1
2
|
import { EnviveMetricsEventName, SpiffyMetricsEventName } from '../../contexts/amplitudeContext';
|
|
2
3
|
|
|
3
4
|
export interface WidgetInteractionContext {
|
|
@@ -114,6 +115,58 @@ export const INTERACTION_TYPE_CLASS: Record<WidgetInteractionType, InteractionCl
|
|
|
114
115
|
[WidgetInteractionType.VARIANT_SELECTOR_DISMISSED]: InteractionClass.NAVIGATIONAL,
|
|
115
116
|
};
|
|
116
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Attribution map: which widget a chat open/close should be credited to, given the surface that
|
|
120
|
+
* triggered it.
|
|
121
|
+
*
|
|
122
|
+
* `null` marks a *system-initiated* trigger — the auto-popup timer, a referrer-based prompt, or a
|
|
123
|
+
* merchant `window` API call. Those are not user clicks, so they must not emit an intentional
|
|
124
|
+
* WIDGET_CLICKED. The chat's own WIDGET_EXPANDED still records that the chat opened, so no signal
|
|
125
|
+
* is lost by skipping them.
|
|
126
|
+
*
|
|
127
|
+
* Keyed exhaustively on purpose: adding a ChatElementDisplayLocationV3 member is a compile error
|
|
128
|
+
* until it is given an attribution here, rather than silently defaulting to the wrong widget.
|
|
129
|
+
*/
|
|
130
|
+
export const CHAT_TRIGGER_WIDGET: Record<
|
|
131
|
+
ChatElementDisplayLocationV3,
|
|
132
|
+
WidgetInteractionComponent | null
|
|
133
|
+
> = {
|
|
134
|
+
[ChatElementDisplayLocationV3.PROMPT_CAROUSEL]: WidgetInteractionComponent.SUGGESTION_BAR,
|
|
135
|
+
[ChatElementDisplayLocationV3.TITLED_PROMPT_CAROUSEL]: WidgetInteractionComponent.EMBEDDED_WIDGET,
|
|
136
|
+
[ChatElementDisplayLocationV3.TYPING_ANIMATION]: WidgetInteractionComponent.EMBEDDED_WIDGET,
|
|
137
|
+
[ChatElementDisplayLocationV3.SOCIAL_PROOF_PRIMARY_BUTTON]:
|
|
138
|
+
WidgetInteractionComponent.SOCIAL_PROOF,
|
|
139
|
+
[ChatElementDisplayLocationV3.SOCIAL_PROOF_SECONDARY_BUTTON]:
|
|
140
|
+
WidgetInteractionComponent.SOCIAL_PROOF,
|
|
141
|
+
[ChatElementDisplayLocationV3.SOCIAL_PROOF_TEXT_FIELD]: WidgetInteractionComponent.SOCIAL_PROOF,
|
|
142
|
+
[ChatElementDisplayLocationV3.CHAT_PREVIEW_COMPARISON_PROMPT_BUTTON]:
|
|
143
|
+
WidgetInteractionComponent.POST_INTERACTION_COMPARISON,
|
|
144
|
+
[ChatElementDisplayLocationV3.CHAT_PREVIEW_COMPARISON_TEXT_FIELD]:
|
|
145
|
+
WidgetInteractionComponent.POST_INTERACTION_COMPARISON,
|
|
146
|
+
[ChatElementDisplayLocationV3.CHAT_PREVIEW_PROMPT_BUTTON]:
|
|
147
|
+
WidgetInteractionComponent.CHAT_PREVIEW,
|
|
148
|
+
[ChatElementDisplayLocationV3.CHAT_PREVIEW_TEXT_FIELD]: WidgetInteractionComponent.CHAT_PREVIEW,
|
|
149
|
+
[ChatElementDisplayLocationV3.PROMPT_BUTTON_CAROUSEL_WITH_IMAGE_PROMPT_BUTTON]:
|
|
150
|
+
WidgetInteractionComponent.SINGLE_IMAGE_PROMPT,
|
|
151
|
+
[ChatElementDisplayLocationV3.PROMPT_BUTTON_CAROUSEL_WITH_IMAGE_TEXT_FIELD]:
|
|
152
|
+
WidgetInteractionComponent.SINGLE_IMAGE_PROMPT,
|
|
153
|
+
[ChatElementDisplayLocationV3.FLOATING_BUTTON]: WidgetInteractionComponent.FLOATING_BUTTON,
|
|
154
|
+
[ChatElementDisplayLocationV3.FLOATING_CHAT_OVERLAY]: WidgetInteractionComponent.FLOATING_CHAT,
|
|
155
|
+
[ChatElementDisplayLocationV3.FLOATING_CHAT_CLOSE_BUTTON]:
|
|
156
|
+
WidgetInteractionComponent.FLOATING_CHAT,
|
|
157
|
+
[ChatElementDisplayLocationV3.FLOATING_CHAT_PROMPT_BUTTON]:
|
|
158
|
+
WidgetInteractionComponent.FLOATING_CHAT,
|
|
159
|
+
[ChatElementDisplayLocationV3.FLOATING_CHAT_TEXT_INPUT]: WidgetInteractionComponent.FLOATING_CHAT,
|
|
160
|
+
[ChatElementDisplayLocationV3.PRODUCT_CARD_PROMPT_BUTTON]:
|
|
161
|
+
WidgetInteractionComponent.IMAGE_PROMPT_CARD,
|
|
162
|
+
[ChatElementDisplayLocationV3.PRODUCT_CARD_TEXT_FIELD]:
|
|
163
|
+
WidgetInteractionComponent.IMAGE_PROMPT_CARD,
|
|
164
|
+
// System-initiated — see note above.
|
|
165
|
+
[ChatElementDisplayLocationV3.WINDOW_API_CALL]: null,
|
|
166
|
+
[ChatElementDisplayLocationV3.REFERRER_BASED_PROMPT]: null,
|
|
167
|
+
[ChatElementDisplayLocationV3.AUTOMATIC_PROMPT]: null,
|
|
168
|
+
};
|
|
169
|
+
|
|
117
170
|
export type URL = {
|
|
118
171
|
url: string;
|
|
119
172
|
};
|
|
@@ -241,6 +241,82 @@ describe('AmplitudeService', () => {
|
|
|
241
241
|
expect(eventProps).toHaveProperty('chat_user_message_input.count', 5);
|
|
242
242
|
});
|
|
243
243
|
|
|
244
|
+
it('should encode supplementary Unicode in widget text events for Amplitude transport', async () => {
|
|
245
|
+
const service = createService();
|
|
246
|
+
|
|
247
|
+
await service.trackEvent({
|
|
248
|
+
eventName: EnviveMetricsEventName.WidgetTextResponse,
|
|
249
|
+
eventProps: {
|
|
250
|
+
widget_text: {
|
|
251
|
+
text_field_placeholder_text: [{ id: 'item-1', value: 'Have a question? Ask away 🙂' }],
|
|
252
|
+
},
|
|
253
|
+
response_id: 'response-1',
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const eventProps = mockTrack.mock.calls[0][1];
|
|
258
|
+
expect(eventProps.widget_text).toEqual({
|
|
259
|
+
text_field_placeholder_text: [
|
|
260
|
+
{
|
|
261
|
+
id: 'item-1',
|
|
262
|
+
value: '__spiffy_utf8__:Have%20a%20question%3F%20Ask%20away%20%F0%9F%99%82',
|
|
263
|
+
},
|
|
264
|
+
],
|
|
265
|
+
});
|
|
266
|
+
expect(eventProps.response_id).toBe('response-1');
|
|
267
|
+
expect(eventProps['widget_text_response.widget_text']).toEqual(eventProps.widget_text);
|
|
268
|
+
|
|
269
|
+
await service.trackEvent({
|
|
270
|
+
eventName: EnviveMetricsEventName.WidgetTextClicked,
|
|
271
|
+
eventProps: {
|
|
272
|
+
response_id: 'response-1',
|
|
273
|
+
string_id: 'item-1',
|
|
274
|
+
text: 'Have a question? Ask away 🙂',
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const clickEventProps = mockTrack.mock.calls[1][1];
|
|
279
|
+
expect(clickEventProps.text).toBe(
|
|
280
|
+
'__spiffy_utf8__:Have%20a%20question%3F%20Ask%20away%20%F0%9F%99%82',
|
|
281
|
+
);
|
|
282
|
+
expect(clickEventProps['widget_text_clicked.text']).toBe(clickEventProps.text);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it.each([
|
|
286
|
+
['emoji', '🙂', '__spiffy_utf8__:%F0%9F%99%82'],
|
|
287
|
+
['ZWJ sequence', '👩💻', '__spiffy_utf8__:%F0%9F%91%A9%E2%80%8D%F0%9F%92%BB'],
|
|
288
|
+
['flag', '🇧🇷', '__spiffy_utf8__:%F0%9F%87%A7%F0%9F%87%B7'],
|
|
289
|
+
['skin tone', '👍🏽', '__spiffy_utf8__:%F0%9F%91%8D%F0%9F%8F%BD'],
|
|
290
|
+
['non-emoji supplementary character', '𝄞', '__spiffy_utf8__:%F0%9D%84%9E'],
|
|
291
|
+
['BMP text', 'café', 'café'],
|
|
292
|
+
])('should preserve %s across widget click transport', async (_name, text, expected) => {
|
|
293
|
+
const service = createService();
|
|
294
|
+
|
|
295
|
+
await service.trackEvent({
|
|
296
|
+
eventName: EnviveMetricsEventName.WidgetTextClicked,
|
|
297
|
+
eventProps: {
|
|
298
|
+
response_id: 'response-matrix',
|
|
299
|
+
string_id: 'item-matrix',
|
|
300
|
+
text,
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
const eventProps = mockTrack.mock.calls[0][1];
|
|
305
|
+
expect(eventProps.text).toBe(expected);
|
|
306
|
+
expect(eventProps['widget_text_clicked.text']).toBe(expected);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('should leave supplementary Unicode unchanged for unrelated events', async () => {
|
|
310
|
+
const service = createService();
|
|
311
|
+
|
|
312
|
+
await service.trackEvent({
|
|
313
|
+
eventName: SpiffyMetricsEventName.ChatUserMessageInput,
|
|
314
|
+
eventProps: { message: 'Hello 🙂' },
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
expect(mockTrack.mock.calls[0][1].message).toBe('Hello 🙂');
|
|
318
|
+
});
|
|
319
|
+
|
|
244
320
|
it('should generate insert_id from event data', async () => {
|
|
245
321
|
const service = createService();
|
|
246
322
|
|