@constructor-io/constructorio-ui-pia 2.10.4 → 2.11.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 (70) hide show
  1. package/README.md +7 -0
  2. package/dist/constructorio-ui-pia-bundled.js +50 -50
  3. package/lib/cjs/components/CioPia/CioPia.js +8 -96
  4. package/lib/cjs/components/CioPia/CioPiaQna.js +110 -0
  5. package/lib/cjs/components/CioPia/CioPiaStyles.css +19 -0
  6. package/lib/cjs/components/CioPia/types.js +2 -0
  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/mocks/agent.js +17 -0
  18. package/lib/cjs/hooks/useCioPia.js +6 -14
  19. package/lib/cjs/hooks/usePiaClient.js +26 -0
  20. package/lib/cjs/hooks/useRecsPod.js +129 -0
  21. package/lib/cjs/hooks/useViewportTracking.js +3 -2
  22. package/lib/cjs/styles.css +1 -0
  23. package/lib/cjs/utils/classNames.js +13 -0
  24. package/lib/cjs/utils/recs.js +13 -0
  25. package/lib/cjs/utils/translate.js +5 -0
  26. package/lib/cjs/version.js +1 -1
  27. package/lib/mjs/components/CioPia/CioPia.js +7 -95
  28. package/lib/mjs/components/CioPia/CioPiaQna.js +105 -0
  29. package/lib/mjs/components/CioPia/CioPiaStyles.css +19 -0
  30. package/lib/mjs/components/CioPia/types.js +1 -0
  31. package/lib/mjs/components/CioPiaRecs/CioPiaRecs.js +69 -0
  32. package/lib/mjs/components/CioPiaRecs/CioPiaRecsStyles.css +138 -0
  33. package/lib/mjs/components/CioPiaRecs/RecsCarouselSkeleton.js +54 -0
  34. package/lib/mjs/components/CioPiaRecs/RecsOptionsSkeleton.js +14 -0
  35. package/lib/mjs/components/CioPiaRecs/RecsPodRefinement.js +23 -0
  36. package/lib/mjs/components/Input/Input.js +27 -9
  37. package/lib/mjs/components/Input/InputStyles.css +27 -0
  38. package/lib/mjs/components/SuggestedQuestion/SuggestedQuestion.js +2 -3
  39. package/lib/mjs/components/SuggestedQuestionsContainer/SuggestedQuestionsContainerStyles.css +1 -1
  40. package/lib/mjs/constants.js +11 -0
  41. package/lib/mjs/hooks/mocks/agent.js +15 -0
  42. package/lib/mjs/hooks/useCioPia.js +6 -14
  43. package/lib/mjs/hooks/usePiaClient.js +22 -0
  44. package/lib/mjs/hooks/useRecsPod.js +125 -0
  45. package/lib/mjs/hooks/useViewportTracking.js +3 -2
  46. package/lib/mjs/styles.css +1 -0
  47. package/lib/mjs/utils/classNames.js +9 -0
  48. package/lib/mjs/utils/recs.js +9 -0
  49. package/lib/mjs/utils/translate.js +6 -1
  50. package/lib/mjs/version.js +1 -1
  51. package/lib/types/components/CioPia/CioPia.d.ts +1 -36
  52. package/lib/types/components/CioPia/CioPiaQna.d.ts +4 -0
  53. package/lib/types/components/CioPia/index.d.ts +1 -0
  54. package/lib/types/components/CioPia/types.d.ts +40 -0
  55. package/lib/types/components/CioPiaRecs/CioPiaRecs.d.ts +13 -0
  56. package/lib/types/components/CioPiaRecs/RecsCarouselSkeleton.d.ts +16 -0
  57. package/lib/types/components/CioPiaRecs/RecsOptionsSkeleton.d.ts +16 -0
  58. package/lib/types/components/CioPiaRecs/RecsPodRefinement.d.ts +21 -0
  59. package/lib/types/components/Input/Input.d.ts +9 -1
  60. package/lib/types/components/SuggestedQuestion/SuggestedQuestion.d.ts +4 -2
  61. package/lib/types/constants.d.ts +11 -0
  62. package/lib/types/hooks/mocks/agent.d.ts +13 -0
  63. package/lib/types/hooks/usePiaClient.d.ts +17 -0
  64. package/lib/types/hooks/useRecsPod.d.ts +52 -0
  65. package/lib/types/hooks/useViewportTracking.d.ts +2 -1
  66. package/lib/types/types.d.ts +76 -1
  67. package/lib/types/utils/classNames.d.ts +9 -0
  68. package/lib/types/utils/recs.d.ts +6 -0
  69. package/lib/types/version.d.ts +1 -1
  70. package/package.json +1 -1
@@ -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
+ }
@@ -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...';
@@ -110,6 +110,21 @@ class MockAgent {
110
110
  throw new Error(String(error));
111
111
  }
112
112
  }
113
+ /**
114
+ * Fetches one set of recommendations, in the shape the pod renders.
115
+ *
116
+ * Recommendations are not available yet: there is no endpoint to ask, so nothing is requested and
117
+ * an empty result is returned. The pod renders nothing in that state, which leaves whatever the
118
+ * retailer already had in that slot showing through. Supply a `cioClient` with your own
119
+ * `agent.getRecs` to drive the pod from your own data in the meantime.
120
+ *
121
+ * `props` is still declared, because it is the contract a caller's own `getRecs` implements and
122
+ * `useRecsPod` passes it - there is simply nothing here to send it to yet.
123
+ */
124
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
125
+ async getRecs(props) {
126
+ return { title: '', items: null, refinement: null, status: 'complete' };
127
+ }
113
128
  async getAnswerResultsStream({ itemId, threadId, variationId, question, parameters, onStart, onMessage, onEnd, }) {
114
129
  if (!itemId)
115
130
  throw new Error('Item ID is required');
@@ -1,21 +1,13 @@
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';
6
4
  export default function useCioPia(props) {
7
5
  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]);
