@constructor-io/constructorio-ui-pia 2.10.4 → 2.12.0

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 (85) hide show
  1. package/README.md +7 -0
  2. package/dist/constructorio-ui-pia-bundled.js +55 -55
  3. package/lib/cjs/components/CioPia/CioPia.js +8 -96
  4. package/lib/cjs/components/CioPia/CioPiaQna.js +111 -0
  5. package/lib/cjs/components/CioPia/CioPiaStyles.css +19 -0
  6. package/lib/cjs/components/CioPia/PiaCustomCarousel.js +24 -29
  7. package/lib/cjs/components/CioPiaRecs/CioPiaRecs.js +73 -0
  8. package/lib/cjs/components/CioPiaRecs/CioPiaRecsStyles.css +138 -0
  9. package/lib/cjs/components/CioPiaRecs/RecsCarouselSkeleton.js +58 -0
  10. package/lib/cjs/components/CioPiaRecs/RecsOptionsSkeleton.js +18 -0
  11. package/lib/cjs/components/CioPiaRecs/RecsPodRefinement.js +27 -0
  12. package/lib/cjs/components/Input/Input.js +27 -9
  13. package/lib/cjs/components/Input/InputStyles.css +27 -0
  14. package/lib/cjs/components/SuggestedQuestion/SuggestedQuestion.js +2 -3
  15. package/lib/cjs/components/SuggestedQuestionsContainer/SuggestedQuestionsContainerStyles.css +1 -1
  16. package/lib/cjs/constants.js +12 -1
  17. package/lib/cjs/hooks/useAnswerResults.js +4 -14
  18. package/lib/cjs/hooks/useCioPia.js +48 -18
  19. package/lib/cjs/hooks/usePiaClient.js +21 -0
  20. package/lib/cjs/hooks/useRecsPod.js +134 -0
  21. package/lib/cjs/hooks/useSuggestedQuestions.js +10 -26
  22. package/lib/cjs/hooks/useViewportTracking.js +3 -2
  23. package/lib/cjs/styles.css +1 -0
  24. package/lib/cjs/types.js +0 -2
  25. package/lib/cjs/utils/classNames.js +13 -0
  26. package/lib/cjs/utils/recs.js +13 -0
  27. package/lib/cjs/utils/translate.js +5 -0
  28. package/lib/cjs/version.js +1 -1
  29. package/lib/mjs/components/CioPia/CioPia.js +7 -95
  30. package/lib/mjs/components/CioPia/CioPiaQna.js +106 -0
  31. package/lib/mjs/components/CioPia/CioPiaStyles.css +19 -0
  32. package/lib/mjs/components/CioPia/PiaCustomCarousel.js +43 -30
  33. package/lib/mjs/components/CioPiaRecs/CioPiaRecs.js +69 -0
  34. package/lib/mjs/components/CioPiaRecs/CioPiaRecsStyles.css +138 -0
  35. package/lib/mjs/components/CioPiaRecs/RecsCarouselSkeleton.js +54 -0
  36. package/lib/mjs/components/CioPiaRecs/RecsOptionsSkeleton.js +14 -0
  37. package/lib/mjs/components/CioPiaRecs/RecsPodRefinement.js +23 -0
  38. package/lib/mjs/components/Input/Input.js +27 -9
  39. package/lib/mjs/components/Input/InputStyles.css +27 -0
  40. package/lib/mjs/components/SuggestedQuestion/SuggestedQuestion.js +2 -3
  41. package/lib/mjs/components/SuggestedQuestionsContainer/SuggestedQuestionsContainerStyles.css +1 -1
  42. package/lib/mjs/constants.js +11 -0
  43. package/lib/mjs/hooks/useAnswerResults.js +4 -13
  44. package/lib/mjs/hooks/useCioPia.js +49 -18
  45. package/lib/mjs/hooks/usePiaClient.js +17 -0
  46. package/lib/mjs/hooks/useRecsPod.js +129 -0
  47. package/lib/mjs/hooks/useSuggestedQuestions.js +12 -24
  48. package/lib/mjs/hooks/useViewportTracking.js +3 -2
  49. package/lib/mjs/styles.css +1 -0
  50. package/lib/mjs/types.js +0 -1
  51. package/lib/mjs/utils/classNames.js +9 -0
  52. package/lib/mjs/utils/recs.js +9 -0
  53. package/lib/mjs/utils/translate.js +6 -1
  54. package/lib/mjs/version.js +1 -1
  55. package/lib/types/components/CioPia/CioPia.d.ts +1 -36
  56. package/lib/types/components/CioPia/CioPiaQna.d.ts +4 -0
  57. package/lib/types/components/CioPia/index.d.ts +1 -0
  58. package/lib/types/components/CioPia/types.d.ts +55 -0
  59. package/lib/types/components/CioPiaRecs/CioPiaRecs.d.ts +13 -0
  60. package/lib/types/components/CioPiaRecs/RecsCarouselSkeleton.d.ts +16 -0
  61. package/lib/types/components/CioPiaRecs/RecsOptionsSkeleton.d.ts +16 -0
  62. package/lib/types/components/CioPiaRecs/RecsPodRefinement.d.ts +21 -0
  63. package/lib/types/components/Input/Input.d.ts +9 -1
  64. package/lib/types/components/SuggestedQuestion/SuggestedQuestion.d.ts +4 -2
  65. package/lib/types/constants.d.ts +11 -0
  66. package/lib/types/hooks/useAnswerResults.d.ts +4 -4
  67. package/lib/types/hooks/useCioPia.d.ts +20 -5
  68. package/lib/types/hooks/usePiaClient.d.ts +12 -0
  69. package/lib/types/hooks/useRecsPod.d.ts +52 -0
  70. package/lib/types/hooks/useSuggestedQuestions.d.ts +3 -4
  71. package/lib/types/hooks/useViewportTracking.d.ts +2 -1
  72. package/lib/types/types.d.ts +119 -7
  73. package/lib/types/utils/classNames.d.ts +9 -0
  74. package/lib/types/utils/recs.d.ts +6 -0
  75. package/lib/types/version.d.ts +1 -1
  76. package/package.json +2 -2
  77. package/lib/cjs/hooks/mocks/MockConstructorIOClient.js +0 -24
  78. package/lib/cjs/hooks/mocks/agent.js +0 -166
  79. package/lib/mjs/hooks/mocks/MockConstructorIOClient.js +0 -38
  80. package/lib/mjs/hooks/mocks/agent.js +0 -158
  81. package/lib/types/hooks/mocks/MockConstructorIOClient.d.ts +0 -14
  82. package/lib/types/hooks/mocks/agent.d.ts +0 -10
  83. package/lib/types/hooks/mocks/types.d.ts +0 -77
  84. /package/lib/cjs/{hooks/mocks → components/CioPia}/types.js +0 -0
  85. /package/lib/mjs/{hooks/mocks → components/CioPia}/types.js +0 -0
