@envive-ai/react-widgets-v3 0.3.62 → 0.3.64

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/debug/MessageContent.cjs +1 -1
  2. package/dist/debug/MessageContent.js +1 -1
  3. package/dist/debug/chatEmbed.cjs +1 -1
  4. package/dist/debug/chatEmbed.js +1 -1
  5. package/dist/debug/reportIssue.cjs +1 -1
  6. package/dist/debug/reportIssue.js +1 -1
  7. package/dist/hocs/withBaseWidget/types.d.cts +7 -0
  8. package/dist/hocs/withBaseWidget/types.d.ts +7 -0
  9. package/dist/hocs/withBaseWidget/withBaseWidget.cjs +30 -3
  10. package/dist/hocs/withBaseWidget/withBaseWidget.d.cts +2 -2
  11. package/dist/hocs/withBaseWidget/withBaseWidget.d.ts +2 -2
  12. package/dist/hocs/withBaseWidget/withBaseWidget.js +30 -3
  13. package/dist/widgets/ChatPreviewComparisonWidget/ChatPreviewComparisonWidget.cjs +2 -2
  14. package/dist/widgets/ChatPreviewComparisonWidget/ChatPreviewComparisonWidget.d.cts +3 -3
  15. package/dist/widgets/ChatPreviewComparisonWidget/ChatPreviewComparisonWidget.d.ts +3 -3
  16. package/dist/widgets/ChatPreviewComparisonWidget/ChatPreviewComparisonWidget.js +2 -2
  17. package/dist/widgets/ChatPreviewLoadingWidget/ChatPreviewLoadingWidget.cjs +2 -2
  18. package/dist/widgets/ChatPreviewLoadingWidget/ChatPreviewLoadingWidget.d.cts +3 -3
  19. package/dist/widgets/ChatPreviewLoadingWidget/ChatPreviewLoadingWidget.d.ts +3 -3
  20. package/dist/widgets/ChatPreviewLoadingWidget/ChatPreviewLoadingWidget.js +2 -2
  21. package/dist/widgets/ChatPreviewWidget/ChatPreviewWidget.cjs +2 -2
  22. package/dist/widgets/ChatPreviewWidget/ChatPreviewWidget.d.ts +3 -3
  23. package/dist/widgets/ChatPreviewWidget/ChatPreviewWidget.js +2 -2
  24. package/dist/widgets/FloatingChatWidget/FloatingChatWidget.cjs +2 -2
  25. package/dist/widgets/FloatingChatWidget/FloatingChatWidget.d.cts +2 -2
  26. package/dist/widgets/FloatingChatWidget/FloatingChatWidget.d.ts +2 -2
  27. package/dist/widgets/FloatingChatWidget/FloatingChatWidget.js +2 -2
  28. package/dist/widgets/FloatingChatWidget/hooks/useAutoPopup.cjs +2 -2
  29. package/dist/widgets/FloatingChatWidget/hooks/useAutoPopup.js +2 -2
  30. package/dist/widgets/FloatingChatWidget/hooks/useFloatingButtonVisibility.cjs +1 -1
  31. package/dist/widgets/FloatingChatWidget/hooks/useFloatingButtonVisibility.js +1 -1
  32. package/dist/widgets/FullPageSalesAgentWidget/FullPageSalesAgentWidget.cjs +2 -2
  33. package/dist/widgets/FullPageSalesAgentWidget/FullPageSalesAgentWidget.d.cts +2 -2
  34. package/dist/widgets/FullPageSalesAgentWidget/FullPageSalesAgentWidget.d.ts +2 -2
  35. package/dist/widgets/FullPageSalesAgentWidget/FullPageSalesAgentWidget.js +2 -2
  36. package/dist/widgets/ProductCardWidget/ProductCardWidget.cjs +8 -18
  37. package/dist/widgets/ProductCardWidget/ProductCardWidget.d.cts +2 -2
  38. package/dist/widgets/ProductCardWidget/ProductCardWidget.d.ts +2 -2
  39. package/dist/widgets/ProductCardWidget/ProductCardWidget.js +8 -18
  40. package/dist/widgets/PromptButtonCarouselWithImageWidget/PromptButtonCarouselWithImageWidget.cjs +2 -2
  41. package/dist/widgets/PromptButtonCarouselWithImageWidget/PromptButtonCarouselWithImageWidget.d.cts +3 -3
  42. package/dist/widgets/PromptButtonCarouselWithImageWidget/PromptButtonCarouselWithImageWidget.d.ts +3 -3
  43. package/dist/widgets/PromptButtonCarouselWithImageWidget/PromptButtonCarouselWithImageWidget.js +2 -2
  44. package/dist/widgets/PromptCarouselWidget/PromptCarouselWidget.cjs +1 -1
  45. package/dist/widgets/PromptCarouselWidget/PromptCarouselWidget.d.cts +2 -2
  46. package/dist/widgets/PromptCarouselWidget/PromptCarouselWidget.d.ts +2 -2
  47. package/dist/widgets/PromptCarouselWidget/PromptCarouselWidget.js +1 -1
  48. package/dist/widgets/SocialProofFlowWidget/SocialProofFlowWidget.d.cts +2 -2
  49. package/dist/widgets/SocialProofFlowWidget/SocialProofFlowWidget.d.ts +2 -2
  50. package/dist/widgets/SocialProofWidget/SocialProofWidget.cjs +2 -2
  51. package/dist/widgets/SocialProofWidget/SocialProofWidget.d.cts +3 -3
  52. package/dist/widgets/SocialProofWidget/SocialProofWidget.js +2 -2
  53. package/dist/widgets/TitledPromptCarouselWidget/TitledPromptCarouselWidget.cjs +1 -1
  54. package/dist/widgets/TitledPromptCarouselWidget/TitledPromptCarouselWidget.d.cts +2 -2
  55. package/dist/widgets/TitledPromptCarouselWidget/TitledPromptCarouselWidget.d.ts +2 -2
  56. package/dist/widgets/TitledPromptCarouselWidget/TitledPromptCarouselWidget.js +1 -1
  57. package/dist/widgets/TypingAnimationFlowWidget/TypingAnimationFlowWidget.d.ts +2 -2
  58. package/dist/widgets/TypingAnimationWidget/TypingAnimationWidget.cjs +2 -2
  59. package/dist/widgets/TypingAnimationWidget/TypingAnimationWidget.d.cts +3 -3
  60. package/dist/widgets/TypingAnimationWidget/TypingAnimationWidget.d.ts +3 -3
  61. package/dist/widgets/TypingAnimationWidget/TypingAnimationWidget.js +2 -2
  62. package/dist/widgets/utils/functions.cjs +1 -1
  63. package/dist/widgets/utils/functions.js +1 -1
  64. package/package.json +1 -1
  65. package/src/hocs/withBaseWidget/__tests__/withBaseWidget.test.tsx +51 -0
  66. package/src/hocs/withBaseWidget/types.ts +7 -0
  67. package/src/hocs/withBaseWidget/withBaseWidget.tsx +56 -6
  68. package/src/stories/FullPageSalesAgentWidget.stories.tsx +64 -0
  69. package/src/widgets/ProductCardWidget/ProductCardWidget.tsx +13 -18
  70. package/src/widgets/ProductCardWidget/__tests__/ProductCardWidget.test.tsx +75 -2
