@envive-ai/react-hooks 0.3.46 → 0.3.48

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 (243) hide show
  1. package/dist/application/commerce-api.cjs +73 -3
  2. package/dist/application/commerce-api.d.cts +16 -1
  3. package/dist/application/commerce-api.d.ts +16 -1
  4. package/dist/application/commerce-api.js +73 -3
  5. package/dist/application/models/api/userEvent.d.cts +9 -2
  6. package/dist/application/models/api/userEvent.d.ts +9 -2
  7. package/dist/application/models/featureGates.cjs +2 -1
  8. package/dist/application/models/featureGates.d.cts +2 -1
  9. package/dist/application/models/featureGates.d.ts +2 -1
  10. package/dist/application/models/featureGates.js +2 -1
  11. package/dist/application/models/graphql/queries/getProductOptionsQuery.cjs +33 -0
  12. package/dist/application/models/graphql/queries/getProductOptionsQuery.d.cts +28 -0
  13. package/dist/application/models/graphql/queries/getProductOptionsQuery.d.ts +28 -0
  14. package/dist/application/models/graphql/queries/getProductOptionsQuery.js +32 -0
  15. package/dist/application/models/validators/validateResponse.cjs +2 -1
  16. package/dist/application/models/validators/validateResponse.js +2 -1
  17. package/dist/application/models/validators/validateUserEvent.cjs +3 -3
  18. package/dist/application/models/validators/validateUserEvent.js +3 -3
  19. package/dist/application/utils/index.cjs +2 -0
  20. package/dist/application/utils/index.d.cts +2 -1
  21. package/dist/application/utils/index.d.ts +2 -1
  22. package/dist/application/utils/index.js +2 -1
  23. package/dist/application/utils/messageFromAddToCartEvent.cjs +32 -0
  24. package/dist/application/utils/messageFromAddToCartEvent.d.cts +19 -0
  25. package/dist/application/utils/messageFromAddToCartEvent.d.ts +19 -0
  26. package/dist/application/utils/messageFromAddToCartEvent.js +31 -0
  27. package/dist/atoms/app/index.cjs +1 -1
  28. package/dist/atoms/app/index.d.cts +7 -7
  29. package/dist/atoms/app/index.d.ts +7 -7
  30. package/dist/atoms/app/index.js +1 -1
  31. package/dist/atoms/app/variant.d.cts +6 -6
  32. package/dist/atoms/app/variant.d.ts +6 -6
  33. package/dist/atoms/chat/chatState.d.cts +19 -19
  34. package/dist/atoms/chat/chatState.d.ts +19 -19
  35. package/dist/atoms/chat/form.d.cts +2 -2
  36. package/dist/atoms/chat/form.d.ts +3 -3
  37. package/dist/atoms/chat/index.d.cts +2 -2
  38. package/dist/atoms/chat/index.d.ts +3 -3
  39. package/dist/atoms/chat/lastMessage.d.cts +2 -2
  40. package/dist/atoms/chat/lastMessage.d.ts +2 -2
  41. package/dist/atoms/chat/messageQueue.d.cts +6 -6
  42. package/dist/atoms/chat/messageQueue.d.ts +7 -7
  43. package/dist/atoms/chat/performanceMetrics.d.cts +6 -6
  44. package/dist/atoms/chat/performanceMetrics.d.ts +6 -6
  45. package/dist/atoms/chat/renderedWidgetRefs.d.cts +2 -2
  46. package/dist/atoms/chat/renderedWidgetRefs.d.ts +3 -3
  47. package/dist/atoms/chat/replies.d.cts +1 -1
  48. package/dist/atoms/chat/replies.d.ts +3 -3
  49. package/dist/atoms/chat/suggestions.d.cts +2 -2
  50. package/dist/atoms/chat/suggestions.d.ts +3 -3
  51. package/dist/atoms/envive/enviveConfig.cjs +7 -4
  52. package/dist/atoms/envive/enviveConfig.d.cts +16 -14
  53. package/dist/atoms/envive/enviveConfig.d.ts +16 -14
  54. package/dist/atoms/envive/enviveConfig.js +6 -4
  55. package/dist/atoms/globalSearch/globalSearch.d.cts +5 -5
  56. package/dist/atoms/globalSearch/globalSearch.d.ts +5 -5
  57. package/dist/atoms/org/customerService.d.cts +6 -6
  58. package/dist/atoms/org/customerService.d.ts +6 -6
  59. package/dist/atoms/org/graphqlConfig.cjs +1 -1
  60. package/dist/atoms/org/graphqlConfig.d.cts +6 -6
  61. package/dist/atoms/org/graphqlConfig.d.ts +6 -6
  62. package/dist/atoms/org/graphqlConfig.js +1 -1
  63. package/dist/atoms/org/newOrgConfigAtom.d.cts +2 -2
  64. package/dist/atoms/org/newOrgConfigAtom.d.ts +2 -2
  65. package/dist/atoms/org/orgAnalyticsConfig.d.cts +4 -4
  66. package/dist/atoms/org/orgAnalyticsConfig.d.ts +4 -4
  67. package/dist/atoms/search/chatSearch.d.cts +17 -17
  68. package/dist/atoms/search/chatSearch.d.ts +17 -17
  69. package/dist/atoms/search/searchAPI.d.cts +13 -13
  70. package/dist/atoms/search/searchAPI.d.ts +13 -13
  71. package/dist/atoms/search/types.d.ts +1 -1
  72. package/dist/atoms/widget/chatPreviewLoading.d.cts +2 -2
  73. package/dist/atoms/widget/chatPreviewLoading.d.ts +2 -2
  74. package/dist/contexts/enviveConfigContext/enviveConfigContext.cjs +6 -4
  75. package/dist/contexts/enviveConfigContext/enviveConfigContext.d.cts +3 -1
  76. package/dist/contexts/enviveConfigContext/enviveConfigContext.d.ts +3 -1
  77. package/dist/contexts/enviveConfigContext/enviveConfigContext.js +6 -4
  78. package/dist/contexts/enviveContext/enviveContext.cjs +7 -3
  79. package/dist/contexts/enviveContext/enviveContext.d.cts +5 -1
  80. package/dist/contexts/enviveContext/enviveContext.d.ts +5 -1
  81. package/dist/contexts/enviveContext/enviveContext.js +7 -3
  82. package/dist/contexts/featureFlagServiceContext/featureFlagServiceContext.cjs +2 -97
  83. package/dist/contexts/featureFlagServiceContext/featureFlagServiceContext.d.cts +3 -25
  84. package/dist/contexts/featureFlagServiceContext/featureFlagServiceContext.d.ts +3 -25
  85. package/dist/contexts/featureFlagServiceContext/featureFlagServiceContext.js +3 -97
  86. package/dist/contexts/featureFlagServiceContext/index.cjs +2 -1
  87. package/dist/contexts/featureFlagServiceContext/index.d.cts +2 -1
  88. package/dist/contexts/featureFlagServiceContext/index.d.ts +2 -1
  89. package/dist/contexts/featureFlagServiceContext/index.js +2 -1
  90. package/dist/contexts/graphqlContext/mockV3Config.cjs +1 -1
  91. package/dist/contexts/graphqlContext/mockV3Config.js +1 -1
  92. package/dist/contexts/localStorageContext/index.cjs +2 -1
  93. package/dist/contexts/localStorageContext/index.d.cts +2 -1
  94. package/dist/contexts/localStorageContext/index.d.ts +2 -1
  95. package/dist/contexts/localStorageContext/index.js +2 -1
  96. package/dist/contexts/localStorageContext/localStorageContext.cjs +5 -11
  97. package/dist/contexts/localStorageContext/localStorageContext.d.cts +3 -7
  98. package/dist/contexts/localStorageContext/localStorageContext.d.ts +3 -7
  99. package/dist/contexts/localStorageContext/localStorageContext.js +3 -8
  100. package/dist/contexts/localStorageContext/localStorageKeys.cjs +12 -0
  101. package/dist/contexts/localStorageContext/localStorageKeys.d.cts +9 -0
  102. package/dist/contexts/localStorageContext/localStorageKeys.d.ts +9 -0
  103. package/dist/contexts/localStorageContext/localStorageKeys.js +11 -0
  104. package/dist/contexts/salesAgentContext/chatAPI.cjs +13 -2
  105. package/dist/contexts/salesAgentContext/chatAPI.d.cts +4 -1
  106. package/dist/contexts/salesAgentContext/chatAPI.d.ts +4 -1
  107. package/dist/contexts/salesAgentContext/chatAPI.js +14 -3
  108. package/dist/contexts/salesAgentContext/salesAgentContext.cjs +12 -4
  109. package/dist/contexts/salesAgentContext/salesAgentContext.js +12 -4
  110. package/dist/contexts/systemSettingsContext/systemSettingsContext.d.cts +2 -2
  111. package/dist/contexts/types.cjs +1 -1
  112. package/dist/contexts/types.d.cts +3 -1
  113. package/dist/contexts/types.d.ts +3 -1
  114. package/dist/contexts/types.js +1 -1
  115. package/dist/contexts/typesV3.cjs +1 -1
  116. package/dist/contexts/typesV3.d.cts +11 -2
  117. package/dist/contexts/typesV3.d.ts +12 -3
  118. package/dist/contexts/typesV3.js +1 -1
  119. package/dist/hooks/AddToCart/addToCartErrorMessage.cjs +7 -0
  120. package/dist/hooks/AddToCart/addToCartErrorMessage.d.cts +5 -0
  121. package/dist/hooks/AddToCart/addToCartErrorMessage.d.ts +5 -0
  122. package/dist/hooks/AddToCart/addToCartErrorMessage.js +6 -0
  123. package/dist/hooks/AddToCart/createAddToCartUserEvent.cjs +20 -0
  124. package/dist/hooks/AddToCart/createAddToCartUserEvent.d.cts +17 -0
  125. package/dist/hooks/AddToCart/createAddToCartUserEvent.d.ts +17 -0
  126. package/dist/hooks/AddToCart/createAddToCartUserEvent.js +19 -0
  127. package/dist/hooks/AddToCart/index.cjs +9 -0
  128. package/dist/hooks/AddToCart/index.d.cts +5 -0
  129. package/dist/hooks/AddToCart/index.d.ts +5 -0
  130. package/dist/hooks/AddToCart/index.js +6 -0
  131. package/dist/hooks/AddToCart/parseAddToCartFunction.cjs +27 -0
  132. package/dist/hooks/AddToCart/parseAddToCartFunction.d.cts +13 -0
  133. package/dist/hooks/AddToCart/parseAddToCartFunction.d.ts +13 -0
  134. package/dist/hooks/AddToCart/parseAddToCartFunction.js +27 -0
  135. package/dist/hooks/AddToCart/useAddToCart.cjs +24 -0
  136. package/dist/hooks/AddToCart/useAddToCart.d.cts +14 -0
  137. package/dist/hooks/AddToCart/useAddToCart.d.ts +14 -0
  138. package/dist/hooks/AddToCart/useAddToCart.js +23 -0
  139. package/dist/hooks/GrabAndScroll/useGrabAndScroll.d.cts +2 -2
  140. package/dist/hooks/LocalStorageOperations/useLocalStorageOperations.cjs +6 -5
  141. package/dist/hooks/LocalStorageOperations/useLocalStorageOperations.js +3 -2
  142. package/dist/hooks/PageViewedEvent/usePageViewedEvent.cjs +5 -2
  143. package/dist/hooks/PageViewedEvent/usePageViewedEvent.js +5 -2
  144. package/dist/hooks/ProductDetails/index.cjs +9 -0
  145. package/dist/hooks/ProductDetails/index.d.cts +4 -0
  146. package/dist/hooks/ProductDetails/index.d.ts +4 -0
  147. package/dist/hooks/ProductDetails/index.js +4 -0
  148. package/dist/hooks/ProductDetails/useProductDetails.cjs +53 -0
  149. package/dist/hooks/ProductDetails/useProductDetails.d.cts +20 -0
  150. package/dist/hooks/ProductDetails/useProductDetails.d.ts +20 -0
  151. package/dist/hooks/ProductDetails/useProductDetails.js +52 -0
  152. package/dist/hooks/ProductDetails/variantSelection.cjs +42 -0
  153. package/dist/hooks/ProductDetails/variantSelection.d.cts +35 -0
  154. package/dist/hooks/ProductDetails/variantSelection.d.ts +35 -0
  155. package/dist/hooks/ProductDetails/variantSelection.js +38 -0
  156. package/dist/hooks/Search/useSearch.d.cts +1 -1
  157. package/dist/hooks/Search/useSearch.d.ts +1 -1
  158. package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.cjs +11 -2
  159. package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.d.cts +1 -10
  160. package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.d.ts +1 -10
  161. package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.js +11 -2
  162. package/dist/hooks/WidgetInteraction/types.cjs +12 -2
  163. package/dist/hooks/WidgetInteraction/types.d.cts +28 -3
  164. package/dist/hooks/WidgetInteraction/types.d.ts +28 -3
  165. package/dist/hooks/WidgetInteraction/types.js +12 -2
  166. package/dist/hooks/utils.d.cts +1 -1
  167. package/dist/hooks/utils.d.ts +1 -1
  168. package/dist/services/amplitudeService/amplitudeService.cjs +16 -10
  169. package/dist/services/amplitudeService/amplitudeService.d.cts +5 -2
  170. package/dist/services/amplitudeService/amplitudeService.d.ts +5 -2
  171. package/dist/services/amplitudeService/amplitudeService.js +14 -7
  172. package/dist/services/enviveConfigService/enviveConfigService.cjs +3 -4
  173. package/dist/services/enviveConfigService/enviveConfigService.d.cts +1 -1
  174. package/dist/services/enviveConfigService/enviveConfigService.d.ts +1 -1
  175. package/dist/services/enviveConfigService/enviveConfigService.js +2 -3
  176. package/dist/services/enviveConfigService/fetchGraphQLConfig.cjs +8 -7
  177. package/dist/services/enviveConfigService/fetchGraphQLConfig.js +8 -6
  178. package/dist/services/featureFlagService/index.cjs +101 -0
  179. package/dist/services/featureFlagService/index.d.cts +28 -0
  180. package/dist/services/featureFlagService/index.d.ts +28 -0
  181. package/dist/services/featureFlagService/index.js +101 -0
  182. package/dist/services/ga4ProjectionService/ga4EventSchema.cjs +8 -2
  183. package/dist/services/ga4ProjectionService/ga4EventSchema.js +8 -2
  184. package/dist/services/hardcopyService/hardcopyService.d.cts +1 -1
  185. package/dist/services/hardcopyService/hardcopyService.d.ts +1 -1
  186. package/dist/services/urlResolverWarmupService/index.cjs +54 -0
  187. package/dist/services/urlResolverWarmupService/index.d.cts +40 -0
  188. package/dist/services/urlResolverWarmupService/index.d.ts +40 -0
  189. package/dist/services/urlResolverWarmupService/index.js +54 -0
  190. package/dist/types/enviveConfig.d.cts +6 -2
  191. package/dist/types/enviveConfig.d.ts +6 -2
  192. package/dist/types/index.d.cts +2 -2
  193. package/dist/types/index.d.ts +2 -2
  194. package/package.json +17 -1
  195. package/src/application/__tests__/commerceApiAddToCartRehydration.test.ts +126 -0
  196. package/src/application/commerce-api.ts +142 -23
  197. package/src/application/models/api/userEvent.ts +8 -1
  198. package/src/application/models/featureGates.ts +5 -0
  199. package/src/application/models/graphql/queries/getProductOptionsQuery.ts +59 -0
  200. package/src/application/models/validators/__tests__/validateUserEvent.test.ts +41 -0
  201. package/src/application/models/validators/validateResponse.ts +1 -0
  202. package/src/application/models/validators/validateUserEvent.ts +7 -2
  203. package/src/application/utils/__tests__/messageFromAddToCartEvent.test.ts +109 -0
  204. package/src/application/utils/index.ts +1 -0
  205. package/src/application/utils/messageFromAddToCartEvent.ts +32 -0
  206. package/src/atoms/app/index.ts +1 -1
  207. package/src/atoms/envive/enviveConfig.ts +6 -1
  208. package/src/atoms/org/graphqlConfig.ts +1 -1
  209. package/src/contexts/enviveConfigContext/enviveConfigContext.tsx +5 -0
  210. package/src/contexts/enviveContext/enviveContext.tsx +10 -0
  211. package/src/contexts/featureFlagServiceContext/featureFlagServiceContext.tsx +6 -173
  212. package/src/contexts/localStorageContext/localStorageContext.tsx +2 -5
  213. package/src/contexts/localStorageContext/localStorageKeys.ts +8 -0
  214. package/src/contexts/salesAgentContext/__tests__/chatAPI.test.tsx +63 -0
  215. package/src/contexts/salesAgentContext/chatAPI.ts +20 -1
  216. package/src/contexts/salesAgentContext/salesAgentContext.tsx +10 -0
  217. package/src/contexts/types.ts +2 -0
  218. package/src/contexts/typesV3.ts +14 -0
  219. package/src/hooks/AddToCart/__tests__/parseAddToCartFunction.test.ts +31 -0
  220. package/src/hooks/AddToCart/__tests__/useAddToCart.test.tsx +204 -0
  221. package/src/hooks/AddToCart/addToCartErrorMessage.ts +2 -0
  222. package/src/hooks/AddToCart/createAddToCartUserEvent.ts +25 -0
  223. package/src/hooks/AddToCart/index.ts +4 -0
  224. package/src/hooks/AddToCart/parseAddToCartFunction.ts +31 -0
  225. package/src/hooks/AddToCart/useAddToCart.ts +35 -0
  226. package/src/hooks/PageViewedEvent/usePageViewedEvent.ts +4 -0
  227. package/src/hooks/ProductDetails/__tests__/useProductDetails.test.tsx +129 -0
  228. package/src/hooks/ProductDetails/__tests__/variantSelection.test.ts +145 -0
  229. package/src/hooks/ProductDetails/index.ts +2 -0
  230. package/src/hooks/ProductDetails/useProductDetails.ts +62 -0
  231. package/src/hooks/ProductDetails/variantSelection.ts +61 -0
  232. package/src/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.ts +15 -0
  233. package/src/hooks/WidgetInteraction/types.ts +42 -1
  234. package/src/services/amplitudeService/__tests__/amplitudeService.test.ts +8 -8
  235. package/src/services/amplitudeService/amplitudeService.ts +42 -23
  236. package/src/services/enviveConfigService/enviveConfigService.ts +2 -2
  237. package/src/services/enviveConfigService/fetchGraphQLConfig.ts +17 -6
  238. package/src/services/featureFlagService/index.ts +176 -0
  239. package/src/services/ga4ProjectionService/__tests__/ga4ProjectionService.test.ts +36 -0
  240. package/src/services/ga4ProjectionService/ga4EventSchema.ts +7 -0
  241. package/src/services/urlResolverWarmupService/__tests__/urlResolverWarmupService.test.ts +85 -0
  242. package/src/services/urlResolverWarmupService/index.ts +89 -0
  243. package/src/types/enviveConfig.ts +3 -0
