@envive-ai/react-hooks 0.3.21 → 0.3.23

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 (111) hide show
  1. package/dist/application/models/featureGates.cjs +2 -1
  2. package/dist/application/models/featureGates.d.cts +2 -1
  3. package/dist/application/models/featureGates.d.ts +2 -1
  4. package/dist/application/models/featureGates.js +2 -1
  5. package/dist/atoms/app/index.d.cts +7 -7
  6. package/dist/atoms/app/variant.d.cts +6 -6
  7. package/dist/atoms/app/variant.d.ts +6 -6
  8. package/dist/atoms/chat/chatState.cjs +3 -1
  9. package/dist/atoms/chat/chatState.d.cts +22 -19
  10. package/dist/atoms/chat/chatState.d.ts +22 -19
  11. package/dist/atoms/chat/chatState.js +3 -2
  12. package/dist/atoms/chat/form.d.cts +3 -3
  13. package/dist/atoms/chat/form.d.ts +2 -2
  14. package/dist/atoms/chat/index.cjs +1 -0
  15. package/dist/atoms/chat/index.d.cts +4 -4
  16. package/dist/atoms/chat/index.d.ts +4 -4
  17. package/dist/atoms/chat/index.js +2 -2
  18. package/dist/atoms/chat/lastMessage.d.cts +2 -2
  19. package/dist/atoms/chat/lastMessage.d.ts +2 -2
  20. package/dist/atoms/chat/messageQueue.d.cts +7 -7
  21. package/dist/atoms/chat/messageQueue.d.ts +6 -6
  22. package/dist/atoms/chat/performanceMetrics.d.cts +6 -6
  23. package/dist/atoms/chat/performanceMetrics.d.ts +6 -6
  24. package/dist/atoms/chat/renderedWidgetRefs.d.cts +3 -3
  25. package/dist/atoms/chat/renderedWidgetRefs.d.ts +2 -2
  26. package/dist/atoms/chat/replies.d.cts +3 -3
  27. package/dist/atoms/chat/replies.d.ts +2 -2
  28. package/dist/atoms/chat/suggestions.d.cts +3 -3
  29. package/dist/atoms/chat/suggestions.d.ts +2 -2
  30. package/dist/atoms/envive/enviveConfig.d.cts +13 -13
  31. package/dist/atoms/envive/enviveConfig.d.ts +1 -1
  32. package/dist/atoms/globalSearch/globalSearch.d.cts +5 -5
  33. package/dist/atoms/globalSearch/globalSearch.d.ts +5 -5
  34. package/dist/atoms/org/customerService.d.cts +6 -6
  35. package/dist/atoms/org/customerService.d.ts +6 -6
  36. package/dist/atoms/org/graphqlConfig.d.cts +4 -4
  37. package/dist/atoms/org/graphqlConfig.d.ts +4 -4
  38. package/dist/atoms/org/newOrgConfigAtom.d.cts +2 -2
  39. package/dist/atoms/org/newOrgConfigAtom.d.ts +2 -2
  40. package/dist/atoms/org/orgAnalyticsConfig.d.cts +5 -5
  41. package/dist/atoms/org/orgAnalyticsConfig.d.ts +5 -5
  42. package/dist/atoms/search/chatSearch.d.cts +17 -17
  43. package/dist/atoms/search/chatSearch.d.ts +17 -17
  44. package/dist/atoms/search/searchAPI.d.cts +13 -13
  45. package/dist/atoms/search/types.d.cts +1 -1
  46. package/dist/atoms/search/types.d.ts +1 -1
  47. package/dist/atoms/search/utils.d.ts +1 -1
  48. package/dist/atoms/widget/chatPreviewLoading.d.cts +2 -2
  49. package/dist/atoms/widget/chatPreviewLoading.d.ts +2 -2
  50. package/dist/contexts/amplitudeContext/amplitudeContext.cjs +9 -3
  51. package/dist/contexts/amplitudeContext/amplitudeContext.d.cts +2 -1
  52. package/dist/contexts/amplitudeContext/amplitudeContext.d.ts +2 -1
  53. package/dist/contexts/amplitudeContext/amplitudeContext.js +9 -3
  54. package/dist/contexts/enviveContext/enviveContext.cjs +3 -3
  55. package/dist/contexts/enviveContext/enviveContext.js +3 -3
  56. package/dist/contexts/enviveContext/types.d.ts +1 -1
  57. package/dist/contexts/hardcopyContext/hardcopyContext.cjs +5 -3
  58. package/dist/contexts/hardcopyContext/hardcopyContext.js +5 -3
  59. package/dist/contexts/salesAgentContext/chatAPI.cjs +12 -5
  60. package/dist/contexts/salesAgentContext/chatAPI.js +13 -6
  61. package/dist/contexts/systemSettingsContext/systemSettingsContext.d.cts +2 -2
  62. package/dist/contexts/types.d.cts +1 -1
  63. package/dist/contexts/typesV3.cjs +1 -1
  64. package/dist/contexts/typesV3.d.cts +2 -1
  65. package/dist/contexts/typesV3.d.ts +2 -1
  66. package/dist/contexts/typesV3.js +1 -1
  67. package/dist/hooks/GrabAndScroll/useGrabAndScroll.d.cts +2 -2
  68. package/dist/hooks/Search/useSearch.cjs +12 -4
  69. package/dist/hooks/Search/useSearch.js +12 -4
  70. package/dist/hooks/Search/useSearchInput.cjs +1 -1
  71. package/dist/hooks/Search/useSearchInput.js +1 -1
  72. package/dist/hooks/SystemSettingsContext/useSystemSettingsContext.d.cts +2 -2
  73. package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.cjs +26 -27
  74. package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.d.cts +8 -8
  75. package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.d.ts +8 -8
  76. package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.js +27 -28
  77. package/dist/hooks/WidgetInteraction/types.cjs +29 -1
  78. package/dist/hooks/WidgetInteraction/types.d.cts +17 -3
  79. package/dist/hooks/WidgetInteraction/types.d.ts +17 -3
  80. package/dist/hooks/WidgetInteraction/types.js +28 -2
  81. package/dist/hooks/WidgetInteraction/useWidgetInteraction.cjs +6 -2
  82. package/dist/hooks/WidgetInteraction/useWidgetInteraction.js +6 -2
  83. package/dist/hooks/utils.d.cts +1 -1
  84. package/dist/hooks/utils.d.ts +1 -1
  85. package/dist/services/amplitudeService/amplitudeService.cjs +9 -1
  86. package/dist/services/amplitudeService/amplitudeService.d.cts +2 -1
  87. package/dist/services/amplitudeService/amplitudeService.d.ts +2 -1
  88. package/dist/services/amplitudeService/amplitudeService.js +9 -1
  89. package/dist/services/ga4ProjectionService/ga4EventSchema.cjs +31 -27
  90. package/dist/services/ga4ProjectionService/ga4EventSchema.js +31 -27
  91. package/dist/services/ga4ProjectionService/ga4ProjectionService.cjs +31 -5
  92. package/dist/services/ga4ProjectionService/ga4ProjectionService.js +31 -5
  93. package/package.json +1 -1
  94. package/src/application/models/featureGates.ts +1 -0
  95. package/src/atoms/chat/chatState.ts +1 -0
  96. package/src/contexts/amplitudeContext/__tests__/amplitudeContext.test.tsx +31 -27
  97. package/src/contexts/amplitudeContext/amplitudeContext.tsx +5 -2
  98. package/src/contexts/hardcopyContext/hardcopyContext.tsx +10 -2
  99. package/src/contexts/pageContext/__tests__/pageContext.test.tsx +10 -0
  100. package/src/contexts/salesAgentContext/chatAPI.ts +6 -2
  101. package/src/contexts/typesV3.ts +1 -0
  102. package/src/hooks/Search/__tests__/useSearch.test.tsx +0 -4
  103. package/src/hooks/Search/useSearch.tsx +14 -8
  104. package/src/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.ts +36 -35
  105. package/src/hooks/WidgetInteraction/types.ts +35 -2
  106. package/src/hooks/WidgetInteraction/useWidgetInteraction.ts +3 -1
  107. package/src/services/amplitudeService/__tests__/amplitudeService.test.ts +69 -6
  108. package/src/services/amplitudeService/amplitudeService.ts +13 -0
  109. package/src/services/ga4ProjectionService/__tests__/ga4ProjectionService.test.ts +110 -49
  110. package/src/services/ga4ProjectionService/ga4EventSchema.ts +35 -27
  111. package/src/services/ga4ProjectionService/ga4ProjectionService.ts +60 -6
