@envive-ai/react-hooks 0.3.22 → 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 (90) 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/index.d.ts +7 -7
  7. package/dist/atoms/app/variant.d.cts +6 -6
  8. package/dist/atoms/app/variant.d.ts +6 -6
  9. package/dist/atoms/chat/chatState.cjs +3 -1
  10. package/dist/atoms/chat/chatState.d.cts +22 -19
  11. package/dist/atoms/chat/chatState.d.ts +5 -2
  12. package/dist/atoms/chat/chatState.js +3 -2
  13. package/dist/atoms/chat/form.d.cts +2 -2
  14. package/dist/atoms/chat/form.d.ts +2 -2
  15. package/dist/atoms/chat/index.cjs +1 -0
  16. package/dist/atoms/chat/index.d.cts +3 -3
  17. package/dist/atoms/chat/index.d.ts +4 -4
  18. package/dist/atoms/chat/index.js +2 -2
  19. package/dist/atoms/chat/lastMessage.d.cts +2 -2
  20. package/dist/atoms/chat/lastMessage.d.ts +2 -2
  21. package/dist/atoms/chat/messageQueue.d.cts +6 -6
  22. package/dist/atoms/chat/messageQueue.d.ts +6 -6
  23. package/dist/atoms/chat/performanceMetrics.d.cts +6 -6
  24. package/dist/atoms/chat/performanceMetrics.d.ts +6 -6
  25. package/dist/atoms/chat/renderedWidgetRefs.d.cts +2 -2
  26. package/dist/atoms/chat/renderedWidgetRefs.d.ts +2 -2
  27. package/dist/atoms/chat/replies.d.cts +3 -3
  28. package/dist/atoms/chat/suggestions.d.cts +2 -2
  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 +13 -13
  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/searchAPI.d.ts +13 -13
  46. package/dist/atoms/search/types.d.cts +1 -1
  47. package/dist/atoms/search/types.d.ts +1 -1
  48. package/dist/atoms/search/utils.d.ts +1 -1
  49. package/dist/atoms/widget/chatPreviewLoading.d.cts +2 -2
  50. package/dist/atoms/widget/chatPreviewLoading.d.ts +2 -2
  51. package/dist/contexts/enviveContext/enviveContext.cjs +3 -3
  52. package/dist/contexts/enviveContext/enviveContext.js +3 -3
  53. package/dist/contexts/hardcopyContext/hardcopyContext.cjs +5 -3
  54. package/dist/contexts/hardcopyContext/hardcopyContext.js +5 -3
  55. package/dist/contexts/salesAgentContext/chatAPI.cjs +12 -5
  56. package/dist/contexts/salesAgentContext/chatAPI.js +13 -6
  57. package/dist/contexts/systemSettingsContext/systemSettingsContext.d.ts +2 -2
  58. package/dist/contexts/types.d.cts +1 -1
  59. package/dist/contexts/types.d.ts +1 -1
  60. package/dist/contexts/typesV3.cjs +1 -1
  61. package/dist/contexts/typesV3.d.cts +2 -1
  62. package/dist/contexts/typesV3.d.ts +2 -1
  63. package/dist/contexts/typesV3.js +1 -1
  64. package/dist/hooks/GrabAndScroll/useGrabAndScroll.d.cts +2 -2
  65. package/dist/hooks/GrabAndScroll/useGrabAndScroll.d.ts +2 -2
  66. package/dist/hooks/Search/useSearchInput.cjs +1 -1
  67. package/dist/hooks/Search/useSearchInput.js +1 -1
  68. package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.cjs +8 -2
  69. package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.js +8 -2
  70. package/dist/hooks/WidgetInteraction/types.cjs +6 -2
  71. package/dist/hooks/WidgetInteraction/types.d.cts +8 -3
  72. package/dist/hooks/WidgetInteraction/types.d.ts +8 -3
  73. package/dist/hooks/WidgetInteraction/types.js +6 -2
  74. package/dist/hooks/utils.d.cts +1 -1
  75. package/dist/hooks/utils.d.ts +1 -1
  76. package/dist/services/ga4ProjectionService/ga4EventSchema.cjs +31 -27
  77. package/dist/services/ga4ProjectionService/ga4EventSchema.js +31 -27
  78. package/dist/services/ga4ProjectionService/ga4ProjectionService.cjs +31 -5
  79. package/dist/services/ga4ProjectionService/ga4ProjectionService.js +31 -5
  80. package/package.json +1 -1
  81. package/src/application/models/featureGates.ts +1 -0
  82. package/src/atoms/chat/chatState.ts +1 -0
  83. package/src/contexts/hardcopyContext/hardcopyContext.tsx +10 -2
  84. package/src/contexts/salesAgentContext/chatAPI.ts +6 -2
  85. package/src/contexts/typesV3.ts +1 -0
  86. package/src/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.ts +10 -2
  87. package/src/hooks/WidgetInteraction/types.ts +10 -1
  88. package/src/services/ga4ProjectionService/__tests__/ga4ProjectionService.test.ts +110 -49
  89. package/src/services/ga4ProjectionService/ga4EventSchema.ts +35 -27
  90. package/src/services/ga4ProjectionService/ga4ProjectionService.ts +60 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@envive-ai/react-hooks",
