@constructor-io/constructorio-ui-pia 2.16.0 → 2.17.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.
@@ -8,6 +8,7 @@ const SuggestedQuestionsContainer_1 = tslib_1.__importDefault(require("../Sugges
8
8
  const SuggestedQuestionsSkeleton_1 = tslib_1.__importDefault(require("../SuggestedQuestionsContainer/SuggestedQuestionsSkeleton"));
9
9
  const useCioPia_1 = tslib_1.__importDefault(require("../../hooks/useCioPia"));
10
10
  const useConversation_1 = tslib_1.__importDefault(require("../../hooks/useConversation"));
11
+ const usePersistedConversation_1 = tslib_1.__importDefault(require("../../hooks/usePersistedConversation"));
11
12
  const useTracking_1 = tslib_1.__importDefault(require("../../hooks/useTracking"));
12
13
  const useViewportTracking_1 = tslib_1.__importDefault(require("../../hooks/useViewportTracking"));
13
14
  const useViewportCallbacks_1 = tslib_1.__importDefault(require("../../hooks/useViewportCallbacks"));
@@ -22,15 +23,22 @@ const CheckoutTriggerBar_1 = tslib_1.__importDefault(require("./CheckoutTriggerB
22
23
  /** The question-and-answer experience: `mode: 'default'`, `mode: 'conversation'`, and the modal. */
23
24
  // eslint-disable-next-line complexity
24
25
  function CioPiaQna(props) {
25
- var _a;
26
- const { apiKey, itemId, itemName, threadId, variationId, cioClient, displayConfigs, componentOverrides, callbacks, formatters, productCardProps, children, translations, suggestedQuestionsParameters, answerParameters, parameters, trackingConfigs, checkoutTriggers, abTest, initialConversationHistory, } = props;
26
+ var _a, _b;
27
+ const { apiKey, itemId, itemName, threadId, variationId, cioClient, displayConfigs, componentOverrides, callbacks, formatters, productCardProps, children, translations, suggestedQuestionsParameters, answerParameters, parameters, trackingConfigs, checkoutTriggers, abTest, initialConversationHistory, persistConversation, userId, } = props;
27
28
  const { learnMoreUrl, showFeedback, mode = 'default', type = 'inline', showPreviousItems, disclaimerPosition = 'bottom', } = displayConfigs || {};
28
29
  const isConversation = mode === 'conversation' || type === 'modal';
29
30
  const { priceCurrency } = productCardProps || {};
30
- const pia = (0, useCioPia_1.default)({
31
+ const persisted = (0, usePersistedConversation_1.default)({
32
+ enabled: Boolean(persistConversation === null || persistConversation === void 0 ? void 0 : persistConversation.enabled) && isConversation,
31
33
  apiKey,
34
+ userId,
32
35
  itemId,
33
36
  threadId,
37
+ });
38
+ const pia = (0, useCioPia_1.default)({
39
+ apiKey,
40
+ itemId,
41
+ threadId: (_a = persisted === null || persisted === void 0 ? void 0 : persisted.threadId) !== null && _a !== void 0 ? _a : threadId,
34
42
  variationId,
35
43
  cioClient,
36
44
  suggestedQuestionsParameters,
@@ -38,6 +46,7 @@ function CioPiaQna(props) {
38
46
  parameters,
39
47
  formatImageUrl: formatters === null || formatters === void 0 ? void 0 : formatters.formatImageUrl,
40
48
  testCells: abTest === null || abTest === void 0 ? void 0 : abTest.testCells,
49
+ userId,
41
50
  });
42
51
  const tracking = (0, useTracking_1.default)({
43
52
  cioClient: pia.cioClient,
@@ -53,6 +62,7 @@ function CioPiaQna(props) {
53
62
  callbacks,
54
63
  tracking,
55
64
  initialConversationHistory,
65
+ persisted,
56
66
  });
57
67
  const { containerRef: viewportContainerRef } = (0, useViewportTracking_1.default)({
58
68
  tracking,
@@ -64,7 +74,7 @@ function CioPiaQna(props) {
64
74
  viewportContainerRef(node);
65
75
  callbackContainerRef(node);
66
76
  }, [viewportContainerRef, callbackContainerRef]);
67
- const qnaResultId = (_a = pia.answers.data) === null || _a === void 0 ? void 0 : _a.qna_result_id;
77
+ const qnaResultId = (_b = pia.answers.data) === null || _b === void 0 ? void 0 : _b.qna_result_id;
68
78
  // The text bars and the "Loading answer" status track the answer request only. The question
69
79
  // row keeps the combined `isLoading`: the follow-up questions that refill it arrive with the
70
80
  // answer, so the row is waiting on either request.
@@ -13,35 +13,51 @@ function useAnswerResults({ itemId, variationId, threadId, cioClient, parameters
13
13
  (0, react_1.useEffect)(() => {
14
14
  cioClientRef.current = cioClient;
15
15
  }, [cioClient]);
16
+ const requestIdRef = (0, react_1.useRef)(0);
16
17
  const fetchResult = (0, react_1.useCallback)((question) => {
17
18
  const client = cioClientRef.current;
18
19
  if (!client)
19
20
  return;
21
+ requestIdRef.current += 1;
22
+ const requestId = requestIdRef.current;
23
+ const isCurrent = () => requestId === requestIdRef.current;
20
24
  setIsLoading(true);
21
25
  setError(null);
22
26
  setAnswerResults(null);
23
27
  client.agent.pia
24
28
  .getAnswerResults(itemId, question, Object.assign({ threadId, variationId }, parameters))
25
29
  .then((response) => {
30
+ if (!isCurrent())
31
+ return;
26
32
  setAnswerResults(response);
27
33
  setError(null);
28
34
  })
29
35
  .catch((err) => {
36
+ if (!isCurrent())
37
+ return;
30
38
  setError(err instanceof Error ? err : new Error('Error fetching answer'));
31
39
  setAnswerResults(null);
32
40
  })
33
41
  .finally(() => {
34
- setIsLoading(false);
42
+ if (isCurrent())
43
+ setIsLoading(false);
35
44
  });
36
45
  },
37
46
  // eslint-disable-next-line react-hooks/exhaustive-deps
38
47
  [itemId, variationId, threadId, serializedParameters]);
48
+ const clear = (0, react_1.useCallback)(() => {
49
+ requestIdRef.current += 1;
50
+ setAnswerResults(null);
51
+ setError(null);
52
+ setIsLoading(false);
53
+ }, []);
39
54
  return {
40
55
  data: answerResults,
41
56
  items,
42
57
  isLoading,
43
58
  error,
44
59
  getAnswer: fetchResult,
60
+ clear,
45
61
  };
46
62
  }
47
63
  exports.default = useAnswerResults;
@@ -40,12 +40,13 @@ 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, testCells, } = props;
43
+ const { apiKey, itemId, variationId, threadId: providedThreadId, cioClient: providedClient, suggestedQuestionsParameters, answerParameters, parameters, formatImageUrl, testCells, userId, } = props;
44
44
  const { cioClient: client, threadId } = (0, usePiaClient_1.default)({
45
45
  apiKey,
46
46
  threadId: providedThreadId,
47
47
  cioClient: providedClient,
48
48
  testCells,
49
+ userId,
49
50
  });
50
51
  const mappedDeprecated = mapDeprecatedParameters(parameters);
51
52
  const mergedSuggestedParams = mappedDeprecated || suggestedQuestionsParameters
@@ -1,40 +1,41 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const react_1 = require("react");
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
- }
4
+ const conversationStorage_1 = require("../utils/conversationStorage");
5
+ // `id` is the React key, so ids from outside the widget cannot be trusted to be unique.
6
+ const renumber = (entries) => entries.map((entry, index) => (Object.assign(Object.assign({}, entry), { id: index + 1 })));
12
7
  // The seed comes from the host's own storage, typed or not: a malformed one must not take the widget down.
13
8
  function restoreHistory(seed) {
14
9
  if (!Array.isArray(seed)) {
15
10
  console.warn('[CioPia] initialConversationHistory must be an array. It was ignored.');
16
11
  return [];
17
12
  }
18
- const entries = seed.filter(isRestorableEntry);
13
+ const entries = seed.filter(conversationStorage_1.isRestorableEntry);
19
14
  if (entries.length < seed.length) {
20
15
  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
16
  }
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 })));
17
+ return renumber(entries);
18
+ }
19
+ function initialHistory(isConversation, seed, persisted) {
20
+ if (!isConversation)
21
+ return [];
22
+ if (seed !== undefined)
23
+ return restoreHistory(seed);
24
+ return persisted ? renumber(persisted.history) : [];
24
25
  }
25
- function useConversation({ pia, itemId, isConversation, callbacks, tracking, initialConversationHistory, }) {
26
+ function useConversation({ pia, itemId, isConversation, callbacks, tracking, initialConversationHistory, persisted, }) {
26
27
  var _a, _b, _c;
27
28
  const { suggestedQuestions, answers, threadId } = pia;
28
29
  const { getAnswer } = answers;
29
30
  const context = (0, react_1.useMemo)(() => ({ itemId, threadId }), [itemId, threadId]);
30
31
  const [currentQuestion, setCurrentQuestion] = (0, react_1.useState)('');
31
32
  const [displayedQuestions, setDisplayedQuestions] = (0, react_1.useState)([]);
32
- const [conversationHistory, setConversationHistory] = (0, react_1.useState)(() => isConversation && initialConversationHistory !== undefined
33
- ? restoreHistory(initialConversationHistory)
34
- : []);
33
+ const [conversationHistory, setConversationHistory] = (0, react_1.useState)(() => initialHistory(isConversation, initialConversationHistory, persisted));
35
34
  const entryIdRef = (0, react_1.useRef)(conversationHistory.length);
36
35
  const conversationHistoryRef = (0, react_1.useRef)(conversationHistory);
37
36
  const prevItemIdRef = (0, react_1.useRef)(itemId);
37
+ const persistedRef = (0, react_1.useRef)(persisted);
38
+ const prevPersistedKeyRef = (0, react_1.useRef)(persisted === null || persisted === void 0 ? void 0 : persisted.key);
38
39
  const prevAnswerDataRef = (0, react_1.useRef)(answers.data);
39
40
  const hasTrackedCurrentAnswerRef = (0, react_1.useRef)(false);
40
41
  const answersRef = (0, react_1.useRef)(answers);
@@ -61,6 +62,9 @@ function useConversation({ pia, itemId, isConversation, callbacks, tracking, ini
61
62
  (0, react_1.useEffect)(() => {
62
63
  conversationHistoryRef.current = conversationHistory;
63
64
  }, [conversationHistory]);
65
+ (0, react_1.useEffect)(() => {
66
+ persistedRef.current = persisted;
67
+ }, [persisted]);
64
68
  const submitQuestion = (0, react_1.useCallback)((question, source) => {
65
69
  lastSourceRef.current = source;
66
70
  lastQuestionRef.current = question;
@@ -101,7 +105,9 @@ function useConversation({ pia, itemId, isConversation, callbacks, tracking, ini
101
105
  const resetState = (0, react_1.useCallback)(() => {
102
106
  setCurrentQuestion('');
103
107
  setDisplayedQuestions(suggestedQuestions.data);
104
- setConversationHistory([]);
108
+ // A stored conversation outlives the modal: it is there again when the modal reopens.
109
+ if (!persistedRef.current)
110
+ setConversationHistory([]);
105
111
  prevAnswerDataRef.current = null;
106
112
  showsFollowUpsRef.current = false;
107
113
  }, [suggestedQuestions.data]);
@@ -116,6 +122,19 @@ function useConversation({ pia, itemId, isConversation, callbacks, tracking, ini
116
122
  prevAnswerDataRef.current = null;
117
123
  showsFollowUpsRef.current = false;
118
124
  }, [itemId]);
125
+ (0, react_1.useEffect)(() => {
126
+ // Runs after the itemId reset above, so another product's stored conversation replaces the empty one.
127
+ if (!persisted || prevPersistedKeyRef.current === persisted.key)
128
+ return;
129
+ prevPersistedKeyRef.current = persisted.key;
130
+ // The live answer belongs to the conversation being replaced: left in place, the restored last
131
+ // turn would show its products and credit its feedback.
132
+ answersRef.current.clear();
133
+ const restored = renumber(persisted.history);
134
+ entryIdRef.current = restored.length;
135
+ setConversationHistory(restored);
136
+ setCurrentQuestion('');
137
+ }, [persisted]);
119
138
  (0, react_1.useEffect)(() => {
120
139
  // The ref, not `answers.data`, which outlives the item it answered.
121
140
  if (showsFollowUpsRef.current)
@@ -149,12 +168,15 @@ function useConversation({ pia, itemId, isConversation, callbacks, tracking, ini
149
168
  const qnaResultId = (_d = answers.data) === null || _d === void 0 ? void 0 : _d.qna_result_id;
150
169
  if (isConversation) {
151
170
  setConversationHistory((prev) => {
152
- var _a, _b;
153
- if (prev.length === 0)
171
+ var _a, _b, _c;
172
+ // Only a question still waiting is answered: after a switch of product or shopper, a late
173
+ // answer must not overwrite a settled turn of the conversation that replaced it.
174
+ if (prev.length === 0 || prev[prev.length - 1].answer !== '')
154
175
  return prev;
155
176
  const updated = [...prev];
156
177
  updated[updated.length - 1] = Object.assign(Object.assign({}, updated[updated.length - 1]), { answer: answerValue, items: answers.items, threadId: answerThreadId, qnaResultId });
157
178
  (_b = (_a = callbacksRef.current) === null || _a === void 0 ? void 0 : _a.onAnswer) === null || _b === void 0 ? void 0 : _b.call(_a, updated, contextRef.current);
179
+ (_c = persistedRef.current) === null || _c === void 0 ? void 0 : _c.save(updated);
158
180
  return updated;
159
181
  });
160
182
  }
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const react_1 = require("react");
4
+ const conversationStorage_1 = require("../utils/conversationStorage");
5
+ function loadIfEnabled({ enabled, apiKey, userId, itemId, threadIdProp, }) {
6
+ if (!enabled)
7
+ return { threadId: '', history: [] };
8
+ const stored = (0, conversationStorage_1.loadConversation)({ apiKey, userId }, itemId);
9
+ if (stored && (!threadIdProp || stored.threadId === threadIdProp)) {
10
+ return { threadId: stored.threadId, history: stored.entries };
11
+ }
12
+ // A product with nothing stored starts its own thread, so returning to another product resumes
13
+ // a thread that only ever heard about that product.
14
+ return { threadId: threadIdProp || crypto.randomUUID(), history: [] };
15
+ }
16
+ /** The stored conversation for this product and shopper, or `undefined` while persistence is off. */
17
+ function usePersistedConversation({ enabled, apiKey, userId: userIdProp, itemId, threadId: threadIdProp, }) {
18
+ const userId = (0, conversationStorage_1.isGuest)(userIdProp) ? undefined : String(userIdProp);
19
+ const userIdOmitted = userIdProp === undefined;
20
+ // An omitted id stores a signed-in shopper as a guest, so say so rather than fail silently.
21
+ (0, react_1.useEffect)(() => {
22
+ if (!enabled || !userIdOmitted)
23
+ return;
24
+ console.warn('[CioPia] persistConversation is on but no userId was passed, so conversations are stored as a guest. Pass the signed-in shopper id, or null for a guest.');
25
+ }, [enabled, userIdOmitted]);
26
+ const target = { enabled, apiKey, userId, itemId, threadIdProp };
27
+ const [shown, setShown] = (0, react_1.useState)(() => (Object.assign(Object.assign(Object.assign({}, target), { key: 0, loggedIn: false }), loadIfEnabled(target))));
28
+ // Adjusted during render, not in an effect: React re-renders before committing, so the first
29
+ // request after a switch of product or shopper already goes out on the new thread. See
30
+ // https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes
31
+ let current = shown;
32
+ if (shown.enabled !== enabled ||
33
+ shown.apiKey !== apiKey ||
34
+ shown.userId !== userId ||
35
+ shown.itemId !== itemId ||
36
+ shown.threadIdProp !== threadIdProp) {
37
+ const loggedIn = enabled &&
38
+ shown.enabled &&
39
+ shown.apiKey === apiKey &&
40
+ shown.userId === undefined &&
41
+ userId !== undefined;
42
+ // A login continues the guest's conversation as the shopper's own. With none to carry over,
43
+ // the shopper's own stored conversation is shown, rather than replaced by an empty one.
44
+ const keepsConversation = loggedIn &&
45
+ shown.itemId === itemId &&
46
+ shown.threadIdProp === threadIdProp &&
47
+ (0, conversationStorage_1.loadConversation)({ apiKey }, itemId) !== undefined;
48
+ const conversation = keepsConversation
49
+ ? { key: shown.key, threadId: shown.threadId, history: shown.history }
50
+ : Object.assign({ key: shown.key + 1 }, loadIfEnabled(target));
51
+ current = Object.assign(Object.assign(Object.assign({}, target), { loggedIn }), conversation);
52
+ setShown(current);
53
+ }
54
+ (0, react_1.useEffect)(() => {
55
+ if (shown.loggedIn && shown.userId)
56
+ (0, conversationStorage_1.moveGuestConversations)(shown.apiKey, shown.userId);
57
+ }, [shown]);
58
+ const shownRef = (0, react_1.useRef)(shown);
59
+ (0, react_1.useEffect)(() => {
60
+ shownRef.current = shown;
61
+ }, [shown]);
62
+ const save = (0, react_1.useCallback)((history) => {
63
+ const latest = shownRef.current;
64
+ if (!latest.enabled)
65
+ return;
66
+ (0, conversationStorage_1.saveConversation)({ apiKey: latest.apiKey, userId: latest.userId }, latest.itemId, latest.threadId, history);
67
+ }, []);
68
+ const { key, threadId, history } = current;
69
+ return (0, react_1.useMemo)(() => (current.enabled ? { key, threadId, history, save } : undefined), [current.enabled, key, threadId, history, save]);
70
+ }
71
+ exports.default = usePersistedConversation;
@@ -4,7 +4,7 @@ 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, testCells, }) {
7
+ function usePiaClient({ apiKey, threadId: providedThreadId, cioClient: providedClient, testCells, userId, }) {
8
8
  const [generatedThreadId] = (0, react_1.useState)(() => crypto.randomUUID());
9
9
  const threadId = providedThreadId || generatedThreadId;
10
10
  const serializedTestCells = (0, react_1.useMemo)(() => JSON.stringify(testCells !== null && testCells !== void 0 ? testCells : null), [testCells]);
@@ -15,6 +15,13 @@ function usePiaClient({ apiKey, threadId: providedThreadId, cioClient: providedC
15
15
  // testCells is compared by serializedTestCells so an inline object does not rebuild the client
16
16
  // eslint-disable-next-line react-hooks/exhaustive-deps
17
17
  }, [apiKey, providedClient, serializedTestCells]);
18
+ // Set rather than passed to the constructor, so a login does not rebuild the client.
19
+ (0, react_1.useEffect)(() => {
20
+ if (providedClient || userId === undefined)
21
+ return;
22
+ // The typings require `apiKey`; the client merges a partial update.
23
+ client.setClientOptions({ userId: userId || undefined });
24
+ }, [client, providedClient, userId]);
18
25
  // A caller's client owns its own options, so say so rather than dropping these silently.
19
26
  (0, react_1.useEffect)(() => {
20
27
  if (!providedClient || !testCells || Object.keys(testCells).length === 0)
package/lib/cjs/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.AgentRequestError = exports.renderMarkdown = exports.sanitizeHtml = exports.CioPia = void 0;
3
+ exports.AgentRequestError = exports.clearPersistedConversations = exports.renderMarkdown = exports.sanitizeHtml = exports.CioPia = void 0;
4
4
  const tslib_1 = require("tslib");
5
5
  const CioPia_1 = tslib_1.__importDefault(require("./components/CioPia"));
6
6
  // Components
@@ -10,6 +10,9 @@ Object.defineProperty(exports, "CioPia", { enumerable: true, get: function () {
10
10
  var contentTransformers_1 = require("./utils/contentTransformers");
11
11
  Object.defineProperty(exports, "sanitizeHtml", { enumerable: true, get: function () { return contentTransformers_1.sanitizeHtml; } });
12
12
  Object.defineProperty(exports, "renderMarkdown", { enumerable: true, get: function () { return contentTransformers_1.renderMarkdown; } });
13
+ // Persistence
14
+ var conversationStorage_1 = require("./utils/conversationStorage");
15
+ Object.defineProperty(exports, "clearPersistedConversations", { enumerable: true, get: function () { return conversationStorage_1.clearPersistedConversations; } });
13
16
  // Errors
14
17
  var errors_1 = require("./errors");
15
18
  Object.defineProperty(exports, "AgentRequestError", { enumerable: true, get: function () { return errors_1.AgentRequestError; } });
@@ -0,0 +1,141 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.clearPersistedConversations = exports.moveGuestConversations = exports.saveConversation = exports.loadConversation = exports.isRestorableEntry = exports.isGuest = exports.PERSISTENCE_TTL_MS = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const KEY_PREFIX = 'cio-pia:chat:v1';
6
+ const STORED_VERSION = 1;
7
+ /** Matches how long the agent remembers a thread. */
8
+ exports.PERSISTENCE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
9
+ const isGuest = (userId) => userId == null || userId === '';
10
+ exports.isGuest = isGuest;
11
+ function isRestorableEntry(entry) {
12
+ if (typeof entry !== 'object' || entry === null)
13
+ return false;
14
+ const { question, answer, items } = entry;
15
+ return (typeof question === 'string' &&
16
+ typeof answer === 'string' &&
17
+ (items === undefined || items === null || Array.isArray(items)));
18
+ }
19
+ exports.isRestorableEntry = isRestorableEntry;
20
+ function isPersistedConversation(value) {
21
+ if (typeof value !== 'object' || value === null)
22
+ return false;
23
+ const { threadId, entries, updatedAt } = value;
24
+ return (typeof threadId === 'string' &&
25
+ threadId !== '' &&
26
+ typeof updatedAt === 'number' &&
27
+ Array.isArray(entries) &&
28
+ entries.every(isRestorableEntry));
29
+ }
30
+ function storageKeyFor({ apiKey, userId }) {
31
+ const parts = (0, exports.isGuest)(userId) ? [apiKey] : [apiKey, String(userId)];
32
+ return [KEY_PREFIX, ...parts.map(encodeURIComponent)].join(':');
33
+ }
34
+ // A guest's conversations end with the tab, so the next person at that browser never sees them.
35
+ function storageFor({ userId }) {
36
+ try {
37
+ if (typeof window === 'undefined')
38
+ return null;
39
+ return (0, exports.isGuest)(userId) ? window.sessionStorage : window.localStorage;
40
+ }
41
+ catch (_a) {
42
+ return null;
43
+ }
44
+ }
45
+ function readItems(storage, key) {
46
+ var _a;
47
+ try {
48
+ const parsed = JSON.parse((_a = storage.getItem(key)) !== null && _a !== void 0 ? _a : 'null');
49
+ if ((parsed === null || parsed === void 0 ? void 0 : parsed.version) !== STORED_VERSION || typeof parsed.items !== 'object' || !parsed.items) {
50
+ return {};
51
+ }
52
+ const cutoff = Date.now() - exports.PERSISTENCE_TTL_MS;
53
+ return Object.fromEntries(Object.entries(parsed.items).filter((pair) => isPersistedConversation(pair[1]) && pair[1].updatedAt >= cutoff));
54
+ }
55
+ catch (_b) {
56
+ return {};
57
+ }
58
+ }
59
+ function tryWrite(storage, key, items) {
60
+ try {
61
+ if (Object.keys(items).length === 0)
62
+ storage.removeItem(key);
63
+ else
64
+ storage.setItem(key, JSON.stringify({ version: STORED_VERSION, items }));
65
+ return true;
66
+ }
67
+ catch (_a) {
68
+ return false;
69
+ }
70
+ }
71
+ // When storage is full, shed other products' conversations oldest first, then this one's oldest
72
+ // turns. If not even one turn fits, this conversation is left out rather than stored half-written.
73
+ function writeFitting(storage, key, items, itemId) {
74
+ if (tryWrite(storage, key, items))
75
+ return;
76
+ const _a = items, _b = itemId, saved = _a[_b], others = tslib_1.__rest(_a, [typeof _b === "symbol" ? _b : _b + ""]);
77
+ const oldestFirst = Object.keys(others).sort((a, b) => others[a].updatedAt - others[b].updatedAt);
78
+ const fitsAfterEviction = oldestFirst.some((_, index) => {
79
+ const kept = Object.fromEntries(oldestFirst.slice(index + 1).map((id) => [id, others[id]]));
80
+ return tryWrite(storage, key, Object.assign(Object.assign({}, kept), { [itemId]: saved }));
81
+ });
82
+ if (fitsAfterEviction)
83
+ return;
84
+ const fitsTrimmed = saved.entries
85
+ .slice(1)
86
+ .some((_, i) => tryWrite(storage, key, { [itemId]: Object.assign(Object.assign({}, saved), { entries: saved.entries.slice(i + 1) }) }));
87
+ if (fitsTrimmed)
88
+ return;
89
+ tryWrite(storage, key, others);
90
+ }
91
+ function loadConversation(owner, itemId) {
92
+ const storage = storageFor(owner);
93
+ return storage ? readItems(storage, storageKeyFor(owner))[itemId] : undefined;
94
+ }
95
+ exports.loadConversation = loadConversation;
96
+ /** Stores the answered turns of one product's conversation. A turn still waiting on its answer is left out. */
97
+ function saveConversation(owner, itemId, threadId, history) {
98
+ const entries = history.filter((entry) => entry.answer !== '');
99
+ const storage = storageFor(owner);
100
+ if (!storage || entries.length === 0)
101
+ return;
102
+ const key = storageKeyFor(owner);
103
+ const items = readItems(storage, key);
104
+ writeFitting(storage, key, Object.assign(Object.assign({}, items), { [itemId]: { threadId, entries, updatedAt: Date.now() } }), itemId);
105
+ }
106
+ exports.saveConversation = saveConversation;
107
+ /** On login: every guest conversation moves into the shopper's store, so none is left for the next guest. */
108
+ function moveGuestConversations(apiKey, userId) {
109
+ const guest = { apiKey };
110
+ const shopper = { apiKey, userId };
111
+ const guestStorage = storageFor(guest);
112
+ const shopperStorage = storageFor(shopper);
113
+ if (!guestStorage || !shopperStorage)
114
+ return;
115
+ const guestKey = storageKeyFor(guest);
116
+ const moved = readItems(guestStorage, guestKey);
117
+ if (Object.keys(moved).length === 0)
118
+ return;
119
+ const shopperKey = storageKeyFor(shopper);
120
+ const merged = readItems(shopperStorage, shopperKey);
121
+ Object.entries(moved).forEach(([itemId, conversation]) => {
122
+ var _a, _b;
123
+ if (((_b = (_a = merged[itemId]) === null || _a === void 0 ? void 0 : _a.updatedAt) !== null && _b !== void 0 ? _b : -Infinity) <= conversation.updatedAt) {
124
+ merged[itemId] = conversation;
125
+ }
126
+ });
127
+ if (tryWrite(shopperStorage, shopperKey, merged))
128
+ tryWrite(guestStorage, guestKey, {});
129
+ }
130
+ exports.moveGuestConversations = moveGuestConversations;
131
+ /**
132
+ * Deletes every stored conversation of one shopper, or of the current tab's guest when `userId` is
133
+ * omitted. A widget already showing one keeps it on screen until its `itemId` or `userId` changes.
134
+ */
135
+ function clearPersistedConversations({ apiKey, userId, }) {
136
+ const owner = { apiKey, userId };
137
+ const storage = storageFor(owner);
138
+ if (storage)
139
+ tryWrite(storage, storageKeyFor(owner), {});
140
+ }
141
+ exports.clearPersistedConversations = clearPersistedConversations;
@@ -1,3 +1,3 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.default = '2.16.0';
3
+ exports.default = '2.17.0';
@@ -5,6 +5,7 @@ import SuggestedQuestionsContainer from '../SuggestedQuestionsContainer/Suggeste
5
5
  import SuggestedQuestionsSkeleton from '../SuggestedQuestionsContainer/SuggestedQuestionsSkeleton';
6
6
  import useCioPia from '../../hooks/useCioPia';
7
7
  import useConversation from '../../hooks/useConversation';
8
+ import usePersistedConversation from '../../hooks/usePersistedConversation';
8
9
  import useTracking from '../../hooks/useTracking';
9
10
  import useViewportTracking from '../../hooks/useViewportTracking';
10
11
  import useViewportCallbacks from '../../hooks/useViewportCallbacks';
@@ -19,14 +20,21 @@ import CheckoutTriggerBar from './CheckoutTriggerBar';
19
20
  /** The question-and-answer experience: `mode: 'default'`, `mode: 'conversation'`, and the modal. */
20
21
  // eslint-disable-next-line complexity
21
22
  export default function CioPiaQna(props) {
22
- const { apiKey, itemId, itemName, threadId, variationId, cioClient, displayConfigs, componentOverrides, callbacks, formatters, productCardProps, children, translations, suggestedQuestionsParameters, answerParameters, parameters, trackingConfigs, checkoutTriggers, abTest, initialConversationHistory, } = props;
23
+ const { apiKey, itemId, itemName, threadId, variationId, cioClient, displayConfigs, componentOverrides, callbacks, formatters, productCardProps, children, translations, suggestedQuestionsParameters, answerParameters, parameters, trackingConfigs, checkoutTriggers, abTest, initialConversationHistory, persistConversation, userId, } = props;
23
24
  const { learnMoreUrl, showFeedback, mode = 'default', type = 'inline', showPreviousItems, disclaimerPosition = 'bottom', } = displayConfigs || {};
24
25
  const isConversation = mode === 'conversation' || type === 'modal';
25
26
  const { priceCurrency } = productCardProps || {};
26
- const pia = useCioPia({
27
+ const persisted = usePersistedConversation({
28
+ enabled: Boolean(persistConversation?.enabled) && isConversation,
27
29
  apiKey,
30
+ userId,
28
31
  itemId,
29
32
  threadId,
33
+ });
34
+ const pia = useCioPia({
35
+ apiKey,
36
+ itemId,
37
+ threadId: persisted?.threadId ?? threadId,
30
38
  variationId,
31
39
  cioClient,
32
40
  suggestedQuestionsParameters,
@@ -34,6 +42,7 @@ export default function CioPiaQna(props) {
34
42
  parameters,
35
43
  formatImageUrl: formatters?.formatImageUrl,
36
44
  testCells: abTest?.testCells,
45
+ userId,
37
46
  });
38
47
  const tracking = useTracking({
39
48
  cioClient: pia.cioClient,
@@ -49,6 +58,7 @@ export default function CioPiaQna(props) {
49
58
  callbacks,
50
59
  tracking,
51
60
  initialConversationHistory,
61
+ persisted,
52
62
  });
53
63
  const { containerRef: viewportContainerRef } = useViewportTracking({
54
64
  tracking,
@@ -11,34 +11,50 @@ export default function useAnswerResults({ itemId, variationId, threadId, cioCli
11
11
  useEffect(() => {
12
12
  cioClientRef.current = cioClient;
13
13
  }, [cioClient]);
14
+ const requestIdRef = useRef(0);
14
15
  const fetchResult = useCallback((question) => {
15
16
  const client = cioClientRef.current;
16
17
  if (!client)
17
18
  return;
19
+ requestIdRef.current += 1;
20
+ const requestId = requestIdRef.current;
21
+ const isCurrent = () => requestId === requestIdRef.current;
18
22
  setIsLoading(true);
19
23
  setError(null);
20
24
  setAnswerResults(null);
21
25
  client.agent.pia
22
26
  .getAnswerResults(itemId, question, { threadId, variationId, ...parameters })
23
27
  .then((response) => {
28
+ if (!isCurrent())
29
+ return;
24
30
  setAnswerResults(response);
25
31
  setError(null);
26
32
  })
27
33
  .catch((err) => {
34
+ if (!isCurrent())
35
+ return;
28
36
  setError(err instanceof Error ? err : new Error('Error fetching answer'));
29
37
  setAnswerResults(null);
30
38
  })
31
39
  .finally(() => {
32
- setIsLoading(false);
40
+ if (isCurrent())
41
+ setIsLoading(false);
33
42
  });
34
43
  },
35
44
  // eslint-disable-next-line react-hooks/exhaustive-deps
36
45
  [itemId, variationId, threadId, serializedParameters]);
46
+ const clear = useCallback(() => {
47
+ requestIdRef.current += 1;
48
+ setAnswerResults(null);
49
+ setError(null);
50
+ setIsLoading(false);
51
+ }, []);
37
52
  return {
38
53
  data: answerResults,
39
54
  items,
40
55
  isLoading,
41
56
  error,
42
57
  getAnswer: fetchResult,
58
+ clear,
43
59
  };
44
60
  }
@@ -37,12 +37,13 @@ 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, testCells, } = props;
40
+ const { apiKey, itemId, variationId, threadId: providedThreadId, cioClient: providedClient, suggestedQuestionsParameters, answerParameters, parameters, formatImageUrl, testCells, userId, } = props;
41
41
  const { cioClient: client, threadId } = usePiaClient({
42
42
  apiKey,
43
43
  threadId: providedThreadId,
44
44
  cioClient: providedClient,
45
45
  testCells,
46
+ userId,
46
47
  });
47
48
  const mappedDeprecated = mapDeprecatedParameters(parameters);
48
49
  const mergedSuggestedParams = mappedDeprecated || suggestedQuestionsParameters