@envive-ai/react-hooks 0.3.19 → 0.3.21

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 (70) hide show
  1. package/dist/application/models/guards/api/isApiPDPEventAttributes.cjs +2 -2
  2. package/dist/application/models/guards/api/isApiPDPEventAttributes.js +2 -2
  3. package/dist/atoms/app/index.d.ts +1 -1
  4. package/dist/atoms/chat/chatState.d.cts +18 -18
  5. package/dist/atoms/chat/chatState.d.ts +1 -1
  6. package/dist/atoms/chat/form.d.cts +2 -2
  7. package/dist/atoms/chat/form.d.ts +2 -2
  8. package/dist/atoms/chat/index.d.cts +3 -3
  9. package/dist/atoms/chat/lastMessage.d.cts +2 -2
  10. package/dist/atoms/chat/lastMessage.d.ts +2 -2
  11. package/dist/atoms/chat/messageQueue.d.cts +6 -6
  12. package/dist/atoms/chat/messageQueue.d.ts +6 -6
  13. package/dist/atoms/chat/performanceMetrics.d.cts +6 -6
  14. package/dist/atoms/chat/performanceMetrics.d.ts +6 -6
  15. package/dist/atoms/chat/renderedWidgetRefs.d.cts +2 -2
  16. package/dist/atoms/chat/renderedWidgetRefs.d.ts +2 -2
  17. package/dist/atoms/chat/replies.d.cts +3 -3
  18. package/dist/atoms/chat/replies.d.ts +3 -3
  19. package/dist/atoms/chat/suggestions.d.cts +2 -2
  20. package/dist/atoms/chat/suggestions.d.ts +2 -2
  21. package/dist/atoms/envive/enviveConfig.d.cts +13 -13
  22. package/dist/atoms/envive/enviveConfig.d.ts +13 -13
  23. package/dist/atoms/globalSearch/globalSearch.d.cts +5 -5
  24. package/dist/atoms/globalSearch/globalSearch.d.ts +5 -5
  25. package/dist/atoms/org/customerService.d.cts +6 -6
  26. package/dist/atoms/org/customerService.d.ts +6 -6
  27. package/dist/atoms/org/graphqlConfig.d.cts +4 -4
  28. package/dist/atoms/org/graphqlConfig.d.ts +4 -4
  29. package/dist/atoms/org/newOrgConfigAtom.d.cts +2 -2
  30. package/dist/atoms/org/newOrgConfigAtom.d.ts +2 -2
  31. package/dist/atoms/org/orgAnalyticsConfig.d.cts +5 -5
  32. package/dist/atoms/org/orgAnalyticsConfig.d.ts +5 -5
  33. package/dist/atoms/search/chatSearch.d.cts +17 -17
  34. package/dist/atoms/search/chatSearch.d.ts +17 -17
  35. package/dist/atoms/search/searchAPI.d.cts +13 -13
  36. package/dist/atoms/search/searchAPI.d.ts +13 -13
  37. package/dist/atoms/search/types.d.ts +1 -1
  38. package/dist/atoms/widget/chatPreviewLoading.d.cts +2 -2
  39. package/dist/atoms/widget/chatPreviewLoading.d.ts +2 -2
  40. package/dist/contexts/enviveContext/enviveContext.cjs +41 -7
  41. package/dist/contexts/enviveContext/enviveContext.d.cts +8 -2
  42. package/dist/contexts/enviveContext/enviveContext.d.ts +8 -2
  43. package/dist/contexts/enviveContext/enviveContext.js +42 -8
  44. package/dist/contexts/enviveContext/index.d.cts +2 -2
  45. package/dist/contexts/enviveContext/index.d.ts +2 -2
  46. package/dist/contexts/enviveContext/types.d.ts +1 -1
  47. package/dist/contexts/pageContext/pageContext.cjs +78 -3
  48. package/dist/contexts/pageContext/pageContext.d.cts +3 -1
  49. package/dist/contexts/pageContext/pageContext.d.ts +3 -1
  50. package/dist/contexts/pageContext/pageContext.js +81 -6
  51. package/dist/contexts/types.d.cts +1 -1
  52. package/dist/contexts/types.d.ts +1 -1
  53. package/dist/contexts/typesV3.d.ts +1 -1
  54. package/dist/hooks/PageViewedEvent/index.cjs +4 -0
  55. package/dist/hooks/PageViewedEvent/index.d.cts +2 -0
  56. package/dist/hooks/PageViewedEvent/index.d.ts +2 -0
  57. package/dist/hooks/PageViewedEvent/index.js +3 -0
  58. package/dist/hooks/PageViewedEvent/usePageViewedEvent.cjs +84 -0
  59. package/dist/hooks/PageViewedEvent/usePageViewedEvent.d.cts +18 -0
  60. package/dist/hooks/PageViewedEvent/usePageViewedEvent.d.ts +18 -0
  61. package/dist/hooks/PageViewedEvent/usePageViewedEvent.js +82 -0
  62. package/dist/hooks/utils.d.cts +1 -1
  63. package/package.json +5 -1
  64. package/src/application/models/guards/api/isApiPDPEventAttributes.ts +1 -1
  65. package/src/contexts/enviveContext/enviveContext.tsx +71 -7
  66. package/src/contexts/pageContext/__tests__/pageContext.test.tsx +6 -0
  67. package/src/contexts/pageContext/pageContext.tsx +79 -2
  68. package/src/hooks/PageViewedEvent/__tests__/usePageViewedEvent.test.ts +297 -0
  69. package/src/hooks/PageViewedEvent/index.ts +1 -0
  70. package/src/hooks/PageViewedEvent/usePageViewedEvent.ts +103 -0
