@constructor-io/constructorio-ui-pia 2.14.0 → 2.16.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 (29) hide show
  1. package/README.md +71 -35
  2. package/dist/constructorio-ui-pia-bundled.js +45 -45
  3. package/lib/cjs/components/CioPia/CheckoutTriggerBar.js +25 -0
  4. package/lib/cjs/components/CioPia/CioPiaQna.js +16 -4
  5. package/lib/cjs/components/CioPia/CioPiaStyles.css +33 -0
  6. package/lib/cjs/components/ConversationHistory/ConversationHistory.js +7 -2
  7. package/lib/cjs/components/PiaConversation/PiaConversation.js +2 -1
  8. package/lib/cjs/components/PiaConversation/PiaConversationStyles.css +5 -0
  9. package/lib/cjs/hooks/useConversation.js +41 -6
  10. package/lib/cjs/stories/assets/storybook-styles.css +12 -10
  11. package/lib/cjs/version.js +1 -1
  12. package/lib/mjs/components/CioPia/CheckoutTriggerBar.js +20 -0
  13. package/lib/mjs/components/CioPia/CioPiaQna.js +16 -4
  14. package/lib/mjs/components/CioPia/CioPiaStyles.css +33 -0
  15. package/lib/mjs/components/ConversationHistory/ConversationHistory.js +7 -3
  16. package/lib/mjs/components/PiaConversation/PiaConversation.js +2 -1
  17. package/lib/mjs/components/PiaConversation/PiaConversationStyles.css +5 -0
  18. package/lib/mjs/hooks/useConversation.js +39 -4
  19. package/lib/mjs/stories/assets/storybook-styles.css +12 -10
  20. package/lib/mjs/version.js +1 -1
  21. package/lib/types/components/CioPia/CheckoutTriggerBar.d.ts +11 -0
  22. package/lib/types/components/CioPia/index.d.ts +1 -1
  23. package/lib/types/components/CioPia/types.d.ts +52 -1
  24. package/lib/types/components/PiaConversation/PiaConversation.d.ts +3 -2
  25. package/lib/types/hooks/useConversation.d.ts +2 -1
  26. package/lib/types/index.d.ts +1 -1
  27. package/lib/types/types.d.ts +5 -0
  28. package/lib/types/version.d.ts +1 -1
  29. 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;
@@ -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, abTest, } = props;
26
+ const { apiKey, itemId, itemName, threadId, variationId, cioClient, displayConfigs, componentOverrides, callbacks, formatters, productCardProps, children, translations, suggestedQuestionsParameters, answerParameters, parameters, trackingConfigs, checkoutTriggers, abTest, initialConversationHistory, } = 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 || {};
@@ -45,7 +46,14 @@ function CioPiaQna(props) {
45
46
  variationId,
46
47
  threadId: pia.threadId,
47
48
  });
48
- const { currentQuestion, displayedQuestions, conversationHistory, currentAnswer, currentItems, isLoading, error, context, handleSubmitQuestion, handleQuestionClick, handleInputFocus, handleFeedback, resetState, } = (0, useConversation_1.default)({ pia, itemId, isConversation, callbacks, tracking });
49
+ const { currentQuestion, displayedQuestions, conversationHistory, currentAnswer, currentItems, isLoading, error, context, handleSubmitQuestion, handleQuestionClick, handleInputFocus, handleFeedback, resetState, } = (0, useConversation_1.default)({
50
+ pia,
51
+ itemId,
52
+ isConversation,
53
+ callbacks,
54
+ tracking,
55
+ initialConversationHistory,
56
+ });
49
57
  const { containerRef: viewportContainerRef } = (0, useViewportTracking_1.default)({
50
58
  tracking,
51
59
  questions: displayedQuestions,
@@ -73,12 +81,15 @@ function CioPiaQna(props) {
73
81
  handleSubmitQuestion,
74
82
  conversationHistory,
75
83
  };
84
+ const checkoutElement = checkoutTriggers && checkoutTriggers.length > 0 ? (react_1.default.createElement(CheckoutTriggerBar_1.default, { triggers: checkoutTriggers, state: renderProps, translations: translations })) : null;
76
85
  const conversationHistoryProps = {
77
86
  conversationHistory,
78
87
  isLoading,
79
88
  isAnswerLoading,
80
89
  error,
81
- currentItems,
90
+ checkoutElement,
91
+ // Until a live answer arrives, a seeded last entry shows its own items. `null` would hide them.
92
+ currentItems: pia.answers.data ? currentItems : undefined,
82
93
  showFeedback,
83
94
  showPreviousItems,
84
95
  learnMoreUrl,
@@ -111,7 +122,8 @@ function CioPiaQna(props) {
111
122
  !isAnswerLoading && error && (react_1.default.createElement(ErrorBlock_1.default, { key: error.message || (0, translate_1.translate)('Unexpected error', translations), message: error.message, translations: translations })),
112
123
  !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 })),
113
124
  isLoading && react_1.default.createElement(SuggestedQuestionsSkeleton_1.default, null),
114
- !isLoading && !error && (react_1.default.createElement(SuggestedQuestionsContainer_1.default, { questions: displayedQuestions, onQuestionClick: handleQuestionClick, componentOverride: componentOverrides === null || componentOverrides === void 0 ? void 0 : componentOverrides.suggestedQuestions }))),
125
+ !isLoading && !error && (react_1.default.createElement(SuggestedQuestionsContainer_1.default, { questions: displayedQuestions, onQuestionClick: handleQuestionClick, componentOverride: componentOverrides === null || componentOverrides === void 0 ? void 0 : componentOverrides.suggestedQuestions })),
126
+ checkoutElement),
115
127
  react_1.default.createElement(StatusRegion_1.default, { message: answerStatus, "data-testid": 'answer-status' })));
