@envive-ai/react-hooks 0.3.39 → 0.3.41

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 (151) hide show
  1. package/dist/application/commerce-api.cjs +3 -2
  2. package/dist/application/commerce-api.js +3 -2
  3. package/dist/application/models/api/orgConfigResults.d.cts +13 -2
  4. package/dist/application/models/api/orgConfigResults.d.ts +13 -2
  5. package/dist/application/models/featureGates.cjs +2 -1
  6. package/dist/application/models/featureGates.d.cts +2 -1
  7. package/dist/application/models/featureGates.d.ts +2 -1
  8. package/dist/application/models/featureGates.js +2 -1
  9. package/dist/application/models/graphql/queries/getWidgetConfigQuery.cjs +23 -0
  10. package/dist/application/models/graphql/queries/getWidgetConfigQuery.js +22 -0
  11. package/dist/application/models/index.d.cts +2 -2
  12. package/dist/application/models/index.d.ts +2 -2
  13. package/dist/application/utils/elementObserver.d.ts +2 -2
  14. package/dist/application/utils/index.cjs +0 -2
  15. package/dist/application/utils/index.d.cts +1 -2
  16. package/dist/application/utils/index.d.ts +1 -2
  17. package/dist/application/utils/index.js +1 -2
  18. package/dist/atoms/app/index.d.cts +1 -1
  19. package/dist/atoms/app/index.d.ts +7 -7
  20. package/dist/atoms/chat/chatState.d.ts +19 -19
  21. package/dist/atoms/chat/form.d.cts +2 -2
  22. package/dist/atoms/chat/form.d.ts +2 -2
  23. package/dist/atoms/chat/index.cjs +1 -1
  24. package/dist/atoms/chat/index.d.cts +2 -2
  25. package/dist/atoms/chat/index.d.ts +3 -3
  26. package/dist/atoms/chat/index.js +1 -1
  27. package/dist/atoms/chat/lastMessage.d.cts +2 -2
  28. package/dist/atoms/chat/lastMessage.d.ts +2 -2
  29. package/dist/atoms/chat/messageQueue.d.cts +6 -6
  30. package/dist/atoms/chat/messageQueue.d.ts +6 -6
  31. package/dist/atoms/chat/performanceMetrics.d.cts +6 -6
  32. package/dist/atoms/chat/performanceMetrics.d.ts +6 -6
  33. package/dist/atoms/chat/renderedWidgetRefs.d.cts +2 -2
  34. package/dist/atoms/chat/renderedWidgetRefs.d.ts +2 -2
  35. package/dist/atoms/chat/replies.d.ts +3 -3
  36. package/dist/atoms/chat/suggestions.d.cts +2 -2
  37. package/dist/atoms/chat/suggestions.d.ts +2 -2
  38. package/dist/atoms/envive/enviveConfig.d.cts +12 -12
  39. package/dist/atoms/envive/enviveConfig.d.ts +13 -13
  40. package/dist/atoms/envive/resolvedBaseConfigVersion.cjs +9 -0
  41. package/dist/atoms/envive/resolvedBaseConfigVersion.js +8 -0
  42. package/dist/atoms/globalSearch/globalSearch.d.cts +5 -5
  43. package/dist/atoms/globalSearch/globalSearch.d.ts +5 -5
  44. package/dist/atoms/org/customerService.d.cts +6 -6
  45. package/dist/atoms/org/customerService.d.ts +6 -6
  46. package/dist/atoms/org/graphqlConfig.d.cts +4 -4
  47. package/dist/atoms/org/graphqlConfig.d.ts +4 -4
  48. package/dist/atoms/org/index.cjs +1 -1
  49. package/dist/atoms/org/index.js +1 -1
  50. package/dist/atoms/org/newOrgConfigAtom.d.cts +2 -2
  51. package/dist/atoms/org/newOrgConfigAtom.d.ts +2 -2
  52. package/dist/atoms/org/orgAnalyticsConfig.d.cts +4 -4
  53. package/dist/atoms/org/orgAnalyticsConfig.d.ts +4 -4
  54. package/dist/atoms/search/chatSearch.cjs +1 -1
  55. package/dist/atoms/search/chatSearch.d.ts +17 -17
  56. package/dist/atoms/search/chatSearch.js +1 -1
  57. package/dist/atoms/search/searchAPI.cjs +1 -1
  58. package/dist/atoms/search/searchAPI.d.ts +13 -13
  59. package/dist/atoms/search/searchAPI.js +1 -1
  60. package/dist/atoms/search/types.d.cts +1 -1
  61. package/dist/atoms/search/types.d.ts +1 -1
  62. package/dist/atoms/search/utils.d.ts +1 -1
  63. package/dist/atoms/widget/chatPreviewLoading.d.cts +2 -2
  64. package/dist/atoms/widget/chatPreviewLoading.d.ts +2 -2
  65. package/dist/contexts/enviveContext/enviveContext.cjs +11 -3
  66. package/dist/contexts/enviveContext/enviveContext.js +11 -3
  67. package/dist/contexts/enviveContext/types.d.cts +1 -1
  68. package/dist/contexts/enviveContext/types.d.ts +1 -1
  69. package/dist/contexts/graphqlContext/graphqlContext.cjs +1 -1
  70. package/dist/contexts/graphqlContext/graphqlContext.d.cts +3 -1
  71. package/dist/contexts/graphqlContext/graphqlContext.d.ts +3 -1
  72. package/dist/contexts/graphqlContext/graphqlContext.js +1 -1
  73. package/dist/contexts/pageContext/types.d.ts +1 -1
  74. package/dist/contexts/salesAgentContext/chatAPI.cjs +2 -2
  75. package/dist/contexts/salesAgentContext/chatAPI.js +2 -2
  76. package/dist/contexts/salesAgentContext/salesAgentService.cjs +10 -4
  77. package/dist/contexts/salesAgentContext/salesAgentService.js +10 -4
  78. package/dist/contexts/searchContext/searchContext.cjs +1 -1
  79. package/dist/contexts/searchContext/searchContext.js +1 -1
  80. package/dist/contexts/systemSettingsContext/systemSettingsContext.d.cts +2 -2
  81. package/dist/contexts/systemSettingsContext/systemSettingsContext.d.ts +2 -2
  82. package/dist/contexts/types.d.cts +1 -1
  83. package/dist/contexts/types.d.ts +1 -1
  84. package/dist/contexts/typesV3.d.cts +1 -1
  85. package/dist/contexts/typesV3.d.ts +1 -1
  86. package/dist/hooks/ChatToggle/useChatToggle.cjs +1 -1
  87. package/dist/hooks/ChatToggle/useChatToggle.js +1 -1
  88. package/dist/hooks/FocusTrap/index.cjs +3 -0
  89. package/dist/hooks/FocusTrap/index.d.cts +2 -0
  90. package/dist/hooks/FocusTrap/index.d.ts +2 -0
  91. package/dist/hooks/FocusTrap/index.js +3 -0
  92. package/dist/hooks/FocusTrap/useFocusTrap.cjs +105 -0
  93. package/dist/hooks/FocusTrap/useFocusTrap.d.cts +20 -0
  94. package/dist/hooks/FocusTrap/useFocusTrap.d.ts +20 -0
  95. package/dist/hooks/FocusTrap/useFocusTrap.js +104 -0
  96. package/dist/hooks/GrabAndScroll/useGrabAndScroll.d.cts +2 -2
  97. package/dist/hooks/GrabAndScroll/useGrabAndScroll.d.ts +2 -2
  98. package/dist/hooks/Search/useSearch.cjs +3 -3
  99. package/dist/hooks/Search/useSearch.js +3 -3
  100. package/dist/hooks/SystemSettingsContext/useSystemSettingsContext.d.ts +2 -2
  101. package/dist/hooks/utils.d.cts +1 -1
  102. package/dist/hooks/utils.d.ts +1 -1
  103. package/dist/services/amplitudeService/amplitudeService.cjs +17 -2
  104. package/dist/services/amplitudeService/amplitudeService.d.cts +3 -1
  105. package/dist/services/amplitudeService/amplitudeService.d.ts +3 -1
  106. package/dist/services/amplitudeService/amplitudeService.js +17 -2
  107. package/dist/services/enviveConfigService/enviveConfigService.cjs +9 -3
  108. package/dist/services/enviveConfigService/enviveConfigService.d.cts +3 -2
  109. package/dist/services/enviveConfigService/enviveConfigService.d.ts +3 -2
  110. package/dist/services/enviveConfigService/enviveConfigService.js +10 -4
  111. package/dist/services/enviveConfigService/fetchGraphQLConfig.cjs +78 -39
  112. package/dist/services/enviveConfigService/fetchGraphQLConfig.js +78 -40
  113. package/dist/services/ga4ProjectionService/ga4ProjectionService.cjs +3 -3
  114. package/dist/services/ga4ProjectionService/ga4ProjectionService.js +3 -3
  115. package/dist/services/hardcopyService/hardcopyService.cjs +4 -2
  116. package/dist/services/hardcopyService/hardcopyService.js +4 -2
  117. package/dist/types/config-versions.cjs +3 -1
  118. package/dist/types/config-versions.js +3 -2
  119. package/dist/types/customerService.cjs +2 -1
  120. package/dist/types/customerService.d.cts +2 -1
  121. package/dist/types/customerService.d.ts +2 -1
  122. package/dist/types/customerService.js +2 -1
  123. package/package.json +5 -1
  124. package/src/application/commerce-api.ts +5 -1
  125. package/src/application/models/api/orgConfigResults.ts +27 -0
  126. package/src/application/models/featureGates.ts +5 -0
  127. package/src/application/models/graphql/queries/getWidgetConfigQuery.ts +34 -0
  128. package/src/application/utils/__tests__/elementObserver.test.ts +200 -0
  129. package/src/application/utils/index.ts +0 -1
  130. package/src/atoms/envive/resolvedBaseConfigVersion.ts +11 -0
  131. package/src/contexts/enviveContext/enviveContext.tsx +20 -0
  132. package/src/contexts/graphqlContext/graphqlContext.tsx +5 -0
  133. package/src/contexts/salesAgentContext/salesAgentService.ts +4 -1
  134. package/src/hooks/FocusTrap/__tests__/useFocusTrap.test.tsx +236 -0
  135. package/src/hooks/FocusTrap/index.ts +1 -0
  136. package/src/hooks/FocusTrap/useFocusTrap.ts +125 -0
  137. package/src/services/amplitudeService/__tests__/amplitudeService.test.ts +95 -0
  138. package/src/services/amplitudeService/amplitudeService.ts +31 -1
  139. package/src/services/enviveConfigService/__tests__/fetchGraphQLConfig.test.ts +107 -1
  140. package/src/services/enviveConfigService/enviveConfigService.ts +35 -7
  141. package/src/services/enviveConfigService/fetchGraphQLConfig.ts +119 -57
  142. package/src/services/ga4ProjectionService/ga4ProjectionService.ts +2 -2
  143. package/src/services/hardcopyService/__tests__/hardcopyService.test.ts +35 -1
  144. package/src/services/hardcopyService/hardcopyService.ts +6 -1
  145. package/src/types/config-versions.ts +8 -0
  146. package/src/types/customerService.ts +1 -0
  147. package/dist/application/utils/merchantUtils.cjs +0 -18
  148. package/dist/application/utils/merchantUtils.d.cts +0 -5
  149. package/dist/application/utils/merchantUtils.d.ts +0 -5
  150. package/dist/application/utils/merchantUtils.js +0 -17
  151. package/src/application/utils/merchantUtils.ts +0 -16
