@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.
Files changed (93) hide show
  1. package/dist/application/commerce-api.cjs +11 -12
  2. package/dist/application/commerce-api.js +11 -12
  3. package/dist/application/logging/index.cjs +13 -0
  4. package/dist/application/logging/index.d.cts +3 -0
  5. package/dist/application/logging/index.d.ts +3 -0
  6. package/dist/application/logging/index.js +4 -0
  7. package/dist/application/logging/logLevel.cjs +123 -0
  8. package/dist/application/logging/logLevel.d.cts +47 -0
  9. package/dist/application/logging/logLevel.d.ts +47 -0
  10. package/dist/application/logging/logLevel.js +115 -0
  11. package/dist/application/logging/logger.cjs +6 -1
  12. package/dist/application/logging/logger.d.cts +13 -0
  13. package/dist/application/logging/logger.d.ts +13 -0
  14. package/dist/application/logging/logger.js +7 -1
  15. package/dist/atoms/app/index.d.cts +7 -7
  16. package/dist/atoms/app/index.d.ts +7 -7
  17. package/dist/atoms/app/variant.d.cts +6 -6
  18. package/dist/atoms/chat/chatState.d.cts +19 -19
  19. package/dist/atoms/chat/chatState.d.ts +19 -19
  20. package/dist/atoms/chat/form.d.cts +2 -2
  21. package/dist/atoms/chat/form.d.ts +2 -2
  22. package/dist/atoms/chat/index.d.cts +3 -3
  23. package/dist/atoms/chat/index.d.ts +3 -3
  24. package/dist/atoms/chat/lastMessage.d.cts +2 -2
  25. package/dist/atoms/chat/lastMessage.d.ts +2 -2
  26. package/dist/atoms/chat/messageQueue.d.cts +6 -6
  27. package/dist/atoms/chat/messageQueue.d.ts +6 -6
  28. package/dist/atoms/chat/performanceMetrics.d.cts +6 -6
  29. package/dist/atoms/chat/performanceMetrics.d.ts +6 -6
  30. package/dist/atoms/chat/renderedWidgetRefs.d.cts +2 -2
  31. package/dist/atoms/chat/renderedWidgetRefs.d.ts +2 -2
  32. package/dist/atoms/chat/replies.d.cts +3 -3
  33. package/dist/atoms/chat/replies.d.ts +3 -3
  34. package/dist/atoms/chat/suggestions.d.cts +2 -2
  35. package/dist/atoms/chat/suggestions.d.ts +2 -2
  36. package/dist/atoms/envive/enviveConfig.d.cts +14 -14
  37. package/dist/atoms/globalSearch/globalSearch.d.cts +5 -5
  38. package/dist/atoms/globalSearch/globalSearch.d.ts +5 -5
  39. package/dist/atoms/org/customerService.d.cts +6 -6
  40. package/dist/atoms/org/customerService.d.ts +6 -6
  41. package/dist/atoms/org/graphqlConfig.d.cts +4 -4
  42. package/dist/atoms/org/newOrgConfigAtom.d.cts +2 -2
  43. package/dist/atoms/org/orgAnalyticsConfig.d.cts +4 -4
  44. package/dist/atoms/org/orgAnalyticsConfig.d.ts +4 -4
  45. package/dist/atoms/search/chatSearch.d.cts +17 -17
  46. package/dist/atoms/search/chatSearch.d.ts +17 -17
  47. package/dist/atoms/search/searchAPI.d.cts +13 -13
  48. package/dist/atoms/search/searchAPI.d.ts +13 -13
  49. package/dist/atoms/search/types.d.cts +1 -1
  50. package/dist/atoms/search/utils.d.cts +1 -1
  51. package/dist/atoms/widget/chatPreviewLoading.d.cts +2 -2
  52. package/dist/atoms/widget/chatPreviewLoading.d.ts +2 -2
  53. package/dist/contexts/salesAgentContext/salesAgentService.cjs +2 -2
  54. package/dist/contexts/salesAgentContext/salesAgentService.js +2 -2
  55. package/dist/contexts/systemSettingsContext/systemSettingsContext.d.ts +2 -2
  56. package/dist/contexts/types.d.cts +1 -1
  57. package/dist/contexts/types.d.ts +1 -1
  58. package/dist/contexts/typesV3.d.ts +1 -1
  59. package/dist/hooks/ChatToggle/useChatToggle.cjs +10 -8
  60. package/dist/hooks/ChatToggle/useChatToggle.d.cts +4 -3
  61. package/dist/hooks/ChatToggle/useChatToggle.d.ts +4 -3
  62. package/dist/hooks/ChatToggle/useChatToggle.js +11 -9
  63. package/dist/hooks/Intersection/useIntersection.cjs +7 -5
  64. package/dist/hooks/Intersection/useIntersection.js +7 -5
  65. package/dist/hooks/ShopifyUrlOperations/useShopifyUrlOperations.d.cts +2 -2
  66. package/dist/hooks/ShopifyUrlOperations/useShopifyUrlOperations.d.ts +2 -2
  67. package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.cjs +8 -6
  68. package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.js +8 -6
  69. package/dist/hooks/WidgetInteraction/types.cjs +40 -1
  70. package/dist/hooks/WidgetInteraction/types.d.cts +16 -2
  71. package/dist/hooks/WidgetInteraction/types.d.ts +16 -2
  72. package/dist/hooks/WidgetInteraction/types.js +41 -2
  73. package/dist/services/amplitudeService/amplitudeService.cjs +14 -5
  74. package/dist/services/amplitudeService/amplitudeService.js +14 -5
  75. package/package.json +6 -2
  76. package/src/application/__tests__/commerceApiStreaming.test.ts +171 -0
  77. package/src/application/commerce-api.ts +21 -21
  78. package/src/application/logging/__tests__/logger.test.ts +159 -0
  79. package/src/application/logging/index.ts +12 -0
  80. package/src/application/logging/logLevel.ts +162 -0
  81. package/src/application/logging/logger.ts +13 -0
  82. package/src/application/utils/__tests__/widgetTextFromApiWidgetTextResponse.test.ts +55 -0
  83. package/src/contexts/salesAgentContext/salesAgentService.ts +1 -1
  84. package/src/contexts/userIdentityContext/__tests__/userIdentityContext.test.tsx +4 -5
  85. package/src/hooks/ChatToggle/__tests__/useChatToggle.test.ts +273 -0
  86. package/src/hooks/ChatToggle/useChatToggle.ts +39 -12
  87. package/src/hooks/Intersection/useIntersection.ts +7 -4
  88. package/src/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.ts +8 -5
  89. package/src/hooks/WidgetInteraction/__tests__/chatTriggerWidget.test.ts +51 -0
  90. package/src/hooks/WidgetInteraction/types.ts +53 -0
  91. package/src/services/amplitudeService/__tests__/amplitudeService.test.ts +76 -0
  92. package/src/services/amplitudeService/amplitudeService.ts +37 -5
  93. 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')).toBeInTheDocument();
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 { WidgetInteractionComponent, WidgetInteractionType } from '../WidgetInteraction/types';
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 = (triggerLocation: ChatElementDisplayLocationV3, triggerId?: string) => {
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
- trackWidgetInteraction({
21
- eventName: EnviveMetricsEventName.WidgetInteraction,
22
- trigger: {
23
- widget: WidgetInteractionComponent.FLOATING_BUTTON,
24
- widget_interaction: WidgetInteractionType.WIDGET_CLICKED,
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.FLOATING_BUTTON,
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 = (triggerLocation: ChatElementDisplayLocationV3, triggerId?: string) => {
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
- console.debug('[useIntersection] effect ran', {
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
- console.debug('[useIntersection] element.current is null — observer NOT attached');
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
- console.debug('[useIntersection] IntersectionObserver fired', {
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
- console.debug('[useIntersection] observer attached to element', current);
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
- console.debug('[useTrackComponentVisibleEvent] effect ran', {
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
- console.debug('[useTrackComponentVisibleEvent] skipped — enabled is false');
58
+ logger.logDebug('skipped — enabled is false');
56
59
  return;
57
60
  }
58
61
  if (hasTrackedEvent.current) {
59
- console.debug('[useTrackComponentVisibleEvent] skipped — event already tracked');
62
+ logger.logDebug('skipped — event already tracked');
60
63
  return;
61
64
  }
62
65
  if (!isVisible) {
63
- console.debug('[useTrackComponentVisibleEvent] skipped — element not visible');
66
+ logger.logDebug('skipped — element not visible');
64
67
  return;
65
68
  }
66
69
 
67
- console.debug('[useTrackComponentVisibleEvent] firing tracking events', eventProps);
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