116
128
  }
117
129
  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
+ }
@@ -22,11 +22,16 @@ function ConversationHistory({ conversationHistory, isLoading, isAnswerLoading =
22
22
  return () => cancelAnimationFrame(frameId);
23
23
  }, [conversationHistory, isLoading]);
24
24
  const lastEntry = conversationHistory[conversationHistory.length - 1];
25
- const answerStatus = (0, StatusRegion_1.answerStatusMessage)(isAnswerLoading, !!(lastEntry === null || lastEntry === void 0 ? void 0 : lastEntry.answer), translations);
25
+ // An entry answered before mount (a restored conversation) was not just answered: announcing
26
+ // "Answer ready" on page load would tell the shopper something happened when nothing did.
27
+ const [answeredOnMountId] = (0, react_1.useState)(() => ((lastEntry === null || lastEntry === void 0 ? void 0 : lastEntry.answer) ? lastEntry.id : undefined));
28
+ const hasNewAnswer = !!(lastEntry === null || lastEntry === void 0 ? void 0 : lastEntry.answer) && lastEntry.id !== answeredOnMountId;
29
+ const answerStatus = (0, StatusRegion_1.answerStatusMessage)(isAnswerLoading, hasNewAnswer, translations);
26
30
  const disclaimer = (react_1.default.createElement(Disclaimer_1.default, { learnMoreUrl: learnMoreUrl, translations: translations, componentOverride: componentOverrides === null || componentOverrides === void 0 ? void 0 : componentOverrides.disclaimer }));