@@ -0,0 +1,236 @@
1
+ import { act, render, screen } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { useRef, useState } from 'react';
4
+ import { createRoot } from 'react-dom/client';
5
+ import { useFocusTrap } from '../useFocusTrap';
6
+
7
+ const TestDialog = ({ enabled }: { enabled: boolean }) => {
8
+ const ref = useRef<HTMLDivElement>(null);
9
+ useFocusTrap(ref, enabled);
10
+ return enabled ? (
11
+ <div
12
+ ref={ref}
13
+ data-testid="dialog"
14
+ role="dialog"
15
+ >
16
+ <button
17
+ type="button"
18
+ data-testid="close"
19
+ >
20
+ Close
21
+ </button>
22
+ <input data-testid="input" />
23
+ <button
24
+ type="button"
25
+ data-testid="action"
26
+ >
27
+ Action
28
+ </button>
29
+ </div>
30
+ ) : null;
31
+ };
32
+
33
+ const LazyContentDialog = () => {
34
+ const ref = useRef<HTMLDivElement>(null);
35
+ const [loaded, setLoaded] = useState(false);
36
+ useFocusTrap(ref, true);
37
+ return (
38
+ <div>
39
+ <button
40
+ type="button"
41
+ data-testid="load"
42
+ onClick={() => setLoaded(true)}
43
+ >
44
+ Load
45
+ </button>
46
+ <div
47
+ ref={ref}
48
+ data-testid="dialog"
49
+ role="dialog"
50
+ >
51
+ {loaded && (
52
+ <>
53
+ <button
54
+ type="button"
55
+ data-testid="close"
56
+ >
57
+ Close
58
+ </button>
59
+ <button
60
+ type="button"
61
+ data-testid="action"
62
+ >
63
+ Action
64
+ </button>
65
+ </>
66
+ )}
67
+ </div>
68
+ </div>
69
+ );
70
+ };
71
+
72
+ describe('useFocusTrap', () => {
73
+ it('moves focus to the first focusable element inside the container when enabled', () => {
74
+ render(<TestDialog enabled />);
75
+ expect(screen.getByTestId('close')).toHaveFocus();
76
+ });
77
+
78
+ it('does nothing while disabled', () => {
79
+ const { container } = render(
80
+ <>
81
+ <button
82
+ type="button"
83
+ data-testid="outside"
84
+ >
85
+ Outside
86
+ </button>
87
+ <TestDialog enabled={false} />
88
+ </>,
89
+ );
90
+ const outside = screen.getByTestId('outside');
91
+ outside.focus();
92
+ expect(outside).toHaveFocus();
93
+ expect(container.querySelector('[data-testid="dialog"]')).toBeNull();
94
+ });
95
+
96
+ it('wraps Tab from the last focusable back to the first', async () => {
97
+ const user = userEvent.setup();
98
+ render(<TestDialog enabled />);
99
+ const close = screen.getByTestId('close');
100
+ const action = screen.getByTestId('action');
101
+
102
+ expect(close).toHaveFocus();
103
+ action.focus();
104
+ expect(action).toHaveFocus();
105
+
106
+ await user.tab();
107
+ expect(close).toHaveFocus();
108
+ });
109
+
110
+ it('wraps Shift+Tab from the first focusable back to the last', async () => {
111
+ const user = userEvent.setup();
112
+ render(<TestDialog enabled />);
113
+ const close = screen.getByTestId('close');
114
+ const action = screen.getByTestId('action');
115
+
116
+ expect(close).toHaveFocus();
117
+
118
+ await user.tab({ shift: true });
119
+ expect(action).toHaveFocus();
120
+ });
121
+
122
+ it('restores focus to the previously focused element on disable', () => {
123
+ const Wrapper = () => {
124
+ const [open, setOpen] = useState(false);
125
+ return (
126
+ <div>
127
+ <button
128
+ type="button"
129
+ data-testid="trigger"
130
+ onClick={() => setOpen(true)}
131
+ >
132
+ Open
133
+ </button>
134
+ {open && (
135
+ <button
136
+ type="button"
137
+ data-testid="programmatic-close"
138
+ onClick={() => setOpen(false)}
139
+ >
140
+ Programmatic close
141
+ </button>
142
+ )}
143
+ <TestDialog enabled={open} />
144
+ </div>
145
+ );
146
+ };
147
+ render(<Wrapper />);
148
+ const trigger = screen.getByTestId('trigger');
149
+ trigger.focus();
150
+ expect(trigger).toHaveFocus();
151
+
152
+ act(() => {
153
+ trigger.click();
154
+ });
155
+ expect(screen.getByTestId('close')).toHaveFocus();
156
+
157
+ act(() => {
158
+ screen.getByTestId('programmatic-close').click();
159
+ });
160
+ expect(trigger).toHaveFocus();
161
+ });
162
+
163
+ it('waits for lazy content and focuses the first focusable when it appears', async () => {
164
+ render(<LazyContentDialog />);
165
+ const load = screen.getByTestId('load');
166
+ expect(load).not.toHaveFocus();
167
+
168
+ await act(async () => {
169
+ load.click();
170
+ });
171
+
172
+ expect(screen.getByTestId('close')).toHaveFocus();
173
+ });
174
+
175
+ it('keeps focus moving freely inside the trap when the container lives in a shadow root', () => {
176
+ // Build the host + shadow root manually so we can mount our React tree inside it.
177
+ const host = document.createElement('div');
178
+ document.body.appendChild(host);
179
+ const shadow = host.attachShadow({ mode: 'open' });
180
+ const mountPoint = document.createElement('div');
181
+ shadow.appendChild(mountPoint);
182
+
183
+ const ShadowDialog = () => {
184
+ const ref = useRef<HTMLDivElement>(null);
185
+ useFocusTrap(ref, true);
186
+ return (
187
+ <div
188
+ ref={ref}
189
+ data-testid="shadow-dialog"
190
+ >
191
+ <button
192
+ type="button"
193
+ data-testid="shadow-close"
194
+ >
195
+ Close
196
+ </button>
197
+ <button
198
+ type="button"
199
+ data-testid="shadow-action"
200
+ >
201
+ Action
202
+ </button>
203
+ </div>
204
+ );
205
+ };
206
+
207
+ // Render React into the shadow root mount point via createRoot to make sure
208
+ // we are exercising the shadow-DOM-aware code paths.
209
+ const reactRoot = createRoot(mountPoint);
210
+ act(() => {
211
+ reactRoot.render(<ShadowDialog />);
212
+ });
213
+
214
+ const close = shadow.querySelector('[data-testid="shadow-close"]') as HTMLButtonElement;
215
+ const action = shadow.querySelector('[data-testid="shadow-action"]') as HTMLButtonElement;
216
+ expect(shadow.activeElement).toBe(close);
217
+
218
+ // Manually move focus to the action button (mimicking a Tab away from first)
219
+ // and dispatch a non-shift Tab. The handler should NOT preventDefault because
220
+ // the active element is inside the container (verified via shadow root).
221
+ action.focus();
222
+ expect(shadow.activeElement).toBe(action);
223
+
224
+ const evt = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true });
225
+ act(() => {
226
+ action.dispatchEvent(evt);
227
+ });
228
+ // Since action is `last`, the trap wraps focus back to `close`.
229
+ expect(shadow.activeElement).toBe(close);
230
+
231
+ act(() => {
232
+ reactRoot.unmount();
233
+ });
234
+ host.remove();
235
+ });
236
+ });
@@ -0,0 +1 @@
1
+ export * from './useFocusTrap';
@@ -0,0 +1,125 @@
1
+ import { RefObject, useEffect } from 'react';
2
+
3
+ const FOCUSABLE_SELECTOR = [
4
+ 'a[href]',
5
+ 'button:not([disabled])',
6
+ 'input:not([disabled]):not([type="hidden"])',
7
+ 'textarea:not([disabled])',
8
+ 'select:not([disabled])',
9
+ '[tabindex]:not([tabindex="-1"])',
10
+ ].join(',');
11
+
12
+ const isElementVisible = (el: HTMLElement): boolean => {
13
+ if (el.hasAttribute('hidden')) return false;
14
+ if (typeof window === 'undefined') return true;
15
+ const style = window.getComputedStyle(el);
16
+ return style.display !== 'none' && style.visibility !== 'hidden';
17
+ };
18
+
19
+ const getFocusableElements = (container: HTMLElement): HTMLElement[] =>
20
+ Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(isElementVisible);
21
+
22
+ /**
23
+ * Returns the deepest active element across (possibly nested) shadow roots,
24
+ * starting from the document. Plain `document.activeElement` returns the shadow
25
+ * host when focus is inside an open shadow root, so we recurse into shadowRoot
26
+ * to find the truly focused element.
27
+ */
28
+ const getDeepActiveElement = (): HTMLElement | null => {
29
+ if (typeof document === 'undefined') return null;
30
+ let active = document.activeElement as HTMLElement | null;
31
+ while (active && active.shadowRoot && active.shadowRoot.activeElement) {
32
+ active = active.shadowRoot.activeElement as HTMLElement;
33
+ }
34
+ return active;
35
+ };
36
+
37
+ /**
38
+ * Trap keyboard focus inside `containerRef` while `enabled` is true.
39
+ *
40
+ * On enable: moves focus to the first focusable element inside the container.
41
+ * If the container is empty (e.g. lazy-loaded content), waits for DOM mutations
42
+ * and focuses the first focusable as soon as it appears.
43
+ *
44
+ * While enabled: Tab and Shift+Tab wrap around so focus stays inside.
45
+ *
46
+ * On disable: restores focus to the previously focused element if it is still
47
+ * present in the document.
48
+ */
49
+ export const useFocusTrap = (containerRef: RefObject<HTMLElement | null>, enabled: boolean) => {
50
+ useEffect(() => {
51
+ if (!enabled || typeof document === 'undefined') return undefined;
52
+
53
+ const container = containerRef.current;
54
+ if (!container) return undefined;
55
+
56
+ // The container may live inside a Shadow DOM (the injection bundle hosts
57
+ // the chat that way). Use the container's root so containment checks and
58
+ // active-element lookups work both in the light DOM and inside shadow roots.
59
+ const root = container.getRootNode() as Document | ShadowRoot;
60
+ const previouslyFocused = getDeepActiveElement();
61
+
62
+ const getActiveElement = (): HTMLElement | null => root.activeElement as HTMLElement | null;
63
+
64
+ const focusFirst = (): boolean => {
65
+ const focusables = getFocusableElements(container);
66
+ const first = focusables[0];
67
+ if (first) {
68
+ first.focus();
69
+ return true;
70
+ }
71
+ return false;
72
+ };
73
+
74
+ let observer: MutationObserver | null = null;
75
+ if (!focusFirst()) {
76
+ observer = new MutationObserver(() => {
77
+ if (focusFirst()) {
78
+ observer?.disconnect();
79
+ observer = null;
80
+ }
81
+ });
82
+ observer.observe(container, { childList: true, subtree: true });
83
+ }
84
+
85
+ const handleKeyDown = (e: KeyboardEvent) => {
86
+ if (e.key !== 'Tab') return;
87
+
88
+ const focusables = getFocusableElements(container);
89
+ if (focusables.length === 0) {
90
+ e.preventDefault();
91
+ return;
92
+ }
93
+
94
+ const first = focusables[0];
95
+ const last = focusables[focusables.length - 1];
96
+ const active = getActiveElement();
97
+ const isInsideContainer = active ? container.contains(active) : false;
98
+
99
+ if (e.shiftKey) {
100
+ if (active === first || !isInsideContainer) {
101
+ e.preventDefault();
102
+ last.focus();
103
+ }
104
+ } else if (active === last || !isInsideContainer) {
105
+ e.preventDefault();
106
+ first.focus();
107
+ }
108
+ };
109
+
110
+ container.addEventListener('keydown', handleKeyDown);
111
+
112
+ return () => {
113
+ observer?.disconnect();
114
+ container.removeEventListener('keydown', handleKeyDown);
115
+
116
+ if (
117
+ previouslyFocused &&
118
+ typeof previouslyFocused.focus === 'function' &&
119
+ previouslyFocused.isConnected
120
+ ) {
121
+ previouslyFocused.focus();
122
+ }
123
+ };
124
+ }, [enabled, containerRef]);
125
+ };
@@ -698,4 +698,99 @@ describe('AmplitudeService', () => {
698
698
  });
