@constructor-io/constructorio-ui-pia 2.13.1 → 2.15.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 (40) hide show
  1. package/README.md +76 -30
  2. package/dist/constructorio-ui-pia-bundled.js +39 -39
  3. package/lib/cjs/components/CioPia/CheckoutTriggerBar.js +25 -0
  4. package/lib/cjs/components/CioPia/CioPia.js +6 -1
  5. package/lib/cjs/components/CioPia/CioPiaQna.js +7 -2
  6. package/lib/cjs/components/CioPia/CioPiaStyles.css +33 -0
  7. package/lib/cjs/components/CioPiaControl/CioPiaControl.js +38 -0
  8. package/lib/cjs/components/CioPiaRecs/CioPiaRecs.js +3 -1
  9. package/lib/cjs/components/PiaConversation/PiaConversation.js +2 -1
  10. package/lib/cjs/components/PiaConversation/PiaConversationStyles.css +5 -0
  11. package/lib/cjs/hooks/useCioPia.js +2 -1
  12. package/lib/cjs/hooks/usePiaClient.js +14 -7
  13. package/lib/cjs/hooks/useViewportTracking.js +9 -4
  14. package/lib/cjs/stories/assets/storybook-styles.css +12 -10
  15. package/lib/cjs/version.js +1 -1
  16. package/lib/mjs/components/CioPia/CheckoutTriggerBar.js +20 -0
  17. package/lib/mjs/components/CioPia/CioPia.js +5 -0
  18. package/lib/mjs/components/CioPia/CioPiaQna.js +7 -2
  19. package/lib/mjs/components/CioPia/CioPiaStyles.css +33 -0
  20. package/lib/mjs/components/CioPiaControl/CioPiaControl.js +34 -0
  21. package/lib/mjs/components/CioPiaRecs/CioPiaRecs.js +3 -1
  22. package/lib/mjs/components/PiaConversation/PiaConversation.js +2 -1
  23. package/lib/mjs/components/PiaConversation/PiaConversationStyles.css +5 -0
  24. package/lib/mjs/hooks/useCioPia.js +2 -1
  25. package/lib/mjs/hooks/usePiaClient.js +15 -3
  26. package/lib/mjs/hooks/useViewportTracking.js +9 -4
  27. package/lib/mjs/stories/assets/storybook-styles.css +12 -10
  28. package/lib/mjs/version.js +1 -1
  29. package/lib/types/components/CioPia/CheckoutTriggerBar.d.ts +11 -0
  30. package/lib/types/components/CioPia/index.d.ts +1 -1
  31. package/lib/types/components/CioPia/types.d.ts +45 -1
  32. package/lib/types/components/CioPiaControl/CioPiaControl.d.ts +9 -0
  33. package/lib/types/components/PiaConversation/PiaConversation.d.ts +3 -2
  34. package/lib/types/hooks/useCioPia.d.ts +2 -0
  35. package/lib/types/hooks/usePiaClient.d.ts +2 -1
  36. package/lib/types/hooks/useViewportTracking.d.ts +6 -1
  37. package/lib/types/index.d.ts +1 -1
  38. package/lib/types/types.d.ts +33 -0
  39. package/lib/types/version.d.ts +1 -1
  40. package/package.json +1 -1
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const tslib_1 = require("tslib");
4
+ const react_1 = tslib_1.__importDefault(require("react"));
5
+ const translate_1 = require("../../utils/translate");
6
+ function CheckoutTriggerBar({ triggers, state, translations, }) {
7
+ const visible = triggers.filter((t) => (t.triggerWhen ? t.triggerWhen(state) : true));
8
+ if (visible.length === 0)
9
+ return null;
10
+ return (react_1.default.createElement("div", { className: 'cio-pia-checkout-triggers', "data-testid": 'cio-pia-checkout-triggers' }, visible.map((trigger) => {
11
+ var _a;
12
+ const label = (_a = trigger.label) !== null && _a !== void 0 ? _a : (0, translate_1.translate)('Checkout', translations);
13
+ const onClick = () => trigger.onTrigger(state);
14
+ if (trigger.renderButton) {
15
+ return (react_1.default.createElement(react_1.default.Fragment, { key: trigger.id }, trigger.renderButton({
16
+ onClick,
17
+ label,
18
+ disabled: trigger.disabled,
19
+ id: trigger.id,
20
+ })));
21
+ }
22
+ return (react_1.default.createElement("button", { key: trigger.id, type: 'button', className: 'cio-pia-checkout-trigger', "data-testid": `cio-pia-checkout-trigger-${trigger.id}`, disabled: trigger.disabled, onClick: onClick }, label));
23
+ })));
24
+ }
25
+ exports.default = CheckoutTriggerBar;
@@ -4,9 +4,14 @@ const tslib_1 = require("tslib");
4
4
  const react_1 = tslib_1.__importDefault(require("react"));
5
5
  const CioPiaQna_1 = tslib_1.__importDefault(require("./CioPiaQna"));
6
6
  const CioPiaRecs_1 = tslib_1.__importDefault(require("../CioPiaRecs/CioPiaRecs"));