27
31
  return (react_1.default.createElement("div", { className: 'cio-pia-conversation-history' },
28
32
  disclaimerPosition === 'top' && disclaimer,
29
33
  react_1.default.createElement("div", { ref: scrollContainerRef, className: 'cio-pia-conversation-entries', role: 'log', tabIndex: 0, "aria-label": (0, translate_1.translate)('Conversation history', translations) }, conversationHistory.map((entry, index) => {
34
+ var _a;
30
35
  const isLast = index === conversationHistory.length - 1;
31
36
  const previousEntryItems = showPreviousItems ? entry.items : null;
32
37
  const latestEntryItems = currentItems !== undefined ? currentItems : entry.items;
@@ -38,7 +43,7 @@ function ConversationHistory({ conversationHistory, isLoading, isAnswerLoading =
38
43
  isLast && !isLoading && error && (react_1.default.createElement(ErrorBlock_1.default, { key: error.message || (0, translate_1.translate)('Unexpected error', translations), message: error.message, translations: translations, announce: false })),
39
44
  entry.answer && (react_1.default.createElement("div", { className: 'cio-pia-answer-container' },
40
45
  react_1.default.createElement(Answer_1.default, { text: entry.answer, componentOverride: componentOverrides === null || componentOverrides === void 0 ? void 0 : componentOverrides.answer }),
41
- carouselItems && (react_1.default.createElement(PiaCustomCarousel_1.default, { items: carouselItems, componentOverrides: componentOverrides === null || componentOverrides === void 0 ? void 0 : componentOverrides.carousel, callbacks: callbacks, onResultClick: onResultClick, question: entry.question, qnaResultId: qnaResultId, translations: translations, priceCurrency: priceCurrency })),
46
+ carouselItems && (react_1.default.createElement(PiaCustomCarousel_1.default, { items: carouselItems, componentOverrides: componentOverrides === null || componentOverrides === void 0 ? void 0 : componentOverrides.carousel, callbacks: callbacks, onResultClick: onResultClick, question: entry.question, qnaResultId: (_a = entry.qnaResultId) !== null && _a !== void 0 ? _a : (isLast ? qnaResultId : undefined), translations: translations, priceCurrency: priceCurrency })),
42
47
  isLast && showFeedback && (react_1.default.createElement(Feedback_1.default, { translations: translations, onFeedback: handleFeedback, componentOverride: componentOverrides === null || componentOverrides === void 0 ? void 0 : componentOverrides.feedback }))))));
43
48
  })),
44
49
  disclaimerPosition === 'bottom' && disclaimer,
@@ -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;
@@ -1,15 +1,40 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const react_1 = require("react");
4
- function useConversation({ pia, itemId, isConversation, callbacks, tracking, }) {
4
+ function isRestorableEntry(entry) {
5
+ if (typeof entry !== 'object' || entry === null)
6
+ return false;
7
+ const { question, answer, items } = entry;
8
+ return (typeof question === 'string' &&
9
+ typeof answer === 'string' &&
10
+ (items === undefined || items === null || Array.isArray(items)));
11
+ }
12
+ // The seed comes from the host's own storage, typed or not: a malformed one must not take the widget down.
13
+ function restoreHistory(seed) {
14
+ if (!Array.isArray(seed)) {
15
+ console.warn('[CioPia] initialConversationHistory must be an array. It was ignored.');
16
+ return [];
17
+ }
18
+ const entries = seed.filter(isRestorableEntry);
19
+ if (entries.length < seed.length) {
20
+ console.warn(`[CioPia] ${seed.length - entries.length} initialConversationHistory entries were ignored. Each needs a string question and answer, and items must be an array.`);
21
+ }
22
+ // Seeded ids are replaced: `id` is the React key, so a caller's values cannot be trusted to be unique.
23
+ return entries.map((entry, index) => (Object.assign(Object.assign({}, entry), { id: index + 1 })));
24
+ }
25
+ function useConversation({ pia, itemId, isConversation, callbacks, tracking, initialConversationHistory, }) {
5
26
  var _a, _b, _c;
6
27
  const { suggestedQuestions, answers, threadId } = pia;
7
28
  const { getAnswer } = answers;
8
29
  const context = (0, react_1.useMemo)(() => ({ itemId, threadId }), [itemId, threadId]);
9
30
  const [currentQuestion, setCurrentQuestion] = (0, react_1.useState)('');
10
31
  const [displayedQuestions, setDisplayedQuestions] = (0, react_1.useState)([]);
11
- const [conversationHistory, setConversationHistory] = (0, react_1.useState)([]);
12
- const entryIdRef = (0, react_1.useRef)(0);
32
+ const [conversationHistory, setConversationHistory] = (0, react_1.useState)(() => isConversation && initialConversationHistory !== undefined
33
+ ? restoreHistory(initialConversationHistory)
34
+ : []);
35
+ const entryIdRef = (0, react_1.useRef)(conversationHistory.length);
36
+ const conversationHistoryRef = (0, react_1.useRef)(conversationHistory);
37
+ const prevItemIdRef = (0, react_1.useRef)(itemId);
13
38
  const prevAnswerDataRef = (0, react_1.useRef)(answers.data);
14
39
  const hasTrackedCurrentAnswerRef = (0, react_1.useRef)(false);
15
40
  const answersRef = (0, react_1.useRef)(answers);
@@ -33,6 +58,9 @@ function useConversation({ pia, itemId, isConversation, callbacks, tracking, })
33
58
  (0, react_1.useEffect)(() => {
34
59
  answersRef.current = answers;
35
60
  }, [answers]);
61
+ (0, react_1.useEffect)(() => {
62
+ conversationHistoryRef.current = conversationHistory;
63
+ }, [conversationHistory]);
36
64
  const submitQuestion = (0, react_1.useCallback)((question, source) => {
37
65
  lastSourceRef.current = source;
38
66
  lastQuestionRef.current = question;
@@ -63,9 +91,12 @@ function useConversation({ pia, itemId, isConversation, callbacks, tracking, })
63
91
  (_c = (_b = callbacksRef.current) === null || _b === void 0 ? void 0 : _b.onFocus) === null || _c === void 0 ? void 0 : _c.call(_b, contextRef.current);
64
92
  }, []);
65
93
  const handleFeedback = (0, react_1.useCallback)((type) => {
66
- var _a, _b, _c, _d;
67
- (_a = trackingRef.current) === null || _a === void 0 ? void 0 : _a.trackAnswerFeedback(type, (_b = answersRef.current.data) === null || _b === void 0 ? void 0 : _b.qna_result_id);
68
- (_d = (_c = callbacksRef.current) === null || _c === void 0 ? void 0 : _c.onFeedback) === null || _d === void 0 ? void 0 : _d.call(_c, type);
94
+ var _a, _b, _c, _d, _e, _f;
95
+ // A seeded last entry has no live response behind it; its own id is the answer rated.
96
+ const history = conversationHistoryRef.current;
97
+ const qnaResultId = (_b = (_a = answersRef.current.data) === null || _a === void 0 ? void 0 : _a.qna_result_id) !== null && _b !== void 0 ? _b : (_c = history[history.length - 1]) === null || _c === void 0 ? void 0 : _c.qnaResultId;
98
+ (_d = trackingRef.current) === null || _d === void 0 ? void 0 : _d.trackAnswerFeedback(type, qnaResultId);
99
+ (_f = (_e = callbacksRef.current) === null || _e === void 0 ? void 0 : _e.onFeedback) === null || _f === void 0 ? void 0 : _f.call(_e, type);
69
100
  }, []);
70
101
  const resetState = (0, react_1.useCallback)(() => {
71
102
  setCurrentQuestion('');
@@ -75,6 +106,10 @@ function useConversation({ pia, itemId, isConversation, callbacks, tracking, })
75
106
  showsFollowUpsRef.current = false;
76
107
  }, [suggestedQuestions.data]);
77
108
  (0, react_1.useEffect)(() => {
109
+ // Skip the mount run: it would wipe the seeded history.
110
+ if (prevItemIdRef.current === itemId)
111
+ return;
112
+ prevItemIdRef.current = itemId;
78
113
  setCurrentQuestion('');
79
114
  setDisplayedQuestions([]);
80
115
  setConversationHistory([]);
@@ -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.14.0';
3
+ exports.default = '2.16.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
+ }
@@ -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, abTest, } = props;
22
+ const { apiKey, itemId, itemName, threadId, variationId, cioClient, displayConfigs, componentOverrides, callbacks, formatters, productCardProps, children, translations, suggestedQuestionsParameters, answerParameters, parameters, trackingConfigs, checkoutTriggers, abTest, initialConversationHistory, } = 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 || {};
@@ -41,7 +42,14 @@ export default function CioPiaQna(props) {
41
42
  variationId,
42
43
  threadId: pia.threadId,
43
44
  });
44
- const { currentQuestion, displayedQuestions, conversationHistory, currentAnswer, currentItems, isLoading, error, context, handleSubmitQuestion, handleQuestionClick, handleInputFocus, handleFeedback, resetState, } = useConversation({ pia, itemId, isConversation, callbacks, tracking });
45
+ const { currentQuestion, displayedQuestions, conversationHistory, currentAnswer, currentItems, isLoading, error, context, handleSubmitQuestion, handleQuestionClick, handleInputFocus, handleFeedback, resetState, } = useConversation({
46
+ pia,
47
+ itemId,
48
+ isConversation,
49
+ callbacks,
50
+ tracking,
51
+ initialConversationHistory,
52
+ });
45
53
  const { containerRef: viewportContainerRef } = useViewportTracking({
46
54
  tracking,
47
55
  questions: displayedQuestions,
@@ -69,12 +77,15 @@ export default function CioPiaQna(props) {
69
77
  handleSubmitQuestion,
70
78
  conversationHistory,
71
79
  };
80
+ const checkoutElement = checkoutTriggers && checkoutTriggers.length > 0 ? (React.createElement(CheckoutTriggerBar, { triggers: checkoutTriggers, state: renderProps, translations: translations })) : null;
72
81
  const conversationHistoryProps = {
73
82
  conversationHistory,
74
83
  isLoading,
75
84
  isAnswerLoading,
76
85
  error,
77
- currentItems,
86
+ checkoutElement,
87
+ // Until a live answer arrives, a seeded last entry shows its own items. `null` would hide them.
88
+ currentItems: pia.answers.data ? currentItems : undefined,
78
89
  showFeedback,
79
90
  showPreviousItems,
80
91
  learnMoreUrl,
@@ -107,6 +118,7 @@ export default function CioPiaQna(props) {
107
118
  !isAnswerLoading && error && (React.createElement(ErrorBlock, { key: error.message || translate('Unexpected error', translations), message: error.message, translations: translations })),
108
119
  !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 })),
109
120
  isLoading && React.createElement(SuggestedQuestionsSkeleton, null),
110
- !isLoading && !error && (React.createElement(SuggestedQuestionsContainer, { questions: displayedQuestions, onQuestionClick: handleQuestionClick, componentOverride: componentOverrides?.suggestedQuestions }))),
121
+ !isLoading && !error && (React.createElement(SuggestedQuestionsContainer, { questions: displayedQuestions, onQuestionClick: handleQuestionClick, componentOverride: componentOverrides?.suggestedQuestions })),
122
+ checkoutElement),
111
123
  React.createElement(StatusRegion, { message: answerStatus, "data-testid": 'answer-status' })));