699
699
  });
700
700
  });
701
+
702
+ describe('Experiment-assignment props', () => {
703
+ it('emits experiment.{ns}.* keys as null when no assignments are configured', async () => {
704
+ const service = createService();
705
+ await service.trackEvent({
706
+ eventName: SpiffyMetricsEventName.ChatUserMessageInput,
707
+ eventProps: {},
708
+ });
709
+ const props = mockTrack.mock.calls[0]?.[1] ?? {};
710
+ expect(props['experiment.ml.layer_name']).toBeNull();
711
+ expect(props['experiment.ml.experiment_name']).toBeNull();
712
+ expect(props['experiment.ml.group_name']).toBeNull();
713
+ expect(props['experiment.ml.rule_id']).toBeNull();
714
+ expect(props['experiment.merchant.layer_name']).toBeNull();
715
+ expect(props['experiment.merchant.experiment_name']).toBeNull();
716
+ expect(props['experiment.merchant.group_name']).toBeNull();
717
+ expect(props['experiment.merchant.rule_id']).toBeNull();
718
+ });
719
+
720
+ it('populates experiment.{ns}.* keys when assignments are passed at construction', async () => {
721
+ const service = createService({
722
+ experimentAssignments: [
723
+ {
724
+ layer_name: 'ml_test_store',
725
+ namespace: 'ml',
726
+ allocated_experiment_name: 'automated_testing_ml_experiment',
727
+ group_name: 'ML Config Variation',
728
+ },
729
+ {
730
+ layer_name: 'merchant_test_store',
731
+ namespace: 'merchant',
732
+ allocated_experiment_name: 'automated_testing_merchant_experiment',
733
+ group_name: 'Merchant Config Variation',
734
+ },
735
+ ],
736
+ });
737
+ await service.trackEvent({
738
+ eventName: SpiffyMetricsEventName.ChatUserMessageInput,
739
+ eventProps: {},
740
+ });
741
+ const props = mockTrack.mock.calls[0]?.[1] ?? {};
742
+ expect(props['experiment.ml.layer_name']).toBe('ml_test_store');
743
+ expect(props['experiment.ml.experiment_name']).toBe('automated_testing_ml_experiment');
744
+ expect(props['experiment.ml.group_name']).toBe('ML Config Variation');
745
+ expect(props['experiment.merchant.layer_name']).toBe('merchant_test_store');
746
+ expect(props['experiment.merchant.experiment_name']).toBe(
747
+ 'automated_testing_merchant_experiment',
748
+ );
749
+ expect(props['experiment.merchant.group_name']).toBe('Merchant Config Variation');
750
+ // rule_id reserved — backend doesn't surface it.
751
+ expect(props['experiment.ml.rule_id']).toBeNull();
752
+ expect(props['experiment.merchant.rule_id']).toBeNull();
753
+ });
754
+
755
+ it('emits the unassigned namespace as null when only one is assigned', async () => {
756
+ const service = createService({
757
+ experimentAssignments: [
758
+ {
759
+ layer_name: 'ml_test_store',
760
+ namespace: 'ml',
761
+ allocated_experiment_name: 'ml_only',
762
+ group_name: 'ML Only',
763
+ },
764
+ ],
765
+ });
766
+ await service.trackEvent({
767
+ eventName: SpiffyMetricsEventName.ChatUserMessageInput,
768
+ eventProps: {},
769
+ });
770
+ const props = mockTrack.mock.calls[0]?.[1] ?? {};
771
+ expect(props['experiment.ml.layer_name']).toBe('ml_test_store');
772
+ expect(props['experiment.merchant.layer_name']).toBeNull();
773
+ });
774
+
775
+ it('drops malformed assignments missing layer_name or namespace', async () => {
776
+ const service = createService({
777
+ experimentAssignments: [
778
+ // Missing layer_name → dropped silently. (Belt-and-braces: pymono's
779
+ // tests enforce the wire shape, but if a field ever goes null we'd
780
+ // rather emit `null` props than crash.)
781
+ {
782
+ namespace: 'ml',
783
+ allocated_experiment_name: 'orphan',
784
+ },
785
+ ],
786
+ });
787
+ await service.trackEvent({
788
+ eventName: SpiffyMetricsEventName.ChatUserMessageInput,
789
+ eventProps: {},
790
+ });
791
+ const props = mockTrack.mock.calls[0]?.[1] ?? {};
792
+ expect(props['experiment.ml.layer_name']).toBeNull();
793
+ expect(props['experiment.ml.experiment_name']).toBeNull();
794
+ });
795
+ });
701
796
  });
