vdb-ai-chat 1.0.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 +153 -0
- package/dist/chat-widget.js +2 -0
- package/dist/chat-widget.js.LICENSE.txt +29 -0
- package/lib/commonjs/api.js +157 -0
- package/lib/commonjs/api.js.map +1 -0
- package/lib/commonjs/components/ChatHeader.js +111 -0
- package/lib/commonjs/components/ChatHeader.js.map +1 -0
- package/lib/commonjs/components/ChatInput.js +144 -0
- package/lib/commonjs/components/ChatInput.js.map +1 -0
- package/lib/commonjs/components/ChatWidget.js +469 -0
- package/lib/commonjs/components/ChatWidget.js.map +1 -0
- package/lib/commonjs/components/MessageBubble.js +122 -0
- package/lib/commonjs/components/MessageBubble.js.map +1 -0
- package/lib/commonjs/components/ProductsList.js +139 -0
- package/lib/commonjs/components/ProductsList.js.map +1 -0
- package/lib/commonjs/components/SuggestionsRow.js +59 -0
- package/lib/commonjs/components/SuggestionsRow.js.map +1 -0
- package/lib/commonjs/components/utils.js +37 -0
- package/lib/commonjs/components/utils.js.map +1 -0
- package/lib/commonjs/index.js +70 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/index.native.js +70 -0
- package/lib/commonjs/index.native.js.map +1 -0
- package/lib/commonjs/index.web.js +30 -0
- package/lib/commonjs/index.web.js.map +1 -0
- package/lib/commonjs/storage.js +136 -0
- package/lib/commonjs/storage.js.map +1 -0
- package/lib/commonjs/theme.js +29 -0
- package/lib/commonjs/theme.js.map +1 -0
- package/lib/commonjs/types.js +2 -0
- package/lib/commonjs/types.js.map +1 -0
- package/lib/module/api.js +146 -0
- package/lib/module/api.js.map +1 -0
- package/lib/module/components/ChatHeader.js +105 -0
- package/lib/module/components/ChatHeader.js.map +1 -0
- package/lib/module/components/ChatInput.js +136 -0
- package/lib/module/components/ChatInput.js.map +1 -0
- package/lib/module/components/ChatWidget.js +461 -0
- package/lib/module/components/ChatWidget.js.map +1 -0
- package/lib/module/components/MessageBubble.js +116 -0
- package/lib/module/components/MessageBubble.js.map +1 -0
- package/lib/module/components/ProductsList.js +133 -0
- package/lib/module/components/ProductsList.js.map +1 -0
- package/lib/module/components/SuggestionsRow.js +52 -0
- package/lib/module/components/SuggestionsRow.js.map +1 -0
- package/lib/module/components/utils.js +29 -0
- package/lib/module/components/utils.js.map +1 -0
- package/lib/module/index.js +7 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/index.native.js +7 -0
- package/lib/module/index.native.js.map +1 -0
- package/lib/module/index.web.js +23 -0
- package/lib/module/index.web.js.map +1 -0
- package/lib/module/storage.js +129 -0
- package/lib/module/storage.js.map +1 -0
- package/lib/module/theme.js +22 -0
- package/lib/module/theme.js.map +1 -0
- package/lib/module/types.js +2 -0
- package/lib/module/types.js.map +1 -0
- package/lib/typescript/api.d.ts +10 -0
- package/lib/typescript/api.d.ts.map +1 -0
- package/lib/typescript/components/ChatHeader.d.ts +6 -0
- package/lib/typescript/components/ChatHeader.d.ts.map +1 -0
- package/lib/typescript/components/ChatInput.d.ts +15 -0
- package/lib/typescript/components/ChatInput.d.ts.map +1 -0
- package/lib/typescript/components/ChatWidget.d.ts +4 -0
- package/lib/typescript/components/ChatWidget.d.ts.map +1 -0
- package/lib/typescript/components/MessageBubble.d.ts +13 -0
- package/lib/typescript/components/MessageBubble.d.ts.map +1 -0
- package/lib/typescript/components/ProductsList.d.ts +9 -0
- package/lib/typescript/components/ProductsList.d.ts.map +1 -0
- package/lib/typescript/components/SuggestionsRow.d.ts +8 -0
- package/lib/typescript/components/SuggestionsRow.d.ts.map +1 -0
- package/lib/typescript/components/utils.d.ts +8 -0
- package/lib/typescript/components/utils.d.ts.map +1 -0
- package/lib/typescript/index.d.ts +6 -0
- package/lib/typescript/index.d.ts.map +1 -0
- package/lib/typescript/index.native.d.ts +6 -0
- package/lib/typescript/index.native.d.ts.map +1 -0
- package/lib/typescript/index.web.d.ts +3 -0
- package/lib/typescript/index.web.d.ts.map +1 -0
- package/lib/typescript/storage.d.ts +28 -0
- package/lib/typescript/storage.d.ts.map +1 -0
- package/lib/typescript/theme.d.ts +4 -0
- package/lib/typescript/theme.d.ts.map +1 -0
- package/lib/typescript/types.d.ts +51 -0
- package/lib/typescript/types.d.ts.map +1 -0
- package/package.json +90 -0
- package/src/api.ts +200 -0
- package/src/components/ChatHeader.tsx +114 -0
- package/src/components/ChatInput.tsx +163 -0
- package/src/components/ChatWidget.tsx +679 -0
- package/src/components/MessageBubble.tsx +181 -0
- package/src/components/ProductsList.tsx +160 -0
- package/src/components/SuggestionsRow.tsx +73 -0
- package/src/components/utils.ts +43 -0
- package/src/index.native.tsx +6 -0
- package/src/index.ts +7 -0
- package/src/index.web.tsx +33 -0
- package/src/storage.ts +142 -0
- package/src/theme.ts +21 -0
- package/src/types.ts +56 -0
|
@@ -0,0 +1,679 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
forwardRef,
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useImperativeHandle,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
} from "react";
|
|
10
|
+
import {
|
|
11
|
+
View,
|
|
12
|
+
StyleSheet,
|
|
13
|
+
ScrollView,
|
|
14
|
+
Text,
|
|
15
|
+
TextInput,
|
|
16
|
+
DeviceEventEmitter,
|
|
17
|
+
Platform,
|
|
18
|
+
} from "react-native";
|
|
19
|
+
import { ChatInput } from "./ChatInput";
|
|
20
|
+
import { MessageBubble } from "./MessageBubble";
|
|
21
|
+
import type { ChatMessage, ChatWidgetProps, ChatWidgetRef } from "../types";
|
|
22
|
+
import { mergeTheme } from "../theme";
|
|
23
|
+
import {
|
|
24
|
+
ChatApiParams,
|
|
25
|
+
fetchInitialMessages,
|
|
26
|
+
getProducts,
|
|
27
|
+
handleFeedbackActionApi,
|
|
28
|
+
normaliseMessages,
|
|
29
|
+
sendUserMessage,
|
|
30
|
+
} from "../api";
|
|
31
|
+
import ChatHeader from "./ChatHeader";
|
|
32
|
+
import SuggestionsRow from "./SuggestionsRow";
|
|
33
|
+
import ProductsList from "./ProductsList";
|
|
34
|
+
import { FeedbackAction, formatToTime } from "./utils";
|
|
35
|
+
import { Storage } from "../storage";
|
|
36
|
+
|
|
37
|
+
export const ChatWidget = forwardRef<ChatWidgetRef, ChatWidgetProps>(
|
|
38
|
+
(
|
|
39
|
+
{
|
|
40
|
+
apiUrl,
|
|
41
|
+
userId: userIdProp,
|
|
42
|
+
userToken: userTokenProp,
|
|
43
|
+
priceMode: priceModeProp,
|
|
44
|
+
modalHeight,
|
|
45
|
+
theme: themeOverrides,
|
|
46
|
+
initialMessages = [],
|
|
47
|
+
placeholder,
|
|
48
|
+
onClose,
|
|
49
|
+
onClearChat,
|
|
50
|
+
onViewAllPress,
|
|
51
|
+
onItemPress: onItemPressProp,
|
|
52
|
+
},
|
|
53
|
+
ref
|
|
54
|
+
) => {
|
|
55
|
+
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages);
|
|
56
|
+
const [input, setInput] = useState("");
|
|
57
|
+
const [loading, setLoading] = useState(false);
|
|
58
|
+
const [loadingMessageId, setLoadingMessageId] = useState<string | null>(
|
|
59
|
+
null
|
|
60
|
+
);
|
|
61
|
+
const [typingMessageId, setTypingMessageId] = useState<string | null>(null);
|
|
62
|
+
const [typingFullText, setTypingFullText] = useState<string>("");
|
|
63
|
+
const [assistantResponse, setAssistantResponse] = useState<
|
|
64
|
+
ChatMessage | undefined
|
|
65
|
+
>(undefined);
|
|
66
|
+
const [products, setProducts] = useState<{
|
|
67
|
+
messageId: string;
|
|
68
|
+
data: any;
|
|
69
|
+
} | null>(null);
|
|
70
|
+
const [priceMode, setPriceMode] = useState<string | null>(
|
|
71
|
+
priceModeProp || null
|
|
72
|
+
);
|
|
73
|
+
const [conversationId, setConversationId] = useState<string | null>(null);
|
|
74
|
+
const [userId, setUserId] = useState<string>(userIdProp || "");
|
|
75
|
+
const [userToken, setUserToken] = useState<string>(userTokenProp || "");
|
|
76
|
+
const scrollRef = useRef<ScrollView | null>(null);
|
|
77
|
+
const inputRef = useRef<TextInput | null>(null);
|
|
78
|
+
const theme = useMemo(() => mergeTheme(themeOverrides), [themeOverrides]);
|
|
79
|
+
|
|
80
|
+
// Load user auth data from storage on mount
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
const loadAuthData = async () => {
|
|
83
|
+
try {
|
|
84
|
+
if (!userIdProp) {
|
|
85
|
+
const userData = await Storage.getJSON<{ id?: string }>(
|
|
86
|
+
"userData",
|
|
87
|
+
{}
|
|
88
|
+
);
|
|
89
|
+
if (userData?.id) {
|
|
90
|
+
setUserId(userData.id);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (!userTokenProp) {
|
|
94
|
+
const token = await Storage.getItem("token");
|
|
95
|
+
if (token) {
|
|
96
|
+
setUserToken(token);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
} catch (e) {
|
|
100
|
+
console.error("Failed to load auth data from storage", e);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
loadAuthData();
|
|
104
|
+
}, [userIdProp, userTokenProp]);
|
|
105
|
+
|
|
106
|
+
const onViewAll = useCallback(() => {
|
|
107
|
+
const searchPayload = JSON.stringify(
|
|
108
|
+
assistantResponse?.agent_response?.payload
|
|
109
|
+
);
|
|
110
|
+
const payload = assistantResponse?.agent_response?.payload;
|
|
111
|
+
if (!payload) return;
|
|
112
|
+
|
|
113
|
+
const domain = apiUrl.split("v3");
|
|
114
|
+
const deepLinkUrl = `${domain[0]}webapp/${
|
|
115
|
+
payload.lab_grown === "true" ? "lab-grown-diamonds" : "natural-diamonds"
|
|
116
|
+
}/search?priceMode=${priceMode}&productType=${
|
|
117
|
+
payload.lab_grown === "true" ? "lab_grown_diamond" : "diamond"
|
|
118
|
+
}&fromNewFilterScreen=false&filterSplitStep=1§ionName=Single%20Stones&breadCrumbLabel=Stone%20Search&enterSecondFlow=false&saved_search=${searchPayload}`;
|
|
119
|
+
|
|
120
|
+
// If custom handler is provided (React Native), use it
|
|
121
|
+
if (onViewAllPress) {
|
|
122
|
+
onViewAllPress(deepLinkUrl, payload);
|
|
123
|
+
} else if (Platform.OS === "web") {
|
|
124
|
+
// Default behavior for web
|
|
125
|
+
window.open(deepLinkUrl, "_parent");
|
|
126
|
+
}
|
|
127
|
+
}, [assistantResponse, apiUrl, priceMode, onViewAllPress]);
|
|
128
|
+
|
|
129
|
+
const onItemPress = useCallback(
|
|
130
|
+
(item: any) => {
|
|
131
|
+
const domain = apiUrl.split("v3");
|
|
132
|
+
const deepLinkUrl = `${domain[0]}webapp/${
|
|
133
|
+
item.type === "lab_grown_diamond"
|
|
134
|
+
? "lab-grown-diamonds"
|
|
135
|
+
: "natural-diamonds"
|
|
136
|
+
}/item-detail/${item.id}?productType=${
|
|
137
|
+
item.type
|
|
138
|
+
}&priceMode=${priceMode}&breadCrumbLabel=Stone%20Details`;
|
|
139
|
+
|
|
140
|
+
// If custom handler is provided (React Native), use it
|
|
141
|
+
if (onItemPressProp) {
|
|
142
|
+
onItemPressProp(deepLinkUrl, item);
|
|
143
|
+
} else if (Platform.OS === "web") {
|
|
144
|
+
// Default behavior for web
|
|
145
|
+
window.open(deepLinkUrl, "_parent");
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
[apiUrl, priceMode, onItemPressProp]
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const hasAuth = useMemo(
|
|
152
|
+
() => Boolean(userId || userToken),
|
|
153
|
+
[userId, userToken]
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const apiParams: ChatApiParams = useMemo(
|
|
157
|
+
() => ({
|
|
158
|
+
conversationId: userToken || userId,
|
|
159
|
+
}),
|
|
160
|
+
[userId, userToken]
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const handleFeedbackAction = useCallback(
|
|
164
|
+
async (
|
|
165
|
+
action: FeedbackAction,
|
|
166
|
+
conversation_id: string,
|
|
167
|
+
message_id: string
|
|
168
|
+
) => {
|
|
169
|
+
try {
|
|
170
|
+
// Use functional update to get current messages without dependency
|
|
171
|
+
let currentReaction = "0";
|
|
172
|
+
setMessages((prev) => {
|
|
173
|
+
const msg = prev.find((m) => m.id === message_id);
|
|
174
|
+
currentReaction = msg?.reaction || "0";
|
|
175
|
+
return prev;
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const nextAction: FeedbackAction =
|
|
179
|
+
currentReaction === action ? FeedbackAction.UNSET : action;
|
|
180
|
+
const res = await handleFeedbackActionApi(
|
|
181
|
+
nextAction,
|
|
182
|
+
conversation_id,
|
|
183
|
+
message_id
|
|
184
|
+
);
|
|
185
|
+
if (res.ok) {
|
|
186
|
+
setMessages((prev) =>
|
|
187
|
+
prev.map((m) =>
|
|
188
|
+
m.id === message_id ? { ...m, reaction: nextAction } : m
|
|
189
|
+
)
|
|
190
|
+
);
|
|
191
|
+
} else {
|
|
192
|
+
console.error("Feedback API failed", res.status);
|
|
193
|
+
}
|
|
194
|
+
} catch (e) {
|
|
195
|
+
console.error("Feedback API error", e);
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
[]
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
// Load initial history on mount
|
|
202
|
+
useEffect(() => {
|
|
203
|
+
let cancelled = false;
|
|
204
|
+
|
|
205
|
+
const load = async () => {
|
|
206
|
+
if (!hasAuth) return;
|
|
207
|
+
try {
|
|
208
|
+
const initial = await fetchInitialMessages(
|
|
209
|
+
apiUrl,
|
|
210
|
+
apiParams,
|
|
211
|
+
priceMode
|
|
212
|
+
);
|
|
213
|
+
if (!cancelled) {
|
|
214
|
+
setMessages(normaliseMessages(initial).reverse());
|
|
215
|
+
}
|
|
216
|
+
} catch (error) {
|
|
217
|
+
console.error("Failed to fetch initial messages", error);
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
if (!initialMessages.length) {
|
|
222
|
+
load();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return () => {
|
|
226
|
+
cancelled = true;
|
|
227
|
+
};
|
|
228
|
+
}, [apiUrl, apiParams, initialMessages.length, hasAuth, priceMode]);
|
|
229
|
+
|
|
230
|
+
const sendMessage = useCallback(
|
|
231
|
+
async (rawText: string) => {
|
|
232
|
+
const trimmed = rawText.trim();
|
|
233
|
+
if (!trimmed || loading) return;
|
|
234
|
+
if (products) {
|
|
235
|
+
setProducts(null);
|
|
236
|
+
}
|
|
237
|
+
const userMessage: ChatMessage = {
|
|
238
|
+
id: `user-${Date.now()}`,
|
|
239
|
+
role: "user",
|
|
240
|
+
text: trimmed,
|
|
241
|
+
createdAt: Date.now(),
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const loadingId = `bot-loading-${Date.now()}`;
|
|
245
|
+
const loadingMessage: ChatMessage = {
|
|
246
|
+
id: loadingId,
|
|
247
|
+
role: "assistant",
|
|
248
|
+
text: "Thinking",
|
|
249
|
+
createdAt: Date.now(),
|
|
250
|
+
isLoading: true,
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
setMessages((prev) => [...prev, userMessage, loadingMessage]);
|
|
254
|
+
setLoadingMessageId(loadingId);
|
|
255
|
+
setLoading(true);
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
if (!hasAuth) {
|
|
259
|
+
setMessages((prev) =>
|
|
260
|
+
prev.map((msg) =>
|
|
261
|
+
msg.id === loadingId
|
|
262
|
+
? {
|
|
263
|
+
...msg,
|
|
264
|
+
text: "Chat is unavailable (missing user authentication).",
|
|
265
|
+
isLoading: false,
|
|
266
|
+
}
|
|
267
|
+
: msg
|
|
268
|
+
)
|
|
269
|
+
);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const historyForApi = [...messages, userMessage];
|
|
274
|
+
const updatedMessages = await sendUserMessage(
|
|
275
|
+
apiUrl,
|
|
276
|
+
trimmed,
|
|
277
|
+
apiParams,
|
|
278
|
+
historyForApi,
|
|
279
|
+
priceMode
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
const normalised = normaliseMessages(updatedMessages);
|
|
283
|
+
const latestAssistant = normalised.find(
|
|
284
|
+
(m) => m.role === "assistant"
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
setAssistantResponse(latestAssistant);
|
|
288
|
+
|
|
289
|
+
if (latestAssistant?.text) {
|
|
290
|
+
// If there is an agent_response, try to fetch products first
|
|
291
|
+
if (
|
|
292
|
+
latestAssistant?.agent_response &&
|
|
293
|
+
typeof latestAssistant.agent_response === "object" &&
|
|
294
|
+
Object.keys(latestAssistant.agent_response).length > 0
|
|
295
|
+
) {
|
|
296
|
+
const productsResult = await getProducts(
|
|
297
|
+
latestAssistant.agent_response.payload
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
const hasDiamonds =
|
|
301
|
+
productsResult &&
|
|
302
|
+
productsResult.response &&
|
|
303
|
+
productsResult.response.body &&
|
|
304
|
+
Array.isArray(productsResult.response.body.diamonds) &&
|
|
305
|
+
productsResult.response.body.diamonds.length > 0;
|
|
306
|
+
|
|
307
|
+
if (!hasDiamonds) {
|
|
308
|
+
const noResultsText =
|
|
309
|
+
"No results found for your search. Try adjusting your filters or query.";
|
|
310
|
+
|
|
311
|
+
setLoadingMessageId(null);
|
|
312
|
+
setTypingMessageId(null);
|
|
313
|
+
setTypingFullText("");
|
|
314
|
+
setProducts(null);
|
|
315
|
+
|
|
316
|
+
setMessages((prev) =>
|
|
317
|
+
prev.map((msg) =>
|
|
318
|
+
msg.id === loadingId
|
|
319
|
+
? {
|
|
320
|
+
...msg,
|
|
321
|
+
id: latestAssistant?.id ?? msg.id,
|
|
322
|
+
text: noResultsText,
|
|
323
|
+
isLoading: false,
|
|
324
|
+
suggestions: [],
|
|
325
|
+
}
|
|
326
|
+
: msg
|
|
327
|
+
)
|
|
328
|
+
);
|
|
329
|
+
inputRef.current?.focus();
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
setProducts({
|
|
333
|
+
messageId: latestAssistant.id,
|
|
334
|
+
data: productsResult,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
setLoadingMessageId(null);
|
|
338
|
+
|
|
339
|
+
setMessages((prev) =>
|
|
340
|
+
prev.map((msg) =>
|
|
341
|
+
msg.id === loadingId
|
|
342
|
+
? {
|
|
343
|
+
...msg,
|
|
344
|
+
id: latestAssistant.id,
|
|
345
|
+
text: "",
|
|
346
|
+
isLoading: false,
|
|
347
|
+
suggestions: latestAssistant.suggestions,
|
|
348
|
+
}
|
|
349
|
+
: msg
|
|
350
|
+
)
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
// Use setTimeout to ensure state is updated before starting typewriter
|
|
354
|
+
setTimeout(() => {
|
|
355
|
+
setTypingMessageId(latestAssistant.id);
|
|
356
|
+
setTypingFullText(latestAssistant.text);
|
|
357
|
+
}, 50);
|
|
358
|
+
} else {
|
|
359
|
+
setMessages((prev) =>
|
|
360
|
+
prev.map((msg) =>
|
|
361
|
+
msg.id === loadingId
|
|
362
|
+
? {
|
|
363
|
+
...msg,
|
|
364
|
+
isLoading: false,
|
|
365
|
+
}
|
|
366
|
+
: msg
|
|
367
|
+
)
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
} catch (err) {
|
|
371
|
+
setMessages((prev) =>
|
|
372
|
+
prev.map((msg) =>
|
|
373
|
+
msg.id === loadingId
|
|
374
|
+
? {
|
|
375
|
+
...msg,
|
|
376
|
+
text: "Sorry, something went wrong. Please try again.",
|
|
377
|
+
isLoading: false,
|
|
378
|
+
}
|
|
379
|
+
: msg
|
|
380
|
+
)
|
|
381
|
+
);
|
|
382
|
+
console.error(err);
|
|
383
|
+
} finally {
|
|
384
|
+
setLoading(false);
|
|
385
|
+
setLoadingMessageId(null);
|
|
386
|
+
inputRef.current?.focus();
|
|
387
|
+
}
|
|
388
|
+
},
|
|
389
|
+
[apiUrl, loading, messages, apiParams, hasAuth, priceMode, products]
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
const handleSend = useCallback(async () => {
|
|
393
|
+
const trimmed = input.trim();
|
|
394
|
+
if (!trimmed) return;
|
|
395
|
+
setInput("");
|
|
396
|
+
sendMessage(trimmed);
|
|
397
|
+
}, [input, sendMessage]);
|
|
398
|
+
|
|
399
|
+
const handleSuggestionSelect = useCallback(
|
|
400
|
+
(value: string) => {
|
|
401
|
+
sendMessage(value);
|
|
402
|
+
},
|
|
403
|
+
[sendMessage]
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
const handleReloadResults = useCallback(async (msg: ChatMessage) => {
|
|
407
|
+
try {
|
|
408
|
+
const payload = msg?.agent_response?.payload;
|
|
409
|
+
if (!payload) return;
|
|
410
|
+
const productsResult = await getProducts(payload);
|
|
411
|
+
setProducts({ messageId: msg.id, data: productsResult });
|
|
412
|
+
} catch (e) {
|
|
413
|
+
console.error("Reload results failed", e);
|
|
414
|
+
}
|
|
415
|
+
}, []);
|
|
416
|
+
|
|
417
|
+
useEffect(() => {
|
|
418
|
+
DeviceEventEmitter.addListener("clearChat", handleClearChat);
|
|
419
|
+
DeviceEventEmitter.addListener(
|
|
420
|
+
"changePriceMode",
|
|
421
|
+
(data: { priceMode: string }) => changePriceMode(data.priceMode)
|
|
422
|
+
);
|
|
423
|
+
changePriceMode(undefined);
|
|
424
|
+
|
|
425
|
+
return () => {
|
|
426
|
+
DeviceEventEmitter.removeAllListeners("clearChat");
|
|
427
|
+
DeviceEventEmitter.removeAllListeners("changePriceMode");
|
|
428
|
+
};
|
|
429
|
+
}, []);
|
|
430
|
+
|
|
431
|
+
const changePriceMode = async (_priceMode?: any) => {
|
|
432
|
+
// Priority: passed argument > prop > storage
|
|
433
|
+
if (!_priceMode && priceModeProp) {
|
|
434
|
+
_priceMode = priceModeProp;
|
|
435
|
+
}
|
|
436
|
+
if (!_priceMode) {
|
|
437
|
+
const userData = await Storage.getJSON<{ price_mode?: string }>(
|
|
438
|
+
"userData",
|
|
439
|
+
{}
|
|
440
|
+
);
|
|
441
|
+
_priceMode = userData?.price_mode;
|
|
442
|
+
}
|
|
443
|
+
setPriceMode(_priceMode || null);
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
// Update conversationId when priceMode changes
|
|
447
|
+
useEffect(() => {
|
|
448
|
+
const loadConversationId = async () => {
|
|
449
|
+
if (!priceMode) {
|
|
450
|
+
setConversationId(null);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
const conversations = await Storage.getJSON<Record<string, any>>(
|
|
454
|
+
"vdbchat_conversations",
|
|
455
|
+
{}
|
|
456
|
+
);
|
|
457
|
+
const storedId = conversations?.[priceMode]?.conversation_id ?? null;
|
|
458
|
+
setConversationId(storedId ? String(storedId) : null);
|
|
459
|
+
};
|
|
460
|
+
loadConversationId();
|
|
461
|
+
}, [priceMode, messages]); // Re-fetch when messages change (new conversation might be created)
|
|
462
|
+
|
|
463
|
+
const handleClearChat = useCallback(async () => {
|
|
464
|
+
try {
|
|
465
|
+
const conversations = await Storage.getJSON<Record<string, any>>(
|
|
466
|
+
"vdbchat_conversations",
|
|
467
|
+
{}
|
|
468
|
+
);
|
|
469
|
+
const storedId =
|
|
470
|
+
conversations?.[priceMode as string]?.conversation_id ?? null;
|
|
471
|
+
if (storedId && priceMode) {
|
|
472
|
+
const updatedConversations = conversations || {};
|
|
473
|
+
delete updatedConversations[priceMode];
|
|
474
|
+
await Storage.setJSON("vdbchat_conversations", updatedConversations);
|
|
475
|
+
}
|
|
476
|
+
setMessages([]);
|
|
477
|
+
setAssistantResponse(undefined);
|
|
478
|
+
setProducts(null);
|
|
479
|
+
setLoading(false);
|
|
480
|
+
setLoadingMessageId(null);
|
|
481
|
+
setTypingMessageId(null);
|
|
482
|
+
setTypingFullText("");
|
|
483
|
+
|
|
484
|
+
const fresh = await fetchInitialMessages(apiUrl, undefined, priceMode);
|
|
485
|
+
setMessages(normaliseMessages(fresh).reverse());
|
|
486
|
+
onClearChat?.();
|
|
487
|
+
} catch (err) {
|
|
488
|
+
console.error("Failed to clear chat", err);
|
|
489
|
+
}
|
|
490
|
+
}, [apiUrl, onClearChat, priceMode]);
|
|
491
|
+
|
|
492
|
+
// Expose clearChat method via ref for external use (React Native)
|
|
493
|
+
useImperativeHandle(
|
|
494
|
+
ref,
|
|
495
|
+
() => ({
|
|
496
|
+
clearChat: handleClearChat,
|
|
497
|
+
}),
|
|
498
|
+
[handleClearChat]
|
|
499
|
+
);
|
|
500
|
+
|
|
501
|
+
// "Thinking..." dot animation while waiting for the API
|
|
502
|
+
useEffect(() => {
|
|
503
|
+
if (!loadingMessageId || typingMessageId) {
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
let step = 0;
|
|
508
|
+
const base = "Thinking";
|
|
509
|
+
const msgId = loadingMessageId;
|
|
510
|
+
|
|
511
|
+
const interval = setInterval(() => {
|
|
512
|
+
step = (step + 1) % 3;
|
|
513
|
+
const dots = ".".repeat(step + 1);
|
|
514
|
+
setMessages((prev) =>
|
|
515
|
+
prev.map((msg) =>
|
|
516
|
+
msg.id === msgId && msg.isLoading
|
|
517
|
+
? { ...msg, text: `${base}${dots}` }
|
|
518
|
+
: msg
|
|
519
|
+
)
|
|
520
|
+
);
|
|
521
|
+
}, 500);
|
|
522
|
+
|
|
523
|
+
return () => {
|
|
524
|
+
clearInterval(interval);
|
|
525
|
+
};
|
|
526
|
+
}, [loadingMessageId, typingMessageId]);
|
|
527
|
+
|
|
528
|
+
// Typewriter-style animation for assistant reply once it arrives
|
|
529
|
+
const typingIndexRef = useRef(0);
|
|
530
|
+
|
|
531
|
+
useEffect(() => {
|
|
532
|
+
if (!typingMessageId || !typingFullText) {
|
|
533
|
+
typingIndexRef.current = 0;
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
typingIndexRef.current = 0;
|
|
538
|
+
const total = typingFullText.length;
|
|
539
|
+
const finalMsgId = typingMessageId;
|
|
540
|
+
const fullText = typingFullText;
|
|
541
|
+
|
|
542
|
+
const interval = setInterval(() => {
|
|
543
|
+
typingIndexRef.current += 1;
|
|
544
|
+
const currentIndex = typingIndexRef.current;
|
|
545
|
+
const slice = fullText.slice(0, currentIndex);
|
|
546
|
+
|
|
547
|
+
setMessages((prev) =>
|
|
548
|
+
prev.map((msg) =>
|
|
549
|
+
msg.id === finalMsgId
|
|
550
|
+
? {
|
|
551
|
+
...msg,
|
|
552
|
+
text: slice,
|
|
553
|
+
}
|
|
554
|
+
: msg
|
|
555
|
+
)
|
|
556
|
+
);
|
|
557
|
+
|
|
558
|
+
if (currentIndex >= total) {
|
|
559
|
+
clearInterval(interval);
|
|
560
|
+
setTypingMessageId(null);
|
|
561
|
+
setTypingFullText("");
|
|
562
|
+
setMessages((prev) =>
|
|
563
|
+
prev.map((msg) =>
|
|
564
|
+
msg.id === finalMsgId ? { ...msg, isLoading: false } : msg
|
|
565
|
+
)
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
}, 25);
|
|
569
|
+
|
|
570
|
+
return () => {
|
|
571
|
+
clearInterval(interval);
|
|
572
|
+
};
|
|
573
|
+
}, [typingMessageId, typingFullText]);
|
|
574
|
+
|
|
575
|
+
return (
|
|
576
|
+
<View
|
|
577
|
+
style={[styles.container, { backgroundColor: theme.backgroundColor }]}
|
|
578
|
+
>
|
|
579
|
+
{Platform.OS === "web" && (
|
|
580
|
+
<ChatHeader onClose={onClose} onClearChat={handleClearChat} />
|
|
581
|
+
)}
|
|
582
|
+
<ScrollView
|
|
583
|
+
ref={scrollRef}
|
|
584
|
+
style={modalHeight ? { height: modalHeight } : undefined}
|
|
585
|
+
contentContainerStyle={{
|
|
586
|
+
backgroundColor: theme?.listContentBackgroundColor || "#f5f5f5",
|
|
587
|
+
...styles.listContent,
|
|
588
|
+
justifyContent: messages.length === 0 ? "center" : "flex-end",
|
|
589
|
+
minHeight: modalHeight ? modalHeight : undefined,
|
|
590
|
+
}}
|
|
591
|
+
onContentSizeChange={() => {
|
|
592
|
+
scrollRef.current?.scrollToEnd({ animated: false });
|
|
593
|
+
}}
|
|
594
|
+
>
|
|
595
|
+
<View style={styles.emptyContainer}>
|
|
596
|
+
<Text style={styles.emptyText}>
|
|
597
|
+
{messages.length === 0
|
|
598
|
+
? "Start a conversation to Find the Perfect Diamond"
|
|
599
|
+
: `Chat Started at ${formatToTime(messages[0].createdAt)}`}
|
|
600
|
+
</Text>
|
|
601
|
+
</View>
|
|
602
|
+
{(() => {
|
|
603
|
+
const ordered = [...messages];
|
|
604
|
+
return ordered.map((item, index) => {
|
|
605
|
+
const isLatest = index === ordered.length - 1;
|
|
606
|
+
return (
|
|
607
|
+
<View key={item.id + index}>
|
|
608
|
+
<MessageBubble
|
|
609
|
+
message={item}
|
|
610
|
+
theme={theme}
|
|
611
|
+
conversationId={conversationId}
|
|
612
|
+
handleFeedbackAction={handleFeedbackAction}
|
|
613
|
+
onReloadResults={handleReloadResults}
|
|
614
|
+
/>
|
|
615
|
+
{item.role === "assistant" &&
|
|
616
|
+
products &&
|
|
617
|
+
products.messageId === item.id && (
|
|
618
|
+
<ProductsList
|
|
619
|
+
data={products?.data?.response?.body?.diamonds}
|
|
620
|
+
onViewAll={onViewAll}
|
|
621
|
+
onItemPress={onItemPress}
|
|
622
|
+
/>
|
|
623
|
+
)}
|
|
624
|
+
{item.role === "assistant" &&
|
|
625
|
+
item.suggestions &&
|
|
626
|
+
item.suggestions.length > 0 &&
|
|
627
|
+
isLatest && (
|
|
628
|
+
<SuggestionsRow
|
|
629
|
+
suggestions={item.suggestions || []}
|
|
630
|
+
onSelect={handleSuggestionSelect}
|
|
631
|
+
/>
|
|
632
|
+
)}
|
|
633
|
+
</View>
|
|
634
|
+
);
|
|
635
|
+
});
|
|
636
|
+
})()}
|
|
637
|
+
</ScrollView>
|
|
638
|
+
<ChatInput
|
|
639
|
+
value={input}
|
|
640
|
+
onChangeText={setInput}
|
|
641
|
+
onSend={handleSend}
|
|
642
|
+
disabled={loading}
|
|
643
|
+
placeholder={placeholder}
|
|
644
|
+
theme={theme}
|
|
645
|
+
inputRef={inputRef}
|
|
646
|
+
/>
|
|
647
|
+
</View>
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
);
|
|
651
|
+
|
|
652
|
+
ChatWidget.displayName = "ChatWidget";
|
|
653
|
+
|
|
654
|
+
const styles = StyleSheet.create({
|
|
655
|
+
container: {
|
|
656
|
+
flex: 1,
|
|
657
|
+
width: "100%",
|
|
658
|
+
},
|
|
659
|
+
listContent: {
|
|
660
|
+
paddingVertical: 8,
|
|
661
|
+
flexGrow: 1,
|
|
662
|
+
justifyContent: "flex-end",
|
|
663
|
+
},
|
|
664
|
+
loading: {
|
|
665
|
+
paddingVertical: 8,
|
|
666
|
+
alignItems: "center",
|
|
667
|
+
},
|
|
668
|
+
emptyContainer: {
|
|
669
|
+
padding: 16,
|
|
670
|
+
alignItems: "center",
|
|
671
|
+
justifyContent: "center",
|
|
672
|
+
backgroundColor: "#f5f5f5",
|
|
673
|
+
},
|
|
674
|
+
emptyText: {
|
|
675
|
+
fontSize: 14,
|
|
676
|
+
fontWeight: "500",
|
|
677
|
+
color: "#666666",
|
|
678
|
+
},
|
|
679
|
+
});
|