@envive-ai/react-hooks 0.3.45 → 0.3.47

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 (293) 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/api/widgetText.d.cts +2 -1
  8. package/dist/application/models/api/widgetText.d.ts +2 -1
  9. package/dist/application/models/featureGates.cjs +2 -1
  10. package/dist/application/models/featureGates.d.cts +2 -1
  11. package/dist/application/models/featureGates.d.ts +2 -1
  12. package/dist/application/models/featureGates.js +2 -1
  13. package/dist/application/models/graphql/queries/getProductOptionsQuery.cjs +33 -0
  14. package/dist/application/models/graphql/queries/getProductOptionsQuery.d.cts +28 -0
  15. package/dist/application/models/graphql/queries/getProductOptionsQuery.d.ts +28 -0
  16. package/dist/application/models/graphql/queries/getProductOptionsQuery.js +32 -0
  17. package/dist/application/models/index.cjs +1 -1
  18. package/dist/application/models/index.js +1 -1
  19. package/dist/application/models/validators/validateGraphQLFrontendConfig.cjs +1 -1
  20. package/dist/application/models/validators/validateGraphQLFrontendConfig.js +1 -1
  21. package/dist/application/models/validators/validateResponse.cjs +2 -1
  22. package/dist/application/models/validators/validateResponse.js +2 -1
  23. package/dist/application/models/validators/validateUserEvent.cjs +3 -3
  24. package/dist/application/models/validators/validateUserEvent.js +3 -3
  25. package/dist/application/utils/elementObserver.d.cts +2 -2
  26. package/dist/application/utils/elementObserver.d.ts +2 -2
  27. package/dist/application/utils/index.cjs +2 -0
  28. package/dist/application/utils/index.d.cts +2 -1
  29. package/dist/application/utils/index.d.ts +2 -1
  30. package/dist/application/utils/index.js +2 -1
  31. package/dist/application/utils/messageFromAddToCartEvent.cjs +32 -0
  32. package/dist/application/utils/messageFromAddToCartEvent.d.cts +19 -0
  33. package/dist/application/utils/messageFromAddToCartEvent.d.ts +19 -0
  34. package/dist/application/utils/messageFromAddToCartEvent.js +31 -0
  35. package/dist/application/utils/widgetTextFromApiWidgetTextResponse.cjs +3 -2
  36. package/dist/application/utils/widgetTextFromApiWidgetTextResponse.js +3 -2
  37. package/dist/atoms/app/index.cjs +1 -1
  38. package/dist/atoms/app/index.d.cts +7 -7
  39. package/dist/atoms/app/index.d.ts +7 -7
  40. package/dist/atoms/app/index.js +1 -1
  41. package/dist/atoms/app/variant.cjs +2 -2
  42. package/dist/atoms/app/variant.d.cts +7 -7
  43. package/dist/atoms/app/variant.d.ts +7 -7
  44. package/dist/atoms/app/variant.js +2 -2
  45. package/dist/atoms/chat/chatState.d.cts +19 -19
  46. package/dist/atoms/chat/chatState.d.ts +19 -19
  47. package/dist/atoms/chat/form.d.cts +2 -2
  48. package/dist/atoms/chat/form.d.ts +2 -2
  49. package/dist/atoms/chat/index.cjs +1 -1
  50. package/dist/atoms/chat/index.d.cts +2 -2
  51. package/dist/atoms/chat/index.d.ts +2 -2
  52. package/dist/atoms/chat/index.js +1 -1
  53. package/dist/atoms/chat/lastMessage.d.cts +2 -2
  54. package/dist/atoms/chat/lastMessage.d.ts +2 -2
  55. package/dist/atoms/chat/messageQueue.d.cts +6 -6
  56. package/dist/atoms/chat/messageQueue.d.ts +6 -6
  57. package/dist/atoms/chat/performanceMetrics.d.cts +6 -6
  58. package/dist/atoms/chat/performanceMetrics.d.ts +6 -6
  59. package/dist/atoms/chat/renderedWidgetRefs.d.cts +2 -2
  60. package/dist/atoms/chat/renderedWidgetRefs.d.ts +2 -2
  61. package/dist/atoms/chat/replies.d.cts +3 -3
  62. package/dist/atoms/chat/replies.d.ts +2 -2
  63. package/dist/atoms/chat/suggestions.d.cts +2 -2
  64. package/dist/atoms/chat/suggestions.d.ts +2 -2
  65. package/dist/atoms/envive/enviveConfig.cjs +7 -4
  66. package/dist/atoms/envive/enviveConfig.d.cts +16 -14
  67. package/dist/atoms/envive/enviveConfig.d.ts +16 -14
  68. package/dist/atoms/envive/enviveConfig.js +6 -4
  69. package/dist/atoms/globalSearch/globalSearch.d.cts +5 -5
  70. package/dist/atoms/globalSearch/globalSearch.d.ts +5 -5
  71. package/dist/atoms/org/customerService.d.cts +6 -6
  72. package/dist/atoms/org/customerService.d.ts +6 -6
  73. package/dist/atoms/org/graphqlConfig.cjs +1 -1
  74. package/dist/atoms/org/graphqlConfig.d.cts +6 -6
  75. package/dist/atoms/org/graphqlConfig.d.ts +6 -6
  76. package/dist/atoms/org/graphqlConfig.js +1 -1
  77. package/dist/atoms/org/index.cjs +1 -1
  78. package/dist/atoms/org/index.js +1 -1
  79. package/dist/atoms/org/newOrgConfigAtom.d.cts +2 -2
  80. package/dist/atoms/org/newOrgConfigAtom.d.ts +2 -2
  81. package/dist/atoms/org/orgAnalyticsConfig.d.cts +4 -4
  82. package/dist/atoms/org/orgAnalyticsConfig.d.ts +4 -4
  83. package/dist/atoms/search/chatSearch.cjs +1 -1
  84. package/dist/atoms/search/chatSearch.d.cts +17 -17
  85. package/dist/atoms/search/chatSearch.d.ts +17 -17
  86. package/dist/atoms/search/chatSearch.js +1 -1
  87. package/dist/atoms/search/searchAPI.cjs +1 -1
  88. package/dist/atoms/search/searchAPI.d.cts +13 -13
  89. package/dist/atoms/search/searchAPI.d.ts +13 -13
  90. package/dist/atoms/search/searchAPI.js +1 -1
  91. package/dist/atoms/widget/chatPreviewLoading.d.cts +2 -2
  92. package/dist/atoms/widget/chatPreviewLoading.d.ts +2 -2
  93. package/dist/contexts/enviveConfigContext/enviveConfigContext.cjs +6 -4
  94. package/dist/contexts/enviveConfigContext/enviveConfigContext.d.cts +3 -1
  95. package/dist/contexts/enviveConfigContext/enviveConfigContext.d.ts +3 -1
  96. package/dist/contexts/enviveConfigContext/enviveConfigContext.js +6 -4
  97. package/dist/contexts/enviveContext/enviveContext.cjs +7 -3
  98. package/dist/contexts/enviveContext/enviveContext.d.cts +5 -1
  99. package/dist/contexts/enviveContext/enviveContext.d.ts +5 -1
  100. package/dist/contexts/enviveContext/enviveContext.js +7 -3
  101. package/dist/contexts/enviveContext/types.d.cts +1 -1
  102. package/dist/contexts/enviveContext/types.d.ts +1 -1
  103. package/dist/contexts/featureFlagServiceContext/featureFlagServiceContext.cjs +2 -97
  104. package/dist/contexts/featureFlagServiceContext/featureFlagServiceContext.d.cts +3 -25
  105. package/dist/contexts/featureFlagServiceContext/featureFlagServiceContext.d.ts +3 -25
  106. package/dist/contexts/featureFlagServiceContext/featureFlagServiceContext.js +3 -97
  107. package/dist/contexts/featureFlagServiceContext/index.cjs +2 -1
  108. package/dist/contexts/featureFlagServiceContext/index.d.cts +2 -1
  109. package/dist/contexts/featureFlagServiceContext/index.d.ts +2 -1
  110. package/dist/contexts/featureFlagServiceContext/index.js +2 -1
  111. package/dist/contexts/graphqlContext/mockV3Config.cjs +1 -1
  112. package/dist/contexts/graphqlContext/mockV3Config.js +1 -1
  113. package/dist/contexts/localStorageContext/index.cjs +2 -1
  114. package/dist/contexts/localStorageContext/index.d.cts +2 -1
  115. package/dist/contexts/localStorageContext/index.d.ts +2 -1
  116. package/dist/contexts/localStorageContext/index.js +2 -1
  117. package/dist/contexts/localStorageContext/localStorageContext.cjs +5 -11
  118. package/dist/contexts/localStorageContext/localStorageContext.d.cts +3 -7
  119. package/dist/contexts/localStorageContext/localStorageContext.d.ts +3 -7
  120. package/dist/contexts/localStorageContext/localStorageContext.js +3 -8
  121. package/dist/contexts/localStorageContext/localStorageKeys.cjs +12 -0
  122. package/dist/contexts/localStorageContext/localStorageKeys.d.cts +9 -0
  123. package/dist/contexts/localStorageContext/localStorageKeys.d.ts +9 -0
  124. package/dist/contexts/localStorageContext/localStorageKeys.js +11 -0
  125. package/dist/contexts/pageContext/pageContext.cjs +2 -2
  126. package/dist/contexts/pageContext/pageContext.js +2 -2
  127. package/dist/contexts/salesAgentContext/chatAPI.cjs +15 -4
  128. package/dist/contexts/salesAgentContext/chatAPI.d.cts +4 -1
  129. package/dist/contexts/salesAgentContext/chatAPI.d.ts +4 -1
  130. package/dist/contexts/salesAgentContext/chatAPI.js +16 -5
  131. package/dist/contexts/salesAgentContext/salesAgentContext.cjs +15 -4
  132. package/dist/contexts/salesAgentContext/salesAgentContext.d.cts +2 -1
  133. package/dist/contexts/salesAgentContext/salesAgentContext.d.ts +2 -1
  134. package/dist/contexts/salesAgentContext/salesAgentContext.js +16 -5
  135. package/dist/contexts/salesAgentContext/salesAgentService.cjs +12 -4
  136. package/dist/contexts/salesAgentContext/salesAgentService.js +12 -4
  137. package/dist/contexts/searchContext/searchContext.cjs +1 -1
  138. package/dist/contexts/searchContext/searchContext.js +1 -1
  139. package/dist/contexts/systemSettingsContext/systemSettingsContext.d.cts +2 -2
  140. package/dist/contexts/systemSettingsContext/systemSettingsContext.d.ts +2 -2
  141. package/dist/contexts/types.cjs +1 -1
  142. package/dist/contexts/types.d.cts +3 -1
  143. package/dist/contexts/types.d.ts +3 -1
  144. package/dist/contexts/types.js +1 -1
  145. package/dist/contexts/typesV3.cjs +1 -1
  146. package/dist/contexts/typesV3.d.cts +11 -2
  147. package/dist/contexts/typesV3.d.ts +12 -3
  148. package/dist/contexts/typesV3.js +1 -1
  149. package/dist/contexts/widgetConfigContext/widgetConfigContext.cjs +1 -1
  150. package/dist/contexts/widgetConfigContext/widgetConfigContext.js +1 -1
  151. package/dist/hooks/AddToCart/addToCartErrorMessage.cjs +7 -0
  152. package/dist/hooks/AddToCart/addToCartErrorMessage.d.cts +5 -0
  153. package/dist/hooks/AddToCart/addToCartErrorMessage.d.ts +5 -0
  154. package/dist/hooks/AddToCart/addToCartErrorMessage.js +6 -0
  155. package/dist/hooks/AddToCart/createAddToCartUserEvent.cjs +20 -0
  156. package/dist/hooks/AddToCart/createAddToCartUserEvent.d.cts +17 -0
  157. package/dist/hooks/AddToCart/createAddToCartUserEvent.d.ts +17 -0
  158. package/dist/hooks/AddToCart/createAddToCartUserEvent.js +19 -0
  159. package/dist/hooks/AddToCart/index.cjs +9 -0
  160. package/dist/hooks/AddToCart/index.d.cts +5 -0
  161. package/dist/hooks/AddToCart/index.d.ts +5 -0
  162. package/dist/hooks/AddToCart/index.js +6 -0
  163. package/dist/hooks/AddToCart/parseAddToCartFunction.cjs +27 -0
  164. package/dist/hooks/AddToCart/parseAddToCartFunction.d.cts +13 -0
  165. package/dist/hooks/AddToCart/parseAddToCartFunction.d.ts +13 -0
  166. package/dist/hooks/AddToCart/parseAddToCartFunction.js +27 -0
  167. package/dist/hooks/AddToCart/useAddToCart.cjs +24 -0
  168. package/dist/hooks/AddToCart/useAddToCart.d.cts +14 -0
  169. package/dist/hooks/AddToCart/useAddToCart.d.ts +14 -0
  170. package/dist/hooks/AddToCart/useAddToCart.js +23 -0
  171. package/dist/hooks/ChatToggle/useChatToggle.cjs +1 -1
  172. package/dist/hooks/ChatToggle/useChatToggle.js +1 -1
  173. package/dist/hooks/LocalStorageOperations/useLocalStorageOperations.cjs +6 -5
  174. package/dist/hooks/LocalStorageOperations/useLocalStorageOperations.js +3 -2
  175. package/dist/hooks/PageViewedEvent/usePageViewedEvent.cjs +6 -3
  176. package/dist/hooks/PageViewedEvent/usePageViewedEvent.js +6 -3
  177. package/dist/hooks/ProductDetails/index.cjs +9 -0
  178. package/dist/hooks/ProductDetails/index.d.cts +4 -0
  179. package/dist/hooks/ProductDetails/index.d.ts +4 -0
  180. package/dist/hooks/ProductDetails/index.js +4 -0
  181. package/dist/hooks/ProductDetails/useProductDetails.cjs +53 -0
  182. package/dist/hooks/ProductDetails/useProductDetails.d.cts +20 -0
  183. package/dist/hooks/ProductDetails/useProductDetails.d.ts +20 -0
  184. package/dist/hooks/ProductDetails/useProductDetails.js +52 -0
  185. package/dist/hooks/ProductDetails/variantSelection.cjs +42 -0
  186. package/dist/hooks/ProductDetails/variantSelection.d.cts +35 -0
  187. package/dist/hooks/ProductDetails/variantSelection.d.ts +35 -0
  188. package/dist/hooks/ProductDetails/variantSelection.js +38 -0
  189. package/dist/hooks/Search/useSearch.cjs +3 -3
  190. package/dist/hooks/Search/useSearch.d.cts +1 -1
  191. package/dist/hooks/Search/useSearch.d.ts +1 -1
  192. package/dist/hooks/Search/useSearch.js +3 -3
  193. package/dist/hooks/SystemSettingsContext/useSystemSettingsContext.d.cts +2 -2
  194. package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.cjs +11 -2
  195. package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.d.cts +1 -10
  196. package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.d.ts +1 -10
  197. package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.js +11 -2
  198. package/dist/hooks/WidgetInteraction/types.cjs +13 -3
  199. package/dist/hooks/WidgetInteraction/types.d.cts +29 -4
  200. package/dist/hooks/WidgetInteraction/types.d.ts +29 -4
  201. package/dist/hooks/WidgetInteraction/types.js +13 -3
  202. package/dist/hooks/WidgetInteraction/utils.cjs +2 -2
  203. package/dist/hooks/WidgetInteraction/utils.js +2 -2
  204. package/dist/hooks/utils.d.cts +1 -1
  205. package/dist/hooks/utils.d.ts +1 -1
  206. package/dist/services/amplitudeService/amplitudeService.cjs +18 -11
  207. package/dist/services/amplitudeService/amplitudeService.d.cts +5 -2
  208. package/dist/services/amplitudeService/amplitudeService.d.ts +5 -2
  209. package/dist/services/amplitudeService/amplitudeService.js +16 -8
  210. package/dist/services/enviveConfigService/enviveConfigService.cjs +36 -4
  211. package/dist/services/enviveConfigService/enviveConfigService.d.cts +4 -1
  212. package/dist/services/enviveConfigService/enviveConfigService.d.ts +4 -1
  213. package/dist/services/enviveConfigService/enviveConfigService.js +35 -3
  214. package/dist/services/enviveConfigService/fetchGraphQLConfig.cjs +9 -8
  215. package/dist/services/enviveConfigService/fetchGraphQLConfig.js +9 -7
  216. package/dist/services/featureFlagService/index.cjs +101 -0
  217. package/dist/services/featureFlagService/index.d.cts +28 -0
  218. package/dist/services/featureFlagService/index.d.ts +28 -0
  219. package/dist/services/featureFlagService/index.js +101 -0
  220. package/dist/services/ga4ProjectionService/ga4EventSchema.cjs +8 -2
  221. package/dist/services/ga4ProjectionService/ga4EventSchema.js +8 -2
  222. package/dist/services/hardcopyService/hardcopyService.cjs +4 -3
  223. package/dist/services/hardcopyService/hardcopyService.d.cts +1 -1
  224. package/dist/services/hardcopyService/hardcopyService.d.ts +1 -1
  225. package/dist/services/hardcopyService/hardcopyService.js +4 -3
  226. package/dist/services/urlResolverWarmupService/index.cjs +54 -0
  227. package/dist/services/urlResolverWarmupService/index.d.cts +40 -0
  228. package/dist/services/urlResolverWarmupService/index.d.ts +40 -0
  229. package/dist/services/urlResolverWarmupService/index.js +54 -0
  230. package/dist/types/enviveConfig.d.cts +6 -2
  231. package/dist/types/enviveConfig.d.ts +6 -2
  232. package/dist/types/index.d.cts +2 -2
  233. package/dist/types/index.d.ts +2 -2
  234. package/package.json +18 -2
  235. package/src/application/__tests__/commerceApiAddToCartRehydration.test.ts +126 -0
  236. package/src/application/commerce-api.ts +142 -23
  237. package/src/application/models/api/userEvent.ts +8 -1
  238. package/src/application/models/api/widgetText.ts +3 -0
  239. package/src/application/models/featureGates.ts +5 -0
  240. package/src/application/models/graphql/queries/getProductOptionsQuery.ts +59 -0
  241. package/src/application/models/validators/__tests__/validateUserEvent.test.ts +41 -0
  242. package/src/application/models/validators/validateResponse.ts +1 -0
  243. package/src/application/models/validators/validateUserEvent.ts +7 -2
  244. package/src/application/utils/__tests__/messageFromAddToCartEvent.test.ts +109 -0
  245. package/src/application/utils/index.ts +1 -0
  246. package/src/application/utils/messageFromAddToCartEvent.ts +32 -0
  247. package/src/application/utils/widgetTextFromApiWidgetTextResponse.ts +1 -0
  248. package/src/atoms/app/index.ts +1 -1
  249. package/src/atoms/app/variant.ts +1 -1
  250. package/src/atoms/envive/enviveConfig.ts +6 -1
  251. package/src/atoms/org/graphqlConfig.ts +1 -1
  252. package/src/contexts/enviveConfigContext/enviveConfigContext.tsx +5 -0
  253. package/src/contexts/enviveContext/enviveContext.tsx +10 -0
  254. package/src/contexts/featureFlagServiceContext/featureFlagServiceContext.tsx +6 -173
  255. package/src/contexts/localStorageContext/localStorageContext.tsx +2 -5
  256. package/src/contexts/localStorageContext/localStorageKeys.ts +8 -0
  257. package/src/contexts/pageContext/pageContext.tsx +1 -1
  258. package/src/contexts/salesAgentContext/__tests__/chatAPI.test.tsx +63 -0
  259. package/src/contexts/salesAgentContext/chatAPI.ts +20 -1
  260. package/src/contexts/salesAgentContext/salesAgentContext.tsx +17 -0
  261. package/src/contexts/salesAgentContext/salesAgentService.ts +12 -3
  262. package/src/contexts/types.ts +2 -0
  263. package/src/contexts/typesV3.ts +14 -0
  264. package/src/hooks/AddToCart/__tests__/parseAddToCartFunction.test.ts +31 -0
  265. package/src/hooks/AddToCart/__tests__/useAddToCart.test.tsx +204 -0
  266. package/src/hooks/AddToCart/addToCartErrorMessage.ts +2 -0
  267. package/src/hooks/AddToCart/createAddToCartUserEvent.ts +25 -0
  268. package/src/hooks/AddToCart/index.ts +4 -0
  269. package/src/hooks/AddToCart/parseAddToCartFunction.ts +31 -0
  270. package/src/hooks/AddToCart/useAddToCart.ts +35 -0
  271. package/src/hooks/PageViewedEvent/__tests__/usePageViewedEvent.test.ts +4 -4
  272. package/src/hooks/PageViewedEvent/usePageViewedEvent.ts +5 -1
  273. package/src/hooks/ProductDetails/__tests__/useProductDetails.test.tsx +129 -0
  274. package/src/hooks/ProductDetails/__tests__/variantSelection.test.ts +145 -0
  275. package/src/hooks/ProductDetails/index.ts +2 -0
  276. package/src/hooks/ProductDetails/useProductDetails.ts +62 -0
  277. package/src/hooks/ProductDetails/variantSelection.ts +61 -0
  278. package/src/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.ts +15 -0
  279. package/src/hooks/WidgetInteraction/types.ts +43 -2
  280. package/src/hooks/WidgetInteraction/utils.ts +1 -1
  281. package/src/services/amplitudeService/__tests__/amplitudeService.test.ts +8 -8
  282. package/src/services/amplitudeService/amplitudeService.ts +43 -23
  283. package/src/services/enviveConfigService/__tests__/enviveConfigService.test.ts +208 -0
  284. package/src/services/enviveConfigService/enviveConfigService.ts +62 -2
  285. package/src/services/enviveConfigService/fetchGraphQLConfig.ts +17 -6
  286. package/src/services/featureFlagService/index.ts +176 -0
  287. package/src/services/ga4ProjectionService/__tests__/ga4ProjectionService.test.ts +36 -0
  288. package/src/services/ga4ProjectionService/ga4EventSchema.ts +7 -0
  289. package/src/services/hardcopyService/__tests__/hardcopyService.test.ts +47 -1
  290. package/src/services/hardcopyService/hardcopyService.ts +5 -0
  291. package/src/services/urlResolverWarmupService/__tests__/urlResolverWarmupService.test.ts +85 -0
  292. package/src/services/urlResolverWarmupService/index.ts +89 -0
  293. package/src/types/enviveConfig.ts +3 -0
