@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,9 +1,24 @@
1
1
  import React, { useCallback, useEffect, useState } from 'react';
2
2
  import { RenderPropsWrapper, } from '@constructor-io/constructorio-ui-components';
3
+ import { cx } from '../../utils/classNames';
3
4
  import { translate } from '../../utils/translate';
4
5
  import { SendIcon } from '../icons';
5
- function Input({ value: providedValue, placeholder, disabled = false, onSubmit, onFocus, translations, componentOverride, }) {
6
+ /**
7
+ * Numbers the `Input`s mounted on the page, so each one can give its error message a DOM `id` no
8
+ * other input will collide with. Nothing to do with messages or errors themselves - the id is
9
+ * needed because that is what the field's `aria-describedby` has to point at.
10
+ *
11
+ * `useId` is the built-in way to do this, but it is React 18+ and this package supports React
12
+ * >= 16.12. Read once per instance through a lazy initializer, so the number an input gets holds
13
+ * across its re-renders.
14
+ */
15
+ let inputIdCounter = 0;
16
+ function Input({ value: providedValue, placeholder, disabled = false, onSubmit, onFocus, translations, componentOverride, error, placeholderKey = 'Ask anything', showSendButton = true, }) {
6
17
  const [value, setValue] = useState(providedValue || '');
18
+ const [errorId] = useState(() => {
19
+ inputIdCounter += 1;
20
+ return `cio-pia-input-${inputIdCounter}-error`;
21
+ });
7
22
  useEffect(() => {
8
23
  if (providedValue) {
9
24
  setValue(providedValue);
@@ -24,20 +39,23 @@ function Input({ value: providedValue, placeholder, disabled = false, onSubmit,
24
39
  }
25
40
  };
26
41
  // Priority: translations > placeholder prop > default
27
- const resolvedPlaceholder = translations?.['Ask anything'] !== undefined
28
- ? translate('Ask anything', translations)
29
- : (placeholder ?? translate('Ask anything'));
42
+ const resolvedPlaceholder = translations?.[placeholderKey] !== undefined
43
+ ? translate(placeholderKey, translations)
44
+ : (placeholder ?? translate(placeholderKey));
30
45
  return (React.createElement(RenderPropsWrapper, { props: {
31
46
  disabled,
32
47
  placeholder: resolvedPlaceholder,
33
48
  onSubmit: handleSubmit,
34
49
  onFocus,
35
50
  translations,
51
+ error,
36
52
  }, override: componentOverride?.reactNode },
37
- React.createElement("div", { className: 'cio-pia-input-container' },
38
- React.createElement("input", { type: 'text', value: value, onChange: (e) => setValue(e.target.value), onKeyDown: handleSubmitOnEnter, onFocus: onFocus, placeholder: resolvedPlaceholder, disabled: disabled, className: 'cio-pia-input' }),
39
- React.createElement("button", { type: 'button', onClick: () => handleSubmit(value), className: 'cio-pia-send-button', disabled: disabled },
40
- translate('Send', translations),
41
- React.createElement(SendIcon, null)))));
53
+ React.createElement(React.Fragment, null,
54
+ React.createElement("div", { className: 'cio-pia-input-container' },
55
+ React.createElement("input", { type: 'text', value: value, onChange: (e) => setValue(e.target.value), onKeyDown: handleSubmitOnEnter, onFocus: onFocus, placeholder: resolvedPlaceholder, disabled: disabled, className: cx('cio-pia-input', error && 'cio-pia-input--error', !showSendButton && 'cio-pia-input--no-send'), "aria-invalid": error ? true : undefined, "aria-describedby": error ? errorId : undefined }),
56
+ showSendButton && (React.createElement("button", { type: 'button', onClick: () => handleSubmit(value), className: 'cio-pia-send-button', disabled: disabled },
57
+ translate('Send', translations),
58
+ React.createElement(SendIcon, null)))),
59
+ error && (React.createElement("span", { className: 'cio-pia-input-error', id: errorId, role: 'alert' }, error)))));
42
60
  }
43
61
  export default Input;
@@ -21,11 +21,38 @@
21
21
  1px 1px 1px 0px rgba(18, 18, 105, 0.05) inset;
22
22
  }
23
23
 
24
+ /*
25
+ * Input Without A Send Button
26
+ *
27
+ * The right padding above is room for the Send button. With no button there is nothing to keep
28
+ * clear of, and leaving it would truncate what the shopper typed well short of the border.
29
+ */
30
+ .cio-pia-input--no-send {
31
+ padding-right: 12px;
32
+ }
33
+
24
34
  /* Input Hover */
25
35
  .cio-pia-input:hover {
26
36
  border: 1px solid #c8cad0;
27
37
  }
28
38
 
39
+ /* Input Rejected Value */
40
+ .cio-pia-input--error,
41
+ .cio-pia-input--error:hover,
42
+ .cio-pia-input--error:focus {
43
+ border: 1px solid #d93025;
44
+ box-shadow: none;
45
+ }
46
+
47
+ /* Input Rejected Value Message */
48
+ .cio-pia-input-error {
49
+ display: block;
50
+ margin-top: 4px;
51
+ color: #d93025;
52
+ font-size: 12px;
53
+ line-height: 16px;
54
+ }
55
+
29
56
  /* Input Focus */
30
57
  .cio-pia-input:focus {
31
58
  outline: none;
@@ -1,9 +1,8 @@
1
1
  import React from 'react';
2
2
  import { QuestionIcon } from '../icons';
3
- function SuggestedQuestion({ question, onClick }) {
3
+ function SuggestedQuestion({ question, onClick, icon }) {
4
4
  return (React.createElement("button", { type: 'button', className: 'cio-pia-suggested-question', onClick: onClick },
5
- React.createElement("div", { className: 'cio-pia-suggested-question-icon' },
6
- React.createElement(QuestionIcon, null)),
5
+ React.createElement("div", { className: 'cio-pia-suggested-question-icon' }, icon ?? React.createElement(QuestionIcon, null)),
7
6
  question));
8
7
  }
9
8
  export default SuggestedQuestion;
@@ -21,7 +21,7 @@
21
21
  }
22
22
 
23
23
  .cio-pia-suggested-question-skeleton {
24
- height: 28px;
24
+ height: 34px;
25
25
  border-radius: 4px;
26
26
  background-color: rgba(0, 0, 0, 0.05);
27
27
  animation: loading-animation 2s infinite ease-in-out;
@@ -12,3 +12,14 @@ export const MOCK_QUESTIONS = [
12
12
  { value: 'What is the serving size and calorie count?' },
13
13
  ];
14
14
  export const DISCLAIMER_TEXT = 'AI-generated answers aim to help, but they may occasionally miss details or be inaccurate. Double-check important information before purchasing.';
15
+ /**
16
+ * Recommendations pod strings the API cannot supply, because in these states there is either
17
+ * no response yet or no usable response at all. Everything else the pod shows comes from the
18
+ * API. All five are `Translations` keys, so reference the constants instead of retyping the
19
+ * literals - a mistyped key falls back to itself with no warning.
20
+ */
21
+ export const RECS_LOADING_TITLE = 'Adapting recommendations to your preference';
22
+ export const RECS_FALLBACK_TITLE = 'Best selling products';
23
+ export const RECS_UNSUPPORTED_REQUEST = 'Unsupported request, try a different feature.';
24
+ export const RECS_REFINEMENT_LABEL = "Not what you're looking for? Try:";
25
+ export const RECS_INPUT_PLACEHOLDER = 'Describe something else...';
@@ -1,15 +1,5 @@
1
1
  import { useCallback, useMemo, useState } from 'react';
2
2
  import { extractAndTransformItems } from '../utils/transformers';
3
- const fetchAnswerResults = async ({ client, itemId, question, variationId, threadId, parameters, }) => {
4
- const response = await client.agent.getAnswerResults({
5
- itemId,
6
- variationId,
7
- threadId,
8
- question,
9
- parameters,
10
- });
11
- return response;
12
- };
13
3
  export default function useAnswerResults({ itemId, variationId, threadId, cioClient, parameters, formatImageUrl, }) {
14
4
  const [answerResults, setAnswerResults] = useState(null);
15
5
  const [isLoading, setIsLoading] = useState(false);
@@ -21,9 +11,10 @@ export default function useAnswerResults({ itemId, variationId, threadId, cioCli
21
11
  setIsLoading(true);
22
12
  setError(null);
23
13
  setAnswerResults(null);
24
- fetchAnswerResults({ client: cioClient, itemId, question, variationId, threadId, parameters })
25
- .then((fetchedAnswerResults) => {
26
- setAnswerResults(fetchedAnswerResults);
14
+ cioClient.agent.pia
15
+ .getAnswerResults(itemId, question, { threadId, variationId, ...parameters })
16
+ .then((response) => {
17
+ setAnswerResults(response);
27
18
  setError(null);
28
19
  })
29
20
  .catch((err) => {
@@ -1,35 +1,66 @@
1
- import { useMemo, useState } from 'react';
2
- import MockConstructorIOClient from './mocks/MockConstructorIOClient';
3
1
  import useAnswerResults from './useAnswerResults';
2
+ import usePiaClient from './usePiaClient';
4
3
  import useSuggestedQuestions from './useSuggestedQuestions';
5
- import version from '../version';
4
+ // TODO: remove SUPPORTED_DEPRECATED_PARAMS and mapDeprecatedParameters once the
5
+ // deprecated `parameters` prop is removed from CioPiaProps.
6
+ const SUPPORTED_DEPRECATED_PARAMS = {
7
+ guard: { camelKey: 'guard', isObject: false },
8
+ num_results: { camelKey: 'numResults', isObject: false },
9
+ numResults: { camelKey: 'numResults', isObject: false },
10
+ pre_filter_expression: { camelKey: 'preFilterExpression', isObject: true },
11
+ preFilterExpression: { camelKey: 'preFilterExpression', isObject: true },
12
+ fmt_options: { camelKey: 'fmtOptions', isObject: true },
13
+ fmtOptions: { camelKey: 'fmtOptions', isObject: true },
14
+ };
15
+ function mapDeprecatedParameters(params) {
16
+ if (!params)
17
+ return undefined;
18
+ const mapped = {};
19
+ let hasEntries = false;
20
+ for (const [key, value] of Object.entries(params)) {
21
+ const config = SUPPORTED_DEPRECATED_PARAMS[key];
22
+ if (!config)
23
+ continue;
24
+ hasEntries = true;
25
+ if (config.isObject && typeof value === 'string') {
26
+ try {
27
+ mapped[config.camelKey] = JSON.parse(value);
28
+ }
29
+ catch {
30
+ mapped[config.camelKey] = value;
31
+ }
32
+ }
33
+ else {
34
+ mapped[config.camelKey] = value;
35
+ }
36
+ }
37
+ return hasEntries ? mapped : undefined;
38
+ }
6
39
  export default function useCioPia(props) {
7
- const { apiKey, itemId, variationId, threadId: providedThreadId, cioClient: providedClient, suggestedQuestionsParameters, parameters, formatImageUrl, } = props;
8
- const [generatedThreadId] = useState(() => crypto.randomUUID());
9
- const threadId = providedThreadId || generatedThreadId;
10
- const client = useMemo(() => {
11
- if (providedClient)
12
- return providedClient;
13
- return new MockConstructorIOClient({
14
- apiKey,
15
- sendTrackingEvents: true,
16
- version: `cio-ui-pia-${version}`,
17
- });
18
- }, [apiKey, providedClient]);
40
+ const { apiKey, itemId, variationId, threadId: providedThreadId, cioClient: providedClient, suggestedQuestionsParameters, answerParameters, parameters, formatImageUrl, } = props;
41
+ const { cioClient: client, threadId } = usePiaClient({
42
+ apiKey,
43
+ threadId: providedThreadId,
44
+ cioClient: providedClient,
45
+ });
46
+ const mappedDeprecated = mapDeprecatedParameters(parameters);
47
+ const mergedSuggestedParams = mappedDeprecated || suggestedQuestionsParameters
48
+ ? { ...mappedDeprecated, ...suggestedQuestionsParameters }
49
+ : undefined;
50
+ const mergedAnswerParams = mappedDeprecated || answerParameters ? { ...mappedDeprecated, ...answerParameters } : undefined;
19
51
  const suggestedQuestions = useSuggestedQuestions({
20
52
  itemId,
21
53
  variationId,
22
54
  threadId,
23
55
  cioClient: client,
24
- parameters: suggestedQuestionsParameters,
25
- requestParameters: parameters,
56
+ parameters: mergedSuggestedParams,
26
57
  });
27
58
  const answers = useAnswerResults({
28
59
  itemId,
29
60
  variationId,
30
61
  threadId,
31
62
  cioClient: client,
32
- parameters,
63
+ parameters: mergedAnswerParams,
33
64
  formatImageUrl,
34
65
  });
35
66
  return {
@@ -0,0 +1,17 @@
1
+ import { useMemo, useState } from 'react';
2
+ import ConstructorIOClient from '@constructor-io/constructorio-client-javascript';
3
+ import version from '../version';
4
+ export default function usePiaClient({ apiKey, threadId: providedThreadId, cioClient: providedClient, }) {
5
+ const [generatedThreadId] = useState(() => crypto.randomUUID());
6
+ const threadId = providedThreadId || generatedThreadId;
7
+ const client = useMemo(() => {
8
+ if (providedClient)
9
+ return providedClient;
10
+ return new ConstructorIOClient({
11
+ apiKey,
12
+ sendTrackingEvents: true,
13
+ version: `cio-ui-pia-${version}`,
14
+ });
15
+ }, [apiKey, providedClient]);
16
+ return { cioClient: client, threadId };
17
+ }
@@ -0,0 +1,129 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { AgentRequestError } from '../errors';
3
+ import { translate } from '../utils/translate';
4
+ import { RECS_FALLBACK_TITLE, RECS_LOADING_TITLE, RECS_UNSUPPORTED_REQUEST } from '../constants';
5
+ const DEFAULT_STRATEGY = 'complementary_items';
6
+ /**
7
+ * Owns everything a recommendations pod shows: one request on mount, one on every refinement,
8
+ * and the title that belongs to whichever of those is happening right now.
9
+ *
10
+ * The previous items are deliberately kept while a new request is in flight, so a refinement
11
+ * swaps the products without collapsing the row they sit in. The title is the one thing that does
12
+ * change, so the shopper can see the pod reacting to what they asked for.
13
+ */
14
+ export default function useRecsPod({ itemId, variationId, threadId, cioClient, parameters, formatImageUrl, translations, }) {
15
+ const [result, setResult] = useState(null);
16
+ const [isLoading, setIsLoading] = useState(true);
17
+ const [error, setError] = useState(null);
18
+ const [hasUnsupportedInput, setHasUnsupportedInput] = useState(false);
19
+ const [lastShopperInput, setLastShopperInput] = useState('');
20
+ // Counts requests so a slow response cannot overwrite a newer one. There is no
21
+ // AbortController in this library, so the older request still completes; its result is
22
+ // simply dropped.
23
+ const requestIdRef = useRef(0);
24
+ // Mirrored into a ref so it never becomes a fetch dependency. It is commonly written inline by
25
+ // the caller, which would give it a new identity on every render and refetch forever.
26
+ const formatImageUrlRef = useRef(formatImageUrl);
27
+ useEffect(() => {
28
+ formatImageUrlRef.current = formatImageUrl;
29
+ }, [formatImageUrl]);
30
+ const strategy = parameters?.strategy || DEFAULT_STRATEGY;
31
+ const { numResults, defaultTitle } = parameters || {};
32
+ const fetchResult = useCallback((shopperInput, source) => {
33
+ // TODO: once getRecs is added to the JS client SDK, replace this guard+cast
34
+ // with a direct call: cioClient.agent.getRecs({...})
35
+ const getRecs = cioClient?.agent?.getRecs;
36
+ if (!cioClient || !getRecs) {
37
+ if (cioClient && !getRecs) {
38
+ console.info('[CioPia] getRecs is not available on the client SDK yet.');
39
+ }
40
+ setIsLoading(false);
41
+ return;
42
+ }
43
+ requestIdRef.current += 1;
44
+ const requestId = requestIdRef.current;
45
+ const isCurrent = () => requestId === requestIdRef.current;
46
+ setIsLoading(true);
47
+ setError(null);
48
+ setHasUnsupportedInput(false);
49
+ getRecs
50
+ .call(cioClient.agent, {
51
+ itemId,
52
+ variationId,
53
+ threadId,
54
+ strategy,
55
+ shopperInput,
56
+ numResults,
57
+ formatImageUrl: formatImageUrlRef.current,
58
+ })
59
+ .then((fetchedResult) => {
60
+ if (!isCurrent())
61
+ return;
62
+ setResult(fetchedResult);
63
+ setLastShopperInput(shopperInput || '');
64
+ })
65
+ .catch((err) => {
66
+ if (!isCurrent())
67
+ return;
68
+ const failure = err instanceof Error ? err : new Error('Error fetching recommendations');
69
+ const status = failure instanceof AgentRequestError ? failure.status : undefined;
70
+ if (status === 400) {
71
+ // A 400 means the request we built was malformed, which is our bug, not the
72
+ // shopper's. Make it loud so it is caught in development.
73
+ console.error('Constructor PIA: the recommendations request was rejected.', failure);
74
+ }
75
+ // A rejected input changes nothing but the input, so the response on screen stays.
76
+ // Only text the shopper wrote can be rejected this way: an option is a label the API
77
+ // suggested, so blaming their input for it would point at the wrong thing entirely.
78
+ if (status === 422 && source === 'input') {
79
+ setHasUnsupportedInput(true);
80
+ return;
81
+ }
82
+ setError(failure);
83
+ })
84
+ .finally(() => {
85
+ if (!isCurrent())
86
+ return;
87
+ setIsLoading(false);
88
+ });
89
+ },
90
+ // Every dependency is a primitive. `formatImageUrl` is read through a ref instead, so a
91
+ // caller writing it inline cannot restart the request on every render.
92
+ [cioClient, itemId, variationId, threadId, strategy, numResults]);
93
+ useEffect(() => {
94
+ fetchResult();
95
+ }, [fetchResult]);
96
+ const refine = useCallback((text, source) => {
97
+ const refinementText = text.trim();
98
+ if (!refinementText)
99
+ return;
100
+ fetchResult(refinementText, source);
101
+ }, [fetchResult]);
102
+ // The title belonging to the last response we kept. `defaultTitle` is the caller's last resort
103
+ // for a response that carried items but no title of its own.
104
+ const settledTitle = result?.title || defaultTitle || '';
105
+ let title = settledTitle;
106
+ if (isLoading) {
107
+ // The loading copy wins whether or not a title is held, so a refinement announces itself
108
+ // rather than leaving the previous personalization above products that no longer match it.
109
+ title = translate(RECS_LOADING_TITLE, translations);
110
+ }
111
+ else if (!hasUnsupportedInput && (error || result?.status === 'partial')) {
112
+ // Either the request failed or it came back degraded, so the personalized title cannot be
113
+ // trusted. Any products it did carry are still worth showing under a generic title.
114
+ title = translate(RECS_FALLBACK_TITLE, translations);
115
+ }
116
+ return {
117
+ title,
118
+ // Normalized so every caller can treat "nothing to render" as a single case. Our own adapter
119
+ // never returns an empty array, but `cioClient` is a public prop and a consumer-supplied client
120
+ // can. Note `[]` is truthy, so `|| null` would not catch it.
121
+ items: result?.items?.length ? result.items : null,
122
+ refinement: result?.refinement || null,
123
+ isLoading,
124
+ error,
125
+ inputError: hasUnsupportedInput ? translate(RECS_UNSUPPORTED_REQUEST, translations) : null,
126
+ lastShopperInput,
127
+ refine,
128
+ };
129
+ }
@@ -1,35 +1,22 @@
1
- import { useCallback, useEffect, useState } from 'react';
2
- const fetchSuggestedQuestions = async ({ client, itemId, variationId, threadId, parameters, requestParameters, }) => {
3
- const response = await client.agent.getSuggestedQuestions({
4
- itemId,
5
- variationId,
6
- threadId,
7
- parameters,
8
- requestParameters,
9
- });
10
- return response.questions;
11
- };
12
- export default function useSuggestedQuestions({ itemId, variationId, threadId, cioClient, parameters, requestParameters, }) {
1
+ import { useCallback, useEffect, useMemo, useState } from 'react';
2
+ export default function useSuggestedQuestions({ itemId, variationId, threadId, cioClient, parameters, }) {
13
3
  const [questions, setQuestions] = useState([]);
14
4
  const [isLoading, setIsLoading] = useState(false);
15
5
  const [error, setError] = useState(null);
16
- // Serialize to a primitive so an object-literal prop doesn't retrigger the auto-fetch effect each render.
17
- const requestParametersKey = JSON.stringify(requestParameters);
6
+ const serializedParameters = useMemo(() => JSON.stringify(parameters), [parameters]);
18
7
  const fetchResult = useCallback(() => {
19
8
  if (!cioClient)
20
9
  return;
21
10
  setIsLoading(true);
22
11
  setError(null);
23
- fetchSuggestedQuestions({
24
- client: cioClient,
25
- itemId,
26
- variationId,
12
+ cioClient.agent.pia
13
+ .getSuggestedQuestions(itemId, {
27
14
  threadId,
28
- parameters,
29
- requestParameters,
15
+ variationId,
16
+ ...parameters,
30
17
  })
31
- .then((fetchedQuestions) => {
32
- setQuestions(fetchedQuestions);
18
+ .then((response) => {
19
+ setQuestions(response.questions);
33
20
  setError(null);
34
21
  })
35
22
  .catch((err) => {
@@ -38,8 +25,9 @@ export default function useSuggestedQuestions({ itemId, variationId, threadId, c
38
25
  .finally(() => {
39
26
  setIsLoading(false);
40
27
  });
41
- // eslint-disable-next-line react-hooks/exhaustive-deps -- primitive deps prevent refetch loop
42
- }, [cioClient, itemId, variationId, threadId, parameters?.numResults, requestParametersKey]);
28
+ // parameters is serialized via serializedParameters to prevent refetch on identity changes
29
+ // eslint-disable-next-line react-hooks/exhaustive-deps
30
+ }, [cioClient, itemId, variationId, threadId, serializedParameters]);
43
31
  useEffect(() => {
44
32
  fetchResult();
45
33
  }, [fetchResult]);
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useEffect, useRef } from 'react';
2
2
  const FLUSH_INTERVAL_MS = 5 * 60 * 1000;
3
- export default function useViewportTracking({ tracking, questions, }) {
3
+ const DEFAULT_VIEW_THRESHOLD = 0.5;
4
+ export default function useViewportTracking({ tracking, questions, viewThreshold = DEFAULT_VIEW_THRESHOLD, }) {
4
5
  const nodeRef = useRef(null);
5
6
  const observerRef = useRef(null);
6
7
  const intervalRef = useRef(null);
@@ -48,7 +49,7 @@ export default function useViewportTracking({ tracking, questions, }) {
48
49
  entryTimeRef.current = null;
49
50
  trackingRef.current.trackOutOfView();
50
51
  }
51
- }, { threshold: 0.5 });
52
+ }, { threshold: viewThreshold });
52
53
  observer.observe(element);
53
54
  observerRef.current = observer;
54
55
  intervalRef.current = setInterval(flush, FLUSH_INTERVAL_MS);
@@ -9,6 +9,7 @@
9
9
  @import './components/CioPia/CioPiaStyles.css';
10
10
  @import './components/ConversationHistory/ConversationHistoryStyles.css';
11
11
  @import './components/PiaConversation/PiaConversationStyles.css';
12
+ @import './components/CioPiaRecs/CioPiaRecsStyles.css';
12
13
 
13
14
  .cio-pia-container {
14
15
  font-family: 'Inter', sans-serif;
package/lib/mjs/types.js CHANGED
@@ -3,4 +3,3 @@ export var FeedbackType;
3
3
  FeedbackType["UP"] = "up";
4
4
  FeedbackType["DOWN"] = "down";
5
5
  })(FeedbackType || (FeedbackType = {}));
6
- export * from './hooks/mocks/types';
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Joins class names, dropping the falsy ones, so a conditional class can be written inline.
3
+ *
4
+ * Deliberately not `clsx` or `classnames`: neither is a dependency of this package, and the copy of
5
+ * `clsx` in the tree belongs to `@constructor-io/constructorio-ui-components`, which is a peer
6
+ * dependency - importing it here would work locally and break in a consumer that hoists
7
+ * differently. Worth revisiting if this ever needs to accept objects or nested arrays.
8
+ */
9
+ export const cx = (...parts) => parts.filter(Boolean).join(' ');
@@ -0,0 +1,9 @@
1
+ import { translate } from './translate';
2
+ import { RECS_REFINEMENT_LABEL } from '../constants';
3
+ /**
4
+ * The line that introduces the refinement options. Prefers the prompt the API sent, so our
5
+ * copy and theirs cannot drift.
6
+ */
7
+ export function resolveRefinementQuestion(refinement, translations) {
8
+ return refinement?.question || translate(RECS_REFINEMENT_LABEL, translations);
9
+ }
@@ -1,4 +1,4 @@
1
- import { DISCLAIMER_TEXT } from '../constants';
1
+ import { DISCLAIMER_TEXT, RECS_FALLBACK_TITLE, RECS_INPUT_PLACEHOLDER, RECS_LOADING_TITLE, RECS_REFINEMENT_LABEL, RECS_UNSUPPORTED_REQUEST, } from '../constants';
2
2
  const defaultTranslations = {
3
3
  'Any questions about this product?': 'Any questions about this product?',
4
4
  'Ask anything': 'Ask anything',
@@ -8,6 +8,11 @@ const defaultTranslations = {
8
8
  'Learn More.': 'Learn More.',
9
9
  'Ask about this product': 'Ask about this product',
10
10
  'Add to Cart': 'Add to Cart',
11
+ [RECS_LOADING_TITLE]: RECS_LOADING_TITLE,
12
+ [RECS_FALLBACK_TITLE]: RECS_FALLBACK_TITLE,
13
+ [RECS_UNSUPPORTED_REQUEST]: RECS_UNSUPPORTED_REQUEST,
14
+ [RECS_REFINEMENT_LABEL]: RECS_REFINEMENT_LABEL,
15
+ [RECS_INPUT_PLACEHOLDER]: RECS_INPUT_PLACEHOLDER,
11
16
  };
12
17
  /**
13
18
  * Translates a word using the provided translations object.
@@ -1 +1 @@
1
- export default '2.10.4';
1
+ export default '2.12.0';
@@ -1,38 +1,3 @@
1
1
  import React from 'react';
2
- import { IncludeComponentOverrides, IncludeRenderProps } from '@constructor-io/constructorio-ui-components';
3
- import MockConstructorIOClient from '../../hooks/mocks/MockConstructorIOClient';
4
- import { CioPiaRenderProps, CioPiaComponentOverrides, Callbacks, CioPiaDisplayConfigs, Translations, SuggestedQuestionsParameters, Formatters, ProductCardDisplayProps } from '../../types';
5
- export interface CioPiaProps extends IncludeRenderProps<CioPiaRenderProps>, IncludeComponentOverrides<CioPiaComponentOverrides> {
6
- /** Your Constructor.io API key. */
7
- apiKey: string;
8
- /** The product item ID to fetch insights for. */
9
- itemId: string;
10
- /** The product display name, sent with tracking events. */
11
- itemName: string;
12
- /** Thread ID for conversation context. Must be a valid UUID (e.g., "550e8400-e29b-41d4-a716-446655440000"). */
13
- threadId?: string;
14
- /** Optional variation ID for the product. */
15
- variationId?: string;
16
- /** Optional Constructor.io client instance. If not provided, one will be created internally. */
17
- cioClient?: MockConstructorIOClient;
18
- /** Display configuration options (mode, type, showFeedback, etc.). */
19
- displayConfigs?: CioPiaDisplayConfigs;
20
- /** Callback handlers for user interactions (onQuestionSubmit, onProductCardClick, onFeedback). */
21
- callbacks?: Callbacks;
22
- /** Custom component overrides via reactNode or render props functions. */
23
- componentOverrides?: CioPiaComponentOverrides;
24
- /** Formatter functions for transforming data before display. */
25
- formatters?: Formatters;
26
- /** Props forwarded to the ProductCard rendered inside the carousel. */
27
- productCardProps?: ProductCardDisplayProps;
28
- /** UI string translations for internationalization. */
29
- translations?: Translations;
30
- /** Parameters for the suggested questions request. */
31
- suggestedQuestionsParameters?: SuggestedQuestionsParameters;
32
- /**
33
- * Extra query parameters appended to PIA API requests (e.g. `ef-*` test cell params).
34
- * Define outside the component or wrap with useMemo to avoid unnecessary re-renders.
35
- */
36
- parameters?: Record<string, string | number | boolean>;
37
- }
2
+ import type { CioPiaProps } from './types';
38
3
  export default function CioPia(props: CioPiaProps): React.JSX.Element;
@@ -0,0 +1,4 @@
1
+ import React from 'react';
2
+ import type { CioPiaProps } from './types';
3
+ /** The question-and-answer experience: `mode: 'default'`, `mode: 'conversation'`, and the modal. */
4
+ export default function CioPiaQna(props: CioPiaProps): React.JSX.Element;
@@ -1,3 +1,4 @@
1
1
  import CioPia from './CioPia';
2
2
  export * from './CioPia';
3
+ export type { CioPiaProps } from './types';
3
4
  export default CioPia;
@@ -0,0 +1,55 @@
1
+ import { IncludeComponentOverrides, IncludeRenderProps } from '@constructor-io/constructorio-ui-components';
2
+ import { CioPiaRenderProps, CioPiaComponentOverrides, Callbacks, CioPiaDisplayConfigs, CioPiaTrackingConfigs, Translations, SuggestedQuestionsParameters, AnswerRequestParameters, RecsPodParameters, Formatters, ProductCardDisplayProps } from '../../types';
3
+ import type { CioClient } from '../../hooks/usePiaClient';
4
+ export interface CioPiaProps extends IncludeRenderProps<CioPiaRenderProps>, IncludeComponentOverrides<CioPiaComponentOverrides> {
5
+ /** Your Constructor.io API key. */
6
+ apiKey: string;
7
+ /** The product item ID to fetch insights for. */
8
+ itemId: string;
9
+ /** The product display name, sent with tracking events. */
10
+ itemName: string;
11
+ /** Thread ID for conversation context. Must be a valid UUID (e.g., "550e8400-e29b-41d4-a716-446655440000"). */
12
+ threadId?: string;
13
+ /** Optional variation ID for the product. */
14
+ variationId?: string;
15
+ /** Optional Constructor.io client instance. If not provided, one will be created internally. */
16
+ cioClient?: CioClient;
17
+ /** Display configuration options (mode, type, showFeedback, etc.). */
18
+ displayConfigs?: CioPiaDisplayConfigs;
19
+ /** Tracking configuration options (viewThreshold, etc.). */
20
+ trackingConfigs?: CioPiaTrackingConfigs;
21
+ /** Callback handlers for user interactions (onQuestionSubmit, onProductCardClick, onFeedback). */
22
+ callbacks?: Callbacks;
23
+ /** Custom component overrides via reactNode or render props functions. */
24
+ componentOverrides?: CioPiaComponentOverrides;
25
+ /** Formatter functions for transforming data before display. */
26
+ formatters?: Formatters;
27
+ /** Props forwarded to the ProductCard rendered inside the carousel. */
28
+ productCardProps?: ProductCardDisplayProps;
29
+ /** UI string translations for internationalization. */
30
+ translations?: Translations;
31
+ /** Parameters for the suggested questions request. */
32
+ suggestedQuestionsParameters?: SuggestedQuestionsParameters;
33
+ /** Parameters for the answer request. */
34
+ answerParameters?: AnswerRequestParameters;
35
+ /** Parameters for the recommendations request, used by `mode: 'recommendations'`. */
36
+ recsPodParameters?: RecsPodParameters;
37
+ /**
38
+ * @deprecated Use `answerParameters` and `suggestedQuestionsParameters` instead.
39
+ *
40
+ * Only the following keys are forwarded (snake_case or camelCase accepted):
41
+ *
42
+ * Suggested-questions endpoint:
43
+ * - `num_results` / `numResults` → numResults
44
+ * - `pre_filter_expression` / `preFilterExpression` → preFilterExpression (JSON-parsed if string)
45
+ *
46
+ * Answer endpoint:
47
+ * - `guard` → guard
48
+ * - `pre_filter_expression` / `preFilterExpression` → preFilterExpression (JSON-parsed if string)
49
+ * - `fmt_options` / `fmtOptions` → fmtOptions (JSON-parsed if string)
50
+ *
51
+ * All other keys (e.g. `ef-*`) are silently dropped.
52
+ * Typed parameters take precedence over values specified here.
53
+ */
54
+ parameters?: Record<string, string | number | boolean>;
55
+ }