@@ -0,0 +1,61 @@
1
+ import CommerceApiClient from 'src/application/commerce-api';
2
+ import {
3
+ ProductDetails,
4
+ ProductVariant,
5
+ } from 'src/application/models/graphql/queries/getProductOptionsQuery';
6
+
7
+ /** A dropdown selection state: option name → chosen value. */
8
+ export type VariantSelection = Record<string, string>;
9
+
10
+ /**
11
+ * Imperative variant-details fetch for click-driven flows (the mount-driven
12
+ * useProductDetails hook is a poor fit when the fetch should ride a button's
13
+ * pending state). Resolves undefined when the product has no options data.
14
+ *
15
+ * @throws on network/HTTP failure or GraphQL errors
16
+ */
17
+ export const fetchProductDetails = (productId: string): Promise<ProductDetails | undefined> =>
18
+ CommerceApiClient.retrieveProductDetails(productId);
19
+
20
+ /**
21
+ * A product needs the variant selector when there is more than one variant to
22
+ * choose between and at least one option actually offers a choice.
23
+ */
24
+ export const needsVariantSelection = (
25
+ details: ProductDetails | undefined,
26
+ ): details is ProductDetails =>
27
+ !!details && details.variants.length > 1 && details.options.some(o => o.values.length > 1);
28
+
29
+ /** The variantId to add directly when the product has exactly one variant. */
30
+ export const getSingleVariantId = (details: ProductDetails | undefined): string | undefined =>
31
+ details && details.variants.length === 1 ? details.variants[0].variantId : undefined;
32
+
33
+ /**
34
+ * Initial dropdown state: the options of the first available variant, falling
35
+ * back to the first variant, falling back to the first value of each option —
36
+ * so the selector opens in an addable state whenever possible.
37
+ */
38
+ export const getDefaultVariantSelection = (details: ProductDetails): VariantSelection => {
39
+ const defaultVariant = details.variants.find(v => v.available) ?? details.variants[0];
40
+ if (defaultVariant) {
41
+ return Object.fromEntries(defaultVariant.options.map(({ name, value }) => [name, value]));
42
+ }
43
+ return Object.fromEntries(
44
+ details.options.filter(o => o.values.length > 0).map(o => [o.name, o.values[0]]),
45
+ );
46
+ };
47
+
48
+ /**
49
+ * The variant whose options exactly match the selection (every option name in
50
+ * the variant agrees with the selected value, and counts match), or undefined
51
+ * when the combination does not exist.
52
+ */
53
+ export const findVariantForSelection = (
54
+ details: ProductDetails,
55
+ selection: VariantSelection,
56
+ ): ProductVariant | undefined =>
57
+ details.variants.find(
58
+ variant =>
59
+ variant.options.length === Object.keys(selection).length &&
60
+ variant.options.every(({ name, value }) => selection[name] === value),
61
+ );
@@ -18,6 +18,14 @@ import {
18
18
  * @param rootMargin - Root margin for the intersection observer (defaults to 0px).
19
19
  * @param enabled - Whether tracking is enabled (defaults to true).
20
20
  */
21
+ const safeNow = (): number | undefined => {
22
+ try {
23
+ return typeof window !== 'undefined' ? window.performance?.now?.() : undefined;
24
+ } catch {
25
+ return undefined;
26
+ }
27
+ };
28
+
21
29
  export const useTrackComponentVisibleEvent = (
22
30
  element: RefObject<HTMLElement>,
23
31
  eventProps?: Record<string, unknown>,
@@ -28,6 +36,12 @@ export const useTrackComponentVisibleEvent = (
28
36
  const hasTrackedEvent = useRef(false);
29
37
  const { trackEvent } = useAmplitude();
30
38
  const variantInfo = useAtomValue(pageVariantInfoAtom);
39
+ // Captured on first render, not when the event fires: Widget Rendered is
40
+ // emitted at visibility, which for below-the-fold widgets can be long
41
+ // after the widget actually rendered. performance.now() is relative to
42
+ // navigation start, so this reads as "ms from initial page load to
43
+ // widget render".
44
+ const renderOffsetMs = useRef(safeNow()).current;
31
45
 
32
46
  useEffect(() => {
33
47
  console.debug('[useTrackComponentVisibleEvent] effect ran', {
@@ -63,6 +77,7 @@ export const useTrackComponentVisibleEvent = (
63
77
  widget: eventProps?.widget_type,
64
78
  },
65
79
  context: variantInfo ? extractPageContext(variantInfo as PageVariantInfo) : null,
80
+ 'performance.render_offset_ms': renderOffsetMs,
66
81
  },
67
82
  });
68
83
  hasTrackedEvent.current = true;
@@ -61,6 +61,8 @@ export enum WidgetInteractionType {
61
61
  SUGGESTION_SCROLLED = 'suggestion_scrolled',
62
62
  LINK_CLICKED = 'link_clicked',
63
63
  PRODUCT_CARD_CLICKED = 'product_card_clicked',
64
+ ADD_TO_CART_CLICKED = 'add_to_cart_clicked',
65
+ ADD_TO_CART_FAILED = 'add_to_cart_failed',
64
66
  TEXT_LINK_CLICKED = 'text_link_clicked',
65
67
  ARTICLE_LINK_CLICKED = 'article_link_clicked',
66
68
  REVIEW_CARD_CLICKED = 'review_card_clicked',
@@ -69,6 +71,9 @@ export enum WidgetInteractionType {
69
71
  VOICE_TRANSCRIPTION_STARTED = 'voice_transcription_started',
70
72
  VOICE_TRANSCRIPTION_COMPLETED = 'voice_transcription_completed',
71
73
  OVERLAY_DISMISSED = 'overlay_dismissed',
74
+ VARIANT_SELECTOR_OPENED = 'variant_selector_opened',
75
+ VARIANT_OPTION_SELECTED = 'variant_option_selected',
76
+ VARIANT_SELECTOR_DISMISSED = 'variant_selector_dismissed',
72
77
  }
73
78
 
74
79
  export enum InteractionClass {
@@ -87,6 +92,8 @@ export const INTERACTION_TYPE_CLASS: Record<WidgetInteractionType, InteractionCl
87
92
  [WidgetInteractionType.SUGGESTION_SCROLLED]: InteractionClass.PASSIVE,
88
93
  [WidgetInteractionType.LINK_CLICKED]: InteractionClass.INTENTIONAL,
89
94
  [WidgetInteractionType.PRODUCT_CARD_CLICKED]: InteractionClass.INTENTIONAL,
95
+ [WidgetInteractionType.ADD_TO_CART_CLICKED]: InteractionClass.INTENTIONAL,
96
+ [WidgetInteractionType.ADD_TO_CART_FAILED]: InteractionClass.PASSIVE,
90
97
  [WidgetInteractionType.TEXT_LINK_CLICKED]: InteractionClass.INTENTIONAL,
91
98
  [WidgetInteractionType.ARTICLE_LINK_CLICKED]: InteractionClass.INTENTIONAL,
92
99
  [WidgetInteractionType.REVIEW_CARD_CLICKED]: InteractionClass.INTENTIONAL,
@@ -95,6 +102,9 @@ export const INTERACTION_TYPE_CLASS: Record<WidgetInteractionType, InteractionCl
95
102
  [WidgetInteractionType.VOICE_TRANSCRIPTION_STARTED]: InteractionClass.INTENTIONAL,
96
103
  [WidgetInteractionType.VOICE_TRANSCRIPTION_COMPLETED]: InteractionClass.INTENTIONAL,
97
104
  [WidgetInteractionType.OVERLAY_DISMISSED]: InteractionClass.NAVIGATIONAL,
105
+ [WidgetInteractionType.VARIANT_SELECTOR_OPENED]: InteractionClass.NAVIGATIONAL,
106
+ [WidgetInteractionType.VARIANT_OPTION_SELECTED]: InteractionClass.INTENTIONAL,
107
+ [WidgetInteractionType.VARIANT_SELECTOR_DISMISSED]: InteractionClass.NAVIGATIONAL,
98
108
  };
99
109
 
100
110
  export type URL = {
@@ -153,6 +163,14 @@ export type ProductCardClicked = {
153
163
  product_card_clicked: Product;
154
164
  };
155
165
 
166
+ export type AddToCartClicked = {
167
+ add_to_cart_clicked: Product;
168
+ };
169
+
170
+ export type AddToCartFailed = {
171
+ add_to_cart_failed: Product;
172
+ };
173
+
156
174
  export type ReviewCardClicked = {
157
175
  review_card_clicked: Review;
158
176
  };
@@ -165,6 +183,24 @@ export type VoiceTranscription = {
165
183
  transcription: string;
166
184
  };
167
185
 
186
+ export type SelectedVariantOption = {
187
+ product_id: string;
188
+ option_name: string;
189
+ option_value: string;
190
+ };
191
+
192
+ export type VariantSelectorOpened = {
193
+ variant_selector_opened: Product;
194
+ };
195
+
196
+ export type VariantOptionSelected = {
197
+ variant_option_selected: SelectedVariantOption;
198
+ };
199
+
200
+ export type VariantSelectorDismissed = {
201
+ variant_selector_dismissed: Product;
202
+ };
203
+
168
204
  export type WidgetInteractionData =
169
205
  | SuggestionScrolled
170
206
  | SuggestionClicked
@@ -174,6 +210,11 @@ export type WidgetInteractionData =
174
210
  | TextLinkClicked
175
211
  | ArticleLinkClicked
176
212
  | ProductCardClicked
213
+ | AddToCartClicked
214
+ | AddToCartFailed
177
215
  | ReviewCardClicked
178
216
  | MessageSubmitted
179
- | VoiceTranscription;
217
+ | VoiceTranscription
218
+ | VariantSelectorOpened
219
+ | VariantOptionSelected
220
+ | VariantSelectorDismissed;
@@ -105,8 +105,8 @@ describe('AmplitudeService', () => {
105
105
  };
106
106
 
107
107
  describe('Initialization', () => {
108
- it('should initialize Amplitude client when all required config is provided', () => {
109
- createService();
108
+ it('should initialize Amplitude client when all required config is provided', async () => {
109
+ await createService().initialized();
110
110
 
111
111
  expect(mockInit).toHaveBeenCalledWith(
112
112
  validApiKey,
@@ -516,9 +516,9 @@ describe('AmplitudeService', () => {
516
516
  configurable: true,
517
517
  });
518
518
 
519
- createService({
519
+ await createService({
520
520
  show: true,
521
- });
521
+ }).initialized();
522
522
 
523
523
  // Simulate a Page Viewed event by calling the enrichment plugin
524
524
  // The enrichment is added during initialization
@@ -564,7 +564,7 @@ describe('AmplitudeService', () => {
564
564
  configurable: true,
565
565
  });
566
566
 
567
- createService();
567
+ await createService().initialized();
568
568
 
569
569
  const enrichmentCall = mockAdd.mock.calls[0];
570
570
  const enrichment = enrichmentCall[0];
@@ -580,7 +580,7 @@ describe('AmplitudeService', () => {
580
580
  });
581
581
 
582
582
  it('should not enrich non-specified events', async () => {
583
- createService();
583
+ await createService().initialized();
584
584
 
585
585
  const enrichmentCall = mockAdd.mock.calls[0];
586
586
  const enrichment = enrichmentCall[0];
@@ -659,8 +659,8 @@ describe('AmplitudeService', () => {
659
659
  });
660
660
 
661
661
  describe('Session replay initialization', () => {
662
- it('should disable session replay by default', () => {
663
- createService();
662
+ it('should disable session replay by default', async () => {
663
+ await createService().initialized();
664
664
 
665
665
  // Session replay is always disabled in the current implementation
666
666
  expect(mockInit).toHaveBeenCalledWith(
@@ -1,4 +1,3 @@
1
- import { createInstance } from '@amplitude/analytics-browser';
2
1
  import { EventsDispatcher, SpiffyEvent } from 'src/events';
3
2
  import { LocalStorageKeys } from 'src/contexts/localStorageContext';
4
3
  import Logger from 'src/application/logging/logger';
@@ -8,7 +7,7 @@ import type {
8
7
  Event,
9
8
  ServerZoneType,
10
9
  } from '@amplitude/analytics-types';
11
- import { FeatureFlagService } from 'src/contexts/featureFlagServiceContext/featureFlagServiceContext';
10
+ import { FeatureFlagService } from 'src/services/featureFlagService';
12
11
  import { WidgetTypeV3 } from 'src/contexts/typesV3';
13
12
  import { ChatElementDisplayLocationV3 } from 'src/application/models/chatElementDisplayLocationV3';
14
13
  import { ExperimentNamespace, OrgConfigExperimentAssignment } from 'src/application/models';
@@ -80,24 +79,36 @@ export class AmplitudeService {
80
79
 
81
80
  private config: AmplitudeServiceConfig;
82
81
 
82
+ private initPromise: Promise<void>;
83
+
83
84
  constructor(config: AmplitudeServiceConfig) {
84
85
  this.config = config;
85
- this.initialize();
86
+ this.initPromise = this.initialize();
86
87
  }
87
88
 
88
89
  get isMockApiKey(): boolean {
89
90
  return this.config.amplitudeApiKey === 'mock-amplitude-key';
90
91
  }
91
92
 
93
+ // Must not depend on `amplitudeClient`: the SDK now loads via dynamic
94
+ // import, and AmplitudeProvider unmounts its entire subtree while
95
+ // isReady is false — gating on the client would block widget rendering
96
+ // on the analytics chunk download. Events awaited in trackEvent queue
97
+ // behind `initPromise` instead.
92
98
  get isReady(): boolean {
93
99
  return Boolean(
94
100
  this.config.userId &&
95
101
  this.config.featureFlagService &&
96
102
  this.config.amplitudeApiKey &&
97
- this.amplitudeClient,
103
+ !this.isMockApiKey,
98
104
  );
99
105
  }
100
106
 
107
+ /** Resolves once SDK initialization has settled (never rejects). */
108
+ initialized(): Promise<void> {
109
+ return this.initPromise;
110
+ }
111
+
101
112
  private getLocalStorageItem(key: string): string | null {
102
113
  if (this.config.getLocalStorageItem) {
103
114
  return this.config.getLocalStorageItem(key);
@@ -246,7 +257,7 @@ export class AmplitudeService {
246
257
  return enrichment;
247
258
  }
248
259
 
249
- private initialize(): void {
260
+ private async initialize(): Promise<void> {
250
261
  const isReady = Boolean(
251
262
  this.config.userId && this.config.featureFlagService && this.config.amplitudeApiKey,
252
263
  );
@@ -270,25 +281,32 @@ export class AmplitudeService {
270
281
  }
271
282
 
272
283
  if (!this.amplitudeClient) {
273
- const currentAmplitudeInstance: BrowserClient = createInstance();
274
-
275
- currentAmplitudeInstance.add(this.getEventTrackingEnrichment());
276
- currentAmplitudeInstance.init(this.config.amplitudeApiKey, this.config.userId, {
277
- serverZone: this.config.dataResidency as ServerZoneType,
278
- trackingOptions: {
279
- ipAddress: true,
280
- },
281
- autocapture: {
282
- attribution: true,
283
- pageViews: {
284
- trackHistoryChanges: 'pathOnly',
284
+ try {
285
+ // Dynamic import keeps the Amplitude SDK out of the eager
286
+ // injection-bundle chunk; trackEvent queues behind initPromise.
287
+ const { createInstance } = await import('@amplitude/analytics-browser');
288
+ const currentAmplitudeInstance: BrowserClient = createInstance();
289
+
290
+ currentAmplitudeInstance.add(this.getEventTrackingEnrichment());
291
+ currentAmplitudeInstance.init(this.config.amplitudeApiKey, this.config.userId, {
292
+ serverZone: this.config.dataResidency as ServerZoneType,
293
+ trackingOptions: {
294
+ ipAddress: true,
285
295
  },
286
- sessions: false,
287
- formInteractions: false,
288
- fileDownloads: false,
289
- },
290
- });
291
- this.amplitudeClient = currentAmplitudeInstance;
296
+ autocapture: {
297
+ attribution: true,
298
+ pageViews: {
299
+ trackHistoryChanges: 'pathOnly',
300
+ },
301
+ sessions: false,
302
+ formInteractions: false,
303
+ fileDownloads: false,
304
+ },
305
+ });
306
+ this.amplitudeClient = currentAmplitudeInstance;
307
+ } catch (err) {
308
+ logger.logError('Failed to load or initialize the Amplitude SDK', err);
309
+ }
292
310
  }
293
311
  }
294
312
 
@@ -396,6 +414,7 @@ export class AmplitudeService {
396
414
  alsoSendToGoogleAnalytics = false,
397
415
  }: TrackEventParams): Promise<void> {
398
416
  if (this.isMockApiKey) return;
417
+ await this.initPromise;
399
418
  logger.logDebug('Submitting event', eventName);
400
419
  try {
401
420
  const decoratedEventName = AmplitudeService.decorateEventName(eventName);
@@ -1,7 +1,7 @@
1
1
  import { OrgConfigExperimentAssignment, OrgConfigFeatureGate } from 'src/application/models';
2
2
  import { FeatureGates } from 'src/application/models/featureGates';
3
- import { FeatureFlagService } from 'src/contexts/featureFlagServiceContext';
4
- import { GraphQlConfigValues } from 'src/contexts/graphqlContext';
3
+ import { FeatureFlagService } from 'src/services/featureFlagService';
4
+ import type { GraphQlConfigValues } from 'src/contexts/graphqlContext';
5
5
  import { configVersionOverride } from 'src/types/config-versions';
6
6
  import { fetchGraphQLConfig, fetchUnifiedGraphQLConfig } from './fetchGraphQLConfig';
7
7
 
@@ -7,13 +7,16 @@ import {
7
7
  } from 'src/application/models';
8
8
  import { FrontendConfigV3 } from 'src/application/models/frontendConfigV3';
9
9
  import { ColorMappingV3 } from 'src/application/models/colorsConfigV3';
10
- import { FloatingButtonLocation } from '@envive-ai/react-toolkit-v3/FloatingButton';
10
+ // Type-only: a value import would pull the FloatingButton component (and
11
+ // React) through the package's component index into the injection bundle's
12
+ // pre-config chunk.
13
+ import type { FloatingButtonLocation } from '@envive-ai/react-toolkit-v3/FloatingButton';
11
14
  import { getColorsAndFrontendQuery } from 'src/application/models/graphql/queries/getColorsAndFrontendQuery';
12
15
  import { getWidgetConfigQuery } from 'src/application/models/graphql/queries/getWidgetConfigQuery';
13
16
  import { mockV3ColorsConfig, mockV3FrontendConfig } from 'src/contexts/graphqlContext/mockV3Config';
14
17
  import { WidgetConfigV3 } from 'src/contexts/typesV3';
15
18
  import { PageVariantConfig, PageVariantTestType, WidgetMountingConfig } from 'src/contexts/types';
16
- import { GraphQlConfigValues } from 'src/contexts/graphqlContext';
19
+ import type { GraphQlConfigValues } from 'src/contexts/graphqlContext';
17
20
  import { configVersionOverride } from 'src/types/config-versions';
18
21
  import { ExperimentConfigResolutionMetadata } from 'src/application/models/api/orgConfigResults';
19
22
 
@@ -39,11 +42,19 @@ export const DEFAULT_PAGE_VARIANTS: PageVariantConfig[] = [
39
42
  },
40
43
  ];
41
44
 
45
+ // String-literal values of the FloatingButtonLocation enum; keep in sync
46
+ // with @envive-ai/react-toolkit-v3/FloatingButton/types/types.ts.
47
+ const FLOATING_BUTTON_POSITIONS = [
48
+ 'bottom-left',
49
+ 'middle-left',
50
+ 'middle-right',
51
+ 'bottom-right',
52
+ ] as const;
53
+
42
54
  const mapPositionToCamelCase = (position: string): FloatingButtonLocation => {
43
- if (position === 'bottom-left') return FloatingButtonLocation.BOTTOM_LEFT;
44
- if (position === 'middle-left') return FloatingButtonLocation.MIDDLE_LEFT;
45
- if (position === 'middle-right') return FloatingButtonLocation.MIDDLE_RIGHT;
46
- if (position === 'bottom-right') return FloatingButtonLocation.BOTTOM_RIGHT;
55
+ if ((FLOATING_BUTTON_POSITIONS as readonly string[]).includes(position)) {
56
+ return position as FloatingButtonLocation;
57
+ }
47
58
  throw new Error(`Invalid position: ${position}`);
48
59
  };
49
60
 
@@ -0,0 +1,176 @@
1
+ import { OrgConfigFeatureGate } from 'src/application/models/api/orgConfigResults';
2
+ import { FeatureGates } from 'src/application/models/featureGates';
3
+ import Logger from 'src/application/logging/logger';
4
+
5
+ const FEATURE_FLAGS_STORAGE_KEY = 'spiffy-feature-flags';
6
+
7
+ const logger = new Logger('featureFlagService');
8
+
9
+ export interface FullFlagValue {
10
+ resolved: boolean;
11
+ initial: boolean | undefined;
12
+ query_param_override: boolean | undefined;
13
+ window_param_override: boolean | undefined;
14
+ stored_override: boolean | undefined;
15
+ }
16
+
17
+ // Kept free of React imports: the injection bundle constructs this service
18
+ // before deciding whether to load the React app, and pulling React into
19
+ // that pre-config code path would put it on the eager critical chunk.
20
+ export class FeatureFlagService {
21
+ private featureGates: OrgConfigFeatureGate[];
22
+
23
+ constructor(featureGates: OrgConfigFeatureGate[]) {
24
+ this.featureGates = featureGates;
25
+ }
26
+
27
+ static getQueryParamFeatureGateOverrides(featureGate: FeatureGates): boolean | undefined {
28
+ const url = window.location?.href ?? '';
29
+ if (url.includes('?')) {
30
+ const urlObj = new URL(url);
31
+ const params = new URLSearchParams(urlObj.search);
32
+ const paramsObj = Object.fromEntries(params.entries());
33
+ const value = paramsObj[featureGate];
34
+
35
+ if (value != null) {
36
+ return value === 'true';
37
+ }
38
+ }
39
+
40
+ return undefined;
41
+ }
42
+
43
+ static getWindowFeatureGateOverrides(featureGate: FeatureGates): boolean | undefined {
44
+ if (typeof window === 'undefined') {
45
+ return undefined;
46
+ }
47
+ const windowOverride = window as Window & {
48
+ _envive?: { featureOverrides?: Record<string, boolean> };
49
+ _spiffy?: { featureOverrides?: Record<string, boolean> };
50
+ };
51
+ const featureOverrides =
52
+ windowOverride?._envive?.featureOverrides ?? windowOverride?._spiffy?.featureOverrides;
53
+ const value = featureOverrides?.[String(featureGate)];
54
+ if (value != null) {
55
+ return value;
56
+ }
57
+ return undefined;
58
+ }
59
+
60
+ static getStoredFeatureGateOverrides(featureGate: FeatureGates): boolean | undefined {
61
+ if (typeof window === 'undefined' || typeof window.localStorage === 'undefined') {
62
+ return undefined;
63
+ }
64
+ try {
65
+ const featureFlags = window.localStorage.getItem(FEATURE_FLAGS_STORAGE_KEY);
66
+ if (!featureFlags) {
67
+ return undefined;
68
+ }
69
+ const featureFlagsObj = JSON.parse(featureFlags) as Record<string, boolean>;
70
+ return featureFlagsObj[featureGate];
71
+ } catch (err) {
72
+ logger.logError('getStoredFeatureGateOverrides: error parsing feature flags', err);
73
+ return undefined;
74
+ }
75
+ }
76
+
77
+ static getFeatureFlagOverride(featureGate: FeatureGates): boolean | undefined {
78
+ const queryOverride = FeatureFlagService.getQueryParamFeatureGateOverrides(featureGate);
79
+ if (queryOverride != null) {
80
+ logger.logDebug(
81
+ `isFeatureGateEnabled featureGate:${featureGate} overridden by URL param. New value: ${queryOverride}`,
82
+ );
83
+ return queryOverride;
84
+ }
85
+
86
+ const windowOverride = FeatureFlagService.getWindowFeatureGateOverrides(featureGate);
87
+ if (windowOverride != null) {
88
+ logger.logDebug(
89
+ `isFeatureGateEnabled featureGate:${featureGate} overridden by window param. New value: ${windowOverride}`,
90
+ );
91
+ return windowOverride;
92
+ }
93
+
94
+ const storedOverride = FeatureFlagService.getStoredFeatureGateOverrides(featureGate);
95
+ if (storedOverride != null) {
96
+ logger.logDebug(
97
+ `isFeatureGateEnabled featureGate:${featureGate} overridden by stored value. New value: ${storedOverride}`,
98
+ );
99
+ return storedOverride;
100
+ }
101
+
102
+ return undefined;
103
+ }
104
+
105
+ isFeatureGateEnabled = (featureGate: FeatureGates): boolean => {
106
+ const gateValue = this.featureGates.find(gate => gate.name === featureGate);
107
+
108
+ const featureFlagOverride = FeatureFlagService.getFeatureFlagOverride(featureGate);
109
+ if (featureFlagOverride !== undefined) {
110
+ return featureFlagOverride;
111
+ }
112
+
113
+ if (gateValue == null || gateValue.value == null) {
114
+ logger.logDebug(
115
+ `isFeatureGateEnabled featureGate:${featureGate} value is undefined - returning false`,
116
+ );
117
+ return false;
118
+ }
119
+ return gateValue.value;
120
+ };
121
+
122
+ // Ensure that standard overrides work as expected
123
+ isClientSessionEnabled = (): boolean =>
124
+ this.isFeatureGateEnabled(FeatureGates.IsClientSessionEnabled);
125
+
126
+ getFeatureFlags = (): Record<string, boolean> => {
127
+ return Object.fromEntries(
128
+ Object.values(FeatureGates).map((featureGate: FeatureGates) => [
129
+ featureGate,
130
+ this.isFeatureGateEnabled(featureGate),
131
+ ]),
132
+ );
133
+ };
134
+
135
+ getFullFlagValue = (featureGate: FeatureGates): FullFlagValue => {
136
+ const gateValue = this.featureGates.find(gate => gate.name === featureGate);
137
+ return {
138
+ resolved: this.isFeatureGateEnabled(featureGate),
139
+ initial: gateValue?.value,
140
+ query_param_override: FeatureFlagService.getQueryParamFeatureGateOverrides(featureGate),
141
+ window_param_override: FeatureFlagService.getWindowFeatureGateOverrides(featureGate),
142
+ stored_override: FeatureFlagService.getStoredFeatureGateOverrides(featureGate),
143
+ };
144
+ };
145
+
146
+ getFullFlagValues = (): Record<string, FullFlagValue> => {
147
+ const featureGates = Object.values(FeatureGates).map((featureGate: FeatureGates) => [
148
+ featureGate,
149
+ this.getFullFlagValue(featureGate),
150
+ ]);
151
+
152
+ return featureGates.length > 0
153
+ ? featureGates.reduce<Record<string, FullFlagValue>>((acc, entry) => {
154
+ const [name, value] = entry as [FeatureGates, FullFlagValue];
155
+ if (name && value != null) {
156
+ return { ...acc, [`feature_gate.${name}`]: value };
157
+ }
158
+ return acc;
159
+ }, {})
160
+ : {};
161
+ };
162
+
163
+ static persistFeatureGateOverrides(): void {
164
+ if (typeof window === 'undefined' || typeof window.localStorage === 'undefined') {
165
+ return;
166
+ }
167
+ const overrides = Object.values(FeatureGates)
168
+ .map(
169
+ featureGate =>
170
+ [featureGate, FeatureFlagService.getFeatureFlagOverride(featureGate)] as const,
171
+ )
172
+ .filter(([, value]) => value !== undefined)
173
+ .reduce<Record<string, boolean>>((acc, [key, value]) => ({ ...acc, [key]: value! }), {});
174
+ window.localStorage.setItem(FEATURE_FLAGS_STORAGE_KEY, JSON.stringify(overrides));
175
+ }
176
+ }
@@ -229,6 +229,42 @@ describe('projectToGA4', () => {
229
229
  expect(window.dataLayer[0]).toHaveProperty('interaction_suggestion_id', 'sug-456');
230
230
  });
231
231
 
232
+ it('should extract product_id and option_name but not option_value for variant_option_selected', () => {
233
+ projectToGA4(EnviveMetricsEventName.WidgetInteraction, {
234
+ 'context.page_type': 'pdp',
235
+ 'context.page_id': 'product-123',
236
+ 'trigger.widget': 'floating_chat',
237
+ 'trigger.widget_interaction': 'variant_option_selected',
238
+ 'trigger.widget_interaction_data': {
239
+ variant_option_selected: {
240
+ product_id: 'sku-789',
241
+ option_name: 'Color',
242
+ option_value: 'Grey',
243
+ },
244
+ },
245
+ });
246
+
247
+ const pushed = window.dataLayer[0];
248
+ expect(pushed).toHaveProperty('interaction_product_id', 'sku-789');
249
+ expect(pushed).toHaveProperty('interaction_option_name', 'Color');
250
+ expect(pushed).not.toHaveProperty('interaction_option_value');
251
+ expect(pushed).not.toHaveProperty('option_value');
252
+ });
253
+
254
+ it('should extract interaction_product_id for variant selector open/dismiss', () => {
255
+ projectToGA4(EnviveMetricsEventName.WidgetInteraction, {
256
+ 'context.page_type': 'pdp',
257
+ 'context.page_id': 'product-123',
258
+ 'trigger.widget': 'floating_chat',
259
+ 'trigger.widget_interaction': 'variant_selector_dismissed',
260
+ 'trigger.widget_interaction_data': {
261
+ variant_selector_dismissed: { product_id: 'sku-789' },
262
+ },
263
+ });
264
+
265
+ expect(window.dataLayer[0]).toHaveProperty('interaction_product_id', 'sku-789');
266
+ });
267
+
232
268
  it('should drop widget_interaction_data for unwhitelisted interactions', () => {
233
269
  projectToGA4(EnviveMetricsEventName.WidgetInteraction, {
234
270
  'context.page_type': 'pdp',
@@ -52,6 +52,13 @@ const WIDGET_INTERACTION_DATA_PROJECTIONS: WidgetInteractionDataProjection = {
52
52
  widget_collapsed: { interaction_collapse_source: 'widget_collapsed.collapse_source' },
53
53
  product_card_clicked: { interaction_product_id: 'product_card_clicked.product_id' },
54
54
  suggestion_scrolled: { interaction_suggestion_id: 'suggestion_scrolled.suggestion_id' },
55
+ variant_selector_opened: { interaction_product_id: 'variant_selector_opened.product_id' },
56
+ // option_value is intentionally excluded — merchant-defined values are high cardinality
57
+ variant_option_selected: {
58
+ interaction_product_id: 'variant_option_selected.product_id',
59
+ interaction_option_name: 'variant_option_selected.option_name',
60
+ },
61
+ variant_selector_dismissed: { interaction_product_id: 'variant_selector_dismissed.product_id' },
55
62
  };
56
63
 
57
64
  export const GA4_EVENT_SCHEMA: Record<EnviveMetricsEventName, GA4EventSchemaEntry> = {