7
+ const CioPiaControl_1 = tslib_1.__importDefault(require("../CioPiaControl/CioPiaControl"));
7
8
  function CioPia(props) {
8
- var _a, _b;
9
+ var _a, _b, _c;
9
10
  const mode = (_b = (_a = props.displayConfigs) === null || _a === void 0 ? void 0 : _a.mode) !== null && _b !== void 0 ? _b : 'default';
11
+ // Before the mode dispatch: a control shopper gets no widget in any mode.
12
+ if ((_c = props.abTest) === null || _c === void 0 ? void 0 : _c.isControl) {
13
+ return react_1.default.createElement(CioPiaControl_1.default, Object.assign({}, props));
14
+ }
10
15
  if (mode === 'recommendations') {
11
16
  return react_1.default.createElement(CioPiaRecs_1.default, Object.assign({}, props));
12
17
  }
@@ -18,11 +18,12 @@ const PiaInlineAnswer_1 = tslib_1.__importDefault(require("../PiaInlineAnswer/Pi
18
18
  const PiaModal_1 = tslib_1.__importDefault(require("../PiaConversation/PiaModal"));
19
19
  const PiaConversation_1 = tslib_1.__importDefault(require("../PiaConversation/PiaConversation"));
20
20
  const StatusRegion_1 = tslib_1.__importStar(require("../StatusRegion/StatusRegion"));
21
+ const CheckoutTriggerBar_1 = tslib_1.__importDefault(require("./CheckoutTriggerBar"));
21
22
  /** The question-and-answer experience: `mode: 'default'`, `mode: 'conversation'`, and the modal. */
22
23
  // eslint-disable-next-line complexity
23
24
  function CioPiaQna(props) {
24
25
  var _a;
25
- const { apiKey, itemId, itemName, threadId, variationId, cioClient, displayConfigs, componentOverrides, callbacks, formatters, productCardProps, children, translations, suggestedQuestionsParameters, answerParameters, parameters, trackingConfigs, } = props;
26
+ const { apiKey, itemId, itemName, threadId, variationId, cioClient, displayConfigs, componentOverrides, callbacks, formatters, productCardProps, children, translations, suggestedQuestionsParameters, answerParameters, parameters, trackingConfigs, checkoutTriggers, abTest, } = props;
26
27
  const { learnMoreUrl, showFeedback, mode = 'default', type = 'inline', showPreviousItems, disclaimerPosition = 'bottom', } = displayConfigs || {};
27
28
  const isConversation = mode === 'conversation' || type === 'modal';
28
29
  const { priceCurrency } = productCardProps || {};
@@ -36,6 +37,7 @@ function CioPiaQna(props) {
36
37
  answerParameters,
37
38
  parameters,
38
39
  formatImageUrl: formatters === null || formatters === void 0 ? void 0 : formatters.formatImageUrl,
40
+ testCells: abTest === null || abTest === void 0 ? void 0 : abTest.testCells,
39
41
  });
40
42
  const tracking = (0, useTracking_1.default)({
41
43
  cioClient: pia.cioClient,
@@ -72,11 +74,13 @@ function CioPiaQna(props) {
72
74
  handleSubmitQuestion,
73
75
  conversationHistory,
74
76
  };
77
+ const checkoutElement = checkoutTriggers && checkoutTriggers.length > 0 ? (react_1.default.createElement(CheckoutTriggerBar_1.default, { triggers: checkoutTriggers, state: renderProps, translations: translations })) : null;
75
78
  const conversationHistoryProps = {
76
79
  conversationHistory,
77
80
  isLoading,
78
81
  isAnswerLoading,
79
82
  error,
83
+ checkoutElement,
80
84
  currentItems,
81
85
  showFeedback,
82
86
  showPreviousItems,
@@ -110,7 +114,8 @@ function CioPiaQna(props) {
110
114
  !isAnswerLoading && error && (react_1.default.createElement(ErrorBlock_1.default, { key: error.message || (0, translate_1.translate)('Unexpected error', translations), message: error.message, translations: translations })),
111
115
  !isAnswerLoading && !error && currentAnswer && (react_1.default.createElement(PiaInlineAnswer_1.default, { 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 })),
112
116
  isLoading && react_1.default.createElement(SuggestedQuestionsSkeleton_1.default, null),
113
- !isLoading && !error && (react_1.default.createElement(SuggestedQuestionsContainer_1.default, { questions: displayedQuestions, onQuestionClick: handleQuestionClick, componentOverride: componentOverrides === null || componentOverrides === void 0 ? void 0 : componentOverrides.suggestedQuestions }))),
117
+ !isLoading && !error && (react_1.default.createElement(SuggestedQuestionsContainer_1.default, { questions: displayedQuestions, onQuestionClick: handleQuestionClick, componentOverride: componentOverrides === null || componentOverrides === void 0 ? void 0 : componentOverrides.suggestedQuestions })),
118
+ checkoutElement),
114
119
  react_1.default.createElement(StatusRegion_1.default, { message: answerStatus, "data-testid": 'answer-status' })));
115
120
  }
116
121
  exports.default = CioPiaQna;
@@ -54,3 +54,36 @@
54
54
  outline: 2px solid #0f1324;
55
55
  outline-offset: 2px;
56
56
  }
57
+
58
+ .cio-pia-checkout-triggers {
59
+ display: flex;
60
+ flex-wrap: wrap;
61
+ gap: 8px;
62
+ margin-top: 4px;
63
+ }
64
+
65
+ .cio-pia-checkout-trigger {
66
+ background: #0f1324;
67
+ color: #fff;
68
+ border: none;
69
+ border-radius: 6px;
70
+ padding: 10px 16px;
71
+ font-size: 14px;
72
+ font-weight: 500;
73
+ cursor: pointer;
74
+ transition: opacity 0.15s ease;
75
+ }
76
+
77
+ .cio-pia-checkout-trigger:hover {
78
+ opacity: 0.9;
79
+ }
80
+
81
+ .cio-pia-checkout-trigger:focus-visible {
82
+ outline: 2px solid #0f1324;
83
+ outline-offset: 2px;
84
+ }
85
+
86
+ .cio-pia-checkout-trigger:disabled {
87
+ opacity: 0.5;
88
+ cursor: not-allowed;
89
+ }
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const tslib_1 = require("tslib");
4
+ const react_1 = tslib_1.__importDefault(require("react"));
5
+ const usePiaClient_1 = tslib_1.__importDefault(require("../../hooks/usePiaClient"));
6
+ const useTracking_1 = tslib_1.__importDefault(require("../../hooks/useTracking"));
7
+ const useViewportTracking_1 = tslib_1.__importDefault(require("../../hooks/useViewportTracking"));
8
+ const NO_QUESTIONS = [];
9
+ /**
10
+ * What a control-group shopper gets instead of the widget: nothing to look at, but a node the
11
+ * view event can be tied to, so both arms of an A/B test are measured the same way.
12
+ *
13
+ * None of the question or answer hooks mount here, so the control arm cannot issue a request.
14
+ */
15
+ function CioPiaControl(props) {
16
+ const { apiKey, itemId, itemName, threadId, variationId, cioClient, abTest, trackingConfigs } = props;
17
+ const { cioClient: client, threadId: resolvedThreadId } = (0, usePiaClient_1.default)({
18
+ apiKey,
19
+ threadId,
20
+ cioClient,
21
+ testCells: abTest === null || abTest === void 0 ? void 0 : abTest.testCells,
22
+ });
23
+ const tracking = (0, useTracking_1.default)({
24
+ cioClient: client,
25
+ itemId,
26
+ itemName,
27
+ variationId,
28
+ threadId: resolvedThreadId,
29
+ });
30
+ const { containerRef } = (0, useViewportTracking_1.default)({
31
+ tracking,
32
+ questions: NO_QUESTIONS,
33
+ viewThreshold: trackingConfigs === null || trackingConfigs === void 0 ? void 0 : trackingConfigs.viewThreshold,
34
+ awaitQuestions: false,
35
+ });
36
+ return (react_1.default.createElement("div", { ref: containerRef, className: 'cio-pia-control-placeholder', "data-testid": 'cio-pia-control-placeholder', style: { width: 1, height: 1, padding: 0, border: 0, margin: 0, overflow: 'hidden' }, "aria-hidden": 'true' }));
37
+ }
38
+ exports.default = CioPiaControl;
@@ -20,8 +20,9 @@ const classNames_1 = require("../../utils/classNames");
20
20
  * that supplied its own root — `children` or `componentOverrides.reactNode` — still gets it in that
21
21
  * state, with `error` available, and decides what belongs there.
22
22
  */
23
+ // eslint-disable-next-line complexity
23
24
  function CioPiaRecs(props) {
24
- const { apiKey, itemId, threadId, variationId, cioClient, componentOverrides, callbacks, formatters, productCardProps, children, translations, recsPodParameters, } = props;
25
+ const { apiKey, itemId, threadId, variationId, cioClient, componentOverrides, callbacks, formatters, productCardProps, children, translations, recsPodParameters, abTest, } = props;
25
26
  const { priceCurrency } = productCardProps || {};
26
27
  const { showInput = true, numResults } = recsPodParameters || {};
27
28
  const { answer: answerOverride, carousel: carouselOverride, loading: loadingOverride, reactNode: rootOverride, } = componentOverrides || {};
@@ -29,6 +30,7 @@ function CioPiaRecs(props) {
29
30
  apiKey,
30
31
  threadId,
31
32
  cioClient,
33
+ testCells: abTest === null || abTest === void 0 ? void 0 : abTest.testCells,
32
34
  });
33
35
  const { title, items, refinement, isLoading, error, inputError, lastShopperInput, refine } = (0, useRecsPod_1.default)({
34
36
  itemId,
@@ -7,11 +7,12 @@ const SuggestedQuestionsContainer_1 = tslib_1.__importDefault(require("../Sugges
7
7
  const SuggestedQuestionsSkeleton_1 = tslib_1.__importDefault(require("../SuggestedQuestionsContainer/SuggestedQuestionsSkeleton"));
8
8
  const translate_1 = require("../../utils/translate");
9
9
  const ConversationHistory_1 = tslib_1.__importDefault(require("../ConversationHistory/ConversationHistory"));
10
- function PiaConversation({ conversationHistory, isLoading, error, currentItems, showFeedback, showPreviousItems, learnMoreUrl, disclaimerPosition, translations, callbacks, componentOverrides, displayedQuestions, handleSubmitQuestion, handleQuestionClick, containerRef, handleFeedback, onResultClick, qnaResultId, onInputFocus, priceCurrency, }) {
10
+ function PiaConversation({ conversationHistory, isLoading, error, currentItems, showFeedback, showPreviousItems, learnMoreUrl, disclaimerPosition, translations, callbacks, componentOverrides, displayedQuestions, handleSubmitQuestion, handleQuestionClick, containerRef, handleFeedback, onResultClick, qnaResultId, onInputFocus, priceCurrency, checkoutElement, }) {
11
11
  const hasHistory = conversationHistory.length > 0;
12
12
  return (react_1.default.createElement("div", { ref: containerRef, className: 'cio-pia-container cio-pia-conversation', "data-testid": 'cio-pia-container' },
13
13
  !hasHistory && (react_1.default.createElement("p", { className: 'cio-pia-title', "data-testid": 'cio-pia-title' }, (0, translate_1.translate)('Any questions about this product?', translations))),
14
14
  react_1.default.createElement(ConversationHistory_1.default, { conversationHistory: conversationHistory, isLoading: isLoading, error: error, currentItems: currentItems, showFeedback: showFeedback, showPreviousItems: showPreviousItems, learnMoreUrl: learnMoreUrl, disclaimerPosition: disclaimerPosition, translations: translations, callbacks: callbacks, componentOverrides: componentOverrides, handleFeedback: handleFeedback, onResultClick: onResultClick, qnaResultId: qnaResultId, priceCurrency: priceCurrency }),
15
+ checkoutElement && react_1.default.createElement("div", { className: 'cio-pia-conversation-checkout' }, checkoutElement),
15
16
  react_1.default.createElement("div", { className: 'cio-pia-conversation-footer' },
16
17
  isLoading && !error && react_1.default.createElement(SuggestedQuestionsSkeleton_1.default, null),
17
18
  !isLoading && !error && (react_1.default.createElement(SuggestedQuestionsContainer_1.default, { questions: displayedQuestions, onQuestionClick: handleQuestionClick, componentOverride: componentOverrides === null || componentOverrides === void 0 ? void 0 : componentOverrides.suggestedQuestions })),
@@ -2,6 +2,11 @@
2
2
  max-height: var(--cio-pia-conversation-max-height);
3
3
  }
4
4
 
5
+ .cio-pia-conversation-checkout {
6
+ flex-shrink: 0;
7
+ margin-bottom: 8px;
8
+ }
9
+
5
10
  .cio-pia-conversation-footer {
6
11
  display: flex;
7
12
  flex-direction: column;
@@ -40,11 +40,12 @@ function mapDeprecatedParameters(params) {
40
40
  return hasEntries ? mapped : undefined;
41
41
  }
42
42
  function useCioPia(props) {
43
- const { apiKey, itemId, variationId, threadId: providedThreadId, cioClient: providedClient, suggestedQuestionsParameters, answerParameters, parameters, formatImageUrl, } = props;
43
+ const { apiKey, itemId, variationId, threadId: providedThreadId, cioClient: providedClient, suggestedQuestionsParameters, answerParameters, parameters, formatImageUrl, testCells, } = props;
44
44
  const { cioClient: client, threadId } = (0, usePiaClient_1.default)({
45
45
  apiKey,
46
46
  threadId: providedThreadId,
47
47
  cioClient: providedClient,
48
+ testCells,
48
49
  });
49
50
  const mappedDeprecated = mapDeprecatedParameters(parameters);
50
51
  const mergedSuggestedParams = mappedDeprecated || suggestedQuestionsParameters
@@ -4,18 +4,25 @@ const tslib_1 = require("tslib");
4
4
  const react_1 = require("react");
5
5
  const constructorio_client_javascript_1 = tslib_1.__importDefault(require("@constructor-io/constructorio-client-javascript"));
6
6
  const version_1 = tslib_1.__importDefault(require("../version"));
7
- function usePiaClient({ apiKey, threadId: providedThreadId, cioClient: providedClient, }) {
7
+ function usePiaClient({ apiKey, threadId: providedThreadId, cioClient: providedClient, testCells, }) {
8
8
  const [generatedThreadId] = (0, react_1.useState)(() => crypto.randomUUID());
9
9
  const threadId = providedThreadId || generatedThreadId;
10
+ const serializedTestCells = (0, react_1.useMemo)(() => JSON.stringify(testCells !== null && testCells !== void 0 ? testCells : null), [testCells]);
10
11
  const client = (0, react_1.useMemo)(() => {
11
12
  if (providedClient)
12
13
  return providedClient;
13
- return new constructorio_client_javascript_1.default({
14
- apiKey,
15
- sendTrackingEvents: true,
16
- version: `cio-ui-pia-${version_1.default}`,
17
- });
18
- }, [apiKey, providedClient]);
14
+ return new constructorio_client_javascript_1.default(Object.assign({ apiKey, sendTrackingEvents: true, version: `cio-ui-pia-${version_1.default}` }, (testCells && { testCells })));
15
+ // testCells is compared by serializedTestCells so an inline object does not rebuild the client
16
+ // eslint-disable-next-line react-hooks/exhaustive-deps
17
+ }, [apiKey, providedClient, serializedTestCells]);
18
+ // A caller's client owns its own options, so say so rather than dropping these silently.
19
+ (0, react_1.useEffect)(() => {
20
+ if (!providedClient || !testCells || Object.keys(testCells).length === 0)
21
+ return;
22
+ console.warn('[CioPia] abTest.testCells is ignored when you supply your own cioClient. Set testCells on that client instead.');
23
+ // serializedTestCells stands in for testCells here too
24
+ // eslint-disable-next-line react-hooks/exhaustive-deps
25
+ }, [providedClient, serializedTestCells]);
19
26
  return { cioClient: client, threadId };
20
27
  }
21
28
  exports.default = usePiaClient;
@@ -3,7 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const react_1 = require("react");
4
4
  const FLUSH_INTERVAL_MS = 5 * 60 * 1000;
5
5
  const DEFAULT_VIEW_THRESHOLD = 0.5;
6
- function useViewportTracking({ tracking, questions, viewThreshold = DEFAULT_VIEW_THRESHOLD, }) {
6
+ function useViewportTracking({ tracking, questions, viewThreshold = DEFAULT_VIEW_THRESHOLD, awaitQuestions = true, }) {
7
7
  const nodeRef = (0, react_1.useRef)(null);
8
8
  const observerRef = (0, react_1.useRef)(null);
9
9
  const intervalRef = (0, react_1.useRef)(null);
@@ -11,10 +11,14 @@ function useViewportTracking({ tracking, questions, viewThreshold = DEFAULT_VIEW
11
11
  const entryTimeRef = (0, react_1.useRef)(null);
12
12
  const questionsRef = (0, react_1.useRef)(questions);
13
13
  const trackingRef = (0, react_1.useRef)(tracking);
14
+ const awaitQuestionsRef = (0, react_1.useRef)(awaitQuestions);
15
+ (0, react_1.useEffect)(() => {
16
+ awaitQuestionsRef.current = awaitQuestions;
17
+ }, [awaitQuestions]);
14
18
  (0, react_1.useEffect)(() => {
15
19
  const questionsJustArrived = questionsRef.current.length === 0 && questions.length > 0;
16
20
  questionsRef.current = questions;
17
- if (questionsJustArrived && entryTimeRef.current) {
21
+ if (awaitQuestionsRef.current && questionsJustArrived && entryTimeRef.current) {
18
22
  trackingRef.current.trackView(questions);
19
23
  }
20
24
  }, [questions]);
@@ -30,7 +34,8 @@ function useViewportTracking({ tracking, questions, viewThreshold = DEFAULT_VIEW
30
34
  });
31
35
  entryTimeRef.current = null;
32
36
  }
33
- if (timespansRef.current.length > 0 && questionsRef.current.length > 0) {
37
+ const hasPayload = !awaitQuestionsRef.current || questionsRef.current.length > 0;
38
+ if (timespansRef.current.length > 0 && hasPayload) {
34
39
  trackingRef.current.trackViews(questionsRef.current, [...timespansRef.current]);
35
40
  timespansRef.current = [];
36
41
  }
@@ -39,7 +44,7 @@ function useViewportTracking({ tracking, questions, viewThreshold = DEFAULT_VIEW
39
44
  const observer = new IntersectionObserver(([entry]) => {
40
45
  if (entry.isIntersecting) {
41
46
  entryTimeRef.current = new Date().toISOString();
42
- if (questionsRef.current.length > 0) {
47
+ if (!awaitQuestionsRef.current || questionsRef.current.length > 0) {
43
48
  trackingRef.current.trackView(questionsRef.current);
44
49
  }
45
50
  }
@@ -25,8 +25,8 @@ div.link-list {
25
25
  }
26
26
  }
27
27
 
28
- @media all and (-ms-high-contrast:none) {
29
- div.link-list {
28
+ @media all and (-ms-high-contrast: none) {
29
+ div.link-list {
30
30
  display: -ms-grid;
31
31
  -ms-grid-columns: 1fr 1fr;
32
32
  -ms-grid-rows: 1fr 1fr;
@@ -38,20 +38,23 @@ a.link-item {
38
38
  padding: 20px;
39
39
  border: 1px solid #00000010;
40
40
  border-radius: 5px;
41
- transition: background 150ms ease-out, border 150ms ease-out, transform 150ms ease-out;
41
+ transition:
42
+ background 150ms ease-out,
43
+ border 150ms ease-out,
44
+ transform 150ms ease-out;
42
45
  color: #333333;
43
46
  display: flex;
44
47
  align-items: flex-start;
45
48
  }
46
49
 
47
50
  a.link-item:hover {
48
- border-color: #1EA7FD50;
51
+ border-color: #1ea7fd50;
49
52
  transform: translate3d(0, -3px, 0);
50
53
  box-shadow: rgba(0, 0, 0, 0.08) 0 3px 10px 0;
51
54
  }
52
55
 
53
56
  a.link-item:active {
54
- border-color: #1EA7FD;
57
+ border-color: #1ea7fd;
55
58
  transform: translate3d(0, 0, 0);
56
59
  }
57
60
 
@@ -81,8 +84,8 @@ span.badge {
81
84
  font-size: 11px;
82
85
  line-height: 12px;
83
86
  font-weight: 700;
84
- background: #E7FDD8;
85
- color: #66BF3C;
87
+ background: #e7fdd8;
88
+ color: #66bf3c;
86
89
  padding: 4px 12px;
87
90
  margin-right: 10px;
88
91
  vertical-align: top;
@@ -100,10 +103,9 @@ div.badge-wrapper code {
100
103
  display: inline-block;
101
104
  }
102
105
 
103
-
104
106
  .badge.tip {
105
- background: #E7FDD8;
106
- color: #66BF3C;
107
+ background: #e7fdd8;
108
+ color: #66bf3c;
107
109
  }
108
110
 
109
111
  .badge.note {
@@ -1,3 +1,3 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.default = '2.13.1';
3
+ exports.default = '2.15.0';
@@ -0,0 +1,20 @@
1
+ import React from 'react';
2
+ import { translate } from '../../utils/translate';
3
+ export default function CheckoutTriggerBar({ triggers, state, translations, }) {
4
+ const visible = triggers.filter((t) => (t.triggerWhen ? t.triggerWhen(state) : true));
5
+ if (visible.length === 0)
6
+ return null;
7
+ return (React.createElement("div", { className: 'cio-pia-checkout-triggers', "data-testid": 'cio-pia-checkout-triggers' }, visible.map((trigger) => {
8
+ const label = trigger.label ?? translate('Checkout', translations);
9
+ const onClick = () => trigger.onTrigger(state);
10
+ if (trigger.renderButton) {
11
+ return (React.createElement(React.Fragment, { key: trigger.id }, trigger.renderButton({
12
+ onClick,
13
+ label,
14
+ disabled: trigger.disabled,
15
+ id: trigger.id,
16
+ })));
17
+ }
18
+ return (React.createElement("button", { key: trigger.id, type: 'button', className: 'cio-pia-checkout-trigger', "data-testid": `cio-pia-checkout-trigger-${trigger.id}`, disabled: trigger.disabled, onClick: onClick }, label));
19
+ })));
20
+ }
@@ -1,8 +1,13 @@
1
1
  import React from 'react';
2
2
  import CioPiaQna from './CioPiaQna';
3
3
  import CioPiaRecs from '../CioPiaRecs/CioPiaRecs';
4
+ import CioPiaControl from '../CioPiaControl/CioPiaControl';
4
5
  export default function CioPia(props) {
5
6
  const mode = props.displayConfigs?.mode ?? 'default';
7
+ // Before the mode dispatch: a control shopper gets no widget in any mode.
8
+ if (props.abTest?.isControl) {
9
+ return React.createElement(CioPiaControl, { ...props });
10
+ }
6
11
  if (mode === 'recommendations') {
7
12
  return React.createElement(CioPiaRecs, { ...props });
8
13
  }
@@ -15,10 +15,11 @@ import PiaInlineAnswer from '../PiaInlineAnswer/PiaInlineAnswer';
15
15
  import PiaModal from '../PiaConversation/PiaModal';
16
16
  import PiaConversation from '../PiaConversation/PiaConversation';
17
17
  import StatusRegion, { answerStatusMessage } from '../StatusRegion/StatusRegion';
18
+ import CheckoutTriggerBar from './CheckoutTriggerBar';
18
19
  /** The question-and-answer experience: `mode: 'default'`, `mode: 'conversation'`, and the modal. */
19
20
  // eslint-disable-next-line complexity
20
21
  export default function CioPiaQna(props) {
21
- const { apiKey, itemId, itemName, threadId, variationId, cioClient, displayConfigs, componentOverrides, callbacks, formatters, productCardProps, children, translations, suggestedQuestionsParameters, answerParameters, parameters, trackingConfigs, } = props;
22
+ const { apiKey, itemId, itemName, threadId, variationId, cioClient, displayConfigs, componentOverrides, callbacks, formatters, productCardProps, children, translations, suggestedQuestionsParameters, answerParameters, parameters, trackingConfigs, checkoutTriggers, abTest, } = props;
22
23
  const { learnMoreUrl, showFeedback, mode = 'default', type = 'inline', showPreviousItems, disclaimerPosition = 'bottom', } = displayConfigs || {};
23
24
  const isConversation = mode === 'conversation' || type === 'modal';
24
25
  const { priceCurrency } = productCardProps || {};
@@ -32,6 +33,7 @@ export default function CioPiaQna(props) {
32
33
  answerParameters,
33
34
  parameters,
34
35
  formatImageUrl: formatters?.formatImageUrl,
36
+ testCells: abTest?.testCells,
35
37
  });
36
38
  const tracking = useTracking({
37
39
  cioClient: pia.cioClient,
@@ -68,11 +70,13 @@ export default function CioPiaQna(props) {
68
70
  handleSubmitQuestion,
69
71
  conversationHistory,
70
72
  };
73
+ const checkoutElement = checkoutTriggers && checkoutTriggers.length > 0 ? (React.createElement(CheckoutTriggerBar, { triggers: checkoutTriggers, state: renderProps, translations: translations })) : null;
71
74
  const conversationHistoryProps = {
72
75
  conversationHistory,
73
76
  isLoading,
74
77
  isAnswerLoading,
75
78
  error,
79
+ checkoutElement,
76
80
  currentItems,
77
81
  showFeedback,
78
82
  showPreviousItems,
@@ -106,6 +110,7 @@ export default function CioPiaQna(props) {
106
110
  !isAnswerLoading && error && (React.createElement(ErrorBlock, { key: error.message || translate('Unexpected error', translations), message: error.message, translations: translations })),
107
111
  !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 })),
108
112
  isLoading && React.createElement(SuggestedQuestionsSkeleton, null),
109
- !isLoading && !error && (React.createElement(SuggestedQuestionsContainer, { questions: displayedQuestions, onQuestionClick: handleQuestionClick, componentOverride: componentOverrides?.suggestedQuestions }))),
113
+ !isLoading && !error && (React.createElement(SuggestedQuestionsContainer, { questions: displayedQuestions, onQuestionClick: handleQuestionClick, componentOverride: componentOverrides?.suggestedQuestions })),
114
+ checkoutElement),
110
115
  React.createElement(StatusRegion, { message: answerStatus, "data-testid": 'answer-status' })));
111
116
  }
@@ -54,3 +54,36 @@
54
54
  outline: 2px solid #0f1324;
55
55
  outline-offset: 2px;
56
56
  }
57
+
58
+ .cio-pia-checkout-triggers {
59
+ display: flex;
60
+ flex-wrap: wrap;
61
+ gap: 8px;
62
+ margin-top: 4px;
63
+ }
64
+
65
+ .cio-pia-checkout-trigger {
66
+ background: #0f1324;
67
+ color: #fff;
68
+ border: none;
69
+ border-radius: 6px;
70
+ padding: 10px 16px;
71
+ font-size: 14px;
72
+ font-weight: 500;
73
+ cursor: pointer;
74
+ transition: opacity 0.15s ease;
75
+ }
76
+
77
+ .cio-pia-checkout-trigger:hover {
78
+ opacity: 0.9;
79
+ }
80
+
81
+ .cio-pia-checkout-trigger:focus-visible {
82
+ outline: 2px solid #0f1324;
83
+ outline-offset: 2px;
84
+ }
85
+
86
+ .cio-pia-checkout-trigger:disabled {
87
+ opacity: 0.5;
88
+ cursor: not-allowed;
89
+ }
@@ -0,0 +1,34 @@
1
+ import React from 'react';
2
+ import usePiaClient from '../../hooks/usePiaClient';
3
+ import useTracking from '../../hooks/useTracking';
4
+ import useViewportTracking from '../../hooks/useViewportTracking';
5
+ const NO_QUESTIONS = [];
6
+ /**
7
+ * What a control-group shopper gets instead of the widget: nothing to look at, but a node the
8
+ * view event can be tied to, so both arms of an A/B test are measured the same way.
9
+ *
10
+ * None of the question or answer hooks mount here, so the control arm cannot issue a request.
11
+ */
12
+ export default function CioPiaControl(props) {
13
+ const { apiKey, itemId, itemName, threadId, variationId, cioClient, abTest, trackingConfigs } = props;
14
+ const { cioClient: client, threadId: resolvedThreadId } = usePiaClient({
15
+ apiKey,
16
+ threadId,
17
+ cioClient,
18
+ testCells: abTest?.testCells,
19
+ });
20
+ const tracking = useTracking({
21
+ cioClient: client,
22
+ itemId,
23
+ itemName,
24
+ variationId,
25
+ threadId: resolvedThreadId,
26
+ });
27
+ const { containerRef } = useViewportTracking({
28
+ tracking,
29
+ questions: NO_QUESTIONS,
30
+ viewThreshold: trackingConfigs?.viewThreshold,
31
+ awaitQuestions: false,
32
+ });
33
+ return (React.createElement("div", { ref: containerRef, className: 'cio-pia-control-placeholder', "data-testid": 'cio-pia-control-placeholder', style: { width: 1, height: 1, padding: 0, border: 0, margin: 0, overflow: 'hidden' }, "aria-hidden": 'true' }));
34
+ }
@@ -17,8 +17,9 @@ import { cx } from '../../utils/classNames';
17
17
  * that supplied its own root — `children` or `componentOverrides.reactNode` — still gets it in that
18
18
  * state, with `error` available, and decides what belongs there.
19
19
  */
20
+ // eslint-disable-next-line complexity
20
21
  export default function CioPiaRecs(props) {
21
- const { apiKey, itemId, threadId, variationId, cioClient, componentOverrides, callbacks, formatters, productCardProps, children, translations, recsPodParameters, } = props;
22
+ const { apiKey, itemId, threadId, variationId, cioClient, componentOverrides, callbacks, formatters, productCardProps, children, translations, recsPodParameters, abTest, } = props;
22
23
  const { priceCurrency } = productCardProps || {};
23
24
  const { showInput = true, numResults } = recsPodParameters || {};
24
25
  const { answer: answerOverride, carousel: carouselOverride, loading: loadingOverride, reactNode: rootOverride, } = componentOverrides || {};
@@ -26,6 +27,7 @@ export default function CioPiaRecs(props) {
26
27
  apiKey,
27
28
  threadId,
28
29
  cioClient,
30
+ testCells: abTest?.testCells,
29
31
  });
30
32
  const { title, items, refinement, isLoading, error, inputError, lastShopperInput, refine } = useRecsPod({
31
33
  itemId,
@@ -4,11 +4,12 @@ import SuggestedQuestionsContainer from '../SuggestedQuestionsContainer/Suggeste
4
4
  import SuggestedQuestionsSkeleton from '../SuggestedQuestionsContainer/SuggestedQuestionsSkeleton';
5
5
  import { translate } from '../../utils/translate';
6
6
  import ConversationHistory from '../ConversationHistory/ConversationHistory';
7
- export default function PiaConversation({ conversationHistory, isLoading, error, currentItems, showFeedback, showPreviousItems, learnMoreUrl, disclaimerPosition, translations, callbacks, componentOverrides, displayedQuestions, handleSubmitQuestion, handleQuestionClick, containerRef, handleFeedback, onResultClick, qnaResultId, onInputFocus, priceCurrency, }) {
7
+ export default function PiaConversation({ conversationHistory, isLoading, error, currentItems, showFeedback, showPreviousItems, learnMoreUrl, disclaimerPosition, translations, callbacks, componentOverrides, displayedQuestions, handleSubmitQuestion, handleQuestionClick, containerRef, handleFeedback, onResultClick, qnaResultId, onInputFocus, priceCurrency, checkoutElement, }) {
8
8
  const hasHistory = conversationHistory.length > 0;
9
9
  return (React.createElement("div", { ref: containerRef, className: 'cio-pia-container cio-pia-conversation', "data-testid": 'cio-pia-container' },
10
10
  !hasHistory && (React.createElement("p", { className: 'cio-pia-title', "data-testid": 'cio-pia-title' }, translate('Any questions about this product?', translations))),
11
11
  React.createElement(ConversationHistory, { conversationHistory: conversationHistory, isLoading: isLoading, error: error, currentItems: currentItems, showFeedback: showFeedback, showPreviousItems: showPreviousItems, learnMoreUrl: learnMoreUrl, disclaimerPosition: disclaimerPosition, translations: translations, callbacks: callbacks, componentOverrides: componentOverrides, handleFeedback: handleFeedback, onResultClick: onResultClick, qnaResultId: qnaResultId, priceCurrency: priceCurrency }),
12
+ checkoutElement && React.createElement("div", { className: 'cio-pia-conversation-checkout' }, checkoutElement),
12
13
  React.createElement("div", { className: 'cio-pia-conversation-footer' },
13
14
  isLoading && !error && React.createElement(SuggestedQuestionsSkeleton, null),
14
15
  !isLoading && !error && (React.createElement(SuggestedQuestionsContainer, { questions: displayedQuestions, onQuestionClick: handleQuestionClick, componentOverride: componentOverrides?.suggestedQuestions })),
@@ -2,6 +2,11 @@
2
2
  max-height: var(--cio-pia-conversation-max-height);
3
3
  }
4
4
 
5
+ .cio-pia-conversation-checkout {
6
+ flex-shrink: 0;
7
+ margin-bottom: 8px;
8
+ }
9
+
5
10
  .cio-pia-conversation-footer {
6
11
  display: flex;
7
12
  flex-direction: column;
@@ -37,11 +37,12 @@ function mapDeprecatedParameters(params) {
37
37
  return hasEntries ? mapped : undefined;
38
38
  }
39
39
  export default function useCioPia(props) {
40
- const { apiKey, itemId, variationId, threadId: providedThreadId, cioClient: providedClient, suggestedQuestionsParameters, answerParameters, parameters, formatImageUrl, } = props;
40
+ const { apiKey, itemId, variationId, threadId: providedThreadId, cioClient: providedClient, suggestedQuestionsParameters, answerParameters, parameters, formatImageUrl, testCells, } = props;
41
41
  const { cioClient: client, threadId } = usePiaClient({
42
42
  apiKey,
43
43
  threadId: providedThreadId,
44
44
  cioClient: providedClient,
45
+ testCells,
45
46
  });
46
47
  const mappedDeprecated = mapDeprecatedParameters(parameters);
47
48
  const mergedSuggestedParams = mappedDeprecated || suggestedQuestionsParameters
@@ -1,9 +1,10 @@
1
- import { useMemo, useState } from 'react';
1
+ import { useEffect, useMemo, useState } from 'react';
2
2
  import ConstructorIOClient from '@constructor-io/constructorio-client-javascript';
3
3
  import version from '../version';
4
- export default function usePiaClient({ apiKey, threadId: providedThreadId, cioClient: providedClient, }) {
4
+ export default function usePiaClient({ apiKey, threadId: providedThreadId, cioClient: providedClient, testCells, }) {
5
5
  const [generatedThreadId] = useState(() => crypto.randomUUID());
6
6
  const threadId = providedThreadId || generatedThreadId;
7
+ const serializedTestCells = useMemo(() => JSON.stringify(testCells ?? null), [testCells]);
7
8
  const client = useMemo(() => {
8
9
  if (providedClient)
9
10
  return providedClient;
@@ -11,7 +12,18 @@ export default function usePiaClient({ apiKey, threadId: providedThreadId, cioCl
11
12
  apiKey,
12
13
  sendTrackingEvents: true,
13
14
  version: `cio-ui-pia-${version}`,
15
+ ...(testCells && { testCells }),
14
16
  });
15
- }, [apiKey, providedClient]);
17
+ // testCells is compared by serializedTestCells so an inline object does not rebuild the client
18
+ // eslint-disable-next-line react-hooks/exhaustive-deps
19
+ }, [apiKey, providedClient, serializedTestCells]);
20
+ // A caller's client owns its own options, so say so rather than dropping these silently.
21
+ useEffect(() => {
22
+ if (!providedClient || !testCells || Object.keys(testCells).length === 0)
23
+ return;
24
+ console.warn('[CioPia] abTest.testCells is ignored when you supply your own cioClient. Set testCells on that client instead.');
25
+ // serializedTestCells stands in for testCells here too
26
+ // eslint-disable-next-line react-hooks/exhaustive-deps
27
+ }, [providedClient, serializedTestCells]);
16
28
  return { cioClient: client, threadId };
17
29
  }