@@ -27,8 +27,8 @@ import { SearchResponseProduct } from '@spiffy-ai/commerce-api-client';
27
27
  import { SearchFilterDatum, SelectFilterItem } from 'src/types/search-filter-types';
28
28
  import { orgShortNameAtom } from 'src/atoms/envive/enviveConfig';
29
29
  import { FrontendConfig, SearchResponseProductAttributes } from 'src/application/models';
30
+ import { useIntersection } from 'src/hooks/Intersection/useIntersection';
30
31
  import { SearchResultsState, getSearchResultsState } from '../utils';
31
- import { useTrackComponentVisibleEvent } from '../TrackComponentVisibleEvent';
32
32
  import { useSearchInput } from './useSearchInput';
33
33
  import { useNewOrgConfig } from '../NewOrgConfig';
34
34
  import { useRecommendedProducts } from './useRecommendedProducts';
@@ -292,13 +292,19 @@ export const useSearch = ({
292
292
  scrollToTop();
293
293
  }, [setProductSorting, clearFilters, scrollToTop]);
294
294
 
295
- // Side Effects
296
- useTrackComponentVisibleEvent(
297
- SpiffyWidgets.SearchResults,
298
- searchResultsRef as RefObject<HTMLElement>,
299
- {},
300
- SpiffyMetricsEventName.SearchComponentVisible,
301
- );
295
+ const searchResultsVisible = useIntersection(searchResultsRef as RefObject<HTMLElement>, '0px');
296
+ const hasTrackedSearchComponentVisible = useRef(false);
297
+
298
+ useEffect(() => {
299
+ if (!searchResultsVisible || hasTrackedSearchComponentVisible.current) {
300
+ return;
301
+ }
302
+ hasTrackedSearchComponentVisible.current = true;
303
+ trackEvent({
304
+ eventName: SpiffyMetricsEventName.SearchComponentVisible,
305
+ eventProps: {},
306
+ });
307
+ }, [searchResultsVisible, trackEvent]);
302
308
 
