@constructor-io/constructorio-ui-pia 2.16.1 → 2.18.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.
- package/README.md +15 -13
- package/dist/constructorio-ui-pia-bundled.js +51 -51
- package/lib/cjs/components/CioPia/CioPiaQna.js +26 -6
- package/lib/cjs/components/CioPiaRecs/RecsPodRefinement.js +13 -3
- package/lib/cjs/components/Input/Input.js +3 -2
- package/lib/cjs/components/PiaConversation/PiaConversation.js +19 -3
- package/lib/cjs/components/PiaConversation/PiaModal.js +24 -1
- package/lib/cjs/hooks/useAnswerResults.js +17 -1
- package/lib/cjs/hooks/useCioPia.js +2 -1
- package/lib/cjs/hooks/useConversation.js +40 -18
- package/lib/cjs/hooks/usePersistedConversation.js +71 -0
- package/lib/cjs/hooks/usePiaClient.js +8 -1
- package/lib/cjs/hooks/useReturnFocusToInput.js +28 -0
- package/lib/cjs/index.js +4 -1
- package/lib/cjs/utils/conversationStorage.js +141 -0
- package/lib/cjs/version.js +1 -1
- package/lib/mjs/components/CioPia/CioPiaQna.js +24 -4
- package/lib/mjs/components/CioPiaRecs/RecsPodRefinement.js +13 -3
- package/lib/mjs/components/Input/Input.js +3 -2
- package/lib/mjs/components/PiaConversation/PiaConversation.js +19 -3
- package/lib/mjs/components/PiaConversation/PiaModal.js +23 -2
- package/lib/mjs/hooks/useAnswerResults.js +17 -1
- package/lib/mjs/hooks/useCioPia.js +2 -1
- package/lib/mjs/hooks/useConversation.js +38 -16
- package/lib/mjs/hooks/usePersistedConversation.js +73 -0
- package/lib/mjs/hooks/usePiaClient.js +8 -1
- package/lib/mjs/hooks/useReturnFocusToInput.js +25 -0
- package/lib/mjs/index.js +2 -0
- package/lib/mjs/utils/conversationStorage.js +129 -0
- package/lib/mjs/version.js +1 -1
- package/lib/types/components/CioPia/types.d.ts +16 -1
- package/lib/types/components/Input/Input.d.ts +2 -1
- package/lib/types/components/PiaConversation/PiaModal.d.ts +2 -0
- package/lib/types/hooks/useAnswerResults.d.ts +2 -0
- package/lib/types/hooks/useCioPia.d.ts +2 -0
- package/lib/types/hooks/useConversation.d.ts +4 -1
- package/lib/types/hooks/usePersistedConversation.d.ts +18 -0
- package/lib/types/hooks/usePiaClient.d.ts +3 -1
- package/lib/types/hooks/useReturnFocusToInput.d.ts +9 -0
- package/lib/types/index.d.ts +1 -0
- package/lib/types/types.d.ts +19 -0
- package/lib/types/utils/conversationStorage.d.ts +20 -0
- package/lib/types/version.d.ts +1 -1
- package/package.json +1 -1
|
@@ -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;
|
package/lib/cjs/version.js
CHANGED
|
@@ -5,9 +5,11 @@ 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';
|
|
12
|
+
import useReturnFocusToInput from '../../hooks/useReturnFocusToInput';
|
|
11
13
|
import ErrorBlock from '../Error/ErrorBlock';
|
|
12
14
|
import LoadingSkeleton from '../LoadingSkeleton/LoadingSkeleton';
|
|
13
15
|
import { translate } from '../../utils/translate';
|
|
@@ -19,14 +21,21 @@ import CheckoutTriggerBar from './CheckoutTriggerBar';
|
|
|
19
21
|
/** The question-and-answer experience: `mode: 'default'`, `mode: 'conversation'`, and the modal. */
|
|
20
22
|
// eslint-disable-next-line complexity
|
|
21
23
|
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;
|
|
24
|
+
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
25
|
const { learnMoreUrl, showFeedback, mode = 'default', type = 'inline', showPreviousItems, disclaimerPosition = 'bottom', } = displayConfigs || {};
|
|
24
26
|
const isConversation = mode === 'conversation' || type === 'modal';
|
|
25
27
|
const { priceCurrency } = productCardProps || {};
|
|
26
|
-
const
|
|
28
|
+
const persisted = usePersistedConversation({
|
|
29
|
+
enabled: Boolean(persistConversation?.enabled) && isConversation,
|
|
27
30
|
apiKey,
|
|
31
|
+
userId,
|
|
28
32
|
itemId,
|
|
29
33
|
threadId,
|
|
34
|
+
});
|
|
35
|
+
const pia = useCioPia({
|
|
36
|
+
apiKey,
|
|
37
|
+
itemId,
|
|
38
|
+
threadId: persisted?.threadId ?? threadId,
|
|
30
39
|
variationId,
|
|
31
40
|
cioClient,
|
|
32
41
|
suggestedQuestionsParameters,
|
|
@@ -34,6 +43,7 @@ export default function CioPiaQna(props) {
|
|
|
34
43
|
parameters,
|
|
35
44
|
formatImageUrl: formatters?.formatImageUrl,
|
|
36
45
|
testCells: abTest?.testCells,
|
|
46
|
+
userId,
|
|
37
47
|
});
|
|
38
48
|
const tracking = useTracking({
|
|
39
49
|
cioClient: pia.cioClient,
|
|
@@ -49,7 +59,17 @@ export default function CioPiaQna(props) {
|
|
|
49
59
|
callbacks,
|
|
50
60
|
tracking,
|
|
51
61
|
initialConversationHistory,
|
|
62
|
+
persisted,
|
|
52
63
|
});
|
|
64
|
+
const { inputRef, returnFocusAfterLoading } = useReturnFocusToInput(isLoading);
|
|
65
|
+
const handleInlineSubmit = useCallback((question) => {
|
|
66
|
+
handleSubmitQuestion(question);
|
|
67
|
+
returnFocusAfterLoading();
|
|
68
|
+
}, [handleSubmitQuestion, returnFocusAfterLoading]);
|
|
69
|
+
const handleInlineQuestionClick = useCallback((question) => {
|
|
70
|
+
handleQuestionClick(question);
|
|
71
|
+
returnFocusAfterLoading();
|
|
72
|
+
}, [handleQuestionClick, returnFocusAfterLoading]);
|
|
53
73
|
const { containerRef: viewportContainerRef } = useViewportTracking({
|
|
54
74
|
tracking,
|
|
55
75
|
questions: displayedQuestions,
|
|
@@ -113,12 +133,12 @@ export default function CioPiaQna(props) {
|
|
|
113
133
|
return (React.createElement("div", { ref: containerRef, className: 'cio-pia-container', "data-testid": 'cio-pia-container' },
|
|
114
134
|
React.createElement(RenderPropsWrapper, { props: renderProps, override: children || componentOverrides?.reactNode },
|
|
115
135
|
React.createElement("p", { className: 'cio-pia-title', "data-testid": 'cio-pia-title' }, translate('Any questions about this product?', translations)),
|
|
116
|
-
React.createElement(Input, { onSubmit:
|
|
136
|
+
React.createElement(Input, { onSubmit: handleInlineSubmit, onFocus: handleInputFocus, inputRef: inputRef, disabled: isLoading, value: currentQuestion, translations: translations, componentOverride: componentOverrides?.input }),
|
|
117
137
|
isAnswerLoading && React.createElement(LoadingSkeleton, { componentOverride: componentOverrides?.loading }),
|
|
118
138
|
!isAnswerLoading && error && (React.createElement(ErrorBlock, { key: error.message || translate('Unexpected error', translations), message: error.message, translations: translations })),
|
|
119
139
|
!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 })),
|
|
120
140
|
isLoading && React.createElement(SuggestedQuestionsSkeleton, null),
|
|
121
|
-
!isLoading && !error && (React.createElement(SuggestedQuestionsContainer, { questions: displayedQuestions, onQuestionClick:
|
|
141
|
+
!isLoading && !error && (React.createElement(SuggestedQuestionsContainer, { questions: displayedQuestions, onQuestionClick: handleInlineQuestionClick, componentOverride: componentOverrides?.suggestedQuestions })),
|
|
122
142
|
checkoutElement),
|
|
123
143
|
React.createElement(StatusRegion, { message: answerStatus, "data-testid": 'answer-status' })));
|
|
124
144
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import React from 'react';
|
|
1
|
+
import React, { useCallback } from 'react';
|
|
2
2
|
import Input from '../Input/Input';
|
|
3
3
|
import SuggestedQuestion from '../SuggestedQuestion/SuggestedQuestion';
|
|
4
4
|
import { SparklesIcon } from '../icons';
|
|
5
5
|
import RecsOptionsSkeleton from './RecsOptionsSkeleton';
|
|
6
6
|
import { resolveRefinementQuestion } from '../../utils/recs';
|
|
7
7
|
import { RECS_INPUT_PLACEHOLDER } from '../../constants';
|
|
8
|
+
import useReturnFocusToInput from '../../hooks/useReturnFocusToInput';
|
|
8
9
|
/**
|
|
9
10
|
* The row under the products: a prompt line, the short options the API suggested, and a box for
|
|
10
11
|
* anything it did not think of. The prompt line is held still while the rest reloads, so the row
|
|
@@ -12,11 +13,20 @@ import { RECS_INPUT_PLACEHOLDER } from '../../constants';
|
|
|
12
13
|
*/
|
|
13
14
|
export default function RecsPodRefinement({ refinement, isLoading, inputError, showInput, translations, componentOverrides, onRefine, onSubmit, onInputFocus, }) {
|
|
14
15
|
const options = refinement?.options || [];
|
|
16
|
+
const { inputRef, returnFocusAfterLoading } = useReturnFocusToInput(isLoading);
|
|
17
|
+
const refine = useCallback((text) => {
|
|
18
|
+
onRefine(text);
|
|
19
|
+
returnFocusAfterLoading();
|
|
20
|
+
}, [onRefine, returnFocusAfterLoading]);
|
|
21
|
+
const submit = useCallback((value) => {
|
|
22
|
+
onSubmit(value);
|
|
23
|
+
returnFocusAfterLoading();
|
|
24
|
+
}, [onSubmit, returnFocusAfterLoading]);
|
|
15
25
|
return (React.createElement("div", { className: 'cio-pia-recs-pod__refinement' },
|
|
16
26
|
React.createElement("span", { className: 'cio-pia-recs-pod__refinement-label' }, resolveRefinementQuestion(refinement, translations)),
|
|
17
|
-
isLoading ? (React.createElement(RecsOptionsSkeleton, { count: options.length || undefined })) : (options.map((option) => (React.createElement(SuggestedQuestion, { key: option, question: option, icon: React.createElement(SparklesIcon, null), onClick: () =>
|
|
27
|
+
isLoading ? (React.createElement(RecsOptionsSkeleton, { count: options.length || undefined })) : (options.map((option) => (React.createElement(SuggestedQuestion, { key: option, question: option, icon: React.createElement(SparklesIcon, null), onClick: () => refine(option) })))),
|
|
18
28
|
showInput && (React.createElement("div", { className: 'cio-pia-recs-pod__input' },
|
|
19
|
-
React.createElement(Input, { onSubmit:
|
|
29
|
+
React.createElement(Input, { onSubmit: submit, onFocus: onInputFocus, inputRef: inputRef, disabled: isLoading, error: inputError ?? undefined, placeholderKey: RECS_INPUT_PLACEHOLDER,
|
|
20
30
|
// The pod's box sits in a row beside the options and submits on Enter. Q&A keeps
|
|
21
31
|
// its button; this one has none in the mocks.
|
|
22
32
|
showSendButton: false, translations: translations, componentOverride: componentOverrides?.input })))));
|
|
@@ -13,7 +13,7 @@ import { SendIcon } from '../icons';
|
|
|
13
13
|
* across its re-renders.
|
|
14
14
|
*/
|
|
15
15
|
let inputIdCounter = 0;
|
|
16
|
-
function Input({ value: providedValue, placeholder, disabled = false, onSubmit, onFocus, translations, componentOverride, error, placeholderKey = 'Ask anything', showSendButton = true, }) {
|
|
16
|
+
function Input({ value: providedValue, placeholder, disabled = false, onSubmit, onFocus, translations, componentOverride, error, inputRef, placeholderKey = 'Ask anything', showSendButton = true, }) {
|
|
17
17
|
const [value, setValue] = useState(providedValue || '');
|
|
18
18
|
const [errorId] = useState(() => {
|
|
19
19
|
inputIdCounter += 1;
|
|
@@ -50,10 +50,11 @@ function Input({ value: providedValue, placeholder, disabled = false, onSubmit,
|
|
|
50
50
|
onFocus,
|
|
51
51
|
translations,
|
|
52
52
|
error,
|
|
53
|
+
inputRef,
|
|
53
54
|
}, override: componentOverride?.reactNode },
|
|
54
55
|
React.createElement(React.Fragment, null,
|
|
55
56
|
React.createElement("div", { className: 'cio-pia-input-container' },
|
|
56
|
-
React.createElement("input", { type: 'text', value: value, onChange: (e) => setValue(e.target.value), onKeyDown: handleSubmitOnEnter, onFocus: onFocus, placeholder: resolvedPlaceholder, "aria-label": accessibleName, disabled: disabled, className: cx('cio-pia-input', error && 'cio-pia-input--error', !showSendButton && 'cio-pia-input--no-send'), "aria-invalid": error ? true : undefined, "aria-describedby": error ? errorId : undefined }),
|
|
57
|
+
React.createElement("input", { ref: inputRef, type: 'text', value: value, onChange: (e) => setValue(e.target.value), onKeyDown: handleSubmitOnEnter, onFocus: onFocus, placeholder: resolvedPlaceholder, "aria-label": accessibleName, disabled: disabled, className: cx('cio-pia-input', error && 'cio-pia-input--error', !showSendButton && 'cio-pia-input--no-send'), "aria-invalid": error ? true : undefined, "aria-describedby": error ? errorId : undefined }),
|
|
57
58
|
showSendButton && (React.createElement("button", { type: 'button', onClick: () => handleSubmit(value), className: 'cio-pia-send-button', disabled: disabled },
|
|
58
59
|
translate('Send', translations),
|
|
59
60
|
React.createElement(SendIcon, null)))),
|
|
@@ -1,17 +1,33 @@
|
|
|
1
|
-
import React from 'react';
|
|
1
|
+
import React, { useCallback, useContext } from 'react';
|
|
2
2
|
import Input from '../Input/Input';
|
|
3
3
|
import SuggestedQuestionsContainer from '../SuggestedQuestionsContainer/SuggestedQuestionsContainer';
|
|
4
4
|
import SuggestedQuestionsSkeleton from '../SuggestedQuestionsContainer/SuggestedQuestionsSkeleton';
|
|
5
5
|
import { translate } from '../../utils/translate';
|
|
6
|
+
import useReturnFocusToInput from '../../hooks/useReturnFocusToInput';
|
|
7
|
+
import { ModalInputRefContext } from './PiaModal';
|
|
6
8
|
import ConversationHistory from '../ConversationHistory/ConversationHistory';
|
|
7
9
|
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
10
|
const hasHistory = conversationHistory.length > 0;
|
|
11
|
+
const { inputRef, returnFocusAfterLoading } = useReturnFocusToInput(isLoading);
|
|
12
|
+
const modalInputRef = useContext(ModalInputRefContext);
|
|
13
|
+
const attachInput = useCallback((node) => {
|
|
14
|
+
inputRef(node);
|
|
15
|
+
modalInputRef?.(node);
|
|
16
|
+
}, [inputRef, modalInputRef]);
|
|
17
|
+
const submitQuestion = useCallback((question) => {
|
|
18
|
+
handleSubmitQuestion(question);
|
|
19
|
+
returnFocusAfterLoading();
|
|
20
|
+
}, [handleSubmitQuestion, returnFocusAfterLoading]);
|
|
21
|
+
const clickQuestion = useCallback((question) => {
|
|
22
|
+
handleQuestionClick(question);
|
|
23
|
+
returnFocusAfterLoading();
|
|
24
|
+
}, [handleQuestionClick, returnFocusAfterLoading]);
|
|
9
25
|
return (React.createElement("div", { ref: containerRef, className: 'cio-pia-container cio-pia-conversation', "data-testid": 'cio-pia-container' },
|
|
10
26
|
!hasHistory && (React.createElement("p", { className: 'cio-pia-title', "data-testid": 'cio-pia-title' }, translate('Any questions about this product?', translations))),
|
|
11
27
|
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
28
|
checkoutElement && React.createElement("div", { className: 'cio-pia-conversation-checkout' }, checkoutElement),
|
|
13
29
|
React.createElement("div", { className: 'cio-pia-conversation-footer' },
|
|
14
30
|
isLoading && !error && React.createElement(SuggestedQuestionsSkeleton, null),
|
|
15
|
-
!isLoading && !error && (React.createElement(SuggestedQuestionsContainer, { questions: displayedQuestions, onQuestionClick:
|
|
16
|
-
React.createElement(Input, { onSubmit:
|
|
31
|
+
!isLoading && !error && (React.createElement(SuggestedQuestionsContainer, { questions: displayedQuestions, onQuestionClick: clickQuestion, componentOverride: componentOverrides?.suggestedQuestions })),
|
|
32
|
+
React.createElement(Input, { onSubmit: submitQuestion, onFocus: onInputFocus, inputRef: attachInput, disabled: isLoading, translations: translations, componentOverride: componentOverrides?.input }))));
|
|
17
33
|
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
1
|
+
import React, { createContext, useCallback, useEffect, useRef, useState, } from 'react';
|
|
2
2
|
import Input from '../Input/Input';
|
|
3
3
|
import { translate } from '../../utils/translate';
|
|
4
4
|
import SuggestedQuestionsContainer from '../SuggestedQuestionsContainer/SuggestedQuestionsContainer';
|
|
5
5
|
const OVERFLOW_HIDDEN_CLASS = 'cio-pia-modal-open';
|
|
6
|
+
/** Lets the conversation rendered as the modal's children hand it the node of its input. */
|
|
7
|
+
export const ModalInputRefContext = createContext(undefined);
|
|
6
8
|
function CloseIcon() {
|
|
7
9
|
return (React.createElement("svg", { width: '20', height: '20', viewBox: '0 0 20 20', fill: 'none', xmlns: 'http://www.w3.org/2000/svg', "aria-hidden": 'true', focusable: 'false' },
|
|
8
10
|
React.createElement("path", { d: 'M15.8334 5.34166L14.6584 4.16666L10.0001 8.82499L5.34175 4.16666L4.16675 5.34166L8.82508 9.99999L4.16675 14.6583L5.34175 15.8333L10.0001 11.175L14.6584 15.8333L15.8334 14.6583L11.1751 9.99999L15.8334 5.34166Z', fill: 'currentColor' })));
|
|
@@ -11,6 +13,11 @@ export default function PiaModal({ initialQuestions, handleSubmitQuestion, handl
|
|
|
11
13
|
const [isOpen, setIsOpen] = useState(false);
|
|
12
14
|
const dialogRef = useRef(null);
|
|
13
15
|
const triggerRef = useRef(null);
|
|
16
|
+
const initialFocusRef = useRef(null);
|
|
17
|
+
const inputNodeRef = useRef(null);
|
|
18
|
+
const setInputNode = useCallback((node) => {
|
|
19
|
+
inputNodeRef.current = node;
|
|
20
|
+
}, []);
|
|
14
21
|
const openModal = useCallback(() => {
|
|
15
22
|
const active = document.activeElement;
|
|
16
23
|
const isRealTrigger = !!active && active !== document.body && active !== document.documentElement;
|
|
@@ -27,6 +34,7 @@ export default function PiaModal({ initialQuestions, handleSubmitQuestion, handl
|
|
|
27
34
|
return undefined;
|
|
28
35
|
if (isOpen) {
|
|
29
36
|
dialog.showModal();
|
|
37
|
+
initialFocusRef.current = document.activeElement;
|
|
30
38
|
document.body.classList.add(OVERFLOW_HIDDEN_CLASS);
|
|
31
39
|
}
|
|
32
40
|
else {
|
|
@@ -38,6 +46,18 @@ export default function PiaModal({ initialQuestions, handleSubmitQuestion, handl
|
|
|
38
46
|
document.body.classList.remove(OVERFLOW_HIDDEN_CLASS);
|
|
39
47
|
};
|
|
40
48
|
}, [isOpen]);
|
|
49
|
+
// The input is disabled when the dialog opens, so showModal() parks focus on the first enabled
|
|
50
|
+
// control. Hand it to the input once the answer has loaded, unless the shopper has moved it.
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!isOpen || isLoading || !initialFocusRef.current)
|
|
53
|
+
return;
|
|
54
|
+
const placed = initialFocusRef.current;
|
|
55
|
+
initialFocusRef.current = null;
|
|
56
|
+
const active = document.activeElement;
|
|
57
|
+
if (active !== placed && active !== document.body)
|
|
58
|
+
return;
|
|
59
|
+
inputNodeRef.current?.focus();
|
|
60
|
+
}, [isOpen, isLoading]);
|
|
41
61
|
// Restore focus to the trigger. Re-runs on `isLoading`: the input trigger is
|
|
42
62
|
// disabled while a request is in flight and `focus()` would no-op.
|
|
43
63
|
useEffect(() => {
|
|
@@ -89,5 +109,6 @@ export default function PiaModal({ initialQuestions, handleSubmitQuestion, handl
|
|
|
89
109
|
React.createElement("p", { className: 'cio-pia-title', id: 'cio-pia-modal-title' }, translate('Ask about this product', translations)),
|
|
90
110
|
React.createElement("button", { type: 'button', className: 'cio-pia-modal-close-button', "aria-label": translate('Close', translations), onClick: closeModal },
|
|
91
111
|
React.createElement(CloseIcon, null))),
|
|
92
|
-
React.createElement("div", { className: 'cio-pia-modal-body' },
|
|
112
|
+
React.createElement("div", { className: 'cio-pia-modal-body' },
|
|
113
|
+
React.createElement(ModalInputRefContext.Provider, { value: setInputNode }, children))))));
|
|
93
114
|
}
|
|
@@ -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
|
-
|
|
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
|
|
@@ -1,12 +1,7 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
const { question, answer, items } = entry;
|
|
6
|
-
return (typeof question === 'string' &&
|
|
7
|
-
typeof answer === 'string' &&
|
|
8
|
-
(items === undefined || items === null || Array.isArray(items)));
|
|
9
|
-
}
|
|
2
|
+
import { isRestorableEntry } from '../utils/conversationStorage';
|
|
3
|
+
// `id` is the React key, so ids from outside the widget cannot be trusted to be unique.
|
|
4
|
+
const renumber = (entries) => entries.map((entry, index) => ({ ...entry, id: index + 1 }));
|
|
10
5
|
// The seed comes from the host's own storage, typed or not: a malformed one must not take the widget down.
|
|
11
6
|
function restoreHistory(seed) {
|
|
12
7
|
if (!Array.isArray(seed)) {
|
|
@@ -17,21 +12,27 @@ function restoreHistory(seed) {
|
|
|
17
12
|
if (entries.length < seed.length) {
|
|
18
13
|
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
14
|
}
|
|
20
|
-
|
|
21
|
-
|
|
15
|
+
return renumber(entries);
|
|
16
|
+
}
|
|
17
|
+
function initialHistory(isConversation, seed, persisted) {
|
|
18
|
+
if (!isConversation)
|
|
19
|
+
return [];
|
|
20
|
+
if (seed !== undefined)
|
|
21
|
+
return restoreHistory(seed);
|
|
22
|
+
return persisted ? renumber(persisted.history) : [];
|
|
22
23
|
}
|
|
23
|
-
export default function useConversation({ pia, itemId, isConversation, callbacks, tracking, initialConversationHistory, }) {
|
|
24
|
+
export default function useConversation({ pia, itemId, isConversation, callbacks, tracking, initialConversationHistory, persisted, }) {
|
|
24
25
|
const { suggestedQuestions, answers, threadId } = pia;
|
|
25
26
|
const { getAnswer } = answers;
|
|
26
27
|
const context = useMemo(() => ({ itemId, threadId }), [itemId, threadId]);
|
|
27
28
|
const [currentQuestion, setCurrentQuestion] = useState('');
|
|
28
29
|
const [displayedQuestions, setDisplayedQuestions] = useState([]);
|
|
29
|
-
const [conversationHistory, setConversationHistory] = useState(() => isConversation
|
|
30
|
-
? restoreHistory(initialConversationHistory)
|
|
31
|
-
: []);
|
|
30
|
+
const [conversationHistory, setConversationHistory] = useState(() => initialHistory(isConversation, initialConversationHistory, persisted));
|
|
32
31
|
const entryIdRef = useRef(conversationHistory.length);
|
|
33
32
|
const conversationHistoryRef = useRef(conversationHistory);
|
|
34
33
|
const prevItemIdRef = useRef(itemId);
|
|
34
|
+
const persistedRef = useRef(persisted);
|
|
35
|
+
const prevPersistedKeyRef = useRef(persisted?.key);
|
|
35
36
|
const prevAnswerDataRef = useRef(answers.data);
|
|
36
37
|
const hasTrackedCurrentAnswerRef = useRef(false);
|
|
37
38
|
const answersRef = useRef(answers);
|
|
@@ -58,6 +59,9 @@ export default function useConversation({ pia, itemId, isConversation, callbacks
|
|
|
58
59
|
useEffect(() => {
|
|
59
60
|
conversationHistoryRef.current = conversationHistory;
|
|
60
61
|
}, [conversationHistory]);
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
persistedRef.current = persisted;
|
|
64
|
+
}, [persisted]);
|
|
61
65
|
const submitQuestion = useCallback((question, source) => {
|
|
62
66
|
lastSourceRef.current = source;
|
|
63
67
|
lastQuestionRef.current = question;
|
|
@@ -94,7 +98,9 @@ export default function useConversation({ pia, itemId, isConversation, callbacks
|
|
|
94
98
|
const resetState = useCallback(() => {
|
|
95
99
|
setCurrentQuestion('');
|
|
96
100
|
setDisplayedQuestions(suggestedQuestions.data);
|
|
97
|
-
|
|
101
|
+
// A stored conversation outlives the modal: it is there again when the modal reopens.
|
|
102
|
+
if (!persistedRef.current)
|
|
103
|
+
setConversationHistory([]);
|
|
98
104
|
prevAnswerDataRef.current = null;
|
|
99
105
|
showsFollowUpsRef.current = false;
|
|
100
106
|
}, [suggestedQuestions.data]);
|
|
@@ -109,6 +115,19 @@ export default function useConversation({ pia, itemId, isConversation, callbacks
|
|
|
109
115
|
prevAnswerDataRef.current = null;
|
|
110
116
|
showsFollowUpsRef.current = false;
|
|
111
117
|
}, [itemId]);
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
// Runs after the itemId reset above, so another product's stored conversation replaces the empty one.
|
|
120
|
+
if (!persisted || prevPersistedKeyRef.current === persisted.key)
|
|
121
|
+
return;
|
|
122
|
+
prevPersistedKeyRef.current = persisted.key;
|
|
123
|
+
// The live answer belongs to the conversation being replaced: left in place, the restored last
|
|
124
|
+
// turn would show its products and credit its feedback.
|
|
125
|
+
answersRef.current.clear();
|
|
126
|
+
const restored = renumber(persisted.history);
|
|
127
|
+
entryIdRef.current = restored.length;
|
|
128
|
+
setConversationHistory(restored);
|
|
129
|
+
setCurrentQuestion('');
|
|
130
|
+
}, [persisted]);
|
|
112
131
|
useEffect(() => {
|
|
113
132
|
// The ref, not `answers.data`, which outlives the item it answered.
|
|
114
133
|
if (showsFollowUpsRef.current)
|
|
@@ -140,7 +159,9 @@ export default function useConversation({ pia, itemId, isConversation, callbacks
|
|
|
140
159
|
const qnaResultId = answers.data?.qna_result_id;
|
|
141
160
|
if (isConversation) {
|
|
142
161
|
setConversationHistory((prev) => {
|
|
143
|
-
|
|
162
|
+
// Only a question still waiting is answered: after a switch of product or shopper, a late
|
|
163
|
+
// answer must not overwrite a settled turn of the conversation that replaced it.
|
|
164
|
+
if (prev.length === 0 || prev[prev.length - 1].answer !== '')
|
|
144
165
|
return prev;
|
|
145
166
|
const updated = [...prev];
|
|
146
167
|
updated[updated.length - 1] = {
|
|
@@ -151,6 +172,7 @@ export default function useConversation({ pia, itemId, isConversation, callbacks
|
|
|
151
172
|
qnaResultId,
|
|
152
173
|
};
|
|
153
174
|
callbacksRef.current?.onAnswer?.(updated, contextRef.current);
|
|
175
|
+
persistedRef.current?.save(updated);
|
|
154
176
|
return updated;
|
|
155
177
|
});
|
|
156
178
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { isGuest, loadConversation, moveGuestConversations, saveConversation, } from '../utils/conversationStorage';
|
|
3
|
+
function loadIfEnabled({ enabled, apiKey, userId, itemId, threadIdProp, }) {
|
|
4
|
+
if (!enabled)
|
|
5
|
+
return { threadId: '', history: [] };
|
|
6
|
+
const stored = loadConversation({ apiKey, userId }, itemId);
|
|
7
|
+
if (stored && (!threadIdProp || stored.threadId === threadIdProp)) {
|
|
8
|
+
return { threadId: stored.threadId, history: stored.entries };
|
|
9
|
+
}
|
|
10
|
+
// A product with nothing stored starts its own thread, so returning to another product resumes
|
|
11
|
+
// a thread that only ever heard about that product.
|
|
12
|
+
return { threadId: threadIdProp || crypto.randomUUID(), history: [] };
|
|
13
|
+
}
|
|
14
|
+
/** The stored conversation for this product and shopper, or `undefined` while persistence is off. */
|
|
15
|
+
export default function usePersistedConversation({ enabled, apiKey, userId: userIdProp, itemId, threadId: threadIdProp, }) {
|
|
16
|
+
const userId = isGuest(userIdProp) ? undefined : String(userIdProp);
|
|
17
|
+
const userIdOmitted = userIdProp === undefined;
|
|
18
|
+
// An omitted id stores a signed-in shopper as a guest, so say so rather than fail silently.
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (!enabled || !userIdOmitted)
|
|
21
|
+
return;
|
|
22
|
+
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.');
|
|
23
|
+
}, [enabled, userIdOmitted]);
|
|
24
|
+
const target = { enabled, apiKey, userId, itemId, threadIdProp };
|
|
25
|
+
const [shown, setShown] = useState(() => ({
|
|
26
|
+
...target,
|
|
27
|
+
key: 0,
|
|
28
|
+
loggedIn: false,
|
|
29
|
+
...loadIfEnabled(target),
|
|
30
|
+
}));
|
|
31
|
+
// Adjusted during render, not in an effect: React re-renders before committing, so the first
|
|
32
|
+
// request after a switch of product or shopper already goes out on the new thread. See
|
|
33
|
+
// https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes
|
|
34
|
+
let current = shown;
|
|
35
|
+
if (shown.enabled !== enabled ||
|
|
36
|
+
shown.apiKey !== apiKey ||
|
|
37
|
+
shown.userId !== userId ||
|
|
38
|
+
shown.itemId !== itemId ||
|
|
39
|
+
shown.threadIdProp !== threadIdProp) {
|
|
40
|
+
const loggedIn = enabled &&
|
|
41
|
+
shown.enabled &&
|
|
42
|
+
shown.apiKey === apiKey &&
|
|
43
|
+
shown.userId === undefined &&
|
|
44
|
+
userId !== undefined;
|
|
45
|
+
// A login continues the guest's conversation as the shopper's own. With none to carry over,
|
|
46
|
+
// the shopper's own stored conversation is shown, rather than replaced by an empty one.
|
|
47
|
+
const keepsConversation = loggedIn &&
|
|
48
|
+
shown.itemId === itemId &&
|
|
49
|
+
shown.threadIdProp === threadIdProp &&
|
|
50
|
+
loadConversation({ apiKey }, itemId) !== undefined;
|
|
51
|
+
const conversation = keepsConversation
|
|
52
|
+
? { key: shown.key, threadId: shown.threadId, history: shown.history }
|
|
53
|
+
: { key: shown.key + 1, ...loadIfEnabled(target) };
|
|
54
|
+
current = { ...target, loggedIn, ...conversation };
|
|
55
|
+
setShown(current);
|
|
56
|
+
}
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (shown.loggedIn && shown.userId)
|
|
59
|
+
moveGuestConversations(shown.apiKey, shown.userId);
|
|
60
|
+
}, [shown]);
|
|
61
|
+
const shownRef = useRef(shown);
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
shownRef.current = shown;
|
|
64
|
+
}, [shown]);
|
|
65
|
+
const save = useCallback((history) => {
|
|
66
|
+
const latest = shownRef.current;
|
|
67
|
+
if (!latest.enabled)
|
|
68
|
+
return;
|
|
69
|
+
saveConversation({ apiKey: latest.apiKey, userId: latest.userId }, latest.itemId, latest.threadId, history);
|
|
70
|
+
}, []);
|
|
71
|
+
const { key, threadId, history } = current;
|
|
72
|
+
return useMemo(() => (current.enabled ? { key, threadId, history, save } : undefined), [current.enabled, key, threadId, history, save]);
|
|
73
|
+
}
|