112
124
  }
@@ -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
+ }
@@ -1,4 +1,4 @@
1
- import React, { useEffect, useRef } from 'react';
1
+ import React, { useEffect, useRef, useState } from 'react';
2
2
  import Answer from '../Answer/Answer';
3
3
  import Feedback from '../Feedback/Feedback';
4
4
  import Disclaimer from '../CioPia/Disclaimer';
@@ -19,7 +19,11 @@ export default function ConversationHistory({ conversationHistory, isLoading, is
19
19
  return () => cancelAnimationFrame(frameId);
20
20
  }, [conversationHistory, isLoading]);
21
21
  const lastEntry = conversationHistory[conversationHistory.length - 1];
22
- const answerStatus = answerStatusMessage(isAnswerLoading, !!lastEntry?.answer, translations);
22
+ // An entry answered before mount (a restored conversation) was not just answered: announcing
23
+ // "Answer ready" on page load would tell the shopper something happened when nothing did.
24
+ const [answeredOnMountId] = useState(() => (lastEntry?.answer ? lastEntry.id : undefined));
25
+ const hasNewAnswer = !!lastEntry?.answer && lastEntry.id !== answeredOnMountId;
26
+ const answerStatus = answerStatusMessage(isAnswerLoading, hasNewAnswer, translations);
23
27
  const disclaimer = (React.createElement(Disclaimer, { learnMoreUrl: learnMoreUrl, translations: translations, componentOverride: componentOverrides?.disclaimer }));