@@ -0,0 +1,62 @@
1
+ import { useEffect, useState } from 'react';
2
+ import CommerceApiClient from 'src/application/commerce-api';
3
+ import { ProductDetails } from 'src/application/models/graphql/queries/getProductOptionsQuery';
4
+
5
+ export type {
6
+ ProductDetails,
7
+ ProductOption,
8
+ ProductVariant,
9
+ ProductVariantOption,
10
+ } from 'src/application/models/graphql/queries/getProductOptionsQuery';
11
+
12
+ export interface UseProductDetailsResult {
13
+ data: ProductDetails | undefined;
14
+ isLoading: boolean;
15
+ error: Error | undefined;
16
+ }
17
+
18
+ /**
19
+ * Fetches variant-level product details (options, availability, pricing, images)
20
+ * for the given product ID via CommerceApiClient.retrieveProductDetails (GraphQL
21
+ * `me.productOptions`). Only meaningful for products that offer variations.
22
+ *
23
+ * Returns an idle result ({ data: undefined, isLoading: false, error: undefined })
24
+ * when productId is not provided.
25
+ */
26
+ export const useProductDetails = (productId: string | undefined): UseProductDetailsResult => {
27
+ const [data, setData] = useState<ProductDetails | undefined>(undefined);
28
+ const [isLoading, setIsLoading] = useState(false);
29
+ const [error, setError] = useState<Error | undefined>(undefined);
30
+
31
+ useEffect(() => {
32
+ if (!productId) {
33
+ setData(undefined);
34
+ setIsLoading(false);
35
+ setError(undefined);
36
+ return undefined;
37
+ }
38
+ let cancelled = false;
39
+ setData(undefined);
40
+ setError(undefined);
41
+ setIsLoading(true);
42
+ CommerceApiClient.retrieveProductDetails(productId).then(
43
+ result => {
44
+ if (!cancelled) {
45
+ setData(result);
46
+ setIsLoading(false);
47
+ }
48
+ },
49
+ err => {
50
+ if (!cancelled) {
51
+ setError(err instanceof Error ? err : new Error(String(err)));
52
+ setIsLoading(false);
53
+ }
54
+ },
55
+ );
56
+ return () => {
57
+ cancelled = true;
58
+ };
59
+ }, [productId]);
60
+
61
+ return { data, isLoading, error };
62
+ };
@@ -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;
@@ -30,7 +30,7 @@ export enum WidgetInteractionPageType {
30
30
  HOMEPAGE = 'homepage',
31
31
  SEARCH = 'search',
32
32
  OTHER = 'other',
33
- FULL_PAGE = 'full_page',
33
+ ADAPTIVE_STOREFRONT = 'adaptive_storefront',
34
34
  }