303
309
  useEffect(() => {
304
310
  if (
@@ -1,54 +1,55 @@
1
+ import { useAtomValue } from 'jotai';
1
2
  import { RefObject, useEffect, useRef } from 'react';
2
- import { SpiffyWidgets } from 'src/application/models/spiffyWidgets';
3
- import { useIntersection } from 'src/hooks/Intersection/useIntersection';
3
+ import { pageVariantInfoAtom } from 'src/atoms/app';
4
4
  import { useAmplitude } from 'src/contexts/amplitudeContext/amplitudeContext';
5
- import { SpiffyMetricsEventName } from 'src/services/amplitudeService/amplitudeService';
5
+ import { PageVariantInfo } from 'src/contexts/pageContext/types';
6
+ import { useIntersection } from 'src/hooks/Intersection/useIntersection';
7
+ import { extractPageContext } from 'src/hooks/WidgetInteraction/utils';
8
+ import {
9
+ EnviveMetricsEventName,
10
+ SpiffyMetricsEventName,
11
+ } from 'src/services/amplitudeService/amplitudeService';
6
12
 
7
13
  /**
8
- * Tracks a component and logs an event to Amplitude when the component is visible.
14
+ * Tracks a component and logs `SpiffyMetricsEventName.ChatComponentVisible` when visible.
9
15
  *
10
- * @param component - The component to track.
11
16
  * @param element - The element to track visibility of.
12
- * @param eventProps - Additional properties to include with the event.
13
- * @param eventName - The Amplitude event name to track (defaults to ChatComponentVisible).
17
+ * @param eventProps - Properties to include with the event.
18
+ * @param rootMargin - Root margin for the intersection observer (defaults to 0px).
19
+ * @param enabled - Whether tracking is enabled (defaults to true).
14
20
  */
15
21
  export const useTrackComponentVisibleEvent = (
16
- component: SpiffyWidgets,
17
22
  element: RefObject<HTMLElement>,
18
23
  eventProps?: Record<string, unknown>,
19
- eventName: SpiffyMetricsEventName = SpiffyMetricsEventName.ChatComponentVisible,
20
- ) => {
21
- const isVisible = useIntersection(element, '0px');
24
+ rootMargin: string = '0px',
25
+ enabled: boolean = true,
26
+ ): { isVisible: boolean } => {
27
+ const isVisible = useIntersection(element, rootMargin);
22
28
  const hasTrackedEvent = useRef(false);
23
29
  const { trackEvent } = useAmplitude();
24
-
25
- const componentProps = (() => {
26
- if (eventName === SpiffyMetricsEventName.ChatComponentVisible) {
27
- return {
28
- chat_component: component,
29
- ...eventProps,
30
- };
31
- }
32
- if (eventName === SpiffyMetricsEventName.SearchComponentVisible) {
33
- return {
34
- search_component: component,
35
- ...eventProps,
36
- };
37
- }
38
- // Default case for other event types
39
- return {
40
- component,
41
- ...eventProps,
42
- };
43
- })();
30
+ const variantInfo = useAtomValue(pageVariantInfoAtom);
44
31
 
45
32
  useEffect(() => {
46
- if (isVisible && !hasTrackedEvent.current) {
33
+ if (!enabled || hasTrackedEvent.current) {
34
+ return;
35
+ }
36
+ if (isVisible) {
37
+ trackEvent({
38
+ eventName: SpiffyMetricsEventName.ChatComponentVisible,
39
+ eventProps,
40
+ });
47
41
  trackEvent({
48
- eventName,
49
- eventProps: componentProps,
42
+ eventName: EnviveMetricsEventName.WidgetRendered,
43
+ eventProps: {
44
+ ...eventProps,
45
+ trigger: {
46
+ widget: eventProps?.widget_type,
47
+ },
48
+ context: variantInfo ? extractPageContext(variantInfo as PageVariantInfo) : null,
49
+ },
50
50
  });
51
51
  hasTrackedEvent.current = true;
52
52
  }
53
- }, [isVisible, component, eventProps, eventName, componentProps, trackEvent]);
53
+ }, [enabled, isVisible, eventProps, trackEvent]);
54
+ return { isVisible };
54
55
  };
@@ -5,11 +5,12 @@ export interface WidgetInteractionContext {
5
5
  page_id: string;
6
6
  }
7
7
 
8
- interface WidgetInteractionTrigger {
8
+ export interface WidgetInteractionTrigger {
9
9
  interaction_id?: string;
10
10
  widget: WidgetInteractionComponent;
11
11
  widget_interaction: WidgetInteractionType;
12
12
  widget_interaction_data?: WidgetInteractionData | null;
13
+ interaction_class?: InteractionClass;
13
14
  }
14
15
 
15
16
  export interface InternalWidgetInteraction {
@@ -61,8 +62,35 @@ export enum WidgetInteractionType {
61
62
  REVIEW_CARD_CLICKED = 'review_card_clicked',
62
63
  MESSAGE_SUBMITTED = 'message_submitted',
63
64
  MANUAL_SCROLL_TO_BOTTOM = 'manual_scroll_to_bottom',
65
+ VOICE_TRANSCRIPTION_STARTED = 'voice_transcription_started',
66
+ VOICE_TRANSCRIPTION_COMPLETED = 'voice_transcription_completed',
64
67
  }
65
68
 
69
+ export enum InteractionClass {
70
+ PASSIVE = 'passive', // No user commitment — hover, scroll, visibility triggers
71
+ NAVIGATIONAL = 'navigational', // Opens/closes something — often a side effect of intent
72
+ INTENTIONAL = 'intentional', // Deliberate user action — clicks, submits, typed queries
73
+ }
74
+
75
+ export const INTERACTION_TYPE_CLASS: Record<WidgetInteractionType, InteractionClass> = {
76
+ [WidgetInteractionType.QUERY_INPUT_CLICKED]: InteractionClass.INTENTIONAL,
77
+ [WidgetInteractionType.SUGGESTION_CLICKED]: InteractionClass.INTENTIONAL,
78
+ [WidgetInteractionType.WIDGET_CLICKED]: InteractionClass.INTENTIONAL,
79
+ [WidgetInteractionType.WIDGET_HOVERED]: InteractionClass.PASSIVE,
80
+ [WidgetInteractionType.WIDGET_EXPANDED]: InteractionClass.NAVIGATIONAL,
81
+ [WidgetInteractionType.WIDGET_COLLAPSED]: InteractionClass.NAVIGATIONAL,
82
+ [WidgetInteractionType.SUGGESTION_SCROLLED]: InteractionClass.PASSIVE,
83
+ [WidgetInteractionType.LINK_CLICKED]: InteractionClass.INTENTIONAL,
84
+ [WidgetInteractionType.PRODUCT_CARD_CLICKED]: InteractionClass.INTENTIONAL,
85
+ [WidgetInteractionType.TEXT_LINK_CLICKED]: InteractionClass.INTENTIONAL,
86
+ [WidgetInteractionType.ARTICLE_LINK_CLICKED]: InteractionClass.INTENTIONAL,
87
+ [WidgetInteractionType.REVIEW_CARD_CLICKED]: InteractionClass.INTENTIONAL,
88
+ [WidgetInteractionType.MESSAGE_SUBMITTED]: InteractionClass.INTENTIONAL,
89
+ [WidgetInteractionType.MANUAL_SCROLL_TO_BOTTOM]: InteractionClass.PASSIVE,
90
+ [WidgetInteractionType.VOICE_TRANSCRIPTION_STARTED]: InteractionClass.INTENTIONAL,
91
+ [WidgetInteractionType.VOICE_TRANSCRIPTION_COMPLETED]: InteractionClass.INTENTIONAL,
92
+ };
93
+
66
94
  export type URL = {
67
95
  url: string;
68
96
  };
@@ -127,6 +155,10 @@ export type MessageSubmitted = {
127
155
  message_submitted: Request;
128
156
  };
129
157
 
158
+ export type VoiceTranscription = {
159
+ transcription: string;
160
+ };
161
+
130
162
  export type WidgetInteractionData =
131
163
  | SuggestionScrolled
132
164
  | SuggestionClicked
@@ -137,4 +169,5 @@ export type WidgetInteractionData =
137
169
  | ArticleLinkClicked
138
170
  | ProductCardClicked
139
171
  | ReviewCardClicked
140
- | MessageSubmitted;
172
+ | MessageSubmitted
173
+ | VoiceTranscription;
@@ -3,7 +3,7 @@ import { pageVariantInfoAtom } from 'src/atoms/app';
3
3
  import { useAmplitude } from 'src/contexts/amplitudeContext';
4
4
  import { PageVariantInfo } from 'src/contexts/pageContext/types';
5
5
  import { v4 as uuid } from 'uuid';
6
- import { InternalWidgetInteraction, WidgetInteraction } from './types';
6
+ import { INTERACTION_TYPE_CLASS, InternalWidgetInteraction, WidgetInteraction } from './types';
7
7
  import { extractPageContext } from './utils';
8
8
 
9
9
  const getInteractionId = () => uuid();
@@ -16,6 +16,8 @@ export const useWidgetInteraction = () => {
16
16
  if (props.trigger) {
17
17
  // eslint-disable-next-line no-param-reassign
18
18
  props.trigger.interaction_id = props.trigger.interaction_id || getInteractionId();
19
+ props.trigger.interaction_class =
20
+ props.trigger.interaction_class || INTERACTION_TYPE_CLASS[props.trigger.widget_interaction];
19
21
  }
20
22
 
21
23
  const eventProps = {
@@ -85,10 +85,12 @@ describe('AmplitudeService', () => {
85
85
  });
86
86
  });
87
87
 
88
+ const validApiKey = 'abcdef1234567890abcdef1234567890';
89
+
88
90
  const createService = (overrides?: Partial<AmplitudeServiceConfig>) => {
89
91
  return new AmplitudeService({
90
92
  userId: 'test-user-id',
91
- amplitudeApiKey: 'test-api-key',
93
+ amplitudeApiKey: validApiKey,
92
94
  dataResidency: 'US',
93
95
  env: 'test',
94
96
  orgId: 'test-org-id',
@@ -107,7 +109,7 @@ describe('AmplitudeService', () => {
107
109
  createService();
108
110
 
109
111
  expect(mockInit).toHaveBeenCalledWith(
110
- 'test-api-key',
112
+ validApiKey,
111
113
  'test-user-id',
112
114
  expect.objectContaining({
113
115
  serverZone: 'US',
@@ -121,7 +123,7 @@ describe('AmplitudeService', () => {
121
123
  it('should not be ready if userId is missing', () => {
122
124
  const service = new AmplitudeService({
123
125
  userId: '',
124
- amplitudeApiKey: 'test-api-key',
126
+ amplitudeApiKey: validApiKey,
125
127
  dataResidency: 'US',
126
128
  env: 'test',
127
129
  orgId: 'test-org-id',
@@ -157,7 +159,7 @@ describe('AmplitudeService', () => {
157
159
  it('should not be ready if featureFlagService is missing', () => {
158
160
  const service = new AmplitudeService({
159
161
  userId: 'test-user-id',
160
- amplitudeApiKey: 'test-api-key',
162
+ amplitudeApiKey: validApiKey,
161
163
  dataResidency: 'US',
162
164
  env: 'test',
163
165
  orgId: 'test-org-id',
@@ -269,7 +271,7 @@ describe('AmplitudeService', () => {
269
271
  it('should not track if client is not initialized', async () => {
270
272
  const service = new AmplitudeService({
271
273
  userId: '',
272
- amplitudeApiKey: 'test-api-key',
274
+ amplitudeApiKey: validApiKey,
273
275
  dataResidency: 'US',
274
276
  env: 'test',
275
277
  orgId: 'test-org-id',
@@ -419,7 +421,7 @@ describe('AmplitudeService', () => {
419
421
  it('should fall back to window.localStorage when getLocalStorageItem is not provided', async () => {
420
422
  const service = new AmplitudeService({
421
423
  userId: 'test-user-id',
422
- amplitudeApiKey: 'test-api-key',
424
+ amplitudeApiKey: validApiKey,
423
425
  dataResidency: 'US',
424
426
  env: 'test',
425
427
  orgId: 'test-org-id',
@@ -597,6 +599,67 @@ describe('AmplitudeService', () => {
597
599
  });
598
600
  });
599
601
 
602
+ describe('isMockApiKey', () => {
603
+ it('should return true when amplitudeApiKey is "mock-amplitude-key"', () => {
604
+ const service = createService({ amplitudeApiKey: 'mock-amplitude-key' });
605
+
606
+ expect(service.isMockApiKey).toBe(true);
607
+ });
608
+
609
+ it('should return false for a real API key', () => {
610
+ const service = createService();
611
+
612
+ expect(service.isMockApiKey).toBe(false);
613
+ });
614
+ });
615
+
616
+ describe('Mock API key behavior', () => {
617
+ it('should not initialize the Amplitude client when using mock API key', () => {
618
+ createService({ amplitudeApiKey: 'mock-amplitude-key' });
619
+
620
+ expect(mockInit).not.toHaveBeenCalled();
621
+ expect(mockAdd).not.toHaveBeenCalled();
622
+ });
623
+
624
+ it('should log a warning when mock API key is detected during initialization', () => {
625
+ const logWarnSpy = vi.spyOn(Logger.prototype, 'logWarn');
626
+
627
+ createService({ amplitudeApiKey: 'mock-amplitude-key' });
628
+
629
+ expect(logWarnSpy).toHaveBeenCalledWith(
630
+ 'Mock API key detected — running in mock mode, no events will be sent to Amplitude.',
631
+ 'mock-amplitude-key',
632
+ );
633
+ });
634
+
635
+ it('should report isReady as false when using mock API key', () => {
636
+ const service = createService({ amplitudeApiKey: 'mock-amplitude-key' });
637
+
638
+ expect(service.isReady).toBe(false);
639
+ });
640
+
641
+ it('should not track events when using mock API key', async () => {
642
+ const service = createService({ amplitudeApiKey: 'mock-amplitude-key' });
643
+
644
+ await service.trackEvent({
645
+ eventName: SpiffyMetricsEventName.ChatUserMessageInput,
646
+ eventProps: { message: 'test' },
647
+ });
648
+
649
+ expect(mockTrack).not.toHaveBeenCalled();
650
+ });
651
+
652
+ it('should not call crypto.subtle.digest when using mock API key', async () => {
653
+ const service = createService({ amplitudeApiKey: 'mock-amplitude-key' });
654
+
655
+ await service.trackEvent({
656
+ eventName: SpiffyMetricsEventName.ChatUserMessageInput,
657
+ });
658
+
659
+ expect(mockDigest).not.toHaveBeenCalled();
660
+ });
661
+ });
662
+
600
663
  describe('Session replay initialization', () => {
601
664
  it('should disable session replay by default', () => {
602
665
  createService();
@@ -56,6 +56,10 @@ export class AmplitudeService {
56
56
  this.initialize();
57
57
  }
58
58
 
59
+ get isMockApiKey(): boolean {
60
+ return this.config.amplitudeApiKey === 'mock-amplitude-key';
61
+ }
62
+
59
63
  get isReady(): boolean {
60
64
  return Boolean(
61
65
  this.config.userId &&
@@ -228,6 +232,14 @@ export class AmplitudeService {
228
232
  return;
229
233
  }
230
234
 
235
+ if (this.isMockApiKey) {
236
+ logger.logWarn(
237
+ 'Mock API key detected — running in mock mode, no events will be sent to Amplitude.',
238
+ this.config.amplitudeApiKey,
239
+ );
240
+ return;
241
+ }
242
+
231
243
  if (!this.amplitudeClient) {
232
244
  const currentAmplitudeInstance: BrowserClient = createInstance();
233
245
 
@@ -353,6 +365,7 @@ export class AmplitudeService {
353
365
  eventGroups,
354
366
  alsoSendToGoogleAnalytics = false,
355
367
  }: TrackEventParams): Promise<void> {
368
+ if (this.isMockApiKey) return;
356
369
  logger.logDebug('Submitting event', eventName);
357
370
  try {
358
371
  const decoratedEventName = AmplitudeService.decorateEventName(eventName);
@@ -28,34 +28,46 @@ describe('projectToGA4', () => {
28
28
  });
29
29
 
30
30
  describe('Widget Rendered', () => {
31
- it('should push filtered and flattened props', () => {
31
+ it('should push renamed GA4 fields from nested input', () => {
32
32
  projectToGA4(EnviveMetricsEventName.WidgetRendered, {
33
- 'context.page_type': 'pdp',
34
- 'context.page_id': 'product-123',
35
- 'trigger.widget': 'floating_button',
36
- 'trigger.interaction_id': 'abc-123',
33
+ trigger: { widget: 'floating_button' },
34
+ context: { page_type: 'pdp', page_id: 'product-123' },
37
35
  });
38
36
 
39
37
  expect(window.dataLayer).toHaveLength(1);
40
38
  expect(window.dataLayer[0]).toEqual({
41
39
  event: 'envive_widget_rendered',
42
- context_page_type: 'pdp',
43
- context_page_id: 'product-123',
44
- trigger_widget: 'floating_button',
40
+ page_type: 'pdp',
41
+ page_id: 'product-123',
42
+ widget: 'floating_button',
45
43
  });
46
44
  });
47
45
 
48
- it('should exclude fields not in allowedFields', () => {
46
+ it('should also accept flat dot-notation input', () => {
49
47
  projectToGA4(EnviveMetricsEventName.WidgetRendered, {
50
48
  'context.page_type': 'pdp',
51
49
  'context.page_id': 'product-123',
52
50
  'trigger.widget': 'floating_button',
53
- 'trigger.interaction_id': 'should-be-dropped',
51
+ 'trigger.interaction_id': 'abc-123',
52
+ });
53
+
54
+ expect(window.dataLayer[0]).toEqual({
55
+ event: 'envive_widget_rendered',
56
+ page_type: 'pdp',
57
+ page_id: 'product-123',
58
+ widget: 'floating_button',
59
+ });
60
+ });
61
+
62
+ it('should exclude fields not in fieldProjections', () => {
63
+ projectToGA4(EnviveMetricsEventName.WidgetRendered, {
64
+ trigger: { widget: 'floating_button', interaction_id: 'should-be-dropped' },
65
+ context: { page_type: 'pdp', page_id: 'product-123' },
54
66
  'some.random_field': 'also-dropped',
55
67
  });
56
68
 
57
69
  const pushed = window.dataLayer[0];
58
- expect(pushed).not.toHaveProperty('trigger_interaction_id');
70
+ expect(pushed).not.toHaveProperty('interaction_id');
59
71
  expect(pushed).not.toHaveProperty('some_random_field');
60
72
  });
61
73
  });
@@ -63,97 +75,146 @@ describe('projectToGA4', () => {
63
75
  describe('page_id sanitization', () => {
64
76
  it('should keep page_id for pdp page type', () => {
65
77
  projectToGA4(EnviveMetricsEventName.WidgetRendered, {
66
- 'context.page_type': 'pdp',
67
- 'context.page_id': 'product-123',
68
- 'trigger.widget': 'floating_button',
78
+ trigger: { widget: 'floating_button' },
79
+ context: { page_type: 'pdp', page_id: 'product-123' },
69
80
  });
70
81
 
71
- expect(window.dataLayer[0]).toHaveProperty('context_page_id', 'product-123');
82
+ expect(window.dataLayer[0]).toHaveProperty('page_id', 'product-123');
72
83
  });
73
84
 
74
85
  it('should keep page_id for plp page type', () => {
75
86
  projectToGA4(EnviveMetricsEventName.WidgetRendered, {
76
- 'context.page_type': 'plp',
77
- 'context.page_id': 'category-456',
78
- 'trigger.widget': 'floating_button',
87
+ trigger: { widget: 'floating_button' },
88
+ context: { page_type: 'plp', page_id: 'category-456' },
79
89
  });
80
90
 
81
- expect(window.dataLayer[0]).toHaveProperty('context_page_id', 'category-456');
91
+ expect(window.dataLayer[0]).toHaveProperty('page_id', 'category-456');
82
92
  });
83
93
 
84
94
  it('should omit page_id for search page type', () => {
85
95
  projectToGA4(EnviveMetricsEventName.WidgetRendered, {
86
- 'context.page_type': 'search',
87
- 'context.page_id': 'some search query',
88
- 'trigger.widget': 'floating_button',
96
+ trigger: { widget: 'floating_button' },
97
+ context: { page_type: 'search', page_id: 'some search query' },
89
98
  });
90
99
 
91
- expect(window.dataLayer[0]).not.toHaveProperty('context_page_id');
100
+ expect(window.dataLayer[0]).not.toHaveProperty('page_id');
92
101
  });
93
102
 
94
103
  it('should omit page_id for homepage page type', () => {
95
104
  projectToGA4(EnviveMetricsEventName.WidgetRendered, {
96
- 'context.page_type': 'homepage',
97
- 'context.page_id': 'https://example.com',
98
- 'trigger.widget': 'floating_button',
105
+ trigger: { widget: 'floating_button' },
106
+ context: { page_type: 'homepage', page_id: 'https://example.com' },
99
107
  });
100
108
 
101
- expect(window.dataLayer[0]).not.toHaveProperty('context_page_id');
109
+ expect(window.dataLayer[0]).not.toHaveProperty('page_id');
102
110
  });
103
111
 
104
112
  it('should omit page_id for other page type', () => {
105
113
  projectToGA4(EnviveMetricsEventName.WidgetRendered, {
106
- 'context.page_type': 'other',
107
- 'context.page_id': 'https://example.com/about',
108
- 'trigger.widget': 'floating_button',
114
+ trigger: { widget: 'floating_button' },
115
+ context: { page_type: 'other', page_id: 'https://example.com/about' },
109
116
  });
110
117
 
111
- expect(window.dataLayer[0]).not.toHaveProperty('context_page_id');
118
+ expect(window.dataLayer[0]).not.toHaveProperty('page_id');
112
119
  });
113
120
  });
114
121
 
115
122
  describe('Widget Interaction', () => {
116
- it('should push base fields and flatten dot keys', () => {
123
+ it('should push renamed GA4 fields (flat dot-notation input)', () => {
117
124
  projectToGA4(EnviveMetricsEventName.WidgetInteraction, {
118
125
  'context.page_type': 'pdp',
119
126
  'context.page_id': 'product-123',
120
127
  'trigger.widget': 'chat_overlay',
121
128
  'trigger.widget_interaction': 'link_clicked',
129
+ 'trigger.interaction_class': 'intentional',
122
130
  });
123
131
 
124
132
  expect(window.dataLayer[0]).toEqual({
125
133
  event: 'envive_widget_interaction',
126
- context_page_type: 'pdp',
127
- context_page_id: 'product-123',
128
- trigger_widget: 'chat_overlay',
129
- trigger_widget_interaction: 'link_clicked',
134
+ page_type: 'pdp',
135
+ page_id: 'product-123',
136
+ widget: 'chat_overlay',
137
+ interaction_type: 'link_clicked',
138
+ interaction_class: 'intentional',
130
139
  });
131
140
  });
132
141
 
133
- it('should extract collapse_source from widget_interaction_data for widget_collapsed', () => {
142
+ it('should project from real Amplitude nested-object props', () => {
143
+ projectToGA4(EnviveMetricsEventName.WidgetInteraction, {
144
+ trigger: {
145
+ widget: 'floating_button',
146
+ widget_interaction: 'widget_clicked',
147
+ interaction_id: '604a2e2c-9848-41dd-a2b1-cb5b8612ec08',
148
+ interaction_class: 'intentional',
149
+ },
150
+ context: {
151
+ page_id: 'hsa-fsa-eligible',
152
+ page_type: 'plp',
153
+ },
154
+ 'environment.execution_context': 'bundle',
155
+ 'org.short_name': 'dermalogica',
156
+ });
157
+
158
+ expect(window.dataLayer[0]).toEqual({
159
+ event: 'envive_widget_interaction',
160
+ page_type: 'plp',
161
+ page_id: 'hsa-fsa-eligible',
162
+ widget: 'floating_button',
163
+ interaction_type: 'widget_clicked',
164
+ interaction_class: 'intentional',
165
+ });
166
+ });
167
+
168
+ it('should omit page_id for non-pdp/plp page types (nested input)', () => {
169
+ projectToGA4(EnviveMetricsEventName.WidgetInteraction, {
170
+ trigger: { widget: 'floating_button', widget_interaction: 'widget_clicked' },
171
+ context: { page_type: 'homepage', page_id: 'https://example.com' },
172
+ });
173
+
174
+ expect(window.dataLayer[0]).not.toHaveProperty('page_id');
175
+ expect(window.dataLayer[0]).toHaveProperty('page_type', 'homepage');
176
+ });
177
+
178
+ it('should extract interaction_collapse_source from widget_interaction_data for widget_collapsed', () => {
134
179
  projectToGA4(EnviveMetricsEventName.WidgetInteraction, {
135
180
  'context.page_type': 'pdp',
136
181
  'context.page_id': 'product-123',
137
182
  'trigger.widget': 'chat_overlay',
138
183
  'trigger.widget_interaction': 'widget_collapsed',
139
- 'trigger.widget_interaction_data': { collapse_source: 'close_button' },
184
+ 'trigger.widget_interaction_data': {
185
+ widget_collapsed: { collapse_source: 'close_button' },
186
+ },
187
+ });
188
+
189
+ expect(window.dataLayer[0]).toHaveProperty('interaction_collapse_source', 'close_button');
190
+ });
191
+
192
+ it('should extract interaction_collapse_source from nested real-data trigger object', () => {
193
+ projectToGA4(EnviveMetricsEventName.WidgetInteraction, {
194
+ trigger: {
195
+ widget: 'floating_button',
196
+ widget_interaction: 'widget_collapsed',
197
+ interaction_class: 'navigational',
198
+ widget_interaction_data: { widget_collapsed: { collapse_source: 'swipe' } },
199
+ },
200
+ context: { page_type: 'pdp', page_id: 'product-123' },
140
201
  });
141
202
 
142
- expect(window.dataLayer[0]).toHaveProperty('trigger_collapse_source', 'close_button');
203
+ expect(window.dataLayer[0]).toHaveProperty('interaction_collapse_source', 'swipe');
204
+ expect(window.dataLayer[0]).toHaveProperty('interaction_class', 'navigational');
143
205
  });
144
206
 
145
- it('should extract product_id from widget_interaction_data for product_card_clicked', () => {
207
+ it('should extract interaction_product_id from widget_interaction_data for product_card_clicked', () => {
146
208
  projectToGA4(EnviveMetricsEventName.WidgetInteraction, {
147
209
  'context.page_type': 'pdp',
148
210
  'context.page_id': 'product-123',
149
211
  'trigger.widget': 'chat_overlay',
150
212
  'trigger.widget_interaction': 'product_card_clicked',
151
- 'trigger.widget_interaction_data': { product_id: 'sku-789', url: 'https://example.com' },
213
+ 'trigger.widget_interaction_data': { product_card_clicked: { product_id: 'sku-789' } },
152
214
  });
153
215
 
154
216
  const pushed = window.dataLayer[0];
155
- expect(pushed).toHaveProperty('trigger_product_id', 'sku-789');
156
- expect(pushed).not.toHaveProperty('url');
217
+ expect(pushed).toHaveProperty('interaction_product_id', 'sku-789');
157
218
  });
158
219
 
159
220
  it('should extract suggestion_id from widget_interaction_data for suggestion_scrolled', () => {
@@ -162,10 +223,10 @@ describe('projectToGA4', () => {
162
223
  'context.page_id': 'product-123',
163
224
  'trigger.widget': 'chat_overlay',
164
225
  'trigger.widget_interaction': 'suggestion_scrolled',
165
- 'trigger.widget_interaction_data': { suggestion_id: 'sug-456' },
226
+ 'trigger.widget_interaction_data': { suggestion_scrolled: { suggestion_id: 'sug-456' } },
166
227
  });
167
228
 
168
- expect(window.dataLayer[0]).toHaveProperty('trigger_suggestion_id', 'sug-456');
229
+ expect(window.dataLayer[0]).toHaveProperty('interaction_suggestion_id', 'sug-456');
169
230
  });
170
231
 
171
232
  it('should drop widget_interaction_data for unwhitelisted interactions', () => {
@@ -281,8 +342,8 @@ describe('projectToGA4', () => {
281
342
 
282
343
  expect(window.dataLayer[0]).toEqual({
283
344
  event: 'envive_page_context_evaluated',
284
- context_page_type: 'pdp',
285
- context_page_id: 'product-123',
345
+ page_type: 'pdp',
346
+ page_id: 'product-123',
286
347
  context_supported: true,
287
348
  context_ready: true,
288
349
  context_page_variant_id: 'variant-A',
@@ -300,8 +361,8 @@ describe('projectToGA4', () => {
300
361
  });
301
362
 
302
363
  const pushed = window.dataLayer[0];
303
- expect(pushed).not.toHaveProperty('context_page_id');
304
- expect(pushed).toHaveProperty('context_page_type', 'search');
364
+ expect(pushed).not.toHaveProperty('page_id');
365
+ expect(pushed).toHaveProperty('page_type', 'search');
305
366
  });
306
367
  });
307
368