@@ -79,6 +79,15 @@ vi.mock('@envive-ai/react-hooks/hooks/TrackComponentVisibleEvent', () => ({
79
79
  useTrackComponentVisibleEvent: (...args: unknown[]) => mockUseTrackComponentVisibleEvent(...args),
80
80
  }));
81
81
 
82
+ vi.mock('@envive-ai/react-hooks/contexts/featureFlagServiceContext', () => ({
83
+ useFeatureFlagService: () => ({ featureFlagService: undefined }),
84
+ }));
85
+
86
+ // Diagnostics pulls its own amplitude context internally; stub it here like the other sub-hooks.
87
+ vi.mock('@envive-ai/react-hooks/hooks/WidgetLoadDiagnostics', () => ({
88
+ useWidgetLoadDiagnostics: vi.fn(),
89
+ }));
90
+
82
91
  // Test widget component
83
92
  interface TestWidgetProps extends BaseWidgetProps {
84
93
  testId?: string;
@@ -153,6 +162,7 @@ describe('withBaseWidget', () => {
153
162
  {
154
163
  widget_config_id: 'test-config-1',
155
164
  widget_type: WidgetTypeV3.PromptCarouselV3,
165
+ load_id: expect.any(String),
156
166
  },
157
167
  '0px',
158
168
  true,
@@ -173,6 +183,7 @@ describe('withBaseWidget', () => {
173
183
  {
174
184
  widget_config_id: 'test-config-2',
175
185
  widget_type: WidgetTypeV3.ChatPreviewV3,
186
+ load_id: expect.any(String),
176
187
  },
177
188
  '100px',
178
189
  true,
@@ -198,6 +209,7 @@ describe('withBaseWidget', () => {
198
209
  {
199
210
  widget_config_id: 'test-config-4',
200
211
  widget_type: WidgetTypeV3.SocialProofV3,
212
+ load_id: expect.any(String),
201
213
  custom_prop: 'custom_value',
202
214
  another_prop: 123,
203
215
  },
@@ -408,6 +420,42 @@ describe('withBaseWidget', () => {
408
420
  });
409
421
  });
410
422
 
423
+ it('stays loading until its OWN hardcopy settles even after the shared flag clears', async () => {
424
+ // Complements INTERRUPT-424: that fix stops a settled widget being reverted when a
425
+ // shared flag flips TRUE. This is the mirror case — another widget's hardcopy finishing
426
+ // flips the single shared HardcopyProvider boolean to FALSE while THIS widget's own
427
+ // hardcopy is still in flight. isLoading must track this widget's own requests, not
428
+ // follow the shared flag down and paint empty content early.
429
+ mockHardcopyLoading = false;
430
+ mockPageLoading = false;
431
+ mockWidgetConfigLoading = false;
432
+ mockUiConfigLoading = false;
433
+ let resolveHardcopy!: (value: HardcopyResponse) => void;
434
+ mockGetHardcopy.mockReturnValue(
435
+ new Promise<HardcopyResponse>(resolve => {
436
+ resolveHardcopy = resolve;
437
+ }),
438
+ );
439
+
440
+ const WrappedWidget = withBaseWidget(TestWidget);
441
+ render(
442
+ <WrappedWidget
443
+ widgetConfigId="test-config-own-hardcopy"
444
+ widgetType={WidgetTypeV3.PromptCarouselV3}
445
+ />,
446
+ );
447
+
448
+ await waitFor(() => {
449
+ expect(screen.getByTestId('is-loading')).toHaveTextContent('true');
450
+ });
451
+
452
+ resolveHardcopy({ responseId: 'r', language: 'en', values: {} });
453
+
454
+ await waitFor(() => {
455
+ expect(screen.getByTestId('is-loading')).toHaveTextContent('false');
456
+ });
457
+ });
458
+
411
459
  it.each([
412
460
  [
413
461
  'widgetConfig',
@@ -611,6 +659,7 @@ describe('withBaseWidget', () => {
611
659
  {
612
660
  widget_config_id: 'test-config-integration',
613
661
  widget_type: WidgetTypeV3.PromptCarouselV3,
662
+ load_id: expect.any(String),
614
663
  test_integration: true,
615
664
  },
616
665
  '0px',
@@ -686,6 +735,7 @@ describe('withBaseWidget', () => {
686
735
  {
687
736
  widget_config_id: 'widget-1',
688
737
  widget_type: WidgetTypeV3.PromptCarouselV3,
738
+ load_id: expect.any(String),
689
739
  },
690
740
  '0px',
691
741
  true,
@@ -695,6 +745,7 @@ describe('withBaseWidget', () => {
695
745
  {
696
746
  widget_config_id: 'widget-2',
697
747
  widget_type: WidgetTypeV3.ChatPreviewV3,
748
+ load_id: expect.any(String),
698
749
  },
699
750
  '0px',
700
751
  true,
@@ -15,6 +15,13 @@ export interface BaseWidgetProps {
15
15
  uiConfig?: OrgUIConfigV3;
16
16
  isLoading?: boolean;
17
17
  isHardcopyLoading?: boolean;
18
+ /**
19
+ * True once THIS widget's own hardcopy request has settled (resolved or errored),
20
+ * regardless of whether it produced content. Lets a widget tell "hardcopy not received
21
+ * yet" apart from "hardcopy finished with no content" so it can leave a loading placeholder
22
+ * after a failed fetch instead of showing it forever.
23
+ */
24
+ isHardcopySettled?: boolean;
18
25
  isPageLoading?: boolean;
19
26
  isWidgetConfigLoading?: boolean;
20
27
  isUiConfigLoading?: boolean;
@@ -8,6 +8,9 @@ import { usePage } from '@envive-ai/react-hooks/contexts/pageContext';
8
8
  import { useWidgetConfig } from '@envive-ai/react-hooks/contexts/widgetConfigContext';
9
9
  import { OrgUIConfigV3, WidgetConfigV3 } from '@envive-ai/react-hooks/contexts/typesV3';
10
10
  import { useUiConfig } from '@envive-ai/react-hooks/contexts/uiConfigContext';
11
+ import { FeatureGates } from '@envive-ai/react-hooks/application/models';
12
+ import { useFeatureFlagService } from '@envive-ai/react-hooks/contexts/featureFlagServiceContext';
13
+ import { useWidgetLoadDiagnostics } from '@envive-ai/react-hooks/hooks/WidgetLoadDiagnostics';
11
14
  import { BaseWidgetProps, WithBaseWidgetOptions } from './types';
12
15
 
13
16
  const logger = new Logger('withBaseWidget');
@@ -51,16 +54,28 @@ export function withBaseWidget<P extends BaseWidgetProps>(
51
54
  const [uiConfigSettled, setUiConfigSettled] = useState(false);
52
55
  const ownRequestsSettled = hardcopySettled && widgetConfigSettled && uiConfigSettled;
53
56
 
54
- const isLoading =
55
- !ownRequestsSettled &&
56
- (isHardcopyLoading || isPageLoading || isWidgetConfigLoading || isUiConfigLoading);
57
+ // --- Widget load diagnostics (see useWidgetLoadDiagnostics) ---
58
+ // load_id correlates this widget's mount / rendered / outcome / stall events for one load.
59
+ const loadId = useRef(
60
+ `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`,
61
+ ).current;
62
+ const { featureFlagService } = useFeatureFlagService();
63
+ // Diagnostics are ON by default; the gate is a kill switch (default false → enabled) so we
64
+ // can gather a cross-merchant baseline. Flip it true (globally or per-org) via Statsig to stop.
65
+ const diagnosticsEnabled = !featureFlagService?.isFeatureGateEnabled(
66
+ FeatureGates.IsWidgetDiagnosticsDisabled,
67
+ );
68
+ // Reason this widget's own hardcopy request rejected (distinct from a served static fallback).
69
+ const hardcopyErrorRef = useRef<string | undefined>(undefined);
57
70
 
58
71
  const visibilityEventProps = {
59
72
  widget_config_id: widgetConfigId,
60
73
  widget_type: widgetType,
74
+ // Threaded onto [Envive] Widget Rendered so it can be joined to the diagnostics events.
75
+ load_id: loadId,
61
76
  ...options?.visibilityEventProps,
62
77
  };
63
- const { isVisible } = useTrackComponentVisibleEvent(
78
+ const { isVisible, visibilitySource } = useTrackComponentVisibleEvent(
64
79
  widgetRef,
65
80
  visibilityEventProps,
66
81
  options?.rootMargin || '0px',
@@ -74,6 +89,15 @@ export function withBaseWidget<P extends BaseWidgetProps>(
74
89
  return isVisible && !!widgetConfigId && !!widgetType;
75
90
  }, [isVisible, widgetConfigId, widgetType]);
76
91
 
92
+ // A widget is loading only while its OWN requests are in flight. Deliberately NOT
93
+ // derived from the shared provider isLoading flags (isHardcopyLoading, isPageLoading,
94
+ // …): each of those is a single boolean shared by every widget, so any widget issuing a
95
+ // fetch flips them for all of them — which let another widget's activity pin this one in
96
+ // (or stuck in) its loading state even after its own requests had settled. `startLoading`
97
+ // gates the leading edge so a not-yet-started widget doesn't report loading off the
98
+ // still-false latches.
99
+ const isLoading = startLoading && !ownRequestsSettled;
100
+
77
101
  // Watchdog: a widget that is still in its loading state this long after it started
78
102
  // fetching is stuck (or on a very slow connection). Log a console error so session
79
103
  // tools that capture the console (Quantum Metric, Datadog RUM) can count and segment
@@ -120,8 +144,14 @@ export function withBaseWidget<P extends BaseWidgetProps>(
120
144
  hardcopyFetchedRef.current = true;
121
145
  getHardcopy({ widgetType, userEvent })
122
146
  .then(setHardcopyContent)
123
- .catch(() => {
124
- // Silently handle errors - widget will render without hardcopy content
147
+ .catch(error => {
148
+ // Silently handle errors - widget will render without hardcopy content. Record the
149
+ // reason for the load-outcome diagnostic (the served static-fallback path resolves
150
+ // rather than rejects, so reaching here means no content at all).
151
+ hardcopyErrorRef.current =
152
+ error instanceof Error && error.message.includes('timed out')
153
+ ? 'hardcopy_timeout'
154
+ : 'hardcopy_error';
125
155
  })
126
156
  .finally(() => {
127
157
  setHardcopySettled(true);
@@ -157,12 +187,32 @@ export function withBaseWidget<P extends BaseWidgetProps>(
157
187
  });
158
188
  }, [startLoading, widgetConfigId, widgetType, getWidgetConfig]);
159
189
 
190
+ const pendingRequests = [
191
+ hardcopySettled ? null : 'hardcopy',
192
+ widgetConfigSettled ? null : 'widgetConfig',
193
+ uiConfigSettled ? null : 'uiConfig',
194
+ ].filter((name): name is string => name !== null);
195
+
196
+ useWidgetLoadDiagnostics({
197
+ enabled: diagnosticsEnabled,
198
+ loadId,
199
+ widgetType,
200
+ widgetConfigId,
201
+ startLoading,
202
+ ownRequestsSettled,
203
+ hardcopyContent,
204
+ visibilitySource,
205
+ pendingRequests,
206
+ hardcopyErrorReason: hardcopyErrorRef.current,
207
+ });
208
+
160
209
  return (
161
210
  <span ref={widgetRef}>
162
211
  <Widget
163
212
  {...props}
164
213
  isLoading={isLoading}
165
214
  isHardcopyLoading={isHardcopyLoading}
215
+ isHardcopySettled={hardcopySettled}
166
216
  isPageLoading={isPageLoading}
167
217
  isWidgetConfigLoading={isWidgetConfigLoading}
168
218
  isUiConfigLoading={isUiConfigLoading}
@@ -1,13 +1,43 @@
1
+ import {
2
+ HardcopyProvider,
3
+ HardcopyResponse,
4
+ } from '@envive-ai/react-hooks/contexts/hardcopyContext';
1
5
  import {
2
6
  PageVisitCategory,
3
7
  useSalesAgent,
4
8
  } from '@envive-ai/react-hooks/contexts/salesAgentContext';
9
+ import { WidgetTypeV3 } from '@envive-ai/react-hooks/contexts/typesV3';
10
+ import { MockHardcopyService } from '@envive-ai/react-hooks/services/hardcopyService';
5
11
  import { UserEventCategory } from '@spiffy-ai/commerce-api-client';
6
12
  import type { Meta, StoryObj } from '@storybook/react-vite';
7
13
  import { useEffect } from 'react';
8
14
  import { v4 as uuid } from 'uuid';
9
15
  import { FullPageSalesAgentWidget } from '../widgets/FullPageSalesAgentWidget/FullPageSalesAgentWidget';
10
16
 
17
+ // Overlay content for the PartialView story below — needs welcomeOverlayTitle
18
+ // set for FullPageSalesAgent's own overlayEnabled check to pass, since
19
+ // partialViewActive is `partialViewEnabled && overlayEnabled`.
20
+ const partialViewHardcopy: HardcopyResponse = {
21
+ responseId: 'mock-fpsa-partial-view',
22
+ language: 'en',
23
+ values: {
24
+ welcomeMessageTitle: "Hi! I'm your personal shopping assistant",
25
+ welcomeMessageText: 'How can I help you today?',
26
+ chatFooterTextFieldPlaceholderText: 'Ask me anything...',
27
+ welcomeOverlayTitle: 'How can I help you find the right product?',
28
+ welcomeOverlayPromptSuggestions: [
29
+ 'Help me find my size',
30
+ 'What are your best sellers?',
31
+ 'Gift ideas for a friend',
32
+ ],
33
+ welcomeOverlayProductCarouselTitle: 'Fan favorites',
34
+ welcomeOverlayProductIds: [
35
+ 'mix-match-nursing-pumping-bra-bundle',
36
+ 'mothers-day-morning-bundle',
37
+ ],
38
+ },
39
+ };
40
+
11
41
  const meta = {
12
42
  title: 'Widgets V3/FullPageSalesAgentWidget',
13
43
  component: FullPageSalesAgentWidget,
@@ -66,3 +96,37 @@ export const Playground: Story = {
66
96
  },
67
97
  },
68
98
  };
99
+
100
+ export const PartialView: Story = {
101
+ name: 'Partial View (default)',
102
+ // 'fake-widget-config-id' short-circuits withBaseWidget's real widget-config
103
+ // fetch (see WidgetConfigProvider.getWidgetConfig) and resolves to `{}` —
104
+ // partialViewEnabled then comes out `undefined`, which now correctly
105
+ // defaults to enabled regardless of which merchant is selected in the
106
+ // toolbar or what that merchant's own backend config has configured.
107
+ args: {
108
+ widgetConfigId: 'fake-widget-config-id',
109
+ },
110
+ decorators: [
111
+ Story => (
112
+ <HardcopyProvider
113
+ hardcopyService={
114
+ new MockHardcopyService({ [WidgetTypeV3.FullPageSalesAgentV3]: partialViewHardcopy })
115
+ }
116
+ >
117
+ <Story />
118
+ </HardcopyProvider>
119
+ ),
120
+ ],
121
+ parameters: {
122
+ docs: {
123
+ description: {
124
+ story:
125
+ 'Partial view is enabled by default — submitting a question opens the conversation in a ' +
126
+ "closed/partial/full bottom sheet instead of taking over the whole screen, and the widget's " +
127
+ 'own product grid stays visible above it. Self-contained (a fake widgetConfigId plus mocked ' +
128
+ "hardcopy) so it renders the same regardless of the toolbar's selected merchant.",
129
+ },
130
+ },
131
+ },
132
+ };
@@ -23,30 +23,24 @@ import { useCallback } from 'react';
23
23
  import { BaseWidgetProps, withBaseWidget } from '../../hocs/withBaseWidget';
24
24
  import { RawValues, getStringIdForText } from '../utils/functions';
25
25
 
26
- const mockPrompts = [
27
- 'Loading prompt 1',
28
- 'Loading prompt 2',
29
- 'Loading prompt 3',
30
- 'Loading prompt 4',
31
- 'Loading prompt 5',
32
- ];
33
-
34
- const mockHeadline = 'Loading...';
35
- const mockAnimatedText = ['Loading...'];
36
-
37
26
  const ProductCardWidgetHandler = (props: BaseWidgetProps) => {
38
27
  const { onTypedMessageSubmitted } = useSalesAgent();
39
28
  const { openChat } = useChatToggle();
40
- const { hardcopyContent, widgetConfig, uiConfig, isLoading } = props;
29
+ const { hardcopyContent, widgetConfig, uiConfig, isLoading, isHardcopySettled } = props;
30
+
31
+ // Show the skeleton while the card is loading, or while its hardcopy hasn't arrived yet (a
32
+ // widget deferred until visible has none). Gate the "no content yet" case on the hardcopy
33
+ // NOT having settled — otherwise a request that finishes without content (a failed/timed-out
34
+ // fetch) would leave `hardcopyContent` undefined and pin the skeleton on forever. Once the
35
+ // hardcopy settles empty, fall through to the normal render (static-copy fallback / empty
36
+ // state) instead of an endless skeleton.
37
+ const showSkeleton = isLoading || (!hardcopyContent && !isHardcopySettled);
41
38
 
42
- const hardCopyHeadline = hardcopyContent?.values?.headline as string | undefined;
43
- const headline = isLoading ? mockHeadline : hardCopyHeadline || '';
39
+ const headline = (hardcopyContent?.values?.headline as string | undefined) || '';
44
40
 
45
- const hardCopyAnimatedText = (hardcopyContent?.values?.animatedText as string[]) || [];
46
- const animatedText = isLoading ? mockAnimatedText : hardCopyAnimatedText;
41
+ const animatedText = (hardcopyContent?.values?.animatedText as string[]) || [];
47
42
 
48
- const hardCopyPrompts = (hardcopyContent?.values?.prompts as string[]) || [];
49
- const prompts = isLoading ? mockPrompts : hardCopyPrompts;
43
+ const prompts = (hardcopyContent?.values?.prompts as string[]) || [];
50
44
 
51
45
  const hardCopyPlaceholder = hardcopyContent?.values?.placeholder as string | undefined;
52
46
  const placeholder = hardCopyPlaceholder;
@@ -141,6 +135,7 @@ const ProductCardWidgetHandler = (props: BaseWidgetProps) => {
141
135
  <ProductCard
142
136
  theme={Theme.GLOBAL_CUSTOM}
143
137
  variant={variant}
138
+ isLoading={showSkeleton}
144
139
  imageSrc={imageSrc}
145
140
  fallbackColor={fallbackColor}
146
141
  headline={headline}
@@ -10,8 +10,21 @@ const mockUseTrackComponentVisibleEvent = vi.fn(() => ({ isVisible: true }));
10
10
 
11
11
  vi.mock('@envive-ai/react-toolkit-v3/ProductCard', () => ({
12
12
  ProductCardVariant: { DARK: 'dark', LIGHT: 'light' },
13
- ProductCard: ({ prompts, onSelect }: { prompts: string[]; onSelect: (text: string) => void }) => (
14
- <div data-testid="product-card-mock">
13
+ // Expose isLoading (the widget's showSkeleton decision) so tests can assert the loading
14
+ // placeholder vs. rendered content across the deferred / resolved / failed fetch paths.
15
+ ProductCard: ({
16
+ prompts,
17
+ onSelect,
18
+ isLoading,
19
+ }: {
20
+ prompts: string[];
21
+ onSelect: (text: string) => void;
22
+ isLoading?: boolean;
23
+ }) => (
24
+ <div
25
+ data-testid="product-card-mock"
26
+ data-loading={String(Boolean(isLoading))}
27
+ >
15
28
  {prompts.map(text => (
16
29
  <button
17
30
  key={text}
@@ -47,6 +60,11 @@ vi.mock('@envive-ai/react-hooks/contexts/featureFlagServiceContext', () => ({
47
60
  useFeatureFlagService: () => ({ featureFlagService: undefined }),
48
61
  }));
49
62
 
63
+ // Diagnostics pulls its own amplitude context internally; stub it like the other sub-hooks.
64
+ vi.mock('@envive-ai/react-hooks/hooks/WidgetLoadDiagnostics', () => ({
65
+ useWidgetLoadDiagnostics: vi.fn(),
66
+ }));
67
+
50
68
  vi.mock('@envive-ai/react-hooks/contexts/hardcopyContext', () => ({
51
69
  useHardcopy: () => ({
52
70
  getHardcopy: mockGetHardcopy,
@@ -118,6 +136,7 @@ describe('ProductCardWidget analytics', () => {
118
136
  {
119
137
  widget_config_id: 'test-config-1',
120
138
  widget_type: WidgetTypeV3.ProductCardV3,
139
+ load_id: expect.any(String),
121
140
  },
122
141
  '0px',
123
142
  true,
@@ -151,3 +170,57 @@ describe('ProductCardWidget analytics', () => {
151
170
  });
152
171
  }, 5000);
153
172
  });
173
+
174
+ describe('ProductCardWidget loading state', () => {
175
+ beforeEach(() => {
176
+ vi.clearAllMocks();
177
+ });
178
+
179
+ const hardcopy = {
180
+ responseId: 'test-response-id',
181
+ language: 'en',
182
+ values: { prompts: ['Prompt 1', 'Prompt 2'] },
183
+ rawValues: { prompts: [{ id: 'id-1', value: 'Prompt 1' }] },
184
+ };
185
+
186
+ const loadingState = () => screen.getByTestId('product-card-mock').dataset.loading;
187
+
188
+ it('shows the skeleton while the hardcopy is pending, then renders content once it resolves', async () => {
189
+ let resolveHardcopy: (value: typeof hardcopy) => void = () => {};
190
+ mockGetHardcopy.mockReturnValue(
191
+ new Promise<typeof hardcopy>(resolve => {
192
+ resolveHardcopy = resolve;
193
+ }),
194
+ );
195
+
196
+ render(<ProductCardWidget widgetConfigId="loading-1" />);
197
+
198
+ // Hardcopy not received yet → skeleton.
199
+ await waitFor(() => expect(loadingState()).toBe('true'), { timeout: 3000 });
200
+
201
+ resolveHardcopy(hardcopy);
202
+
203
+ // Content arrived → skeleton gone.
204
+ await waitFor(() => expect(loadingState()).toBe('false'), { timeout: 3000 });
205
+ expect(screen.getByText('Prompt 1')).toBeInTheDocument();
206
+ }, 5000);
207
+
208
+ it('renders content and drops the skeleton after a successful fetch', async () => {
209
+ mockGetHardcopy.mockResolvedValue(hardcopy);
210
+
211
+ render(<ProductCardWidget widgetConfigId="loading-2" />);
212
+
213
+ await waitFor(() => expect(loadingState()).toBe('false'), { timeout: 3000 });
214
+ expect(screen.getByText('Prompt 1')).toBeInTheDocument();
215
+ }, 5000);
216
+
217
+ it('drops the skeleton after the hardcopy fetch fails instead of hanging on it forever', async () => {
218
+ // A rejected (or timed-out) hardcopy settles without content. The widget must fall through
219
+ // to the normal render rather than pinning the skeleton on indefinitely.
220
+ mockGetHardcopy.mockRejectedValue(new Error('hardcopy failed'));
221
+
222
+ render(<ProductCardWidget widgetConfigId="loading-3" />);
223
+
224
+ await waitFor(() => expect(loadingState()).toBe('false'), { timeout: 3000 });
225
+ }, 5000);
226
+ });