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.
Files changed (102) hide show
  1. package/README.md +153 -0
  2. package/dist/chat-widget.js +2 -0
  3. package/dist/chat-widget.js.LICENSE.txt +29 -0
  4. package/lib/commonjs/api.js +157 -0
  5. package/lib/commonjs/api.js.map +1 -0
  6. package/lib/commonjs/components/ChatHeader.js +111 -0
  7. package/lib/commonjs/components/ChatHeader.js.map +1 -0
  8. package/lib/commonjs/components/ChatInput.js +144 -0
  9. package/lib/commonjs/components/ChatInput.js.map +1 -0
  10. package/lib/commonjs/components/ChatWidget.js +469 -0
  11. package/lib/commonjs/components/ChatWidget.js.map +1 -0
  12. package/lib/commonjs/components/MessageBubble.js +122 -0
  13. package/lib/commonjs/components/MessageBubble.js.map +1 -0
  14. package/lib/commonjs/components/ProductsList.js +139 -0
  15. package/lib/commonjs/components/ProductsList.js.map +1 -0
  16. package/lib/commonjs/components/SuggestionsRow.js +59 -0
  17. package/lib/commonjs/components/SuggestionsRow.js.map +1 -0
  18. package/lib/commonjs/components/utils.js +37 -0
  19. package/lib/commonjs/components/utils.js.map +1 -0
  20. package/lib/commonjs/index.js +70 -0
  21. package/lib/commonjs/index.js.map +1 -0
  22. package/lib/commonjs/index.native.js +70 -0
  23. package/lib/commonjs/index.native.js.map +1 -0
  24. package/lib/commonjs/index.web.js +30 -0
  25. package/lib/commonjs/index.web.js.map +1 -0
  26. package/lib/commonjs/storage.js +136 -0
  27. package/lib/commonjs/storage.js.map +1 -0
  28. package/lib/commonjs/theme.js +29 -0
  29. package/lib/commonjs/theme.js.map +1 -0
  30. package/lib/commonjs/types.js +2 -0
  31. package/lib/commonjs/types.js.map +1 -0
  32. package/lib/module/api.js +146 -0
  33. package/lib/module/api.js.map +1 -0
  34. package/lib/module/components/ChatHeader.js +105 -0
  35. package/lib/module/components/ChatHeader.js.map +1 -0
  36. package/lib/module/components/ChatInput.js +136 -0
  37. package/lib/module/components/ChatInput.js.map +1 -0
  38. package/lib/module/components/ChatWidget.js +461 -0
  39. package/lib/module/components/ChatWidget.js.map +1 -0
  40. package/lib/module/components/MessageBubble.js +116 -0
  41. package/lib/module/components/MessageBubble.js.map +1 -0
  42. package/lib/module/components/ProductsList.js +133 -0
  43. package/lib/module/components/ProductsList.js.map +1 -0
  44. package/lib/module/components/SuggestionsRow.js +52 -0
  45. package/lib/module/components/SuggestionsRow.js.map +1 -0
  46. package/lib/module/components/utils.js +29 -0
  47. package/lib/module/components/utils.js.map +1 -0
  48. package/lib/module/index.js +7 -0
  49. package/lib/module/index.js.map +1 -0
  50. package/lib/module/index.native.js +7 -0
  51. package/lib/module/index.native.js.map +1 -0
  52. package/lib/module/index.web.js +23 -0
  53. package/lib/module/index.web.js.map +1 -0
  54. package/lib/module/storage.js +129 -0
  55. package/lib/module/storage.js.map +1 -0
  56. package/lib/module/theme.js +22 -0
  57. package/lib/module/theme.js.map +1 -0
  58. package/lib/module/types.js +2 -0
  59. package/lib/module/types.js.map +1 -0
  60. package/lib/typescript/api.d.ts +10 -0
  61. package/lib/typescript/api.d.ts.map +1 -0
  62. package/lib/typescript/components/ChatHeader.d.ts +6 -0
  63. package/lib/typescript/components/ChatHeader.d.ts.map +1 -0
  64. package/lib/typescript/components/ChatInput.d.ts +15 -0
  65. package/lib/typescript/components/ChatInput.d.ts.map +1 -0
  66. package/lib/typescript/components/ChatWidget.d.ts +4 -0
  67. package/lib/typescript/components/ChatWidget.d.ts.map +1 -0
  68. package/lib/typescript/components/MessageBubble.d.ts +13 -0
  69. package/lib/typescript/components/MessageBubble.d.ts.map +1 -0
  70. package/lib/typescript/components/ProductsList.d.ts +9 -0
  71. package/lib/typescript/components/ProductsList.d.ts.map +1 -0
  72. package/lib/typescript/components/SuggestionsRow.d.ts +8 -0
  73. package/lib/typescript/components/SuggestionsRow.d.ts.map +1 -0
  74. package/lib/typescript/components/utils.d.ts +8 -0
  75. package/lib/typescript/components/utils.d.ts.map +1 -0
  76. package/lib/typescript/index.d.ts +6 -0
  77. package/lib/typescript/index.d.ts.map +1 -0
  78. package/lib/typescript/index.native.d.ts +6 -0
  79. package/lib/typescript/index.native.d.ts.map +1 -0
  80. package/lib/typescript/index.web.d.ts +3 -0
  81. package/lib/typescript/index.web.d.ts.map +1 -0
  82. package/lib/typescript/storage.d.ts +28 -0
  83. package/lib/typescript/storage.d.ts.map +1 -0
  84. package/lib/typescript/theme.d.ts +4 -0
  85. package/lib/typescript/theme.d.ts.map +1 -0
  86. package/lib/typescript/types.d.ts +51 -0
  87. package/lib/typescript/types.d.ts.map +1 -0
  88. package/package.json +90 -0
  89. package/src/api.ts +200 -0
  90. package/src/components/ChatHeader.tsx +114 -0
  91. package/src/components/ChatInput.tsx +163 -0
  92. package/src/components/ChatWidget.tsx +679 -0
  93. package/src/components/MessageBubble.tsx +181 -0
  94. package/src/components/ProductsList.tsx +160 -0
  95. package/src/components/SuggestionsRow.tsx +73 -0
  96. package/src/components/utils.ts +43 -0
  97. package/src/index.native.tsx +6 -0
  98. package/src/index.ts +7 -0
  99. package/src/index.web.tsx +33 -0
  100. package/src/storage.ts +142 -0
  101. package/src/theme.ts +21 -0
  102. 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&sectionName=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
+ });