24
28
  return (React.createElement("div", { className: 'cio-pia-conversation-history' },
25
29
  disclaimerPosition === 'top' && disclaimer,
@@ -35,7 +39,7 @@ export default function ConversationHistory({ conversationHistory, isLoading, is
35
39
  isLast && !isLoading && error && (React.createElement(ErrorBlock, { key: error.message || translate('Unexpected error', translations), message: error.message, translations: translations, announce: false })),
36
40
  entry.answer && (React.createElement("div", { className: 'cio-pia-answer-container' },
37
41
  React.createElement(Answer, { text: entry.answer, componentOverride: componentOverrides?.answer }),
38
- carouselItems && (React.createElement(PiaCustomCarousel, { items: carouselItems, componentOverrides: componentOverrides?.carousel, callbacks: callbacks, onResultClick: onResultClick, question: entry.question, qnaResultId: qnaResultId, translations: translations, priceCurrency: priceCurrency })),
42
+ carouselItems && (React.createElement(PiaCustomCarousel, { items: carouselItems, componentOverrides: componentOverrides?.carousel, callbacks: callbacks, onResultClick: onResultClick, question: entry.question, qnaResultId: entry.qnaResultId ?? (isLast ? qnaResultId : undefined), translations: translations, priceCurrency: priceCurrency })),
39
43
  isLast && showFeedback && (React.createElement(Feedback, { translations: translations, onFeedback: handleFeedback, componentOverride: componentOverrides?.feedback }))))));
40
44
  })),
41
45
  disclaimerPosition === 'bottom' && disclaimer,