@@ -1,98 +1,10 @@
1
- import React, { useCallback } from 'react';
2
- import { RenderPropsWrapper, } from '@constructor-io/constructorio-ui-components';
3
- import Input from '../Input/Input';
4
- import SuggestedQuestionsContainer from '../SuggestedQuestionsContainer/SuggestedQuestionsContainer';
5
- import useCioPia from '../../hooks/useCioPia';
6
- import useConversation from '../../hooks/useConversation';
7
- import useTracking from '../../hooks/useTracking';
8
- import useViewportTracking from '../../hooks/useViewportTracking';
9
- import useViewportCallbacks from '../../hooks/useViewportCallbacks';
10
- import ErrorBlock from '../Error/ErrorBlock';
11
- import LoadingSkeleton from '../LoadingSkeleton/LoadingSkeleton';
12
- import { translate } from '../../utils/translate';
13
- import PiaInlineAnswer from '../PiaInlineAnswer/PiaInlineAnswer';
14
- import PiaModal from '../PiaConversation/PiaModal';
15
- import PiaConversation from '../PiaConversation/PiaConversation';
16
- // eslint-disable-next-line complexity
1
+ import React from 'react';
2
+ import CioPiaQna from './CioPiaQna';
3
+ import CioPiaRecs from '../CioPiaRecs/CioPiaRecs';
17
4
  export default function CioPia(props) {
18
- const { apiKey, itemId, itemName, threadId, variationId, cioClient, displayConfigs, componentOverrides, callbacks, formatters, productCardProps, children, translations, suggestedQuestionsParameters, parameters, } = props;
19
- const { learnMoreUrl, showFeedback, mode = 'default', type = 'inline', showPreviousItems, disclaimerPosition = 'bottom', } = displayConfigs || {};
20
- const isConversation = mode === 'conversation' || type === 'modal';
21
- const { priceCurrency } = productCardProps || {};
22
- const pia = useCioPia({
23
- apiKey,
24
- itemId,
25
- threadId,
26
- variationId,
27
- cioClient,
28
- suggestedQuestionsParameters,
29
- parameters,
30
- formatImageUrl: formatters?.formatImageUrl,
31
- });
32
- const tracking = useTracking({
33
- cioClient: pia.cioClient,
34
- itemId,
35
- itemName,
36
- variationId,
37
- threadId: pia.threadId,
38
- });
39
- const { currentQuestion, displayedQuestions, conversationHistory, currentAnswer, currentItems, isLoading, error, context, handleSubmitQuestion, handleQuestionClick, handleInputFocus, handleFeedback, resetState, } = useConversation({ pia, itemId, isConversation, callbacks, tracking });
40
- const { containerRef: viewportContainerRef } = useViewportTracking({
41
- tracking,
42
- questions: displayedQuestions,
43
- });
44
- const { containerRef: callbackContainerRef } = useViewportCallbacks({ callbacks, context });
45
- const containerRef = useCallback((node) => {
46
- viewportContainerRef(node);
47
- callbackContainerRef(node);
48
- }, [viewportContainerRef, callbackContainerRef]);
49
- const qnaResultId = pia.answers.data?.qna_result_id;
50
- const renderProps = {
51
- items: currentItems,
52
- isLoading,
53
- error,
54
- currentAnswer,
55
- currentQuestion,
56
- displayedQuestions,
57
- handleSubmitQuestion,
58
- conversationHistory,
59
- };
60
- const conversationHistoryProps = {
61
- conversationHistory,
62
- isLoading,
63
- error,
64
- currentItems,
65
- showFeedback,
66
- showPreviousItems,
67
- learnMoreUrl,
68
- disclaimerPosition,
69
- translations,
70
- callbacks,
71
- componentOverrides,
72
- displayedQuestions,
73
- handleSubmitQuestion,
74
- handleQuestionClick,
75
- handleFeedback,
76
- onResultClick: tracking.trackResultClick,
77
- qnaResultId,
78
- containerRef,
79
- onInputFocus: handleInputFocus,
80
- priceCurrency,
81
- };
82
- if (type === 'modal') {
83
- return (React.createElement(PiaModal, { initialQuestions: pia.suggestedQuestions.data, handleSubmitQuestion: handleSubmitQuestion, handleQuestionClick: handleQuestionClick, containerRef: containerRef, isLoading: isLoading, componentOverrides: componentOverrides, translations: translations, onInputFocus: handleInputFocus, onClose: resetState },
84
- React.createElement(PiaConversation, { ...conversationHistoryProps })));
5
+ const mode = props.displayConfigs?.mode ?? 'default';
6
+ if (mode === 'recommendations') {
7
+ return React.createElement(CioPiaRecs, { ...props });
85
8
  }
86
- if (isConversation)
87
- return React.createElement(PiaConversation, { ...conversationHistoryProps });
88
- // Default inline mode
89
- return (React.createElement("div", { ref: containerRef, className: 'cio-pia-container', "data-testid": 'cio-pia-container' },
90
- React.createElement(RenderPropsWrapper, { props: renderProps, override: children || componentOverrides?.reactNode },
91
- React.createElement("p", { className: 'cio-pia-title', "data-testid": 'cio-pia-title' }, translate('Any questions about this product?', translations)),
92
- React.createElement(Input, { onSubmit: handleSubmitQuestion, onFocus: handleInputFocus, value: currentQuestion, translations: translations, componentOverride: componentOverrides?.input }),
93
- isLoading && React.createElement(LoadingSkeleton, { componentOverride: componentOverrides?.loading }),
94
- !isLoading && error && React.createElement(ErrorBlock, { message: error?.message || 'Unexpected error' }),
95
- !isLoading && !error && (React.createElement(React.Fragment, null,
96
- currentAnswer && (React.createElement(PiaInlineAnswer, { currentAnswer: currentAnswer, currentItems: currentItems, showFeedback: showFeedback, learnMoreUrl: learnMoreUrl, disclaimerPosition: disclaimerPosition, translations: translations, callbacks: callbacks, componentOverrides: componentOverrides, onFeedback: handleFeedback, onResultClick: tracking.trackResultClick, question: currentQuestion, qnaResultId: qnaResultId, priceCurrency: priceCurrency })),
97
- React.createElement(SuggestedQuestionsContainer, { questions: displayedQuestions, onQuestionClick: handleQuestionClick, componentOverride: componentOverrides?.suggestedQuestions }))))));
9
+ return React.createElement(CioPiaQna, { ...props });
98
10
  }
@@ -0,0 +1,106 @@
1
+ import React, { useCallback } from 'react';
2
+ import { RenderPropsWrapper } from '@constructor-io/constructorio-ui-components';
3
+ import Input from '../Input/Input';
4
+ import SuggestedQuestionsContainer from '../SuggestedQuestionsContainer/SuggestedQuestionsContainer';
5
+ import SuggestedQuestionsSkeleton from '../SuggestedQuestionsContainer/SuggestedQuestionsSkeleton';
6
+ import useCioPia from '../../hooks/useCioPia';
7
+ import useConversation from '../../hooks/useConversation';
8
+ import useTracking from '../../hooks/useTracking';
9
+ import useViewportTracking from '../../hooks/useViewportTracking';
10
+ import useViewportCallbacks from '../../hooks/useViewportCallbacks';
11
+ import ErrorBlock from '../Error/ErrorBlock';
12
+ import LoadingSkeleton from '../LoadingSkeleton/LoadingSkeleton';
13
+ import { translate } from '../../utils/translate';
14
+ import PiaInlineAnswer from '../PiaInlineAnswer/PiaInlineAnswer';
15
+ import PiaModal from '../PiaConversation/PiaModal';
16
+ import PiaConversation from '../PiaConversation/PiaConversation';
17
+ /** The question-and-answer experience: `mode: 'default'`, `mode: 'conversation'`, and the modal. */
18
+ // eslint-disable-next-line complexity
19
+ export default function CioPiaQna(props) {
20
+ const { apiKey, itemId, itemName, threadId, variationId, cioClient, displayConfigs, componentOverrides, callbacks, formatters, productCardProps, children, translations, suggestedQuestionsParameters, answerParameters, parameters, trackingConfigs, } = props;
21
+ const { learnMoreUrl, showFeedback, mode = 'default', type = 'inline', showPreviousItems, disclaimerPosition = 'bottom', } = displayConfigs || {};
22
+ const isConversation = mode === 'conversation' || type === 'modal';
23
+ const { priceCurrency } = productCardProps || {};
24
+ const pia = useCioPia({
25
+ apiKey,
26
+ itemId,
27
+ threadId,
28
+ variationId,
29
+ cioClient,
30
+ suggestedQuestionsParameters,
31
+ answerParameters,
32
+ parameters,
33
+ formatImageUrl: formatters?.formatImageUrl,
34
+ });
35
+ const tracking = useTracking({
36
+ cioClient: pia.cioClient,
37
+ itemId,
38
+ itemName,
39
+ variationId,
40
+ threadId: pia.threadId,
41
+ });
42
+ const { currentQuestion, displayedQuestions, conversationHistory, currentAnswer, currentItems, isLoading, error, context, handleSubmitQuestion, handleQuestionClick, handleInputFocus, handleFeedback, resetState, } = useConversation({ pia, itemId, isConversation, callbacks, tracking });
43
+ const { containerRef: viewportContainerRef } = useViewportTracking({
44
+ tracking,
45
+ questions: displayedQuestions,
46
+ viewThreshold: trackingConfigs?.viewThreshold,
47
+ });
48
+ const { containerRef: callbackContainerRef } = useViewportCallbacks({ callbacks, context });
49
+ const containerRef = useCallback((node) => {
50
+ viewportContainerRef(node);
51
+ callbackContainerRef(node);
52
+ }, [viewportContainerRef, callbackContainerRef]);
53
+ const qnaResultId = pia.answers.data?.qna_result_id;
54
+ // The text bars stand in for the answer alone, so they track the answer request only. The question
55
+ // row keeps the combined `isLoading`: the follow-up questions that refill it arrive with the
56
+ // answer, so the row is waiting on either request.
57
+ const isAnswerLoading = pia.answers.isLoading;
58
+ const renderProps = {
59
+ items: currentItems,
60
+ isLoading,
61
+ error,
62
+ currentAnswer,
63
+ currentQuestion,
64
+ displayedQuestions,
65
+ handleSubmitQuestion,
66
+ conversationHistory,
67
+ };
68
+ const conversationHistoryProps = {
69
+ conversationHistory,
70
+ isLoading,
71
+ error,
72
+ currentItems,
73
+ showFeedback,
74
+ showPreviousItems,
75
+ learnMoreUrl,
76
+ disclaimerPosition,
77
+ translations,
78
+ callbacks,
79
+ componentOverrides,
80
+ displayedQuestions,
81
+ handleSubmitQuestion,
82
+ handleQuestionClick,
83
+ handleFeedback,
84
+ onResultClick: tracking.trackResultClick,
85
+ qnaResultId,
86
+ containerRef,
87
+ onInputFocus: handleInputFocus,
88
+ priceCurrency,
89
+ };
90
+ if (type === 'modal') {
91
+ return (React.createElement(PiaModal, { initialQuestions: pia.suggestedQuestions.data, handleSubmitQuestion: handleSubmitQuestion, handleQuestionClick: handleQuestionClick, containerRef: containerRef, isLoading: isLoading, componentOverrides: componentOverrides, translations: translations, onInputFocus: handleInputFocus, onClose: resetState },
92
+ React.createElement(PiaConversation, { ...conversationHistoryProps })));
93
+ }
94
+ if (isConversation)
95
+ return React.createElement(PiaConversation, { ...conversationHistoryProps });
96
+ // Default inline mode
97
+ return (React.createElement("div", { ref: containerRef, className: 'cio-pia-container', "data-testid": 'cio-pia-container' },
98
+ React.createElement(RenderPropsWrapper, { props: renderProps, override: children || componentOverrides?.reactNode },
99
+ React.createElement("p", { className: 'cio-pia-title', "data-testid": 'cio-pia-title' }, translate('Any questions about this product?', translations)),
100
+ React.createElement(Input, { onSubmit: handleSubmitQuestion, onFocus: handleInputFocus, value: currentQuestion, translations: translations, componentOverride: componentOverrides?.input }),
101
+ isAnswerLoading && React.createElement(LoadingSkeleton, { componentOverride: componentOverrides?.loading }),
102
+ !isAnswerLoading && error && React.createElement(ErrorBlock, { message: error?.message || 'Unexpected error' }),
103
+ !isAnswerLoading && !error && currentAnswer && (React.createElement(PiaInlineAnswer, { currentAnswer: currentAnswer, currentItems: currentItems, showFeedback: showFeedback, learnMoreUrl: learnMoreUrl, disclaimerPosition: disclaimerPosition, translations: translations, callbacks: callbacks, componentOverrides: componentOverrides, onFeedback: handleFeedback, onResultClick: tracking.trackResultClick, question: currentQuestion, qnaResultId: qnaResultId, priceCurrency: priceCurrency })),
104
+ isLoading && React.createElement(SuggestedQuestionsSkeleton, null),
105
+ !isLoading && !error && (React.createElement(SuggestedQuestionsContainer, { questions: displayedQuestions, onQuestionClick: handleQuestionClick, componentOverride: componentOverrides?.suggestedQuestions })))));
106
+ }
@@ -30,3 +30,22 @@
30
30
  .cio-pia-learn-more {
31
31
  color: inherit;
32
32
  }
33
+
34
+ /* Neutral placeholder shown until the product image itself paints over it.
35
+ Set on the image element so it inherits the card's own size and corner radius. */
36
+ .cio-pia-answer-container .cio-product-card-image {
37
+ background-color: rgba(0, 0, 0, 0.05);
38
+ }
39
+
40
+ /* Restores the clamped, muted look of the stock ProductCard description, which the
41
+ custom HTML description rendering does not inherit. */
42
+ .cio-pia-answer-container .cio-product-card-description {
43
+ display: -webkit-box;
44
+ -webkit-line-clamp: 3;
45
+ -webkit-box-orient: vertical;
46
+ line-clamp: 3;
47
+ overflow: hidden;
48
+ font-size: 14px;
49
+ line-height: 20px;
50
+ color: #6b7280;
51
+ }
@@ -1,5 +1,5 @@
1
- import React, { useCallback, useRef, useEffect, useMemo } from 'react';
2
- import { Carousel, ProductCard, CIO_EVENTS, } from '@constructor-io/constructorio-ui-components';
1
+ import React, { useCallback, useMemo } from 'react';
2
+ import { Carousel, ProductCard, } from '@constructor-io/constructorio-ui-components';
3
3
  import { sanitizeHtml } from '../../utils/contentTransformers';
4
4
  import { translate } from '../../utils/translate';
5
5
  function HtmlDescription({ product }) {
@@ -55,12 +55,10 @@ function buildMergedOverrides(componentOverrides, priceSectionOverride) {
55
55
  };
56
56
  }
57
57
  export default function PiaCustomCarousel({ items, componentOverrides, callbacks, onResultClick, question, qnaResultId, translations, priceCurrency, }) {
58
- const wrapperRef = useRef(null);
59
58
  const { onAddToCart } = callbacks || {};
60
59
  const addToCartHandler = useCallback((event, product) => {
61
60
  onAddToCart?.(product, event);
62
61
  }, [onAddToCart]);
63
- // Determine to use user-defined click handler or default behavior
64
62
  const productClickHandler = useCallback((item) => {
65
63
  const position = items.findIndex((i) => i.id === item.id);
66
64
  if (onResultClick && question) {
@@ -74,47 +72,62 @@ export default function PiaCustomCarousel({ items, componentOverrides, callbacks
74
72
  window.open(item.url, '_blank', 'noopener,noreferrer');
75
73
  }
76
74
  }, [callbacks, items, onResultClick, question, qnaResultId]);
77
- // Set up event listener for product card clicks
78
- useEffect(() => {
79
- const el = wrapperRef.current;
80
- if (!el)
81
- return undefined;
82
- const handleClick = (e) => {
83
- const { product } = e.detail;
84
- if (product) {
85
- productClickHandler(product);
86
- }
87
- };
88
- el.addEventListener(CIO_EVENTS.productCard.click, handleClick);
89
- return () => {
90
- el.removeEventListener(CIO_EVENTS.productCard.click, handleClick);
91
- };
92
- }, [productClickHandler]);
93
75
  const priceSectionOverride = useMemo(() => (priceCurrency ? createPriceSectionOverride(priceCurrency) : undefined), [priceCurrency]);
76
+ const clickHandlerProps = useMemo(() => ({
77
+ onProductClick: productClickHandler,
78
+ onAddToCart: onAddToCart ? addToCartHandler : undefined,
79
+ }), [productClickHandler, onAddToCart, addToCartHandler]);
94
80
  const mergedOverrides = useMemo(() => {
95
81
  const baseOverrides = buildMergedOverrides(componentOverrides, priceSectionOverride);
82
+ // Override priority: carousel.reactNode → item.reactNode → item.productCard.reactNode → default ProductCard.
83
+ // The casts are required because the override signatures don't declare these extra props,
84
+ // but consumers receive them as part of the render-props contract documented in CioPiaProps.
85
+ const carouselOverride = baseOverrides.reactNode;
86
+ if (typeof carouselOverride === 'function') {
87
+ return {
88
+ ...baseOverrides,
89
+ reactNode: (props) => carouselOverride({ ...props, ...clickHandlerProps }),
90
+ };
91
+ }
96
92
  const cardOverrides = baseOverrides.item?.productCard;
97
- // The carousel never passes onAddToCart down and the card hides the button without it, so
98
- // re-render the card with the handler. A full card override owns its own layout - skip it.
99
- if (!onAddToCart || cardOverrides?.reactNode) {
100
- return baseOverrides;
93
+ const itemOverride = baseOverrides.item?.reactNode;
94
+ if (typeof itemOverride === 'function') {
95
+ return {
96
+ ...baseOverrides,
97
+ item: {
98
+ ...baseOverrides.item,
99
+ reactNode: (props) => itemOverride({ ...props, ...clickHandlerProps }),
100
+ },
101
+ };
102
+ }
103
+ if (typeof cardOverrides?.reactNode === 'function') {
104
+ const originalReactNode = cardOverrides.reactNode;
105
+ return {
106
+ ...baseOverrides,
107
+ item: {
108
+ ...baseOverrides.item,
109
+ productCard: {
110
+ ...cardOverrides,
111
+ reactNode: (props) => originalReactNode({ ...props, ...clickHandlerProps }),
112
+ },
113
+ },
114
+ };
101
115
  }
102
- const addToCartText = translate('Add to Cart', translations);
116
+ // Default: render ProductCard with handlers directly
117
+ const addToCartText = onAddToCart ? translate('Add to Cart', translations) : undefined;
103
118
  return {
104
119
  ...baseOverrides,
105
120
  item: {
106
121
  ...baseOverrides.item,
107
122
  productCard: {
108
123
  ...cardOverrides,
109
- reactNode: ({ product }) => (React.createElement(ProductCard, { product: product, className: 'w-full h-full', addToCartText: addToCartText, onAddToCart: addToCartHandler, componentOverrides: cardOverrides })),
124
+ reactNode: ({ product }) => (React.createElement(ProductCard, { product: product, className: 'w-full h-full', addToCartText: addToCartText, onAddToCart: clickHandlerProps.onAddToCart, onProductClick: clickHandlerProps.onProductClick, componentOverrides: cardOverrides })),
110
125
  },
111
126
  },
112
127
  };
113
- }, [componentOverrides, priceSectionOverride, onAddToCart, translations, addToCartHandler]);
114
- // If there are no items, do not render the carousel
128
+ }, [componentOverrides, priceSectionOverride, clickHandlerProps, onAddToCart, translations]);
115
129
  if (items.length === 0) {
116
130
  return null;
117
131
  }
118
- return (React.createElement("div", { ref: wrapperRef },
119
- React.createElement(Carousel, { items: items, componentOverrides: mergedOverrides })));
132
+ return React.createElement(Carousel, { items: items, componentOverrides: mergedOverrides });
120
133
  }
@@ -0,0 +1,69 @@
1
+ import React, { useCallback, useMemo } from 'react';
2
+ import { RenderPropsWrapper } from '@constructor-io/constructorio-ui-components';
3
+ import PiaCustomCarousel from '../CioPia/PiaCustomCarousel';
4
+ import RecsPodRefinement from './RecsPodRefinement';
5
+ import RecsCarouselSkeleton from './RecsCarouselSkeleton';
6
+ import usePiaClient from '../../hooks/usePiaClient';
7
+ import useRecsPod from '../../hooks/useRecsPod';
8
+ import useViewportCallbacks from '../../hooks/useViewportCallbacks';
9
+ import { cx } from '../../utils/classNames';
10
+ /**
11
+ * A pod of AI-curated product suggestions: a personalized title, a row of products, and short
12
+ * options for narrowing them down. It asks no questions and makes no Q&A requests, so a retailer
13
+ * can use it on its own.
14
+ *
15
+ * Renders nothing at all once a request has settled with no products, so whatever the retailer
16
+ * already had in that slot shows through instead of an empty shell. That is the default: a caller
17
+ * that supplied its own root — `children` or `componentOverrides.reactNode` — still gets it in that
18
+ * state, with `error` available, and decides what belongs there.
19
+ */
20
+ export default function CioPiaRecs(props) {
21
+ const { apiKey, itemId, threadId, variationId, cioClient, componentOverrides, callbacks, formatters, productCardProps, children, translations, recsPodParameters, } = props;
22
+ const { priceCurrency } = productCardProps || {};
23
+ const { showInput = true, numResults } = recsPodParameters || {};
24
+ const { answer: answerOverride, carousel: carouselOverride, loading: loadingOverride, reactNode: rootOverride, } = componentOverrides || {};
25
+ const { cioClient: client, threadId: resolvedThreadId } = usePiaClient({
26
+ apiKey,
27
+ threadId,
28
+ cioClient,
29
+ });
30
+ const { title, items, refinement, isLoading, error, inputError, lastShopperInput, refine } = useRecsPod({
31
+ itemId,
32
+ variationId,
33
+ threadId: resolvedThreadId,
34
+ cioClient: client,
35
+ parameters: recsPodParameters,
36
+ formatImageUrl: formatters?.formatImageUrl,
37
+ translations,
38
+ });
39
+ const context = useMemo(() => ({ itemId, threadId: resolvedThreadId }), [itemId, resolvedThreadId]);
40
+ const { containerRef } = useViewportCallbacks({ callbacks, context });
41
+ const handleInputFocus = useCallback(() => callbacks?.onFocus?.(context), [callbacks, context]);
42
+ const refineFromOption = useCallback((text) => refine(text, 'option'), [refine]);
43
+ const refineFromInput = useCallback((text) => refine(text, 'input'), [refine]);
44
+ const renderProps = {
45
+ items,
46
+ isLoading,
47
+ error,
48
+ currentAnswer: title,
49
+ currentQuestion: lastShopperInput,
50
+ displayedQuestions: (refinement?.options || []).map((value) => ({ value })),
51
+ // A caller's own root gets the free-text behavior: whatever it submits is the shopper's, so a
52
+ // rejection belongs under their input.
53
+ handleSubmitQuestion: refineFromInput,
54
+ conversationHistory: [],
55
+ };
56
+ // Nothing of ours to show. Rendering no markup at all is the default, so whatever the retailer
57
+ // already had in that slot shows through instead of an empty shell. TODO: an interim, not a
58
+ // settled decision — the empty-state design and the shape of a hard failure are both still open.
59
+ // A caller who supplied its own root still gets it, with `error` in hand, so a consumer can put
60
+ // its own message here.
61
+ if (!isLoading && !items && !children && !rootOverride)
62
+ return null;
63
+ return (React.createElement("div", { ref: containerRef, className: cx('cio-pia-container', 'cio-pia-recs-pod', isLoading && 'cio-pia-recs-pod--loading'), "data-testid": 'cio-pia-recs-pod' },
64
+ React.createElement(RenderPropsWrapper, { props: renderProps, override: children || rootOverride },
65
+ title && (React.createElement(RenderPropsWrapper, { props: { text: title }, override: answerOverride?.reactNode },
66
+ React.createElement("h3", { key: title, className: 'cio-pia-recs-pod__title' }, title))),
67
+ React.createElement("div", { className: 'cio-pia-recs-pod__products', "data-testid": 'cio-pia-recs-pod-products' }, isLoading || !items ? (React.createElement(RecsCarouselSkeleton, { count: numResults ?? items?.length, componentOverride: loadingOverride })) : (React.createElement(PiaCustomCarousel, { items: items, componentOverrides: carouselOverride, callbacks: callbacks, translations: translations, priceCurrency: priceCurrency }))),
68
+ React.createElement(RecsPodRefinement, { refinement: refinement, isLoading: isLoading, inputError: inputError, showInput: showInput, translations: translations, componentOverrides: componentOverrides, onRefine: refineFromOption, onSubmit: refineFromInput, onInputFocus: handleInputFocus }))));
69
+ }
@@ -0,0 +1,138 @@
1
+ .cio-pia-recs-pod {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: 12px;
5
+ width: 100%;
6
+ }
7
+
8
+ /*
9
+ * The title is the one part of the pod that changes on a refinement, so it fades in rather than
10
+ * snapping. `CioPiaRecs` keys the node on the title, which remounts it and replays this.
11
+ *
12
+ * This is a fade-in on the arriving title, not a cross-fade: React removes the outgoing text
13
+ * synchronously, so there is nothing left on screen to fade out.
14
+ */
15
+ .cio-pia-recs-pod__title {
16
+ min-height: 20px;
17
+ margin: 0;
18
+ font-size: 16px;
19
+ font-weight: 500;
20
+ color: #14151a;
21
+ text-align: start;
22
+ animation: recs-pod-title-fade-in 200ms ease-in;
23
+ }
24
+
25
+ @keyframes recs-pod-title-fade-in {
26
+ from {
27
+ opacity: 0;
28
+ }
29
+
30
+ to {
31
+ opacity: 1;
32
+ }
33
+ }
34
+
35
+ /* Holds the products, and the placeholders that stand in for them while a request is in flight */
36
+ .cio-pia-recs-pod__products {
37
+ display: flex;
38
+ flex-direction: column;
39
+ }
40
+
41
+ /* Options and the free-text box share one row, wrapping on narrow screens */
42
+ .cio-pia-recs-pod__refinement {
43
+ display: flex;
44
+ flex-wrap: wrap;
45
+ align-items: center;
46
+ gap: 8px;
47
+ }
48
+
49
+ .cio-pia-recs-pod__refinement-label {
50
+ font-size: 12px;
51
+ font-weight: 500;
52
+ color: #0f1324;
53
+ }
54
+
55
+ .cio-pia-recs-pod__input {
56
+ display: flex;
57
+ flex-direction: column;
58
+ flex: 1 1 200px;
59
+ min-width: 200px;
60
+ }
61
+
62
+ /* Nothing in the row is clickable while its contents are being replaced */
63
+ .cio-pia-recs-pod--loading .cio-pia-recs-pod__refinement {
64
+ pointer-events: none;
65
+ }
66
+
67
+ /* Placeholders, shaped like the real content so the page does not jump when data lands */
68
+ .cio-pia-recs-skeleton {
69
+ display: flex;
70
+ gap: 8px;
71
+ width: 100%;
72
+ }
73
+
74
+ /*
75
+ * This one holds the real carousel, arrows and all, so nothing in it is clickable while its
76
+ * contents are being replaced.
77
+ */
78
+ .cio-pia-recs-skeleton--carousel {
79
+ flex: 1 1 auto;
80
+ pointer-events: none;
81
+ }
82
+
83
+ /*
84
+ * The only two measurements taken from the package, both from the card's own image
85
+ * (`min-h-[224px] rounded-2xl`). The image is what gives a card its height, so these are what stop
86
+ * the refinement row moving when the products land. Everything else about the card - its
87
+ * background, radius, shadow, padding, and how many fit across - comes from the carousel itself.
88
+ */
89
+ .cio-pia-recs-skeleton__image {
90
+ min-height: 224px;
91
+ border-radius: 16px;
92
+ }
93
+
94
+ /* Bar heights are the line boxes of the text they stand in for, so the card totals the same */
95
+ .cio-pia-recs-skeleton__price {
96
+ width: 40%;
97
+ height: 28px;
98
+ }
99
+
100
+ .cio-pia-recs-skeleton__title {
101
+ width: 80%;
102
+ height: 24px;
103
+ }
104
+
105
+ .cio-pia-recs-skeleton__desc {
106
+ display: flex;
107
+ flex-direction: column;
108
+ gap: 4px;
109
+ }
110
+
111
+ .cio-pia-recs-skeleton__desc .skeleton-bar {
112
+ height: 18px;
113
+ }
114
+
115
+ /* Short last line, the way a wrapped sentence ends */
116
+ .cio-pia-recs-skeleton__desc .skeleton-bar:last-child {
117
+ width: 60%;
118
+ }
119
+
120
+ .cio-pia-recs-skeleton--options {
121
+ width: auto;
122
+ }
123
+
124
+ .cio-pia-recs-skeleton--options .cio-pia-recs-skeleton__block {
125
+ height: 26px;
126
+ width: 96px;
127
+ }
128
+
129
+ @media (prefers-reduced-motion: reduce) {
130
+ .cio-pia-recs-skeleton__block,
131
+ .cio-pia-recs-skeleton__image,
132
+ .cio-pia-recs-skeleton__price,
133
+ .cio-pia-recs-skeleton__title,
134
+ .cio-pia-recs-skeleton__desc .skeleton-bar,
135
+ .cio-pia-recs-pod__title {
136
+ animation: none;
137
+ }
138
+ }
@@ -0,0 +1,54 @@
1
+ import React from 'react';
2
+ import { Carousel, RenderPropsWrapper, } from '@constructor-io/constructorio-ui-components';
3
+ /** The widest band the carousel lays out. Narrower ones need no count: its viewport clips them. */
4
+ const DEFAULT_COUNT = 6;
5
+ /**
6
+ * Grey bars standing in for a card's own sections. The card around them - white background,
7
+ * rounded corners, shadow, padding - and the row it sits in are the real ones, so nothing here has
8
+ * to know their measurements.
9
+ *
10
+ * `RenderPropsWrapper` draws an override in place of a section rather than alongside it, so these
11
+ * appear even though the placeholder carries no image, price or name of its own.
12
+ *
13
+ * A caller's own `productCard` overrides are deliberately not merged in, unlike the real row in
14
+ * `PiaCustomCarousel`: the placeholders below are empty objects, so a caller's section renderer
15
+ * would be handed a product with every field `undefined`.
16
+ */
17
+ const CARD_SKELETON = {
18
+ item: {
19
+ productCard: {
20
+ image: {
21
+ reactNode: () => (React.createElement("div", { className: 'skeleton-bar cio-pia-recs-skeleton__image', "data-testid": 'cio-pia-recs-skeleton-card' })),
22
+ },
23
+ content: {
24
+ price: { reactNode: () => React.createElement("div", { className: 'skeleton-bar cio-pia-recs-skeleton__price' }) },
25
+ title: { reactNode: () => React.createElement("div", { className: 'skeleton-bar cio-pia-recs-skeleton__title' }) },
26
+ description: {
27
+ // Two lines, not one. The card clamps its description at three lines and stretches every
28
+ // card in a row to the tallest of them, so a single line lands short of where real
29
+ // products settle.
30
+ reactNode: () => (React.createElement("div", { className: 'cio-pia-recs-skeleton__desc' },
31
+ React.createElement("div", { className: 'skeleton-bar' }),
32
+ React.createElement("div", { className: 'skeleton-bar' }))),
33
+ },
34
+ },
35
+ },
36
+ },
37
+ };
38
+ /**
39
+ * Placeholders exist only to give the carousel something to lay out. They deliberately carry no
40
+ * `id` and no `name`: `ProductCard` spreads those into `data-cnstrc-item-*`, and React omits an
41
+ * attribute whose value is `undefined`, so a loading pod emits no product data attributes at all.
42
+ */
43
+ const placeholderItems = (count) => Array.from({ length: count }, () => ({}));
44
+ /**
45
+ * The product row, greyed out. It uses the real carousel rather than a set of measurements copied
46
+ * out of the package, so its padding, its gaps, how many cards it fits at the current width and its
47
+ * arrows are all right by construction. Matching the real heights is what keeps the page from
48
+ * jumping when the data lands.
49
+ */
50
+ export default function RecsCarouselSkeleton({ count, componentOverride, }) {
51
+ const skeleton = (React.createElement("div", { className: 'cio-pia-recs-skeleton cio-pia-recs-skeleton--carousel', "data-testid": 'cio-pia-recs-skeleton-carousel', "aria-hidden": 'true' },
52
+ React.createElement(Carousel, { items: placeholderItems(count ?? DEFAULT_COUNT), componentOverrides: CARD_SKELETON })));
53
+ return (React.createElement(RenderPropsWrapper, { props: { skeleton }, override: componentOverride?.reactNode }, skeleton));
54
+ }
@@ -0,0 +1,14 @@
1
+ import React from 'react';
2
+ const DEFAULT_COUNT = 3;
3
+ /**
4
+ * The refinement options, greyed out. Matching the real height is what keeps the row from
5
+ * collapsing and shifting the page when the options land.
6
+ *
7
+ * Takes no override, the same way `SuggestedQuestionsSkeleton` takes none: `componentOverrides.
8
+ * loading` stands in for the band above this one, and a caller who replaced it keeps these pills
9
+ * underneath, exactly as a caller who replaces the answer skeleton in question-and-answer keeps the
10
+ * question pills under theirs.
11
+ */
12
+ export default function RecsOptionsSkeleton({ count }) {
13
+ return (React.createElement("div", { className: 'cio-pia-recs-skeleton cio-pia-recs-skeleton--options', "data-testid": 'cio-pia-recs-skeleton-options', "aria-hidden": 'true' }, Array.from({ length: count ?? DEFAULT_COUNT }, (_, index) => (React.createElement("div", { key: index, className: 'skeleton-bar cio-pia-recs-skeleton__block' })))));
14
+ }
@@ -0,0 +1,23 @@
1
+ import React from 'react';
2
+ import Input from '../Input/Input';
3
+ import SuggestedQuestion from '../SuggestedQuestion/SuggestedQuestion';
4
+ import { SparklesIcon } from '../icons';
5
+ import RecsOptionsSkeleton from './RecsOptionsSkeleton';
6
+ import { resolveRefinementQuestion } from '../../utils/recs';
7
+ import { RECS_INPUT_PLACEHOLDER } from '../../constants';
8
+ /**
9
+ * The row under the products: a prompt line, the short options the API suggested, and a box for
10
+ * anything it did not think of. The prompt line is held still while the rest reloads, so the row
11
+ * never changes height under the shopper's cursor.
12
+ */
13
+ export default function RecsPodRefinement({ refinement, isLoading, inputError, showInput, translations, componentOverrides, onRefine, onSubmit, onInputFocus, }) {
14
+ const options = refinement?.options || [];
15
+ return (React.createElement("div", { className: 'cio-pia-recs-pod__refinement' },
16
+ React.createElement("span", { className: 'cio-pia-recs-pod__refinement-label' }, resolveRefinementQuestion(refinement, translations)),
17
+ isLoading ? (React.createElement(RecsOptionsSkeleton, { count: options.length || undefined })) : (options.map((option) => (React.createElement(SuggestedQuestion, { key: option, question: option, icon: React.createElement(SparklesIcon, null), onClick: () => onRefine(option) })))),
18
+ showInput && (React.createElement("div", { className: 'cio-pia-recs-pod__input' },
19
+ React.createElement(Input, { onSubmit: onSubmit, onFocus: onInputFocus, disabled: isLoading, error: inputError ?? undefined, placeholderKey: RECS_INPUT_PLACEHOLDER,
20
+ // The pod's box sits in a row beside the options and submits on Enter. Q&A keeps
21
+ // its button; this one has none in the mocks.
22
+ showSendButton: false, translations: translations, componentOverride: componentOverrides?.input })))));
23
+ }