3
- "version": "0.3.22",
3
+ "version": "0.3.23",
4
4
  "description": "React hooks for connecting to Envive AI services.",
5
5
  "keywords": [
6
6
  "react",
@@ -27,4 +27,5 @@ export enum FeatureGates {
27
27
  IsAiSuggestionsVariantEnabled = 'is_ai_suggestions_variant_enabled',
28
28
  IsAiSuggestionsVariantAEnabled = 'is_ai_suggestions_variant_a_enabled',
29
29
  IsAiSuggestionsVariantBEnabled = 'is_ai_suggestions_variant_b_enabled',
30
+ IsVoiceInputEnabled = 'is_voice_input_enabled',
30
31
  }
@@ -26,6 +26,7 @@ export const initializedAtom = atom<boolean>(false);
26
26
  export const chatIsOpenAtom = atom<boolean>(false);
27
27
  export const requestFailureAtom = atom<boolean>(false);
28
28
  export const formSubmitAtom = atom<FormSubmittedAttributes>();
29
+ export const listeningToSpeechAtom = atom<boolean>(false);
29
30
  export const chatOnToggleAtom = atom(
30
31
  null,
31
32
  (
@@ -219,10 +219,18 @@ export const HardcopyProvider: React.FC<HardcopyProviderProps> = ({
219
219
  const { featureFlagService } = useFeatureFlagService();
220
220
  const getHardcopyFromBackend = useCallback(
221
221
  async (request: HardcopyRequest): Promise<HardcopyResponse> => {
222
- if (hardcopyOverride?.[request.widgetType]) {
222
+ const fallbackWidgetType =
223
+ request.widgetType === WidgetTypeV3.ProductCardV3
224
+ ? WidgetTypeV3.ImagePromptCardV3
225
+ : request.widgetType;
226
+
227
+ const overrideEntry =
228
+ hardcopyOverride?.[request.widgetType] ?? hardcopyOverride?.[fallbackWidgetType];
229
+ if (overrideEntry) {
223
230
  logger.logDebug('using hardcopy override', request.widgetType);
224
- return hardcopyOverride[request.widgetType]!;
231
+ return overrideEntry;
225
232
  }
233
+
226
234
  const overrideConfigVersion =
227
235
  getQueryParam('spiffy_config_version') ||
228
236
  getQueryParam('envive_config_version') ||
@@ -14,6 +14,7 @@ import {
14
14
  import { hasParsedVariantInfoAtom } from 'src/atoms/app';
15
15
  import { analyticsContextAtom } from 'src/atoms/app/variant';
16
16
  import { formSubmitAtom, replyEventCategoryAtom } from 'src/atoms/chat';
17
+ import { suggestionsAtom } from 'src/atoms/chat/chatState';
17
18
  import { queueUserEventAtom } from 'src/atoms/chat/messageQueue';
18
19
  import { useWidgetInteraction } from 'src/hooks/WidgetInteraction';
19
20
  import {
@@ -56,6 +57,7 @@ export const useSalesAgentChatAPI = (widget?: WidgetInteractionComponent) => {
56
57
  const context = useAtomValue(analyticsContextAtom);
57
58
  const hasParsedVariantInfo = useAtomValue(hasParsedVariantInfoAtom);
58
59
  const queueUserEvent = useSetAtom(queueUserEventAtom);
60
+ const setSuggestions = useSetAtom(suggestionsAtom);
59
61
  const setReplyEventCategory = useSetAtom(replyEventCategoryAtom);
60
62
  const setFormSubmit = useSetAtom(formSubmitAtom);
61
63
  const { trackEvent } = useAmplitude();
@@ -164,9 +166,10 @@ export const useSalesAgentChatAPI = (widget?: WidgetInteractionComponent) => {
164
166
  content: suggestion.content,
165
167
  },
166
168
  };
169
+ setSuggestions([]);
167
170
  queueUserEvent(event);
168
171
  },
169
- [queueUserEvent, trackEvent, context],
172
+ [queueUserEvent, setSuggestions, trackEvent, context],
170
173
  );
171
174
  const onTypedMessageSubmitted = useCallback(
172
175
  ({
@@ -229,9 +232,10 @@ export const useSalesAgentChatAPI = (widget?: WidgetInteractionComponent) => {
229
232
  userTyped,
230
233
  },
231
234
  };
235
+ setSuggestions([]);
232
236
  queueUserEvent(event);
233
237
  },
234
- [queueUserEvent, trackEvent, context],
238
+ [queueUserEvent, setSuggestions, trackEvent, context],
235
239
  );
236
240
  const onFormResponseSubmitted = useCallback(
237
241
  (form: FormSubmittedAttributes) => {
@@ -156,6 +156,7 @@ type LookAndFeelConfig = {
156
156
  chatHeaderLogoLightSrc: string;
157
157
  widgetLogoSrc: string;
158
158
  hideWidgetLogo: boolean;
159
+ voiceInputEnabled?: boolean;
159
160
  elementRadius: number;
160
161
  imageAspectRatio: ImageAspectRatio;
161
162
  buttonColors: ButtonColors;
@@ -1,6 +1,10 @@
1
+ import { useAtomValue } from 'jotai';
1
2
  import { RefObject, useEffect, useRef } from 'react';
2
- import { useIntersection } from 'src/hooks/Intersection/useIntersection';
3
+ import { pageVariantInfoAtom } from 'src/atoms/app';
3
4
  import { useAmplitude } from 'src/contexts/amplitudeContext/amplitudeContext';
5
+ import { PageVariantInfo } from 'src/contexts/pageContext/types';
6
+ import { useIntersection } from 'src/hooks/Intersection/useIntersection';
7
+ import { extractPageContext } from 'src/hooks/WidgetInteraction/utils';
4
8
  import {
5
9
  EnviveMetricsEventName,
6
10
  SpiffyMetricsEventName,
@@ -23,6 +27,7 @@ export const useTrackComponentVisibleEvent = (
23
27
  const isVisible = useIntersection(element, rootMargin);
24
28
  const hasTrackedEvent = useRef(false);
25
29
  const { trackEvent } = useAmplitude();
30
+ const variantInfo = useAtomValue(pageVariantInfoAtom);
26
31
 
27
32
  useEffect(() => {
28
33
  if (!enabled || hasTrackedEvent.current) {
@@ -37,7 +42,10 @@ export const useTrackComponentVisibleEvent = (
37
42
  eventName: EnviveMetricsEventName.WidgetRendered,
38
43
  eventProps: {
39
44
  ...eventProps,
40
- 'trigger.widget': eventProps?.widget_type,
45
+ trigger: {
46
+ widget: eventProps?.widget_type,
47
+ },
48
+ context: variantInfo ? extractPageContext(variantInfo as PageVariantInfo) : null,
41
49
  },
42
50
  });
43
51
  hasTrackedEvent.current = true;
@@ -62,6 +62,8 @@ export enum WidgetInteractionType {
62
62
  REVIEW_CARD_CLICKED = 'review_card_clicked',
63
63
  MESSAGE_SUBMITTED = 'message_submitted',
64
64
  MANUAL_SCROLL_TO_BOTTOM = 'manual_scroll_to_bottom',
65
+ VOICE_TRANSCRIPTION_STARTED = 'voice_transcription_started',
66
+ VOICE_TRANSCRIPTION_COMPLETED = 'voice_transcription_completed',
65
67
  }
66
68
 
67
69
  export enum InteractionClass {
@@ -85,6 +87,8 @@ export const INTERACTION_TYPE_CLASS: Record<WidgetInteractionType, InteractionCl
85
87
  [WidgetInteractionType.REVIEW_CARD_CLICKED]: InteractionClass.INTENTIONAL,
86
88
  [WidgetInteractionType.MESSAGE_SUBMITTED]: InteractionClass.INTENTIONAL,
87
89
  [WidgetInteractionType.MANUAL_SCROLL_TO_BOTTOM]: InteractionClass.PASSIVE,
90
+ [WidgetInteractionType.VOICE_TRANSCRIPTION_STARTED]: InteractionClass.INTENTIONAL,
91
+ [WidgetInteractionType.VOICE_TRANSCRIPTION_COMPLETED]: InteractionClass.INTENTIONAL,
88
92
  };
89
93
 
90
94
  export type URL = {
@@ -151,6 +155,10 @@ export type MessageSubmitted = {
151
155
  message_submitted: Request;
152
156
  };
153
157
 
158
+ export type VoiceTranscription = {
159
+ transcription: string;
160
+ };
161
+
154
162
  export type WidgetInteractionData =
155
163
  | SuggestionScrolled
156
164
  | SuggestionClicked
@@ -161,4 +169,5 @@ export type WidgetInteractionData =
161
169
  | ArticleLinkClicked
162
170
  | ProductCardClicked
163
171
  | ReviewCardClicked
164
- | MessageSubmitted;
172
+ | MessageSubmitted
173
+ | VoiceTranscription;
@@ -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
 
@@ -34,6 +34,11 @@ export type WidgetInteractionDataProjection = Record<string, Record<string, stri
34
34
  export interface GA4ProjectedEventConfig {
35
35
  gaEventName: string;
36
36
  allowedFields: readonly string[];
37
+ /**
38
+ * When present, used instead of `allowedFields` to both filter and rename fields.
39
+ * Key: the desired GA4 parameter name. Value: the source dot-notation key in eventProps.
40
+ */
41
+ fieldProjections?: Record<string, string>;
37
42
  widgetInteractionDataProjections?: WidgetInteractionDataProjection;
38
43
  }
39
44
 
@@ -44,34 +49,36 @@ export interface GA4ExcludedEventConfig {
44
49
  export type GA4EventSchemaEntry = GA4ProjectedEventConfig | GA4ExcludedEventConfig;
45
50
 
46
51
  const WIDGET_INTERACTION_DATA_PROJECTIONS: WidgetInteractionDataProjection = {
47
- widget_collapsed: { trigger_collapse_source: 'collapse_source' },
48
- product_card_clicked: { trigger_product_id: 'product_id' },
49
- suggestion_scrolled: { trigger_suggestion_id: 'suggestion_id' },
52
+ widget_collapsed: { interaction_collapse_source: 'widget_collapsed.collapse_source' },
53
+ product_card_clicked: { interaction_product_id: 'product_card_clicked.product_id' },
54
+ suggestion_scrolled: { interaction_suggestion_id: 'suggestion_scrolled.suggestion_id' },
50
55
  };
51
56
 
52
57
  export const GA4_EVENT_SCHEMA: Record<EnviveMetricsEventName, GA4EventSchemaEntry> = {
53
58
  [EnviveMetricsEventName.WidgetRendered]: {
54
59
  gaEventName: 'envive_widget_rendered',
55
- allowedFields: [
56
- 'context.page_type',
57
- 'context.page_id',
58
- 'trigger.widget',
59
- 'trigger.widget_role',
60
- 'context.surface',
61
- ],
60
+ allowedFields: [],
61
+ fieldProjections: {
62
+ page_type: 'context.page_type',
63
+ page_id: 'context.page_id',
64
+ surface: 'context.surface',
65
+ widget: 'trigger.widget',
66
+ widget_role: 'trigger.widget_role',
67
+ },
62
68
  },
63
69
 
64
- // This event is not currently implemented
65
70
  [EnviveMetricsEventName.WidgetInteraction]: {
66
71
  gaEventName: 'envive_widget_interaction',
67
- allowedFields: [
68
- 'context.page_type',
69
- 'context.page_id',
70
- 'trigger.widget',
71
- 'trigger.widget_interaction',
72
- 'trigger.entity_role',
73
- 'context.surface',
74
- ],
72
+ allowedFields: [],
73
+ fieldProjections: {
74
+ page_type: 'context.page_type',
75
+ page_id: 'context.page_id',
76
+ surface: 'context.surface',
77
+ widget: 'trigger.widget',
78
+ interaction_type: 'trigger.widget_interaction',
79
+ interaction_class: 'trigger.interaction_class',
80
+ widget_role: 'trigger.widget_role',
81
+ },
75
82
  widgetInteractionDataProjections: WIDGET_INTERACTION_DATA_PROJECTIONS,
76
83
  },
77
84
 
@@ -120,14 +127,15 @@ export const GA4_EVENT_SCHEMA: Record<EnviveMetricsEventName, GA4EventSchemaEntr
120
127
  // This event is not currently implemented
121
128
  [EnviveMetricsEventName.PageViewed]: {
122
129
  gaEventName: 'envive_page_context_evaluated',
123
- allowedFields: [
124
- 'context.page_type',
125
- 'context.page_id',
126
- 'context.supported',
127
- 'context.ready',
128
- 'context.page_variant_id',
129
- 'environment.envive_enabled',
130
- ],
130
+ allowedFields: [],
131
+ fieldProjections: {
132
+ page_type: 'context.page_type',
133
+ page_id: 'context.page_id',
134
+ context_supported: 'context.supported',
135
+ context_ready: 'context.ready',
136
+ context_page_variant_id: 'context.page_variant_id',
137
+ environment_envive_enabled: 'environment.envive_enabled',
138
+ },
131
139
  },
132
140
 
133
141
  [EnviveMetricsEventName.WidgetTextRequest]: {
@@ -17,6 +17,42 @@ const filterToSchema = (
17
17
  return result;
18
18
  };
19
19
 
20
+ // Flatten only true top-level nested objects (keys with no dots) one level deep.
21
+ // Keys that already contain dots are left intact so that e.g.
22
+ // `trigger.widget_interaction_data: { ... }` is preserved for downstream extraction.
23
+ const flattenOneLevel = (obj: Record<string, unknown>): Record<string, unknown> => {
24
+ const result: Record<string, unknown> = {};
25
+ for (const [key, value] of Object.entries(obj)) {
26
+ if (
27
+ !key.includes('.') &&
28
+ value !== null &&
29
+ typeof value === 'object' &&
30
+ !Array.isArray(value)
31
+ ) {
32
+ for (const [subKey, subValue] of Object.entries(value as Record<string, unknown>)) {
33
+ result[`${key}.${subKey}`] = subValue;
34
+ }
35
+ } else {
36
+ result[key] = value;
37
+ }
38
+ }
39
+ return result;
40
+ };
41
+
42
+ // Apply a GA4 projection map { ga4Key: sourceKey } to extract and rename fields.
43
+ const filterWithProjections = (
44
+ eventProps: Record<string, unknown>,
45
+ projections: Record<string, string>,
46
+ ): Record<string, unknown> => {
47
+ const result: Record<string, unknown> = {};
48
+ for (const [ga4Key, sourceKey] of Object.entries(projections)) {
49
+ if (sourceKey in eventProps) {
50
+ result[ga4Key] = eventProps[sourceKey];
51
+ }
52
+ }
53
+ return result;
54
+ };
55
+
20
56
  // "context.page_type" → "context_page_type"
21
57
  const flattenDotKeys = (obj: Record<string, unknown>): Record<string, unknown> => {
22
58
  const result: Record<string, unknown> = {};
@@ -32,16 +68,28 @@ const flattenDotKeys = (obj: Record<string, unknown>): Record<string, unknown> =
32
68
  // Search: search query
33
69
  // Other: page url
34
70
  // We want to omit all but pdp and plp page types to provide a clear, consistent interface for merchants.
71
+ // Handles both dot-notation keys (allowedFields path) and renamed GA4 keys (fieldProjections path).
35
72
  const sanitizePageId = (filtered: Record<string, unknown>): Record<string, unknown> => {
36
- const pageType = filtered['context.page_type'];
73
+ const pageType = filtered['page_type'] ?? filtered['context.page_type'];
37
74
  if (pageType === 'pdp' || pageType === 'plp') {
38
75
  return filtered;
39
76
  }
40
77
  const rest = { ...filtered };
78
+ delete rest['page_id'];
41
79
  delete rest['context.page_id'];
42
80
  return rest;
43
81
  };
44
82
 
83
+ const getNestedValue = (obj: Record<string, unknown>, path: string): unknown => {
84
+ const parts = path.split('.');
85
+ let current: unknown = obj;
86
+ for (const part of parts) {
87
+ if (current === null || typeof current !== 'object') return undefined;
88
+ current = (current as Record<string, unknown>)[part];
89
+ }
90
+ return current;
91
+ };
92
+
45
93
  // Extract whitelisted sub-fields from trigger.widget_interaction_data
46
94
  const projectWidgetInteractionData = (
47
95
  eventProps: Record<string, unknown>,
@@ -72,9 +120,10 @@ const projectWidgetInteractionData = (
72
120
 
73
121
  const data = interactionData as Record<string, unknown>;
74
122
  const result: Record<string, unknown> = {};
75
- for (const [gaKey, sourceField] of Object.entries(projectionMap)) {
76
- if (sourceField in data) {
77
- result[gaKey] = data[sourceField];
123
+ for (const [gaKey, sourcePath] of Object.entries(projectionMap)) {
124
+ const value = getNestedValue(data, sourcePath);
125
+ if (value !== undefined) {
126
+ result[gaKey] = value;
78
127
  }
79
128
  }
80
129
  return result;
@@ -113,9 +162,14 @@ export const projectToGA4 = (
113
162
  }
114
163
 
115
164
  const config = schemaEntry;
116
- const props = eventProps ?? {};
165
+ const props = flattenOneLevel(eventProps ?? {});
117
166
 
118
- let filtered = filterToSchema(props, config.allowedFields);
167
+ let filtered: Record<string, unknown>;
168
+ if (config.fieldProjections) {
169
+ filtered = filterWithProjections(props, config.fieldProjections);
170
+ } else {
171
+ filtered = filterToSchema(props, config.allowedFields);
172
+ }
119
173
  filtered = sanitizePageId(filtered);
120
174
 
121
175
  const interactionFields = projectWidgetInteractionData(props, config);