@constructor-io/constructorio-ui-pia 2.13.0 → 2.14.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 (35) hide show
  1. package/README.md +49 -1
  2. package/dist/constructorio-ui-pia-bundled.js +58 -58
  3. package/lib/cjs/components/CioPia/CioPia.js +6 -1
  4. package/lib/cjs/components/CioPia/CioPiaQna.js +2 -1
  5. package/lib/cjs/components/CioPiaControl/CioPiaControl.js +38 -0
  6. package/lib/cjs/components/CioPiaRecs/CioPiaRecs.js +3 -1
  7. package/lib/cjs/hooks/useAnswerResults.js +12 -3
  8. package/lib/cjs/hooks/useCioPia.js +2 -1
  9. package/lib/cjs/hooks/useConversation.js +14 -2
  10. package/lib/cjs/hooks/usePiaClient.js +14 -7
  11. package/lib/cjs/hooks/useRecsPod.js +15 -7
  12. package/lib/cjs/hooks/useSuggestedQuestions.js +12 -3
  13. package/lib/cjs/hooks/useViewportTracking.js +9 -4
  14. package/lib/cjs/version.js +1 -1
  15. package/lib/mjs/components/CioPia/CioPia.js +5 -0
  16. package/lib/mjs/components/CioPia/CioPiaQna.js +2 -1
  17. package/lib/mjs/components/CioPiaControl/CioPiaControl.js +34 -0
  18. package/lib/mjs/components/CioPiaRecs/CioPiaRecs.js +3 -1
  19. package/lib/mjs/hooks/useAnswerResults.js +13 -4
  20. package/lib/mjs/hooks/useCioPia.js +2 -1
  21. package/lib/mjs/hooks/useConversation.js +14 -2
  22. package/lib/mjs/hooks/usePiaClient.js +15 -3
  23. package/lib/mjs/hooks/useRecsPod.js +15 -7
  24. package/lib/mjs/hooks/useSuggestedQuestions.js +13 -4
  25. package/lib/mjs/hooks/useViewportTracking.js +9 -4
  26. package/lib/mjs/version.js +1 -1
  27. package/lib/types/components/CioPia/types.d.ts +3 -1
  28. package/lib/types/components/CioPiaControl/CioPiaControl.d.ts +9 -0
  29. package/lib/types/hooks/useCioPia.d.ts +2 -0
  30. package/lib/types/hooks/usePiaClient.d.ts +2 -1
  31. package/lib/types/hooks/useViewportTracking.d.ts +6 -1
  32. package/lib/types/types.d.ts +33 -0
  33. package/lib/types/utils/contentTransformers.d.ts +2 -2
  34. package/lib/types/version.d.ts +1 -1
  35. package/package.json +6 -5
@@ -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
  }