6
+ const { cioClient: client, threadId } = usePiaClient({
7
+ apiKey,
8
+ threadId: providedThreadId,
9
+ cioClient: providedClient,
10
+ });
19
11
  const suggestedQuestions = useSuggestedQuestions({
20
12
  itemId,
21
13
  variationId,
@@ -0,0 +1,22 @@
1
+ import { useMemo, useState } from 'react';
2
+ import MockConstructorIOClient from './mocks/MockConstructorIOClient';
3
+ import version from '../version';
4
+ /**
5
+ * Resolves the client and thread ID every mode needs: the caller's client when they supplied
6
+ * one, otherwise a client built from the API key, plus a generated thread ID that stays stable
7
+ * for the life of the component.
8
+ */
9
+ export default function usePiaClient({ apiKey, threadId: providedThreadId, cioClient: providedClient, }) {
10
+ const [generatedThreadId] = useState(() => crypto.randomUUID());
11
+ const threadId = providedThreadId || generatedThreadId;
12
+ const client = useMemo(() => {
13
+ if (providedClient)
14
+ return providedClient;
15
+ return new MockConstructorIOClient({
16
+ apiKey,
17
+ sendTrackingEvents: true,
18
+ version: `cio-ui-pia-${version}`,
19
+ });
20
+ }, [apiKey, providedClient]);
21
+ return { cioClient: client, threadId };
22
+ }
@@ -0,0 +1,125 @@
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
+ // Nothing to ask, so settle the loading state rather than leaving the caller in a skeleton
34
+ // that never resolves.
35
+ if (!cioClient) {
36
+ setIsLoading(false);
37
+ return;
38
+ }
39
+ requestIdRef.current += 1;
40
+ const requestId = requestIdRef.current;
41
+ const isCurrent = () => requestId === requestIdRef.current;
42
+ setIsLoading(true);
43
+ setError(null);
44
+ setHasUnsupportedInput(false);
45
+ cioClient.agent
46
+ .getRecs({
47
+ itemId,
48
+ variationId,
49
+ threadId,
50
+ strategy,
51
+ shopperInput,
52
+ numResults,
53
+ formatImageUrl: formatImageUrlRef.current,
54
+ })
55
+ .then((fetchedResult) => {
56
+ if (!isCurrent())
57
+ return;
58
+ setResult(fetchedResult);
59
+ setLastShopperInput(shopperInput || '');
60
+ })
61
+ .catch((err) => {
62
+ if (!isCurrent())
63
+ return;
64
+ const failure = err instanceof Error ? err : new Error('Error fetching recommendations');
65
+ const status = failure instanceof AgentRequestError ? failure.status : undefined;
66
+ if (status === 400) {
67
+ // A 400 means the request we built was malformed, which is our bug, not the
68
+ // shopper's. Make it loud so it is caught in development.
69
+ console.error('Constructor PIA: the recommendations request was rejected.', failure);
70
+ }
71
+ // A rejected input changes nothing but the input, so the response on screen stays.
72
+ // Only text the shopper wrote can be rejected this way: an option is a label the API
73
+ // suggested, so blaming their input for it would point at the wrong thing entirely.
74
+ if (status === 422 && source === 'input') {
75
+ setHasUnsupportedInput(true);
76
+ return;
77
+ }
78
+ setError(failure);
79
+ })
80
+ .finally(() => {
81
+ if (!isCurrent())
82
+ return;
83
+ setIsLoading(false);
84
+ });
85
+ },
86
+ // Every dependency is a primitive. `formatImageUrl` is read through a ref instead, so a
87
+ // caller writing it inline cannot restart the request on every render.
88
+ [cioClient, itemId, variationId, threadId, strategy, numResults]);
89
+ useEffect(() => {
90
+ fetchResult();
91
+ }, [fetchResult]);
92
+ const refine = useCallback((text, source) => {
93
+ const refinementText = text.trim();
94
+ if (!refinementText)
95
+ return;
96
+ fetchResult(refinementText, source);
97
+ }, [fetchResult]);
98
+ // The title belonging to the last response we kept. `defaultTitle` is the caller's last resort
99
+ // for a response that carried items but no title of its own.
100
+ const settledTitle = result?.title || defaultTitle || '';
101
+ let title = settledTitle;
102
+ if (isLoading) {
103
+ // The loading copy wins whether or not a title is held, so a refinement announces itself
104
+ // rather than leaving the previous personalization above products that no longer match it.
105
+ title = translate(RECS_LOADING_TITLE, translations);
106
+ }
107
+ else if (!hasUnsupportedInput && (error || result?.status === 'partial')) {
108
+ // Either the request failed or it came back degraded, so the personalized title cannot be
109
+ // trusted. Any products it did carry are still worth showing under a generic title.
110
+ title = translate(RECS_FALLBACK_TITLE, translations);
111
+ }
112
+ return {
113
+ title,
114
+ // Normalized so every caller can treat "nothing to render" as a single case. Our own adapter
115
+ // never returns an empty array, but `cioClient` is a public prop and a consumer-supplied client
116
+ // can. Note `[]` is truthy, so `|| null` would not catch it.
117
+ items: result?.items?.length ? result.items : null,
118
+ refinement: result?.refinement || null,
119
+ isLoading,
120
+ error,
121
+ inputError: hasUnsupportedInput ? translate(RECS_UNSUPPORTED_REQUEST, translations) : null,
122
+ lastShopperInput,
123
+ refine,
124
+ };
125
+ }
@@ -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);