@@ -1,4 +1,4 @@
1
- import { ReactNode, useEffect, useMemo, useState } from 'react';
1
+ import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
 
3
3
  import { OrgConfigFeatureGate } from 'src/application/models/api/orgConfigResults';
4
4
  import { EnviveConfigService, EnviveServiceConfig } from 'src/services/enviveConfigService';
@@ -30,6 +30,12 @@ interface AgentWrapperProps {
30
30
  enabledAgents: EnviveAgent[];
31
31
  }
32
32
 
33
+ export interface ExtensionStatusCallbacks {
34
+ onInjectionLoading?: () => Promise<unknown> | void;
35
+ onInjectionSuccess?: () => Promise<unknown> | void;
36
+ onInjectionError?: (error: Error) => Promise<unknown> | void;
37
+ }
38
+
33
39
  interface EnviveProviderProps extends AgentWrapperProps {
34
40
  amplitudeApiKey?: string;
35
41
  dataResidency?: string;
@@ -58,6 +64,7 @@ interface EnviveProviderProps extends AgentWrapperProps {
58
64
  overrideConfig?: GraphQlConfigValues;
59
65
  mockSalesAgentData?: any;
60
66
  hardcopyOverride?: Partial<Record<WidgetTypeV3, HardcopyResponse>>;
67
+ extensionService?: ExtensionStatusCallbacks;
61
68
  }
62
69
 
63
70
  const SearchAgentWrapper: React.FC<AgentWrapperProps> = ({ children, enabledAgents }) => {
@@ -108,9 +115,34 @@ export const EnviveProvider: React.FC<EnviveProviderProps> = ({
108
115
  requestV3Config = false,
109
116
  mockSalesAgentData,
110
117
  hardcopyOverride,
118
+ extensionService,
111
119
  ...config
112
120
  }) => {
113
121
  const [userId, setUserId] = useState<string>('');
122
+ const didReportSuccessRef = useRef(false);
123
+
124
+ const invokeExtensionCallback = useCallback((callback?: () => Promise<unknown> | void) => {
125
+ if (!callback) {
126
+ return;
127
+ }
128
+
129
+ Promise.resolve(callback()).catch(error => {
130
+ logger.logDebug('Extension status callback failed', error);
131
+ });
132
+ }, []);
133
+
134
+ const invokeExtensionErrorCallback = useCallback(
135
+ (error: Error) => {
136
+ if (!extensionService?.onInjectionError) {
137
+ return;
138
+ }
139
+
140
+ Promise.resolve(extensionService.onInjectionError(error)).catch(callbackError => {
141
+ logger.logDebug('Extension error callback failed', callbackError);
142
+ });
143
+ },
144
+ [extensionService],
145
+ );
114
146
 
115
147
  const userIdService = useMemo(
116
148
  () =>
@@ -133,6 +165,8 @@ export const EnviveProvider: React.FC<EnviveProviderProps> = ({
133
165
  const [enviveServiceConfig, setEnviveServiceConfig] = useState<EnviveServiceConfig | null>(null);
134
166
 
135
167
  useEffect(() => {
168
+ invokeExtensionCallback(extensionService?.onInjectionLoading);
169
+
136
170
  // Really not happy with this approach, but I'm seeing 429 errors in the tests
137
171
  // because of the rate limiting on the API.
138
172
  const enviveConfigService =
@@ -144,11 +178,38 @@ export const EnviveProvider: React.FC<EnviveProviderProps> = ({
144
178
  namespace: namespace || '',
145
179
  source: source || '',
146
180
  });
147
- enviveConfigService.getEnviveConfig().then(newConfig => {
148
- setEnviveServiceConfig(newConfig);
149
- return newConfig;
150
- });
151
- }, [config, userId, namespace, source, inputEnviveConfigService]);
181
+ const fetchEnviveConfig = async () => {
182
+ try {
183
+ const newConfig = await enviveConfigService.getEnviveConfig();
184
+ setEnviveServiceConfig(newConfig);
185
+ } catch (error) {
186
+ const resolvedError =
187
+ error instanceof Error ? error : new Error('Failed to load envive config');
188
+ logger.logError('Failed to initialize EnviveProvider', resolvedError);
189
+ invokeExtensionErrorCallback(resolvedError);
190
+ }
191
+ };
192
+
193
+ fetchEnviveConfig();
194
+ }, [
195
+ config,
196
+ userId,
197
+ namespace,
198
+ source,
199
+ inputEnviveConfigService,
200
+ extensionService,
201
+ invokeExtensionCallback,
202
+ invokeExtensionErrorCallback,
203
+ ]);
204
+
205
+ useEffect(() => {
206
+ if (!enviveServiceConfig || didReportSuccessRef.current) {
207
+ return;
208
+ }
209
+
210
+ didReportSuccessRef.current = true;
211
+ invokeExtensionCallback(extensionService?.onInjectionSuccess);
212
+ }, [enviveServiceConfig, extensionService, invokeExtensionCallback]);
152
213
 
153
214
  const enviveConfig = useMemo(
154
215
  () => ({
@@ -182,7 +243,10 @@ export const EnviveProvider: React.FC<EnviveProviderProps> = ({
182
243
  <FeatureFlagServiceProvider featureFlagService={featureFlagService}>
183
244
  <UserIdentityProvider userIdService={userIdService}>
184
245
  <AmplitudeProvider externalAmplitudeService={inputAmplitudeService}>
185
- <PageProvider previewMode={previewMode}>
246
+ <PageProvider
247
+ previewMode={previewMode}
248
+ onUrlResolverNotReady={extensionService?.onInjectionError}
249
+ >
186
250
  <WidgetConfigProvider>
187
251
  <HardcopyProvider hardcopyOverride={hardcopyOverride}>
188
252
  <EnviveCssProvider>
@@ -27,6 +27,12 @@ vi.mock('src/application/commerce-api', () => ({
27
27
  },
28
28
  }));
29
29
 
30
+ // Mock useAmplitude (PageProvider dispatches page viewed event internally)
31
+ vi.mock('src/contexts/amplitudeContext', () => ({
32
+ EnviveMetricsEventName: { PageViewed: 'Page Viewed' },
33
+ useAmplitude: () => ({ trackEvent: vi.fn() }),
34
+ }));
35
+
30
36
  // Component that uses the usePage hook
31
37
  const MockComponent: React.FC = () => {
32
38
  const context = usePage();
@@ -5,19 +5,23 @@ import {
5
5
  useContext,
6
6
  useEffect,
7
7
  useMemo,
8
+ useRef,
8
9
  useState,
9
10
  } from 'react';
10
11
  import {
11
12
  UrlResolverResponse,
13
+ hasParsedVariantInfoAtom,
12
14
  pageUserEventAtom,
13
15
  pageVariantInfoAtom,
14
16
  urlResolverAtom,
17
+ variantInfoAtom,
15
18
  } from 'src/atoms/app/variant';
16
- import { useAtom } from 'jotai';
19
+ import { useAtom, useAtomValue } from 'jotai';
17
20
  import CommerceApiClient from 'src/application/commerce-api';
18
21
  import Logger from 'src/application/logging/logger';
19
22
  import { PageVisitCategory, UserEventCategory } from '@spiffy-ai/commerce-api-client';
20
23
  import { VariantTypeEnum } from 'src/application/models';
24
+ import { EnviveMetricsEventName, useAmplitude } from 'src/contexts/amplitudeContext';
21
25
  import { mapApiUserEventToUserEvent, mapUrlResolverResponseToVariantInfo } from './mapping';
22
26
  import { PageDetails } from './types';
23
27
 
@@ -35,13 +39,17 @@ const PageContext = createContext<
35
39
  export const PageProvider: React.FC<{
36
40
  children: ReactNode;
37
41
  previewMode: boolean;
38
- }> = ({ children, previewMode = false }) => {
42
+ /** When URL resolver returns `ready: false`, invoked for extension/injection failure reporting. */
43
+ onUrlResolverNotReady?: (error: Error) => Promise<unknown> | void;
44
+ }> = ({ children, previewMode = false, onUrlResolverNotReady }) => {
39
45
  const [pageUrl, setPageUrl] = useState<string | undefined>(undefined);
40
46
  const [urlResolverResponse, setUrlResolverResponse] = useAtom(urlResolverAtom);
41
47
  const [isLoading, setIsLoading] = useState(false);
42
48
  const [variantInfo, setVariantInfo] = useAtom(pageVariantInfoAtom);
43
49
  const [userEvent, setUserEvent] = useAtom(pageUserEventAtom);
44
50
  const [isSupported, setIsSupported] = useState(false);
51
+ const lastNotReadyUrlRef = useRef<string | null>(null);
52
+
45
53
  useEffect(() => {
46
54
  setPageUrl(window.location.href);
47
55
  }, []);
@@ -72,11 +80,13 @@ export const PageProvider: React.FC<{
72
80
  if (response.ready) {
73
81
  const newVariantInfo = mapUrlResolverResponseToVariantInfo(url, response);
74
82
  const newUserEvent = mapApiUserEventToUserEvent(response.user_event ?? undefined);
83
+ logger.logDebug('setting variant info and user event', { newVariantInfo, newUserEvent });
75
84
  setVariantInfo(newVariantInfo);
76
85
  setUserEvent(newUserEvent);
77
86
  } else {
78
87
  setUserEvent(undefined);
79
88
  setVariantInfo(undefined);
89
+ logger.logDebug('set variant info and user event to undefined', { variantInfo, userEvent });
80
90
  }
81
91
  },
82
92
  [setVariantInfo, setUserEvent],
@@ -104,6 +114,16 @@ export const PageProvider: React.FC<{
104
114
  setVariantFromUrlResolver(cleansedUrl, response);
105
115
  setIsLoading(false);
106
116
  setIsSupported(response.ready);
117
+
118
+ if (response.ready) {
119
+ lastNotReadyUrlRef.current = null;
120
+ } else if (onUrlResolverNotReady && lastNotReadyUrlRef.current !== cleansedUrl) {
121
+ lastNotReadyUrlRef.current = cleansedUrl;
122
+ const err = new Error(`URL resolver returned ready=false for ${cleansedUrl}`);
123
+ Promise.resolve(onUrlResolverNotReady(err)).catch(callbackError => {
124
+ logger.logDebug('onUrlResolverNotReady callback failed', callbackError);
125
+ });
126
+ }
107
127
  } catch (e) {
108
128
  setIsLoading(false);
109
129
  logger.logError('Failed to resolve page URL', e, { pageUrl });
@@ -118,8 +138,65 @@ export const PageProvider: React.FC<{
118
138
  userEvent,
119
139
  variantInfo,
120
140
  previewMode,
141
+ onUrlResolverNotReady,
121
142
  ]);
122
143
 
144
+ const { trackEvent } = useAmplitude();
145
+ const resolvedVariantInfo = useAtomValue(variantInfoAtom);
146
+ const hasParsedVariantInfo = useAtomValue(hasParsedVariantInfoAtom);
147
+ const pageViewedFired = useRef(false);
148
+
149
+ useEffect(() => {
150
+ if (pageViewedFired.current || isLoading || !hasParsedVariantInfo || !variantInfo) {
151
+ return;
152
+ }
153
+
154
+ let pageType: string;
155
+ let pageId: string;
156
+
157
+ switch (variantInfo.variantType) {
158
+ case VariantTypeEnum.Pdp:
159
+ pageType = 'pdp';
160
+ pageId = variantInfo.productId;
161
+ break;
162
+ case VariantTypeEnum.Plp:
163
+ pageType = 'plp';
164
+ pageId = variantInfo.plpId;
165
+ break;
166
+ case VariantTypeEnum.Home:
167
+ pageType = 'homepage';
168
+ pageId = variantInfo.url;
169
+ break;
170
+ case VariantTypeEnum.Other:
171
+ pageType = 'other';
172
+ pageId = variantInfo.url;
173
+ break;
174
+ case VariantTypeEnum.PageVisit:
175
+ pageType = 'other';
176
+ pageId = variantInfo.url;
177
+ break;
178
+ case VariantTypeEnum.FullPageSalesAgent:
179
+ pageType = 'full_page_sales_agent';
180
+ pageId = variantInfo.url;
181
+ break;
182
+ default:
183
+ return;
184
+ }
185
+
186
+ pageViewedFired.current = true;
187
+
188
+ trackEvent({
189
+ eventName: EnviveMetricsEventName.PageViewed,
190
+ eventProps: {
191
+ 'context.page_type': pageType,
192
+ 'context.page_id': pageId,
193
+ 'context.supported': isSupported,
194
+ 'context.ready': isSupported,
195
+ 'context.page_variant_id': resolvedVariantInfo.variantId,
196
+ },
197
+ });
198
+ }, [isLoading, isSupported, hasParsedVariantInfo, variantInfo, resolvedVariantInfo, trackEvent]);
199
+
123
200
  const setPageUrlStable = useCallback((url: string) => {
124
201
  setPageUrl(url);
125
202
  }, []);
@@ -0,0 +1,297 @@
1
+ import { renderHook } from '@testing-library/react';
2
+ import { VariantTypeEnum } from 'src/application/models';
3
+ import { EnviveMetricsEventName } from 'src/contexts/amplitudeContext';
4
+ import {
5
+ FullPageSalesAgentVariantInfo,
6
+ HomeVariantInfo,
7
+ OtherVariantInfo,
8
+ PDPPageVariantInfo,
9
+ PLPPageVariantInfo,
10
+ PageVariantInfo,
11
+ VisitPageVariantInfo,
12
+ } from 'src/contexts/pageContext/types';
13
+ import { extractPageViewedContext, usePageViewedEvent } from '../usePageViewedEvent';
14
+
15
+ const mockTrackEvent = vi.fn();
16
+
17
+ vi.mock('src/contexts/amplitudeContext', async () => {
18
+ const actual = await vi.importActual('src/contexts/amplitudeContext');
19
+ return {
20
+ ...actual,
21
+ useAmplitude: () => ({
22
+ trackEvent: mockTrackEvent,
23
+ }),
24
+ };
25
+ });
26
+
27
+ const mockUsePage = vi.fn();
28
+ vi.mock('src/contexts/pageContext', () => ({
29
+ usePage: () => mockUsePage(),
30
+ }));
31
+
32
+ const mockUseAtomValue = vi.fn();
33
+ vi.mock('jotai', async () => {
34
+ const actual = await vi.importActual('jotai');
35
+ return {
36
+ ...actual,
37
+ useAtomValue: () => mockUseAtomValue(),
38
+ };
39
+ });
40
+
41
+ describe('extractPageViewedContext', () => {
42
+ it('should return pdp context', () => {
43
+ const variant: PDPPageVariantInfo = {
44
+ variantType: VariantTypeEnum.Pdp,
45
+ productId: 'product-123',
46
+ collections: [],
47
+ numberOfReviews: 0,
48
+ merchantTags: [],
49
+ url: 'https://example.com/products/product-123',
50
+ };
51
+ expect(extractPageViewedContext(variant)).toEqual({
52
+ page_type: 'pdp',
53
+ page_id: 'product-123',
54
+ });
55
+ });
56
+
57
+ it('should return plp context', () => {
58
+ const variant: PLPPageVariantInfo = {
59
+ variantType: VariantTypeEnum.Plp,
60
+ plpId: 'collection-456',
61
+ topCategory: 'shoes',
62
+ url: 'https://example.com/collections/shoes',
63
+ };
64
+ expect(extractPageViewedContext(variant)).toEqual({
65
+ page_type: 'plp',
66
+ page_id: 'collection-456',
67
+ });
68
+ });
69
+
70
+ it('should return homepage context', () => {
71
+ const variant: HomeVariantInfo = {
72
+ variantType: VariantTypeEnum.Home,
73
+ url: 'https://example.com/',
74
+ };
75
+ expect(extractPageViewedContext(variant)).toEqual({
76
+ page_type: 'homepage',
77
+ page_id: 'https://example.com/',
78
+ });
79
+ });
80
+
81
+ it('should return other context for Other variant', () => {
82
+ const variant: OtherVariantInfo = {
83
+ variantType: VariantTypeEnum.Other,
84
+ url: 'https://example.com/about',
85
+ };
86
+ expect(extractPageViewedContext(variant)).toEqual({
87
+ page_type: 'other',
88
+ page_id: 'https://example.com/about',
89
+ });
90
+ });
91
+
92
+ it('should return other context for PageVisit variant', () => {
93
+ const variant: VisitPageVariantInfo = {
94
+ variantType: VariantTypeEnum.PageVisit,
95
+ url: 'https://example.com/contact',
96
+ };
97
+ expect(extractPageViewedContext(variant)).toEqual({
98
+ page_type: 'other',
99
+ page_id: 'https://example.com/contact',
100
+ });
101
+ });
102
+
103
+ it('should return full_page_sales_agent context', () => {
104
+ const variant: FullPageSalesAgentVariantInfo = {
105
+ variantType: VariantTypeEnum.FullPageSalesAgent,
106
+ url: 'https://example.com/agent',
107
+ };
108
+ expect(extractPageViewedContext(variant)).toEqual({
109
+ page_type: 'full_page_sales_agent',
110
+ page_id: 'https://example.com/agent',
111
+ });
112
+ });
113
+
114
+ it('should return null for unknown variant type', () => {
115
+ const variant = { variantType: 'unknown' } as unknown as PageVariantInfo;
116
+ expect(extractPageViewedContext(variant)).toBeNull();
117
+ });
118
+ });
119
+
120
+ describe('usePageViewedEvent', () => {
121
+ beforeEach(() => {
122
+ vi.clearAllMocks();
123
+ });
124
+
125
+ it('should not fire when still loading', () => {
126
+ mockUsePage.mockReturnValue({
127
+ variantInfo: {
128
+ variantType: VariantTypeEnum.Pdp,
129
+ productId: 'p1',
130
+ collections: [],
131
+ numberOfReviews: 0,
132
+ merchantTags: [],
133
+ url: '',
134
+ },
135
+ isSupported: true,
136
+ isLoading: true,
137
+ });
138
+ mockUseAtomValue
139
+ .mockReturnValueOnce({ variantId: 'v1', variant: 'pdp' })
140
+ .mockReturnValueOnce(true);
141
+
142
+ renderHook(() => usePageViewedEvent());
143
+ expect(mockTrackEvent).not.toHaveBeenCalled();
144
+ });
145
+
146
+ it('should not fire when hasParsedVariantInfo is false', () => {
147
+ mockUsePage.mockReturnValue({
148
+ variantInfo: {
149
+ variantType: VariantTypeEnum.Pdp,
150
+ productId: 'p1',
151
+ collections: [],
152
+ numberOfReviews: 0,
153
+ merchantTags: [],
154
+ url: '',
155
+ },
156
+ isSupported: true,
157
+ isLoading: false,
158
+ });
159
+ mockUseAtomValue
160
+ .mockReturnValueOnce({ variantId: 'v1', variant: 'pdp' })
161
+ .mockReturnValueOnce(false);
162
+
163
+ renderHook(() => usePageViewedEvent());
164
+ expect(mockTrackEvent).not.toHaveBeenCalled();
165
+ });
166
+
167
+ it('should not fire when pageVariantInfo is undefined', () => {
168
+ mockUsePage.mockReturnValue({
169
+ variantInfo: undefined,
170
+ isSupported: false,
171
+ isLoading: false,
172
+ });
173
+ mockUseAtomValue
174
+ .mockReturnValueOnce({ variantId: 'v1', variant: 'pdp' })
175
+ .mockReturnValueOnce(true);
176
+
177
+ renderHook(() => usePageViewedEvent());
178
+ expect(mockTrackEvent).not.toHaveBeenCalled();
179
+ });
180
+
181
+ it('should fire with correct props for pdp page', () => {
182
+ mockUsePage.mockReturnValue({
183
+ variantInfo: {
184
+ variantType: VariantTypeEnum.Pdp,
185
+ productId: 'product-123',
186
+ collections: [],
187
+ numberOfReviews: 5,
188
+ merchantTags: [],
189
+ url: 'https://example.com/products/product-123',
190
+ },
191
+ isSupported: true,
192
+ isLoading: false,
193
+ });
194
+ mockUseAtomValue
195
+ .mockReturnValueOnce({ variantId: 'variant-A', variant: 'pdp' })
196
+ .mockReturnValueOnce(true);
197
+
198
+ renderHook(() => usePageViewedEvent());
199
+
200
+ expect(mockTrackEvent).toHaveBeenCalledTimes(1);
201
+ expect(mockTrackEvent).toHaveBeenCalledWith({
202
+ eventName: EnviveMetricsEventName.PageViewed,
203
+ eventProps: {
204
+ 'context.page_type': 'pdp',
205
+ 'context.page_id': 'product-123',
206
+ 'context.supported': true,
207
+ 'context.ready': true,
208
+ 'context.page_variant_id': 'variant-A',
209
+ },
210
+ });
211
+ });
212
+
213
+ it('should fire only once even if dependencies change', () => {
214
+ mockUsePage.mockReturnValue({
215
+ variantInfo: {
216
+ variantType: VariantTypeEnum.Pdp,
217
+ productId: 'product-123',
218
+ collections: [],
219
+ numberOfReviews: 0,
220
+ merchantTags: [],
221
+ url: '',
222
+ },
223
+ isSupported: true,
224
+ isLoading: false,
225
+ });
226
+ mockUseAtomValue
227
+ .mockReturnValueOnce({ variantId: 'v1', variant: 'pdp' })
228
+ .mockReturnValueOnce(true);
229
+
230
+ const { rerender } = renderHook(() => usePageViewedEvent());
231
+
232
+ mockUseAtomValue
233
+ .mockReturnValueOnce({ variantId: 'v1', variant: 'pdp' })
234
+ .mockReturnValueOnce(true);
235
+
236
+ rerender();
237
+
238
+ expect(mockTrackEvent).toHaveBeenCalledTimes(1);
239
+ });
240
+
241
+ it('should fire with full_page_sales_agent page type', () => {
242
+ mockUsePage.mockReturnValue({
243
+ variantInfo: {
244
+ variantType: VariantTypeEnum.FullPageSalesAgent,
245
+ url: 'https://example.com/agent',
246
+ },
247
+ isSupported: true,
248
+ isLoading: false,
249
+ });
250
+ mockUseAtomValue
251
+ .mockReturnValueOnce({ variantId: 'fpsa-variant', variant: 'full_page' })
252
+ .mockReturnValueOnce(true);
253
+
254
+ renderHook(() => usePageViewedEvent());
255
+
256
+ expect(mockTrackEvent).toHaveBeenCalledTimes(1);
257
+ expect(mockTrackEvent).toHaveBeenCalledWith({
258
+ eventName: EnviveMetricsEventName.PageViewed,
259
+ eventProps: {
260
+ 'context.page_type': 'full_page_sales_agent',
261
+ 'context.page_id': 'https://example.com/agent',
262
+ 'context.supported': true,
263
+ 'context.ready': true,
264
+ 'context.page_variant_id': 'fpsa-variant',
265
+ },
266
+ });
267
+ });
268
+
269
+ it('should set supported and ready to false when isSupported is false', () => {
270
+ mockUsePage.mockReturnValue({
271
+ variantInfo: {
272
+ variantType: VariantTypeEnum.Pdp,
273
+ productId: 'product-456',
274
+ collections: [],
275
+ numberOfReviews: 0,
276
+ merchantTags: [],
277
+ url: '',
278
+ },
279
+ isSupported: false,
280
+ isLoading: false,
281
+ });
282
+ mockUseAtomValue
283
+ .mockReturnValueOnce({ variantId: 'v2', variant: 'pdp' })
284
+ .mockReturnValueOnce(true);
285
+
286
+ renderHook(() => usePageViewedEvent());
287
+
288
+ expect(mockTrackEvent).toHaveBeenCalledWith(
289
+ expect.objectContaining({
290
+ eventProps: expect.objectContaining({
291
+ 'context.supported': false,
292
+ 'context.ready': false,
293
+ }),
294
+ }),
295
+ );
296
+ });
297
+ });
@@ -0,0 +1 @@
1
+ export * from './usePageViewedEvent';
@@ -0,0 +1,103 @@
1
+ import { useAtomValue } from 'jotai';
2
+ import { useEffect, useRef } from 'react';
3
+ import { VariantTypeEnum } from 'src/application/models';
4
+ import { hasParsedVariantInfoAtom, variantInfoAtom } from 'src/atoms/app/variant';
5
+ import { EnviveMetricsEventName, useAmplitude } from 'src/contexts/amplitudeContext';
6
+ import { usePage } from 'src/contexts/pageContext';
7
+ import {
8
+ FullPageSalesAgentVariantInfo,
9
+ HomeVariantInfo,
10
+ OtherVariantInfo,
11
+ PDPPageVariantInfo,
12
+ PLPPageVariantInfo,
13
+ PageVariantInfo,
14
+ VisitPageVariantInfo,
15
+ } from 'src/contexts/pageContext/types';
16
+
17
+ interface PageViewedContext {
18
+ page_type: string;
19
+ page_id: string;
20
+ }
21
+
22
+ export const extractPageViewedContext = (
23
+ pageVariant: PageVariantInfo,
24
+ ): PageViewedContext | null => {
25
+ switch (pageVariant.variantType) {
26
+ case VariantTypeEnum.Pdp:
27
+ return {
28
+ page_type: 'pdp',
29
+ page_id: (pageVariant as PDPPageVariantInfo).productId,
30
+ };
31
+ case VariantTypeEnum.Plp:
32
+ return {
33
+ page_type: 'plp',
34
+ page_id: (pageVariant as PLPPageVariantInfo).plpId,
35
+ };
36
+ case VariantTypeEnum.Home:
37
+ return {
38
+ page_type: 'homepage',
39
+ page_id: (pageVariant as HomeVariantInfo).url,
40
+ };
41
+ case VariantTypeEnum.Other:
42
+ return {
43
+ page_type: 'other',
44
+ page_id: (pageVariant as OtherVariantInfo).url,
45
+ };
46
+ case VariantTypeEnum.PageVisit:
47
+ return {
48
+ page_type: 'other',
49
+ page_id: (pageVariant as VisitPageVariantInfo).url,
50
+ };
51
+ case VariantTypeEnum.FullPageSalesAgent:
52
+ return {
53
+ page_type: 'full_page_sales_agent',
54
+ page_id: (pageVariant as FullPageSalesAgentVariantInfo).url,
55
+ };
56
+ default:
57
+ return null;
58
+ }
59
+ };
60
+
61
+ /**
62
+ * Fires the [Envive] Page Viewed event once per page load when page context data is ready.
63
+ *
64
+ * This event captures page context information and is intended to eventually replace
65
+ * the [Amplitude] Page Viewed event.
66
+ */
67
+ export const usePageViewedEvent = () => {
68
+ const hasFired = useRef(false);
69
+ const { trackEvent } = useAmplitude();
70
+ const { variantInfo: pageVariantInfo, isSupported, isLoading } = usePage();
71
+ const variantInfo = useAtomValue(variantInfoAtom);
72
+ const hasParsedVariantInfo = useAtomValue(hasParsedVariantInfoAtom);
73
+
74
+ useEffect(() => {
75
+ if (hasFired.current) {
76
+ return;
77
+ }
78
+
79
+ if (isLoading || !hasParsedVariantInfo || !pageVariantInfo) {
80
+ return;
81
+ }
82
+
83
+ const pageContext = extractPageViewedContext(pageVariantInfo);
84
+ if (!pageContext) {
85
+ return;
86
+ }
87
+
88
+ hasFired.current = true;
89
+
90
+ trackEvent({
91
+ eventName: EnviveMetricsEventName.PageViewed,
92
+ eventProps: {
93
+ 'context.page_type': pageContext.page_type,
94
+ 'context.page_id': pageContext.page_id,
95
+ // context.supported and context.ready both derive from isSupported (response.ready)
96
+ // because the supportedEventAtom is not currently populated.
97
+ 'context.supported': isSupported,
98
+ 'context.ready': isSupported,
99
+ 'context.page_variant_id': variantInfo.variantId,
100
+ },
101
+ });
102
+ }, [isLoading, isSupported, hasParsedVariantInfo, pageVariantInfo, variantInfo, trackEvent]);
103
+ };