@@ -22,7 +22,7 @@ const StatusRegion_1 = tslib_1.__importStar(require("../StatusRegion/StatusRegio
22
22
  // eslint-disable-next-line complexity
23
23
  function CioPiaQna(props) {
24
24
  var _a;
25
- const { apiKey, itemId, itemName, threadId, variationId, cioClient, displayConfigs, componentOverrides, callbacks, formatters, productCardProps, children, translations, suggestedQuestionsParameters, answerParameters, parameters, trackingConfigs, } = props;
25
+ const { apiKey, itemId, itemName, threadId, variationId, cioClient, displayConfigs, componentOverrides, callbacks, formatters, productCardProps, children, translations, suggestedQuestionsParameters, answerParameters, parameters, trackingConfigs, abTest, } = props;
26
26
  const { learnMoreUrl, showFeedback, mode = 'default', type = 'inline', showPreviousItems, disclaimerPosition = 'bottom', } = displayConfigs || {};
27
27
  const isConversation = mode === 'conversation' || type === 'modal';
28
28
  const { priceCurrency } = productCardProps || {};
@@ -36,6 +36,7 @@ function CioPiaQna(props) {
36
36
  answerParameters,
37
37
  parameters,
38
38
  formatImageUrl: formatters === null || formatters === void 0 ? void 0 : formatters.formatImageUrl,
39
+ testCells: abTest === null || abTest === void 0 ? void 0 : abTest.testCells,
39
40
  });
40
41
  const tracking = (0, useTracking_1.default)({
41
42
  cioClient: pia.cioClient,
@@ -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,
@@ -6,14 +6,21 @@ function useAnswerResults({ itemId, variationId, threadId, cioClient, parameters
6
6
  const [answerResults, setAnswerResults] = (0, react_1.useState)(null);
7
7
  const [isLoading, setIsLoading] = (0, react_1.useState)(false);
8
8
  const [error, setError] = (0, react_1.useState)(null);
9
+ const serializedParameters = (0, react_1.useMemo)(() => JSON.stringify(parameters), [parameters]);
9
10
  const items = (0, react_1.useMemo)(() => (0, transformers_1.extractAndTransformItems)(answerResults, formatImageUrl), [answerResults, formatImageUrl]);
11
+ // A host that rebuilds the client on render must not change getAnswer's identity.
12
+ const cioClientRef = (0, react_1.useRef)(cioClient);
13
+ (0, react_1.useEffect)(() => {
14
+ cioClientRef.current = cioClient;
15
+ }, [cioClient]);
10
16
  const fetchResult = (0, react_1.useCallback)((question) => {
11
- if (!cioClient)
17
+ const client = cioClientRef.current;
18
+ if (!client)
12
19
  return;
13
20
  setIsLoading(true);
14
21
  setError(null);
15
22
  setAnswerResults(null);
16
- cioClient.agent.pia
23
+ client.agent.pia
17
24
  .getAnswerResults(itemId, question, Object.assign({ threadId, variationId }, parameters))
18
25
  .then((response) => {
19
26
  setAnswerResults(response);
@@ -26,7 +33,9 @@ function useAnswerResults({ itemId, variationId, threadId, cioClient, parameters
26
33
  .finally(() => {
27
34
  setIsLoading(false);
28
35
  });
29
- }, [cioClient, itemId, variationId, threadId, parameters]);
36
+ },
37
+ // eslint-disable-next-line react-hooks/exhaustive-deps
38
+ [itemId, variationId, threadId, serializedParameters]);
30
39
  return {
31
40
  data: answerResults,
32
41
  items,
@@ -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
@@ -18,6 +18,9 @@ function useConversation({ pia, itemId, isConversation, callbacks, tracking, })
18
18
  const trackingRef = (0, react_1.useRef)(tracking);
19
19
  const lastQuestionRef = (0, react_1.useRef)('');
20
20
  const lastSourceRef = (0, react_1.useRef)('user');
21
+ // Set once an answer owns the question row, so a later suggested-questions
22
+ // response cannot take it back.
23
+ const showsFollowUpsRef = (0, react_1.useRef)(false);
21
24
  (0, react_1.useEffect)(() => {
22
25
  callbacksRef.current = callbacks;
23
26
  }, [callbacks]);
@@ -69,20 +72,29 @@ function useConversation({ pia, itemId, isConversation, callbacks, tracking, })
69
72
  setDisplayedQuestions(suggestedQuestions.data);
70
73
  setConversationHistory([]);
71
74
  prevAnswerDataRef.current = null;
75
+ showsFollowUpsRef.current = false;
72
76
  }, [suggestedQuestions.data]);
73
77
  (0, react_1.useEffect)(() => {
74
78
  setCurrentQuestion('');
75
79
  setDisplayedQuestions([]);
76
80
  setConversationHistory([]);
77
81
  prevAnswerDataRef.current = null;
82
+ showsFollowUpsRef.current = false;
78
83
  }, [itemId]);
79
84
  (0, react_1.useEffect)(() => {
85
+ // The ref, not `answers.data`, which outlives the item it answered.
86
+ if (showsFollowUpsRef.current)
87
+ return;
80
88
  setDisplayedQuestions(suggestedQuestions.data);
81
89
  }, [suggestedQuestions.data]);
82
90
  (0, react_1.useEffect)(() => {
83
91
  var _a, _b;
84
- if ((_a = answers.data) === null || _a === void 0 ? void 0 : _a.follow_up_questions)
85
- setDisplayedQuestions(answers.data.follow_up_questions);
92
+ if (answers.data) {
93
+ // An answer with no follow-ups empties the row: the pre-generated questions
94
+ // include the one just asked.
95
+ setDisplayedQuestions((_a = answers.data.follow_up_questions) !== null && _a !== void 0 ? _a : []);
96
+ showsFollowUpsRef.current = true;
97
+ }
86
98
  if (answers.data && lastQuestionRef.current && !hasTrackedCurrentAnswerRef.current) {
87
99
  hasTrackedCurrentAnswerRef.current = true;
88
100
  (_b = trackingRef.current) === null || _b === void 0 ? void 0 : _b.trackAnswerView(lastQuestionRef.current, answers.data, answers.items);
@@ -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;
@@ -30,15 +30,23 @@ function useRecsPod({ itemId, variationId, threadId, cioClient, parameters, form
30
30
  (0, react_1.useEffect)(() => {
31
31
  formatImageUrlRef.current = formatImageUrl;
32
32
  }, [formatImageUrl]);
33
+ // A host that rebuilds the client on render must not restart the request.
34
+ const cioClientRef = (0, react_1.useRef)(cioClient);
35
+ (0, react_1.useEffect)(() => {
36
+ cioClientRef.current = cioClient;
37
+ }, [cioClient]);
38
+ // A client arriving where there was none still warrants a fetch.
39
+ const hasClient = !!cioClient;
33
40
  const strategy = (parameters === null || parameters === void 0 ? void 0 : parameters.strategy) || DEFAULT_STRATEGY;
34
41
  const { numResults, defaultTitle } = parameters || {};
35
42
  const fetchResult = (0, react_1.useCallback)((shopperInput, source) => {
36
43
  var _a;
37
44
  // TODO: once getRecs is added to the JS client SDK, replace this guard+cast
38
45
  // with a direct call: cioClient.agent.getRecs({...})
39
- const getRecs = (_a = cioClient === null || cioClient === void 0 ? void 0 : cioClient.agent) === null || _a === void 0 ? void 0 : _a.getRecs;
40
- if (!cioClient || !getRecs) {
41
- if (cioClient && !getRecs) {
46
+ const client = cioClientRef.current;
47
+ const getRecs = (_a = client === null || client === void 0 ? void 0 : client.agent) === null || _a === void 0 ? void 0 : _a.getRecs;
48
+ if (!client || !getRecs) {
49
+ if (client && !getRecs) {
42
50
  console.info('[CioPia] getRecs is not available on the client SDK yet.');
43
51
  }
44
52
  setIsLoading(false);
@@ -51,7 +59,7 @@ function useRecsPod({ itemId, variationId, threadId, cioClient, parameters, form
51
59
  setError(null);
52
60
  setHasUnsupportedInput(false);
53
61
  getRecs
54
- .call(cioClient.agent, {
62
+ .call(client.agent, {
55
63
  itemId,
56
64
  variationId,
57
65
  threadId,
@@ -91,9 +99,9 @@ function useRecsPod({ itemId, variationId, threadId, cioClient, parameters, form
91
99
  setIsLoading(false);
92
100
  });
93
101
  },
94
- // Every dependency is a primitive. `formatImageUrl` is read through a ref instead, so a
95
- // caller writing it inline cannot restart the request on every render.
96
- [cioClient, itemId, variationId, threadId, strategy, numResults]);
102
+ // Every dependency is a primitive; `formatImageUrl` and the client are read through refs.
103
+ // eslint-disable-next-line react-hooks/exhaustive-deps
104
+ [hasClient, itemId, variationId, threadId, strategy, numResults]);
97
105
  (0, react_1.useEffect)(() => {
98
106
  fetchResult();
99
107
  }, [fetchResult]);
@@ -6,12 +6,21 @@ function useSuggestedQuestions({ itemId, variationId, threadId, cioClient, param
6
6
  const [isLoading, setIsLoading] = (0, react_1.useState)(false);
7
7
  const [error, setError] = (0, react_1.useState)(null);
8
8
  const serializedParameters = (0, react_1.useMemo)(() => JSON.stringify(parameters), [parameters]);
9
+ // The client is how the question is asked, not part of the question: a host that
10
+ // rebuilds it on render must not make the widget ask the same thing again.
11
+ const cioClientRef = (0, react_1.useRef)(cioClient);
12
+ (0, react_1.useEffect)(() => {
13
+ cioClientRef.current = cioClient;
14
+ }, [cioClient]);
15
+ // A client arriving where there was none is the one client change worth a fetch.
16
+ const hasClient = !!cioClient;
9
17
  const fetchResult = (0, react_1.useCallback)(() => {
10
- if (!cioClient)
18
+ const client = cioClientRef.current;
19
+ if (!client)
11
20
  return;
12
21
  setIsLoading(true);
13
22
  setError(null);
14
- cioClient.agent.pia
23
+ client.agent.pia
15
24
  .getSuggestedQuestions(itemId, Object.assign({ threadId,
16
25
  variationId }, parameters))
17
26
  .then((response) => {
@@ -26,7 +35,7 @@ function useSuggestedQuestions({ itemId, variationId, threadId, cioClient, param
26
35
  });
27
36
  // parameters is serialized via serializedParameters to prevent refetch on identity changes
28
37
  // eslint-disable-next-line react-hooks/exhaustive-deps
29
- }, [cioClient, itemId, variationId, threadId, serializedParameters]);
38
+ }, [hasClient, itemId, variationId, threadId, serializedParameters]);
30
39
  (0, react_1.useEffect)(() => {
31
40
  fetchResult();
32
41
  }, [fetchResult]);
@@ -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
  }
@@ -1,3 +1,3 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.default = '2.13.0';
3
+ exports.default = '2.14.0';
@@ -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
  }
@@ -18,7 +18,7 @@ import StatusRegion, { answerStatusMessage } from '../StatusRegion/StatusRegion'
18
18
  /** The question-and-answer experience: `mode: 'default'`, `mode: 'conversation'`, and the modal. */
19
19
  // eslint-disable-next-line complexity
20
20
  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;
21
+ const { apiKey, itemId, itemName, threadId, variationId, cioClient, displayConfigs, componentOverrides, callbacks, formatters, productCardProps, children, translations, suggestedQuestionsParameters, answerParameters, parameters, trackingConfigs, abTest, } = props;
22
22
  const { learnMoreUrl, showFeedback, mode = 'default', type = 'inline', showPreviousItems, disclaimerPosition = 'bottom', } = displayConfigs || {};
23
23
  const isConversation = mode === 'conversation' || type === 'modal';
24
24
  const { priceCurrency } = productCardProps || {};
@@ -32,6 +32,7 @@ export default function CioPiaQna(props) {
32
32
  answerParameters,
33
33
  parameters,
34
34
  formatImageUrl: formatters?.formatImageUrl,
35
+ testCells: abTest?.testCells,
35
36
  });
36
37
  const tracking = useTracking({
37
38
  cioClient: pia.cioClient,
@@ -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,
@@ -1,17 +1,24 @@
1
- import { useCallback, useMemo, useState } from 'react';
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { extractAndTransformItems } from '../utils/transformers';
3
3
  export default function useAnswerResults({ itemId, variationId, threadId, cioClient, parameters, formatImageUrl, }) {
4
4
  const [answerResults, setAnswerResults] = useState(null);
5
5
  const [isLoading, setIsLoading] = useState(false);
6
6
  const [error, setError] = useState(null);
7
+ const serializedParameters = useMemo(() => JSON.stringify(parameters), [parameters]);
7
8
  const items = useMemo(() => extractAndTransformItems(answerResults, formatImageUrl), [answerResults, formatImageUrl]);
9
+ // A host that rebuilds the client on render must not change getAnswer's identity.
10
+ const cioClientRef = useRef(cioClient);
11
+ useEffect(() => {
12
+ cioClientRef.current = cioClient;
13
+ }, [cioClient]);
8
14
  const fetchResult = useCallback((question) => {
9
- if (!cioClient)
15
+ const client = cioClientRef.current;
16
+ if (!client)
10
17
  return;
11
18
  setIsLoading(true);
12
19
  setError(null);
13
20
  setAnswerResults(null);
14
- cioClient.agent.pia
21
+ client.agent.pia
15
22
  .getAnswerResults(itemId, question, { threadId, variationId, ...parameters })
16
23
  .then((response) => {
17
24
  setAnswerResults(response);
@@ -24,7 +31,9 @@ export default function useAnswerResults({ itemId, variationId, threadId, cioCli
24
31
  .finally(() => {
25
32
  setIsLoading(false);
26
33
  });
27
- }, [cioClient, itemId, variationId, threadId, parameters]);
34
+ },
35
+ // eslint-disable-next-line react-hooks/exhaustive-deps
36
+ [itemId, variationId, threadId, serializedParameters]);
28
37
  return {
29
38
  data: answerResults,
30
39
  items,
@@ -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
@@ -15,6 +15,9 @@ export default function useConversation({ pia, itemId, isConversation, callbacks
15
15
  const trackingRef = useRef(tracking);
16
16
  const lastQuestionRef = useRef('');
17
17
  const lastSourceRef = useRef('user');
18
+ // Set once an answer owns the question row, so a later suggested-questions
19
+ // response cannot take it back.
20
+ const showsFollowUpsRef = useRef(false);
18
21
  useEffect(() => {
19
22
  callbacksRef.current = callbacks;
20
23
  }, [callbacks]);
@@ -62,19 +65,28 @@ export default function useConversation({ pia, itemId, isConversation, callbacks
62
65
  setDisplayedQuestions(suggestedQuestions.data);
63
66
  setConversationHistory([]);
64
67
  prevAnswerDataRef.current = null;
68
+ showsFollowUpsRef.current = false;
65
69
  }, [suggestedQuestions.data]);
66
70
  useEffect(() => {
67
71
  setCurrentQuestion('');
68
72
  setDisplayedQuestions([]);
69
73
  setConversationHistory([]);
70
74
  prevAnswerDataRef.current = null;
75
+ showsFollowUpsRef.current = false;
71
76
  }, [itemId]);
72
77
  useEffect(() => {
78
+ // The ref, not `answers.data`, which outlives the item it answered.
79
+ if (showsFollowUpsRef.current)
80
+ return;
73
81
  setDisplayedQuestions(suggestedQuestions.data);
74
82
  }, [suggestedQuestions.data]);
75
83
  useEffect(() => {
76
- if (answers.data?.follow_up_questions)
77
- setDisplayedQuestions(answers.data.follow_up_questions);
84
+ if (answers.data) {
85
+ // An answer with no follow-ups empties the row: the pre-generated questions
86
+ // include the one just asked.
87
+ setDisplayedQuestions(answers.data.follow_up_questions ?? []);
88
+ showsFollowUpsRef.current = true;
89
+ }
78
90
  if (answers.data && lastQuestionRef.current && !hasTrackedCurrentAnswerRef.current) {
79
91
  hasTrackedCurrentAnswerRef.current = true;
80
92
  trackingRef.current?.trackAnswerView(lastQuestionRef.current, answers.data, answers.items);
@@ -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
  }
@@ -27,14 +27,22 @@ export default function useRecsPod({ itemId, variationId, threadId, cioClient, p
27
27
  useEffect(() => {
28
28
  formatImageUrlRef.current = formatImageUrl;
29
29
  }, [formatImageUrl]);
30
+ // A host that rebuilds the client on render must not restart the request.
31
+ const cioClientRef = useRef(cioClient);
32
+ useEffect(() => {
33
+ cioClientRef.current = cioClient;
34
+ }, [cioClient]);
35
+ // A client arriving where there was none still warrants a fetch.
36
+ const hasClient = !!cioClient;
30
37
  const strategy = parameters?.strategy || DEFAULT_STRATEGY;
31
38
  const { numResults, defaultTitle } = parameters || {};
32
39
  const fetchResult = useCallback((shopperInput, source) => {
33
40
  // TODO: once getRecs is added to the JS client SDK, replace this guard+cast
34
41
  // with a direct call: cioClient.agent.getRecs({...})
35
- const getRecs = cioClient?.agent?.getRecs;
36
- if (!cioClient || !getRecs) {
37
- if (cioClient && !getRecs) {
42
+ const client = cioClientRef.current;
43
+ const getRecs = client?.agent?.getRecs;
44
+ if (!client || !getRecs) {
45
+ if (client && !getRecs) {
38
46
  console.info('[CioPia] getRecs is not available on the client SDK yet.');
39
47
  }
40
48
  setIsLoading(false);
@@ -47,7 +55,7 @@ export default function useRecsPod({ itemId, variationId, threadId, cioClient, p
47
55
  setError(null);
48
56
  setHasUnsupportedInput(false);
49
57
  getRecs
50
- .call(cioClient.agent, {
58
+ .call(client.agent, {
51
59
  itemId,
52
60
  variationId,
53
61
  threadId,
@@ -87,9 +95,9 @@ export default function useRecsPod({ itemId, variationId, threadId, cioClient, p
87
95
  setIsLoading(false);
88
96
  });
89
97
  },
90
- // Every dependency is a primitive. `formatImageUrl` is read through a ref instead, so a
91
- // caller writing it inline cannot restart the request on every render.
92
- [cioClient, itemId, variationId, threadId, strategy, numResults]);
98
+ // Every dependency is a primitive; `formatImageUrl` and the client are read through refs.
99
+ // eslint-disable-next-line react-hooks/exhaustive-deps
100
+ [hasClient, itemId, variationId, threadId, strategy, numResults]);
93
101
  useEffect(() => {
94
102
  fetchResult();
95
103
  }, [fetchResult]);