@@ -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;
@@ -1,12 +1,37 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
- export default function useConversation({ pia, itemId, isConversation, callbacks, tracking, }) {
2
+ function isRestorableEntry(entry) {
3
+ if (typeof entry !== 'object' || entry === null)
4
+ return false;
5
+ const { question, answer, items } = entry;
6
+ return (typeof question === 'string' &&
7
+ typeof answer === 'string' &&
8
+ (items === undefined || items === null || Array.isArray(items)));
9
+ }
10
+ // The seed comes from the host's own storage, typed or not: a malformed one must not take the widget down.
11
+ function restoreHistory(seed) {
12
+ if (!Array.isArray(seed)) {
13
+ console.warn('[CioPia] initialConversationHistory must be an array. It was ignored.');
14
+ return [];
15
+ }
16
+ const entries = seed.filter(isRestorableEntry);
17
+ if (entries.length < seed.length) {
18
+ console.warn(`[CioPia] ${seed.length - entries.length} initialConversationHistory entries were ignored. Each needs a string question and answer, and items must be an array.`);
19
+ }
20
+ // Seeded ids are replaced: `id` is the React key, so a caller's values cannot be trusted to be unique.
21
+ return entries.map((entry, index) => ({ ...entry, id: index + 1 }));
22
+ }
23
+ export default function useConversation({ pia, itemId, isConversation, callbacks, tracking, initialConversationHistory, }) {
3
24
  const { suggestedQuestions, answers, threadId } = pia;
4
25
  const { getAnswer } = answers;
5
26
  const context = useMemo(() => ({ itemId, threadId }), [itemId, threadId]);
6
27
  const [currentQuestion, setCurrentQuestion] = useState('');
7
28
  const [displayedQuestions, setDisplayedQuestions] = useState([]);
8
- const [conversationHistory, setConversationHistory] = useState([]);
9
- const entryIdRef = useRef(0);
29
+ const [conversationHistory, setConversationHistory] = useState(() => isConversation && initialConversationHistory !== undefined
30
+ ? restoreHistory(initialConversationHistory)
31
+ : []);
32
+ const entryIdRef = useRef(conversationHistory.length);
33
+ const conversationHistoryRef = useRef(conversationHistory);
34
+ const prevItemIdRef = useRef(itemId);
10
35
  const prevAnswerDataRef = useRef(answers.data);
11
36
  const hasTrackedCurrentAnswerRef = useRef(false);
12
37
  const answersRef = useRef(answers);
@@ -30,6 +55,9 @@ export default function useConversation({ pia, itemId, isConversation, callbacks
30
55
  useEffect(() => {
31
56
  answersRef.current = answers;
32
57
  }, [answers]);
58
+ useEffect(() => {
59
+ conversationHistoryRef.current = conversationHistory;
60
+ }, [conversationHistory]);
33
61
  const submitQuestion = useCallback((question, source) => {
34
62
  lastSourceRef.current = source;
35
63
  lastQuestionRef.current = question;
@@ -57,7 +85,10 @@ export default function useConversation({ pia, itemId, isConversation, callbacks
57
85
  callbacksRef.current?.onFocus?.(contextRef.current);
58
86
  }, []);
59
87
  const handleFeedback = useCallback((type) => {
60
- trackingRef.current?.trackAnswerFeedback(type, answersRef.current.data?.qna_result_id);
88
+ // A seeded last entry has no live response behind it; its own id is the answer rated.
89
+ const history = conversationHistoryRef.current;
90
+ const qnaResultId = answersRef.current.data?.qna_result_id ?? history[history.length - 1]?.qnaResultId;
91
+ trackingRef.current?.trackAnswerFeedback(type, qnaResultId);
61
92
  callbacksRef.current?.onFeedback?.(type);
62
93
  }, []);
63
94
  const resetState = useCallback(() => {
@@ -68,6 +99,10 @@ export default function useConversation({ pia, itemId, isConversation, callbacks
68
99
  showsFollowUpsRef.current = false;
69
100
  }, [suggestedQuestions.data]);
70
101
  useEffect(() => {
102
+ // Skip the mount run: it would wipe the seeded history.
103
+ if (prevItemIdRef.current === itemId)
104
+ return;
105
+ prevItemIdRef.current = itemId;
71
106
  setCurrentQuestion('');
72
107
  setDisplayedQuestions([]);
73
108
  setConversationHistory([]);
@@ -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 +1 @@
1
- export default '2.14.0';
1
+ export default '2.16.0';