35
35
 
36
36
  export enum WidgetInteractionComponent {
@@ -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;
@@ -36,7 +36,7 @@ export const extractPageContext = (
36
36
  case VariantTypeEnum.FullPageSalesAgent:
37
37
  return {
38
38
  page_id: (pageVariant as FullPageSalesAgentVariantInfo).url,
39
- page_type: WidgetInteractionPageType.FULL_PAGE,
39
+ page_type: WidgetInteractionPageType.ADAPTIVE_STOREFRONT,
40
40
  };
41
41
  default:
42
42
  return null;
@@ -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
 
@@ -296,6 +314,7 @@ export class AmplitudeService {
296
314
  switch (widgetType) {
297
315
  case WidgetTypeV3.FloatingButtonV3:
298
316
  return 'floating_button';
317
+ case WidgetTypeV3.TypingAnimationV3:
299
318
  case WidgetTypeV3.ChatPreviewV3:
300
319
  case WidgetTypeV3.SocialProofV3:
301
320
  return 'embedded_widget';
@@ -395,6 +414,7 @@ export class AmplitudeService {
395
414
  alsoSendToGoogleAnalytics = false,
396
415
  }: TrackEventParams): Promise<void> {
397
416
  if (this.isMockApiKey) return;
417
+ await this.initPromise;
398
418
  logger.logDebug('Submitting event', eventName);
399
419
  try {
400
420
  const decoratedEventName = AmplitudeService.decorateEventName(eventName);
@@ -0,0 +1,208 @@
1
+ import { EnviveConfigService } from '../enviveConfigService';
2
+
3
+ const mockFetch = vi.fn();
4
+ globalThis.fetch = mockFetch;
5
+
6
+ const ORG_CONFIG_RESPONSE = {
7
+ org: {
8
+ org: {
9
+ id: 'org-1',
10
+ short_name: 'test-org',
11
+ display_name: 'Test Org',
12
+ domain: 'test.com',
13
+ status: 'active',
14
+ created_at: '2024-01-01T00:00:00.000Z',
15
+ updated_at: '2024-01-01T00:00:00.000Z',
16
+ },
17
+ },
18
+ gates: [],
19
+ };
20
+
21
+ function makeOkOrgConfigFetch(overrides: Record<string, unknown> = {}) {
22
+ mockFetch.mockResolvedValueOnce({
23
+ ok: true,
24
+ json: () => Promise.resolve({ ...ORG_CONFIG_RESPONSE, ...overrides }),
25
+ });
26
+ }
27
+
28
+ // productsConfig has no colors, so fetchGraphQLConfig falls back to its mock
29
+ // config (see fetchGraphQLConfig.test.ts "no colors fallback") - fine here
30
+ // since these tests only care about caching/fetch-count behaviour.
31
+ function makeOkGraphqlFetch() {
32
+ mockFetch.mockResolvedValueOnce({
33
+ ok: true,
34
+ json: () => Promise.resolve({ data: { me: { getProductsConfigByVersion: {} } } }),
35
+ });
36
+ }
37
+
38
+ function makeService(
39
+ overrides: Partial<ConstructorParameters<typeof EnviveConfigService>[0]> = {},
40
+ ) {
41
+ return new EnviveConfigService({
42
+ baseUrl: 'https://api.example.com',
43
+ apiKey: 'key',
44
+ userId: 'user-1',
45
+ namespace: 'spiffy-react-components',
46
+ source: 'app',
47
+ ...overrides,
48
+ });
49
+ }
50
+
51
+ beforeEach(() => {
52
+ vi.clearAllMocks();
53
+ sessionStorage.clear();
54
+ window.history.replaceState({}, '', '/');
55
+ });
56
+
57
+ afterEach(() => {
58
+ vi.useRealTimers();
59
+ vi.restoreAllMocks();
60
+ });
61
+
62
+ describe('EnviveConfigService caching', () => {
63
+ it('fetches org config + GraphQL config on a cold cache', async () => {
64
+ makeOkOrgConfigFetch();
65
+ makeOkGraphqlFetch();
66
+
67
+ const result = await makeService().getEnviveConfig();
68
+
69
+ expect(mockFetch).toHaveBeenCalledTimes(2);
70
+ expect(result.org.org.id).toBe('org-1');
71
+ });
72
+
73
+ it('serves a warm cache to a new instance with identical params without hitting the network', async () => {
74
+ makeOkOrgConfigFetch();
75
+ makeOkGraphqlFetch();
76
+ await makeService().getEnviveConfig();
77
+ expect(mockFetch).toHaveBeenCalledTimes(2);
78
+
79
+ // A fresh instance (as happens on the next page navigation) should read
80
+ // from the sessionStorage cache instead of refetching.
81
+ const result = await makeService().getEnviveConfig();
82
+
83
+ expect(mockFetch).toHaveBeenCalledTimes(2);
84
+ expect(result.org.org.id).toBe('org-1');
85
+ });
86
+
87
+ it('refetches once the cache entry has expired past the 5-minute TTL', async () => {
88
+ vi.useFakeTimers();
89
+ vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
90
+
91
+ makeOkOrgConfigFetch();
92
+ makeOkGraphqlFetch();
93
+ await makeService().getEnviveConfig();
94
+ expect(mockFetch).toHaveBeenCalledTimes(2);
95
+
96
+ vi.setSystemTime(new Date('2026-01-01T00:05:01.000Z'));
97
+
98
+ makeOkOrgConfigFetch();
99
+ makeOkGraphqlFetch();
100
+ await makeService().getEnviveConfig();
101
+
102
+ expect(mockFetch).toHaveBeenCalledTimes(4);
103
+ });
104
+
105
+ it.each(['envive_config_version', 'spiffy_config_version'])(
106
+ 'always bypasses the cache (read and write) when %s is present',
107
+ async param => {
108
+ window.history.replaceState({}, '', `/?${param}=1.2.3`);
109
+
110
+ makeOkOrgConfigFetch();
111
+ makeOkGraphqlFetch();
112
+ await makeService().getEnviveConfig();
113
+ expect(mockFetch).toHaveBeenCalledTimes(2);
114
+
115
+ makeOkOrgConfigFetch();
116
+ makeOkGraphqlFetch();
117
+ await makeService().getEnviveConfig();
118
+
119
+ // No cache read/write happened, so a second instance refetches instead
120
+ // of reusing anything the first instance stored.
121
+ expect(mockFetch).toHaveBeenCalledTimes(4);
122
+ },
123
+ );
124
+
125
+ it('does not read a version-pinned cache entry after the override is removed', async () => {
126
+ window.history.replaceState({}, '', '/?envive_config_version=1.2.3');
127
+ makeOkOrgConfigFetch();
128
+ makeOkGraphqlFetch();
129
+ await makeService().getEnviveConfig();
130
+
131
+ window.history.replaceState({}, '', '/');
132
+ makeOkOrgConfigFetch();
133
+ makeOkGraphqlFetch();
134
+ await makeService().getEnviveConfig();
135
+
136
+ expect(mockFetch).toHaveBeenCalledTimes(4);
137
+ });
138
+
139
+ it('keeps separate cache entries per userId', async () => {
140
+ makeOkOrgConfigFetch();
141
+ makeOkGraphqlFetch();
142
+ await makeService({ userId: 'user-1' }).getEnviveConfig();
143
+ expect(mockFetch).toHaveBeenCalledTimes(2);
144
+
145
+ makeOkOrgConfigFetch();
146
+ makeOkGraphqlFetch();
147
+ await makeService({ userId: 'user-2' }).getEnviveConfig();
148
+
149
+ expect(mockFetch).toHaveBeenCalledTimes(4);
150
+ });
151
+
152
+ it('keeps separate cache entries per namespace/source/baseUrl/apiKey', async () => {
153
+ makeOkOrgConfigFetch();
154
+ makeOkGraphqlFetch();
155
+ await makeService({ namespace: 'ns-a' }).getEnviveConfig();
156
+ expect(mockFetch).toHaveBeenCalledTimes(2);
157
+
158
+ makeOkOrgConfigFetch();
159
+ makeOkGraphqlFetch();
160
+ await makeService({ namespace: 'ns-b' }).getEnviveConfig();
161
+
162
+ expect(mockFetch).toHaveBeenCalledTimes(4);
163
+ });
164
+
165
+ it('fails open and still returns config when sessionStorage.setItem throws', async () => {
166
+ vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
167
+ throw new Error('quota exceeded');
168
+ });
169
+
170
+ makeOkOrgConfigFetch();
171
+ makeOkGraphqlFetch();
172
+
173
+ const result = await makeService().getEnviveConfig();
174
+
175
+ expect(result.org.org.id).toBe('org-1');
176
+ });
177
+
178
+ it('fails open and refetches when sessionStorage.getItem throws', async () => {
179
+ vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
180
+ throw new Error('storage disabled');
181
+ });
182
+
183
+ makeOkOrgConfigFetch();
184
+ makeOkGraphqlFetch();
185
+
186
+ const result = await makeService().getEnviveConfig();
187
+
188
+ expect(result.org.org.id).toBe('org-1');
189
+ });
190
+
191
+ it('fails open and refetches when a cached entry is corrupted JSON', async () => {
192
+ const service = makeService();
193
+ // Seed a corrupted entry under whatever key the service reads from by
194
+ // populating every session storage key with garbage after a real write,
195
+ // then corrupting it directly.
196
+ makeOkOrgConfigFetch();
197
+ makeOkGraphqlFetch();
198
+ await service.getEnviveConfig();
199
+ const [key] = Object.keys(sessionStorage);
200
+ sessionStorage.setItem(key, 'not json');
201
+
202
+ makeOkOrgConfigFetch();
203
+ makeOkGraphqlFetch();
204
+ const result = await makeService().getEnviveConfig();
205
+
206
+ expect(result.org.org.id).toBe('org-1');
207
+ });
208
+ });