@@ -11,6 +11,7 @@ import type {
11
11
  import { FeatureFlagService } from 'src/contexts/featureFlagServiceContext/featureFlagServiceContext';
12
12
  import { WidgetTypeV3 } from 'src/contexts/typesV3';
13
13
  import { ChatElementDisplayLocationV3 } from 'src/application/models/chatElementDisplayLocationV3';
14
+ import { ExperimentNamespace, OrgConfigExperimentAssignment } from 'src/application/models';
14
15
  import { projectToGA4 } from '../ga4ProjectionService/ga4ProjectionService';
15
16
  import { EnviveMetricsEventName, SpiffyMetricsEventName } from './eventNames';
16
17
 
@@ -39,8 +40,37 @@ export interface AmplitudeServiceConfig {
39
40
  enviveOn: boolean;
40
41
  enabledFeatures?: Record<string, boolean>;
41
42
  getLocalStorageItem: null | ((key: string) => string | null);
43
+ // Captured at construction. Drives `experiment.{ns}.*` event-property
44
+ // enrichment. Sourced from /v1/org/config when the `use_unified_config`
45
+ // gate is on; otherwise omitted (props emit as null).
46
+ experimentAssignments?: OrgConfigExperimentAssignment[];
42
47
  }
43
48
 
49
+ // Namespaces emitted on every event with constant keys, so the BigQuery
50
+ // schema is stable regardless of which (if any) experiment is running.
51
+ // Currently `ml` + `merchant`; matches `ExperimentNamespace`.
52
+ const EXPERIMENT_NAMESPACES: ExperimentNamespace[] = ['ml', 'merchant'];
53
+
54
+ const buildExperimentProps = (
55
+ assignments: OrgConfigExperimentAssignment[] | undefined,
56
+ ): Record<string, unknown> => {
57
+ const byNamespace = new Map<ExperimentNamespace, OrgConfigExperimentAssignment>();
58
+ (assignments ?? []).forEach(a => {
59
+ if (a.layer_name != null && a.namespace != null) {
60
+ byNamespace.set(a.namespace, a);
61
+ }
62
+ });
63
+ return EXPERIMENT_NAMESPACES.reduce<Record<string, unknown>>((acc, ns) => {
64
+ const a = byNamespace.get(ns);
65
+ acc[`experiment.${ns}.layer_name`] = a?.layer_name ?? null;
66
+ acc[`experiment.${ns}.experiment_name`] = a?.allocated_experiment_name ?? null;
67
+ acc[`experiment.${ns}.group_name`] = a?.group_name ?? null;
68
+ // `rule_id` reserved — backend doesn't currently surface it.
69
+ acc[`experiment.${ns}.rule_id`] = null;
70
+ return acc;
71
+ }, {});
72
+ };
73
+
44
74
  export class AmplitudeService {
45
75
  private amplitudeClient: BrowserClient | undefined;
46
76
 
@@ -91,7 +121,7 @@ export class AmplitudeService {
91
121
  return acc;
92
122
  }, {})
93
123
  : {};
94
- const experimentProps = {}; // No direct equivalent for experiments in EnviveConfig yet
124
+ const experimentProps = buildExperimentProps(this.config.experimentAssignments);
95
125
 
96
126
  const environmentProps = {
97
127
  'environment.execution_context': 'bundle',
@@ -1,6 +1,10 @@
1
- import { fetchGraphQLConfig, DEFAULT_PAGE_VARIANTS } from '../fetchGraphQLConfig';
2
1
  import { mockV3ColorsConfig, mockV3FrontendConfig } from 'src/contexts/graphqlContext/mockV3Config';
3
2
  import { FloatingButtonLocation } from '@envive-ai/react-toolkit-v3/FloatingButton';
3
+ import {
4
+ DEFAULT_PAGE_VARIANTS,
5
+ fetchGraphQLConfig,
6
+ fetchUnifiedGraphQLConfig,
7
+ } from '../fetchGraphQLConfig';
4
8
 
5
9
  const { MockLogger } = vi.hoisted(() => {
6
10
  const MockLogger = vi.fn(function () {
@@ -423,3 +427,105 @@ describe('fetchGraphQLConfig', () => {
423
427
  });
424
428
  });
425
429
  });
430
+
431
+ describe('fetchUnifiedGraphQLConfig', () => {
432
+ function makeUnifiedOkFetch(v3RootConfigValues: unknown, resolution?: unknown) {
433
+ mockFetch.mockResolvedValueOnce({
434
+ ok: true,
435
+ json: () =>
436
+ Promise.resolve({
437
+ data: {
438
+ me: {
439
+ getWidgetConfig: {
440
+ productsConfig: {
441
+ v_three_config: { values: v3RootConfigValues },
442
+ },
443
+ resolution,
444
+ },
445
+ },
446
+ },
447
+ }),
448
+ });
449
+ }
450
+
451
+ it('issues a getWidgetConfig query (not getProductsConfigByVersion) and forwards userId', async () => {
452
+ makeUnifiedOkFetch(null);
453
+
454
+ await fetchUnifiedGraphQLConfig('https://api.example.com', 'key', 'user_1');
455
+
456
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
457
+ expect(body.query).toContain('getWidgetConfig');
458
+ expect(body.query).not.toContain('getProductsConfigByVersion');
459
+ // userId is required by the schema; omitting it 400s. Regression guard.
460
+ // version is absent without a URL-param override so the resolver uses
461
+ // Statsig bucketing (passing 'deployed' here would bypass Statsig).
462
+ expect(body.variables).toEqual({ userId: 'user_1' });
463
+ });
464
+
465
+ it('threads a spiffy_config_version URL param through as the version variable', async () => {
466
+ window.history.replaceState({}, '', '/?spiffy_config_version=1.2.3');
467
+ try {
468
+ makeUnifiedOkFetch(null);
469
+
470
+ await fetchUnifiedGraphQLConfig('https://api.example.com', 'key', 'user_1');
471
+
472
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
473
+ expect(body.variables).toEqual({ userId: 'user_1', version: '1.2.3' });
474
+ } finally {
475
+ window.history.replaceState({}, '', '/');
476
+ }
477
+ });
478
+
479
+ it('parses productsConfig into the same shape as fetchGraphQLConfig', async () => {
480
+ makeUnifiedOkFetch(baseV3Config);
481
+
482
+ const result = await fetchUnifiedGraphQLConfig('https://api.example.com', 'key', 'user_1');
483
+
484
+ expect(result.colorsConfig).toBeDefined();
485
+ expect(result.frontendConfig).toBeDefined();
486
+ expect(result.orgPageConfig).toBeDefined();
487
+ });
488
+
489
+ it('returns resolution metadata alongside the config so callers can thread baseVersion into inference', async () => {
490
+ makeUnifiedOkFetch(baseV3Config, {
491
+ baseVersion: '1.0.670',
492
+ baseVersionSource: 'merchant_layer',
493
+ });
494
+
495
+ const result = await fetchUnifiedGraphQLConfig('https://api.example.com', 'key', 'user_1');
496
+
497
+ expect(result.resolution).toEqual({
498
+ baseVersion: '1.0.670',
499
+ baseVersionSource: 'merchant_layer',
500
+ });
501
+ });
502
+
503
+ it('selects resolution fields in the query so the server can fill them', async () => {
504
+ makeUnifiedOkFetch(null);
505
+
506
+ await fetchUnifiedGraphQLConfig('https://api.example.com', 'key', 'user_1');
507
+
508
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
509
+ expect(body.query).toContain('resolution');
510
+ expect(body.query).toContain('baseVersion');
511
+ expect(body.query).toContain('baseVersionSource');
512
+ });
513
+
514
+ it('falls back to DEFAULT_PAGE_VARIANTS when productsConfig has no page_variants array', async () => {
515
+ // Reuses the no-colors fallback path: when productsConfig is missing
516
+ // colors, DEFAULT_PAGE_VARIANTS is returned as the orgPageConfig.
517
+ makeUnifiedOkFetch(null);
518
+
519
+ const result = await fetchUnifiedGraphQLConfig('https://api.example.com', 'key', 'user_1');
520
+
521
+ expect(result.orgPageConfig?.pageVariants).toEqual(DEFAULT_PAGE_VARIANTS);
522
+ });
523
+
524
+ it('returns empty config on network failure', async () => {
525
+ mockFetch.mockRejectedValueOnce(new Error('boom'));
526
+
527
+ const result = await fetchUnifiedGraphQLConfig('https://api.example.com', 'key', 'user_1');
528
+
529
+ expect(result).toEqual({ colorsConfig: undefined, frontendConfig: undefined });
530
+ });
531
+ });