@envive-ai/react-hooks 0.3.56 → 0.3.58
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.
- package/dist/application/commerce-api.cjs +11 -12
- package/dist/application/commerce-api.js +11 -12
- package/dist/application/logging/index.cjs +13 -0
- package/dist/application/logging/index.d.cts +3 -0
- package/dist/application/logging/index.d.ts +3 -0
- package/dist/application/logging/index.js +4 -0
- package/dist/application/logging/logLevel.cjs +123 -0
- package/dist/application/logging/logLevel.d.cts +47 -0
- package/dist/application/logging/logLevel.d.ts +47 -0
- package/dist/application/logging/logLevel.js +115 -0
- package/dist/application/logging/logger.cjs +6 -1
- package/dist/application/logging/logger.d.cts +13 -0
- package/dist/application/logging/logger.d.ts +13 -0
- package/dist/application/logging/logger.js +7 -1
- package/dist/atoms/app/index.d.cts +7 -7
- package/dist/atoms/app/index.d.ts +6 -6
- package/dist/atoms/app/variant.d.ts +6 -6
- package/dist/atoms/chat/chatState.d.cts +18 -18
- package/dist/atoms/chat/chatState.d.ts +19 -19
- package/dist/atoms/chat/form.d.cts +2 -2
- package/dist/atoms/chat/form.d.ts +2 -2
- package/dist/atoms/chat/index.d.ts +2 -2
- package/dist/atoms/chat/lastMessage.d.cts +2 -2
- package/dist/atoms/chat/lastMessage.d.ts +2 -2
- package/dist/atoms/chat/messageQueue.d.cts +6 -6
- package/dist/atoms/chat/messageQueue.d.ts +6 -6
- package/dist/atoms/chat/performanceMetrics.d.cts +6 -6
- package/dist/atoms/chat/performanceMetrics.d.ts +6 -6
- package/dist/atoms/chat/renderedWidgetRefs.d.cts +2 -2
- package/dist/atoms/chat/renderedWidgetRefs.d.ts +2 -2
- package/dist/atoms/chat/replies.d.cts +1 -1
- package/dist/atoms/chat/replies.d.ts +3 -3
- package/dist/atoms/chat/suggestions.d.cts +2 -2
- package/dist/atoms/chat/suggestions.d.ts +2 -2
- package/dist/atoms/envive/enviveConfig.d.ts +14 -14
- package/dist/atoms/globalSearch/globalSearch.d.cts +5 -5
- package/dist/atoms/globalSearch/globalSearch.d.ts +5 -5
- package/dist/atoms/org/customerService.d.cts +6 -6
- package/dist/atoms/org/customerService.d.ts +6 -6
- package/dist/atoms/org/graphqlConfig.d.cts +4 -4
- package/dist/atoms/org/graphqlConfig.d.ts +5 -5
- package/dist/atoms/org/newOrgConfigAtom.d.cts +2 -2
- package/dist/atoms/org/newOrgConfigAtom.d.ts +3 -3
- package/dist/atoms/org/orgAnalyticsConfig.d.cts +4 -4
- package/dist/atoms/org/orgAnalyticsConfig.d.ts +5 -5
- package/dist/atoms/search/chatSearch.d.cts +17 -17
- package/dist/atoms/search/chatSearch.d.ts +17 -17
- package/dist/atoms/search/searchAPI.d.cts +13 -13
- package/dist/atoms/search/searchAPI.d.ts +13 -13
- package/dist/atoms/search/types.d.cts +1 -1
- package/dist/atoms/search/types.d.ts +1 -1
- package/dist/atoms/widget/chatPreviewLoading.d.ts +2 -2
- package/dist/contexts/salesAgentContext/salesAgentService.cjs +2 -2
- package/dist/contexts/salesAgentContext/salesAgentService.js +2 -2
- package/dist/contexts/systemSettingsContext/systemSettingsContext.d.cts +2 -2
- package/dist/contexts/types.d.cts +1 -1
- package/dist/contexts/types.d.ts +1 -1
- package/dist/contexts/typesV3.d.cts +1 -1
- package/dist/contexts/typesV3.d.ts +1 -1
- package/dist/hooks/ChatToggle/useChatToggle.cjs +10 -8
- package/dist/hooks/ChatToggle/useChatToggle.d.cts +4 -3
- package/dist/hooks/ChatToggle/useChatToggle.d.ts +4 -3
- package/dist/hooks/ChatToggle/useChatToggle.js +11 -9
- package/dist/hooks/GrabAndScroll/useGrabAndScroll.d.cts +2 -2
- package/dist/hooks/Intersection/useIntersection.cjs +7 -5
- package/dist/hooks/Intersection/useIntersection.js +7 -5
- package/dist/hooks/Search/useSearchInput.cjs +1 -1
- package/dist/hooks/Search/useSearchInput.js +1 -1
- package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.cjs +8 -6
- package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.js +8 -6
- package/dist/hooks/WidgetInteraction/types.cjs +40 -1
- package/dist/hooks/WidgetInteraction/types.d.cts +16 -2
- package/dist/hooks/WidgetInteraction/types.d.ts +16 -2
- package/dist/hooks/WidgetInteraction/types.js +41 -2
- package/dist/hooks/utils.d.cts +1 -1
- package/dist/hooks/utils.d.ts +1 -1
- package/dist/services/amplitudeService/amplitudeService.cjs +14 -5
- package/dist/services/amplitudeService/amplitudeService.js +14 -5
- package/package.json +5 -1
- package/src/application/__tests__/commerceApiStreaming.test.ts +171 -0
- package/src/application/commerce-api.ts +21 -21
- package/src/application/logging/__tests__/logger.test.ts +159 -0
- package/src/application/logging/index.ts +12 -0
- package/src/application/logging/logLevel.ts +162 -0
- package/src/application/logging/logger.ts +13 -0
- package/src/contexts/salesAgentContext/salesAgentService.ts +1 -1
- package/src/contexts/userIdentityContext/__tests__/userIdentityContext.test.tsx +4 -5
- package/src/hooks/ChatToggle/__tests__/useChatToggle.test.ts +273 -0
- package/src/hooks/ChatToggle/useChatToggle.ts +39 -12
- package/src/hooks/Intersection/useIntersection.ts +7 -4
- package/src/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.ts +8 -5
- package/src/hooks/WidgetInteraction/__tests__/chatTriggerWidget.test.ts +51 -0
- package/src/hooks/WidgetInteraction/types.ts +53 -0
- package/src/services/amplitudeService/__tests__/amplitudeService.test.ts +76 -0
- package/src/services/amplitudeService/amplitudeService.ts +37 -5
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Console log-level gate shared by every package's Logger.
|
|
3
|
+
*
|
|
4
|
+
* Debug and info are muted by default: a single merchant page load produces
|
|
5
|
+
* ~950 debug lines (the bulk of them from featureFlagService checking gates on
|
|
6
|
+
* every render), which is unusable noise on a customer's site.
|
|
7
|
+
*
|
|
8
|
+
* To turn logging back on, in priority order:
|
|
9
|
+
*
|
|
10
|
+
* 1. Query param — `?envive_log_level=debug`
|
|
11
|
+
* The support lever. Wins over everything else so logging can always be
|
|
12
|
+
* re-enabled on a live merchant page, even if the embedding page pinned a
|
|
13
|
+
* level at bootstrap. The chosen level is persisted to localStorage so it
|
|
14
|
+
* survives navigation (a support session spans more than one page view);
|
|
15
|
+
* clear it with `?envive_log_level=warn` or by removing the stored key.
|
|
16
|
+
*
|
|
17
|
+
* 2. `setLogLevel('debug')` — programmatic, e.g. bootstrap code or tests.
|
|
18
|
+
*
|
|
19
|
+
* 3. `window._envive.logLevel = 'debug'` (or `window._spiffy.logLevel`)
|
|
20
|
+
* Read on every call, so it can be flipped from the browser console
|
|
21
|
+
* mid-session without a reload.
|
|
22
|
+
*
|
|
23
|
+
* 4. `localStorage['envive-log-level']` — sticky per browser.
|
|
24
|
+
*
|
|
25
|
+
* 5. Default: 'warn'.
|
|
26
|
+
*
|
|
27
|
+
* Levels, least to most verbose: silent < error < warn < info < debug.
|
|
28
|
+
* A message emits when its own level is at or below the resolved level, so
|
|
29
|
+
* 'silent' mutes everything including errors.
|
|
30
|
+
*
|
|
31
|
+
* Kept free of React imports (like featureFlagService): the injection bundle
|
|
32
|
+
* logs from its pre-config code path, before it decides whether to load the
|
|
33
|
+
* React app, and pulling React in here would put it on the eager chunk.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
export const LOG_LEVELS = ['silent', 'error', 'warn', 'info', 'debug'] as const;
|
|
37
|
+
|
|
38
|
+
export type LogLevel = (typeof LOG_LEVELS)[number];
|
|
39
|
+
|
|
40
|
+
const LEVEL_SEVERITY: Record<LogLevel, number> = {
|
|
41
|
+
silent: 0,
|
|
42
|
+
error: 1,
|
|
43
|
+
warn: 2,
|
|
44
|
+
info: 3,
|
|
45
|
+
debug: 4,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/* Errors and warnings only. logDebug/logInfo make up the bulk of the call sites
|
|
49
|
+
* in this repo and used to print on every merchant page load. */
|
|
50
|
+
export const DEFAULT_LOG_LEVEL: LogLevel = 'warn';
|
|
51
|
+
|
|
52
|
+
export const LOG_LEVEL_QUERY_PARAM = 'envive_log_level';
|
|
53
|
+
export const LOG_LEVEL_STORAGE_KEY = 'envive-log-level';
|
|
54
|
+
|
|
55
|
+
const asLogLevel = (value: unknown): LogLevel | undefined => {
|
|
56
|
+
if (typeof value !== 'string') {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
const normalized = value.toLowerCase();
|
|
60
|
+
return (LOG_LEVELS as readonly string[]).includes(normalized)
|
|
61
|
+
? (normalized as LogLevel)
|
|
62
|
+
: undefined;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/* Set by setLogLevel(). Overrides window/stored/default, but deliberately not
|
|
66
|
+
* the query param, so support can always turn logging back on in a live session
|
|
67
|
+
* even if the embedding page pinned a level at bootstrap. */
|
|
68
|
+
let programmaticLevel: LogLevel | undefined;
|
|
69
|
+
|
|
70
|
+
/* The query param and localStorage cannot change without a navigation, so both
|
|
71
|
+
* are read once. `undefined` means "not resolved yet" and `null` caches a miss,
|
|
72
|
+
* keeping repeat lookups off the logging hot path. */
|
|
73
|
+
let cachedQueryLevel: LogLevel | null | undefined;
|
|
74
|
+
let cachedStoredLevel: LogLevel | null | undefined;
|
|
75
|
+
|
|
76
|
+
/* Persists the level so it survives navigation — a support session spans more
|
|
77
|
+
* than one page view. */
|
|
78
|
+
const storeLevel = (level: LogLevel): void => {
|
|
79
|
+
if (typeof window === 'undefined' || typeof window.localStorage === 'undefined') {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
window.localStorage.setItem(LOG_LEVEL_STORAGE_KEY, level);
|
|
84
|
+
} catch {
|
|
85
|
+
// Storage can be full or blocked; the level still applies to this page.
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const readQueryLevel = (): LogLevel | undefined => {
|
|
90
|
+
if (typeof window === 'undefined') {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
const url = window.location?.href ?? '';
|
|
94
|
+
if (!url.includes('?')) {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
const params = new URLSearchParams(new URL(url).search);
|
|
99
|
+
return asLogLevel(params.get(LOG_LEVEL_QUERY_PARAM));
|
|
100
|
+
} catch {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const queryLevel = (): LogLevel | undefined => {
|
|
106
|
+
if (cachedQueryLevel === undefined) {
|
|
107
|
+
const level = readQueryLevel();
|
|
108
|
+
if (level) {
|
|
109
|
+
storeLevel(level);
|
|
110
|
+
}
|
|
111
|
+
cachedQueryLevel = level ?? null;
|
|
112
|
+
}
|
|
113
|
+
return cachedQueryLevel ?? undefined;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const storedLevel = (): LogLevel | undefined => {
|
|
117
|
+
if (cachedStoredLevel === undefined) {
|
|
118
|
+
let level: LogLevel | undefined;
|
|
119
|
+
if (typeof window !== 'undefined' && typeof window.localStorage !== 'undefined') {
|
|
120
|
+
try {
|
|
121
|
+
level = asLogLevel(window.localStorage.getItem(LOG_LEVEL_STORAGE_KEY));
|
|
122
|
+
} catch {
|
|
123
|
+
level = undefined;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
cachedStoredLevel = level ?? null;
|
|
127
|
+
}
|
|
128
|
+
return cachedStoredLevel ?? undefined;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/* Read on every call, so it can be flipped from the browser console mid-session
|
|
132
|
+
* without a reload. */
|
|
133
|
+
const windowLevel = (): LogLevel | undefined => {
|
|
134
|
+
if (typeof window === 'undefined') {
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
const windowOverride = window as Window & {
|
|
138
|
+
_envive?: { logLevel?: string };
|
|
139
|
+
_spiffy?: { logLevel?: string };
|
|
140
|
+
};
|
|
141
|
+
return asLogLevel(windowOverride._envive?.logLevel ?? windowOverride._spiffy?.logLevel);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/* Resolution order mirrors featureFlagService's override chain: query param >
|
|
145
|
+
* setLogLevel() > window._envive.logLevel > localStorage > default. */
|
|
146
|
+
export const getLogLevel = (): LogLevel =>
|
|
147
|
+
queryLevel() ?? programmaticLevel ?? windowLevel() ?? storedLevel() ?? DEFAULT_LOG_LEVEL;
|
|
148
|
+
|
|
149
|
+
/* Pass undefined to clear the override and fall back to the levers above. */
|
|
150
|
+
export const setLogLevel = (level: LogLevel | undefined): void => {
|
|
151
|
+
programmaticLevel = level;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export const isLevelEnabled = (level: Exclude<LogLevel, 'silent'>): boolean =>
|
|
155
|
+
LEVEL_SEVERITY[getLogLevel()] >= LEVEL_SEVERITY[level];
|
|
156
|
+
|
|
157
|
+
/* Test seam: drops the memoized query-param and localStorage resolution. */
|
|
158
|
+
export const resetLogLevelForTesting = (): void => {
|
|
159
|
+
programmaticLevel = undefined;
|
|
160
|
+
cachedQueryLevel = undefined;
|
|
161
|
+
cachedStoredLevel = undefined;
|
|
162
|
+
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/* eslint-disable no-console */
|
|
2
|
+
import { isLevelEnabled } from 'src/application/logging/logLevel';
|
|
2
3
|
|
|
3
4
|
class Logger {
|
|
4
5
|
private readonly caller: string;
|
|
@@ -15,6 +16,9 @@ class Logger {
|
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
logInfo(message: string, ...args: unknown[]): void {
|
|
19
|
+
if (!isLevelEnabled('info')) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
18
22
|
console.info(
|
|
19
23
|
`INFO: [envive-ai] ${Logger.getTimestamp()} - ${this.caller} - ${message}`,
|
|
20
24
|
...args,
|
|
@@ -22,6 +26,9 @@ class Logger {
|
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
logDebug(message: string, ...args: unknown[]): void {
|
|
29
|
+
if (!isLevelEnabled('debug')) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
25
32
|
console.debug(
|
|
26
33
|
`DEBUG: [envive-ai] ${Logger.getTimestamp()} - ${this.caller} - ${message}`,
|
|
27
34
|
...args,
|
|
@@ -29,6 +36,9 @@ class Logger {
|
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
logError(message: string, error: unknown | undefined, ...args: unknown[]): void {
|
|
39
|
+
if (!isLevelEnabled('error')) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
32
42
|
console.error(
|
|
33
43
|
`ERROR: [envive-ai] ${Logger.getTimestamp()} - ${this.caller} - ${message} error=${error}`,
|
|
34
44
|
args,
|
|
@@ -36,6 +46,9 @@ class Logger {
|
|
|
36
46
|
}
|
|
37
47
|
|
|
38
48
|
logWarn(message: string, error: unknown | undefined, ...args: unknown[]): void {
|
|
49
|
+
if (!isLevelEnabled('warn')) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
39
52
|
console.warn(
|
|
40
53
|
`WARN: [envive-ai] ${Logger.getTimestamp()} - ${this.caller} - ${message} error=${error}`,
|
|
41
54
|
args,
|
|
@@ -313,7 +313,7 @@ export const useSalesAgentService: () => SalesAgentService = () => {
|
|
|
313
313
|
// },
|
|
314
314
|
// });
|
|
315
315
|
} catch (e) {
|
|
316
|
-
|
|
316
|
+
logger.logError('error getting streaming responses', e);
|
|
317
317
|
successfulResponse = false;
|
|
318
318
|
// Log failed next_responses call
|
|
319
319
|
// const responseTime = Date.now() - startTime;
|
|
@@ -177,13 +177,12 @@ describe('UserIdentityProvider', () => {
|
|
|
177
177
|
it('should allow components to access context via useUserIdentity hook', async () => {
|
|
178
178
|
renderWithProviders(<MockComponent />);
|
|
179
179
|
|
|
180
|
+
// Verify all context methods are accessible. The user id is populated by an async
|
|
181
|
+
// effect, so the content assertions must wait for it rather than assert right away.
|
|
180
182
|
await waitFor(() => {
|
|
181
|
-
expect(screen.getByTestId('user-id')).
|
|
183
|
+
expect(screen.getByTestId('user-id').textContent).toContain('spiffy-user-id-');
|
|
184
|
+
expect(screen.getByTestId('is-ready').textContent).toBe('true');
|
|
182
185
|
});
|
|
183
|
-
|
|
184
|
-
// Verify all context methods are accessible
|
|
185
|
-
expect(screen.getByTestId('user-id').textContent).toContain('spiffy-user-id-');
|
|
186
|
-
expect(screen.getByTestId('is-ready').textContent).toBe('true');
|
|
187
186
|
});
|
|
188
187
|
|
|
189
188
|
it('should throw error when used outside of UserIdentityProvider', () => {
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { renderHook } from '@testing-library/react';
|
|
2
|
+
import { ChatElementDisplayLocationV3 } from 'src/application/models';
|
|
3
|
+
import { EnviveMetricsEventName } from 'src/contexts/amplitudeContext';
|
|
4
|
+
import {
|
|
5
|
+
WidgetInteraction,
|
|
6
|
+
WidgetInteractionComponent,
|
|
7
|
+
WidgetInteractionType,
|
|
8
|
+
} from '../../WidgetInteraction/types';
|
|
9
|
+
import { useChatToggle } from '../useChatToggle';
|
|
10
|
+
|
|
11
|
+
const mockTrackEvent = vi.fn();
|
|
12
|
+
const mockTrackWidgetInteraction = vi.fn<(props: WidgetInteraction) => void>();
|
|
13
|
+
const mockOnToggle = vi.fn();
|
|
14
|
+
|
|
15
|
+
let isOpen = false;
|
|
16
|
+
|
|
17
|
+
vi.mock('src/contexts/amplitudeContext/amplitudeContext', async () => {
|
|
18
|
+
const actual = await vi.importActual('src/contexts/amplitudeContext/amplitudeContext');
|
|
19
|
+
return {
|
|
20
|
+
...actual,
|
|
21
|
+
useAmplitude: () => ({ trackEvent: mockTrackEvent }),
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
vi.mock('../../WidgetInteraction', () => ({
|
|
26
|
+
useWidgetInteraction: () => ({ trackWidgetInteraction: mockTrackWidgetInteraction }),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
vi.mock('jotai', async () => {
|
|
30
|
+
const actual = await vi.importActual('jotai');
|
|
31
|
+
return {
|
|
32
|
+
...actual,
|
|
33
|
+
useAtomValue: () => ({ isOpen }),
|
|
34
|
+
useSetAtom: () => mockOnToggle,
|
|
35
|
+
};
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const emitted = (): WidgetInteraction[] =>
|
|
39
|
+
(mockTrackWidgetInteraction.mock.calls as unknown as WidgetInteraction[][]).map(
|
|
40
|
+
([interaction]) => interaction,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
/** Every WidgetInteraction emission made during the call, as [widget, interactionType] pairs. */
|
|
44
|
+
const emissions = () =>
|
|
45
|
+
emitted().map(({ trigger }) => [trigger.widget, trigger.widget_interaction]);
|
|
46
|
+
|
|
47
|
+
const emissionsOfType = (type: WidgetInteractionType) =>
|
|
48
|
+
emitted().filter(({ trigger }) => trigger.widget_interaction === type);
|
|
49
|
+
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
vi.clearAllMocks();
|
|
52
|
+
isOpen = false;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('useChatToggle — open', () => {
|
|
56
|
+
it('attributes the click to the widget that was actually clicked', () => {
|
|
57
|
+
const { result } = renderHook(() => useChatToggle());
|
|
58
|
+
|
|
59
|
+
result.current.toggle(ChatElementDisplayLocationV3.SOCIAL_PROOF_PRIMARY_BUTTON);
|
|
60
|
+
|
|
61
|
+
expect(emissions()).toEqual([
|
|
62
|
+
[WidgetInteractionComponent.SOCIAL_PROOF, WidgetInteractionType.WIDGET_CLICKED],
|
|
63
|
+
]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it.each([
|
|
67
|
+
[
|
|
68
|
+
ChatElementDisplayLocationV3.CHAT_PREVIEW_PROMPT_BUTTON,
|
|
69
|
+
WidgetInteractionComponent.CHAT_PREVIEW,
|
|
70
|
+
],
|
|
71
|
+
[ChatElementDisplayLocationV3.TYPING_ANIMATION, WidgetInteractionComponent.EMBEDDED_WIDGET],
|
|
72
|
+
[ChatElementDisplayLocationV3.PROMPT_CAROUSEL, WidgetInteractionComponent.SUGGESTION_BAR],
|
|
73
|
+
[
|
|
74
|
+
ChatElementDisplayLocationV3.PRODUCT_CARD_TEXT_FIELD,
|
|
75
|
+
WidgetInteractionComponent.IMAGE_PROMPT_CARD,
|
|
76
|
+
],
|
|
77
|
+
[ChatElementDisplayLocationV3.FLOATING_BUTTON, WidgetInteractionComponent.FLOATING_BUTTON],
|
|
78
|
+
])('attributes %s to %s', (location, expectedWidget) => {
|
|
79
|
+
const { result } = renderHook(() => useChatToggle());
|
|
80
|
+
|
|
81
|
+
result.current.toggle(location);
|
|
82
|
+
|
|
83
|
+
expect(emissions()).toEqual([[expectedWidget, WidgetInteractionType.WIDGET_CLICKED]]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it.each([
|
|
87
|
+
ChatElementDisplayLocationV3.AUTOMATIC_PROMPT,
|
|
88
|
+
ChatElementDisplayLocationV3.REFERRER_BASED_PROMPT,
|
|
89
|
+
ChatElementDisplayLocationV3.WINDOW_API_CALL,
|
|
90
|
+
])('emits no widget interaction for the system-initiated trigger %s', location => {
|
|
91
|
+
const { result } = renderHook(() => useChatToggle());
|
|
92
|
+
|
|
93
|
+
result.current.toggle(location);
|
|
94
|
+
|
|
95
|
+
expect(mockTrackWidgetInteraction).not.toHaveBeenCalled();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('still emits the v2 expanded event for system-initiated opens', () => {
|
|
99
|
+
// B2 suppresses only the v3 intentional click. The v2 event is unchanged, so merchants
|
|
100
|
+
// still on v2 dashboards see no difference at all.
|
|
101
|
+
const { result } = renderHook(() => useChatToggle());
|
|
102
|
+
|
|
103
|
+
result.current.toggle(ChatElementDisplayLocationV3.AUTOMATIC_PROMPT);
|
|
104
|
+
|
|
105
|
+
expect(mockTrackEvent).toHaveBeenCalledTimes(1);
|
|
106
|
+
expect(mockTrackEvent.mock.calls[0][0].eventProps.message_metadata.trigger_location).toBe(
|
|
107
|
+
ChatElementDisplayLocationV3.AUTOMATIC_PROMPT,
|
|
108
|
+
);
|
|
109
|
+
expect(mockOnToggle).toHaveBeenCalledTimes(1);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('never emits a collapse when opening', () => {
|
|
113
|
+
const { result } = renderHook(() => useChatToggle());
|
|
114
|
+
|
|
115
|
+
result.current.toggle(ChatElementDisplayLocationV3.SOCIAL_PROOF_PRIMARY_BUTTON);
|
|
116
|
+
|
|
117
|
+
expect(emissionsOfType(WidgetInteractionType.WIDGET_COLLAPSED)).toHaveLength(0);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('useChatToggle — close', () => {
|
|
122
|
+
beforeEach(() => {
|
|
123
|
+
isOpen = true;
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('emits exactly one collapse, attributed to the floating chat', () => {
|
|
127
|
+
const { result } = renderHook(() => useChatToggle());
|
|
128
|
+
|
|
129
|
+
result.current.closeChat(
|
|
130
|
+
ChatElementDisplayLocationV3.FLOATING_CHAT_OVERLAY,
|
|
131
|
+
undefined,
|
|
132
|
+
'body_click',
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
expect(emissions()).toEqual([
|
|
136
|
+
[WidgetInteractionComponent.FLOATING_CHAT, WidgetInteractionType.WIDGET_COLLAPSED],
|
|
137
|
+
]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it.each(['body_click', 'swipe', 'close_button'] as const)(
|
|
141
|
+
'carries collapse_source %s',
|
|
142
|
+
source => {
|
|
143
|
+
const { result } = renderHook(() => useChatToggle());
|
|
144
|
+
|
|
145
|
+
result.current.closeChat(
|
|
146
|
+
ChatElementDisplayLocationV3.FLOATING_CHAT_CLOSE_BUTTON,
|
|
147
|
+
undefined,
|
|
148
|
+
source,
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const [collapse] = emissionsOfType(WidgetInteractionType.WIDGET_COLLAPSED);
|
|
152
|
+
expect(collapse.trigger.widget_interaction_data).toEqual({
|
|
153
|
+
widget_collapsed: { collapse_source: source },
|
|
154
|
+
});
|
|
155
|
+
},
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
it('omits widget_interaction_data when no collapse source is given', () => {
|
|
159
|
+
const { result } = renderHook(() => useChatToggle());
|
|
160
|
+
|
|
161
|
+
result.current.closeChat(ChatElementDisplayLocationV3.WINDOW_API_CALL);
|
|
162
|
+
|
|
163
|
+
const [collapse] = emissionsOfType(WidgetInteractionType.WIDGET_COLLAPSED);
|
|
164
|
+
expect(collapse.trigger.widget_interaction_data).toBeUndefined();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('never emits a click when closing', () => {
|
|
168
|
+
const { result } = renderHook(() => useChatToggle());
|
|
169
|
+
|
|
170
|
+
result.current.closeChat(ChatElementDisplayLocationV3.FLOATING_CHAT_CLOSE_BUTTON);
|
|
171
|
+
|
|
172
|
+
expect(emissionsOfType(WidgetInteractionType.WIDGET_CLICKED)).toHaveLength(0);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('useChatToggle — guards', () => {
|
|
177
|
+
it('openChat does nothing when the chat is already open', () => {
|
|
178
|
+
isOpen = true;
|
|
179
|
+
const { result } = renderHook(() => useChatToggle());
|
|
180
|
+
|
|
181
|
+
result.current.openChat(ChatElementDisplayLocationV3.SOCIAL_PROOF_PRIMARY_BUTTON);
|
|
182
|
+
|
|
183
|
+
expect(mockTrackWidgetInteraction).not.toHaveBeenCalled();
|
|
184
|
+
expect(mockOnToggle).not.toHaveBeenCalled();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('closeChat does nothing when the chat is already closed', () => {
|
|
188
|
+
const { result } = renderHook(() => useChatToggle());
|
|
189
|
+
|
|
190
|
+
result.current.closeChat(ChatElementDisplayLocationV3.FLOATING_CHAT_CLOSE_BUTTON);
|
|
191
|
+
|
|
192
|
+
expect(mockTrackWidgetInteraction).not.toHaveBeenCalled();
|
|
193
|
+
expect(mockOnToggle).not.toHaveBeenCalled();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('hover stays attributed to the floating button', () => {
|
|
197
|
+
// onHover is only wired to the floating button itself, so it is intentionally unchanged.
|
|
198
|
+
const { result } = renderHook(() => useChatToggle());
|
|
199
|
+
|
|
200
|
+
result.current.onHover();
|
|
201
|
+
|
|
202
|
+
expect(emissions()).toEqual([
|
|
203
|
+
[WidgetInteractionComponent.FLOATING_BUTTON, WidgetInteractionType.WIDGET_HOVERED],
|
|
204
|
+
]);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe('useChatToggle — adaptive storefront safety', () => {
|
|
209
|
+
it('emits no ASF widget under any trigger location', () => {
|
|
210
|
+
// The ASF dashboard filters on v3_widget in {full_page_sales_agent,
|
|
211
|
+
// full_page_sales_agent_recommendations}. This hook must never produce either value, or the
|
|
212
|
+
// change could move ASF product-surface metrics.
|
|
213
|
+
const asfWidgets: WidgetInteractionComponent[] = [
|
|
214
|
+
WidgetInteractionComponent.FULL_PAGE_SALES_AGENT,
|
|
215
|
+
WidgetInteractionComponent.FULL_PAGE_SALES_AGENT_RECOMMENDATIONS,
|
|
216
|
+
WidgetInteractionComponent.FULL_PAGE_SALES_AGENT_RECOMMENDATIONS_ATC_FALLBACK,
|
|
217
|
+
];
|
|
218
|
+
|
|
219
|
+
for (const location of Object.values(ChatElementDisplayLocationV3)) {
|
|
220
|
+
vi.clearAllMocks();
|
|
221
|
+
|
|
222
|
+
isOpen = false;
|
|
223
|
+
const opened = renderHook(() => useChatToggle());
|
|
224
|
+
opened.result.current.toggle(location);
|
|
225
|
+
|
|
226
|
+
isOpen = true;
|
|
227
|
+
const closed = renderHook(() => useChatToggle());
|
|
228
|
+
closed.result.current.toggle(location);
|
|
229
|
+
|
|
230
|
+
for (const [widget] of emissions()) {
|
|
231
|
+
expect(asfWidgets).not.toContain(widget);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('only ever emits click, collapse or hover — never an ASF-dashboard interaction type', () => {
|
|
237
|
+
// The ASF product funnels filter on product_card_clicked / add_to_cart_clicked, and the
|
|
238
|
+
// engagement funnels on interaction_class=intentional. This hook must not emit any
|
|
239
|
+
// product-related type.
|
|
240
|
+
const forbidden = [
|
|
241
|
+
WidgetInteractionType.PRODUCT_CARD_CLICKED,
|
|
242
|
+
WidgetInteractionType.ADD_TO_CART_CLICKED,
|
|
243
|
+
WidgetInteractionType.MESSAGE_SUBMITTED,
|
|
244
|
+
WidgetInteractionType.SUGGESTION_CLICKED,
|
|
245
|
+
];
|
|
246
|
+
|
|
247
|
+
for (const location of Object.values(ChatElementDisplayLocationV3)) {
|
|
248
|
+
vi.clearAllMocks();
|
|
249
|
+
|
|
250
|
+
isOpen = false;
|
|
251
|
+
renderHook(() => useChatToggle()).result.current.toggle(location);
|
|
252
|
+
|
|
253
|
+
isOpen = true;
|
|
254
|
+
renderHook(() => useChatToggle()).result.current.toggle(location);
|
|
255
|
+
|
|
256
|
+
for (const [, type] of emissions()) {
|
|
257
|
+
expect(forbidden).not.toContain(type);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
describe('useChatToggle — event names', () => {
|
|
264
|
+
it('emits the v3 widget interaction event name', () => {
|
|
265
|
+
const { result } = renderHook(() => useChatToggle());
|
|
266
|
+
|
|
267
|
+
result.current.toggle(ChatElementDisplayLocationV3.SOCIAL_PROOF_PRIMARY_BUTTON);
|
|
268
|
+
|
|
269
|
+
expect(mockTrackWidgetInteraction.mock.calls[0][0].eventName).toBe(
|
|
270
|
+
EnviveMetricsEventName.WidgetInteraction,
|
|
271
|
+
);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
@@ -7,7 +7,12 @@ import {
|
|
|
7
7
|
useAmplitude,
|
|
8
8
|
} from 'src/contexts/amplitudeContext/amplitudeContext';
|
|
9
9
|
import { useWidgetInteraction } from '../WidgetInteraction';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
CHAT_TRIGGER_WIDGET,
|
|
12
|
+
Collapse,
|
|
13
|
+
WidgetInteractionComponent,
|
|
14
|
+
WidgetInteractionType,
|
|
15
|
+
} from '../WidgetInteraction/types';
|
|
11
16
|
|
|
12
17
|
export const useChatToggle = () => {
|
|
13
18
|
const onToggle = useSetAtom(chatOnToggleAtom);
|
|
@@ -15,15 +20,27 @@ export const useChatToggle = () => {
|
|
|
15
20
|
const { trackEvent } = useAmplitude();
|
|
16
21
|
const { trackWidgetInteraction } = useWidgetInteraction();
|
|
17
22
|
|
|
18
|
-
const toggle = (
|
|
23
|
+
const toggle = (
|
|
24
|
+
triggerLocation: ChatElementDisplayLocationV3,
|
|
25
|
+
triggerId?: string,
|
|
26
|
+
collapseSource?: Collapse['collapse_source'],
|
|
27
|
+
) => {
|
|
28
|
+
// null for system-initiated triggers, undefined if a caller passed no location at all.
|
|
29
|
+
const triggerWidget = CHAT_TRIGGER_WIDGET[triggerLocation] ?? null;
|
|
30
|
+
|
|
19
31
|
if (!isOpen) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
32
|
+
// Only a real user click on a real widget counts as an intentional interaction. System
|
|
33
|
+
// triggers (auto-popup, referrer prompt, window API) are still recorded by the floating
|
|
34
|
+
// chat's own WIDGET_EXPANDED event, so nothing is lost by skipping the click here.
|
|
35
|
+
if (triggerWidget) {
|
|
36
|
+
trackWidgetInteraction({
|
|
37
|
+
eventName: EnviveMetricsEventName.WidgetInteraction,
|
|
38
|
+
trigger: {
|
|
39
|
+
widget: triggerWidget,
|
|
40
|
+
widget_interaction: WidgetInteractionType.WIDGET_CLICKED,
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
}
|
|
27
44
|
|
|
28
45
|
trackEvent({
|
|
29
46
|
eventName: SpiffyMetricsEventName.ChatComponentExpanded,
|
|
@@ -35,11 +52,17 @@ export const useChatToggle = () => {
|
|
|
35
52
|
},
|
|
36
53
|
});
|
|
37
54
|
} else {
|
|
55
|
+
// The thing being collapsed is always the floating chat, whatever triggered the close.
|
|
56
|
+
// This is the single emitter for WIDGET_COLLAPSED — callers pass collapseSource rather
|
|
57
|
+
// than firing their own duplicate event.
|
|
38
58
|
trackWidgetInteraction({
|
|
39
59
|
eventName: EnviveMetricsEventName.WidgetInteraction,
|
|
40
60
|
trigger: {
|
|
41
|
-
widget: WidgetInteractionComponent.
|
|
61
|
+
widget: WidgetInteractionComponent.FLOATING_CHAT,
|
|
42
62
|
widget_interaction: WidgetInteractionType.WIDGET_COLLAPSED,
|
|
63
|
+
...(collapseSource
|
|
64
|
+
? { widget_interaction_data: { widget_collapsed: { collapse_source: collapseSource } } }
|
|
65
|
+
: {}),
|
|
43
66
|
},
|
|
44
67
|
});
|
|
45
68
|
|
|
@@ -63,9 +86,13 @@ export const useChatToggle = () => {
|
|
|
63
86
|
}
|
|
64
87
|
};
|
|
65
88
|
|
|
66
|
-
const closeChat = (
|
|
89
|
+
const closeChat = (
|
|
90
|
+
triggerLocation: ChatElementDisplayLocationV3,
|
|
91
|
+
triggerId?: string,
|
|
92
|
+
collapseSource?: Collapse['collapse_source'],
|
|
93
|
+
) => {
|
|
67
94
|
if (isOpen) {
|
|
68
|
-
toggle(triggerLocation, triggerId);
|
|
95
|
+
toggle(triggerLocation, triggerId, collapseSource);
|
|
69
96
|
}
|
|
70
97
|
};
|
|
71
98
|
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { RefObject, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import Logger from 'src/application/logging/logger';
|
|
3
|
+
|
|
4
|
+
const logger = new Logger('useIntersection');
|
|
2
5
|
|
|
3
6
|
// https://rasilbaidar.medium.com/trigger-event-when-element-enters-viewport-the-react-way-168509da2e23
|
|
4
7
|
export const useIntersection = (element: RefObject<HTMLElement>, rootMargin: string) => {
|
|
@@ -10,21 +13,21 @@ export const useIntersection = (element: RefObject<HTMLElement>, rootMargin: str
|
|
|
10
13
|
// Bails immediately once the observer is attached, keeping per-render cost O(1).
|
|
11
14
|
useEffect(() => {
|
|
12
15
|
const current = element?.current;
|
|
13
|
-
|
|
16
|
+
logger.logDebug('effect ran', {
|
|
14
17
|
elementCurrent: current,
|
|
15
18
|
rootMargin,
|
|
16
19
|
alreadyObserving: !!observerRef.current,
|
|
17
20
|
});
|
|
18
21
|
|
|
19
22
|
if (!current) {
|
|
20
|
-
|
|
23
|
+
logger.logDebug('element.current is null — observer NOT attached');
|
|
21
24
|
return;
|
|
22
25
|
}
|
|
23
26
|
if (observerRef.current) return; // already observing
|
|
24
27
|
|
|
25
28
|
const observer = new IntersectionObserver(
|
|
26
29
|
([entry]) => {
|
|
27
|
-
|
|
30
|
+
logger.logDebug('IntersectionObserver fired', {
|
|
28
31
|
isIntersecting: entry.isIntersecting,
|
|
29
32
|
intersectionRatio: entry.intersectionRatio,
|
|
30
33
|
boundingClientRect: entry.boundingClientRect,
|
|
@@ -37,7 +40,7 @@ export const useIntersection = (element: RefObject<HTMLElement>, rootMargin: str
|
|
|
37
40
|
|
|
38
41
|
observer.observe(current);
|
|
39
42
|
observerRef.current = observer;
|
|
40
|
-
|
|
43
|
+
logger.logDebug('observer attached to element', current);
|
|
41
44
|
});
|
|
42
45
|
|
|
43
46
|
// Disconnect on unmount only — intentionally separate from the setup effect above.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useAtomValue } from 'jotai';
|
|
2
2
|
import { RefObject, useEffect, useRef } from 'react';
|
|
3
|
+
import Logger from 'src/application/logging/logger';
|
|
3
4
|
import { pageVariantInfoAtom } from 'src/atoms/app';
|
|
4
5
|
import { useAmplitude } from 'src/contexts/amplitudeContext/amplitudeContext';
|
|
5
6
|
import { PageVariantInfo } from 'src/contexts/pageContext/types';
|
|
@@ -10,6 +11,8 @@ import {
|
|
|
10
11
|
SpiffyMetricsEventName,
|
|
11
12
|
} from 'src/services/amplitudeService/amplitudeService';
|
|
12
13
|
|
|
14
|
+
const logger = new Logger('useTrackComponentVisibleEvent');
|
|
15
|
+
|
|
13
16
|
/**
|
|
14
17
|
* Tracks a component and logs `SpiffyMetricsEventName.ChatComponentVisible` when visible.
|
|
15
18
|
*
|
|
@@ -44,7 +47,7 @@ export const useTrackComponentVisibleEvent = (
|
|
|
44
47
|
const renderOffsetMs = useRef(safeNow()).current;
|
|
45
48
|
|
|
46
49
|
useEffect(() => {
|
|
47
|
-
|
|
50
|
+
logger.logDebug('effect ran', {
|
|
48
51
|
enabled,
|
|
49
52
|
isVisible,
|
|
50
53
|
hasTrackedEvent: hasTrackedEvent.current,
|
|
@@ -52,19 +55,19 @@ export const useTrackComponentVisibleEvent = (
|
|
|
52
55
|
});
|
|
53
56
|
|
|
54
57
|
if (!enabled) {
|
|
55
|
-
|
|
58
|
+
logger.logDebug('skipped — enabled is false');
|
|
56
59
|
return;
|
|
57
60
|
}
|
|
58
61
|
if (hasTrackedEvent.current) {
|
|
59
|
-
|
|
62
|
+
logger.logDebug('skipped — event already tracked');
|
|
60
63
|
return;
|
|
61
64
|
}
|
|
62
65
|
if (!isVisible) {
|
|
63
|
-
|
|
66
|
+
logger.logDebug('skipped — element not visible');
|
|
64
67
|
return;
|
|
65
68
|
}
|
|
66
69
|
|
|
67
|
-
|
|
70
|
+
logger.logDebug('firing tracking events', eventProps);
|
|
68
71
|
trackEvent({
|
|
69
72
|
eventName: SpiffyMetricsEventName.ChatComponentVisible,
|
|
70
73
|
eventProps,
|