vdb-ai-chat 1.0.1 → 1.0.3

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 (128) hide show
  1. package/dist/10.chat-widget.js +2 -0
  2. package/dist/10.chat-widget.js.LICENSE.txt +1 -0
  3. package/dist/104.chat-widget.js +1 -0
  4. package/dist/50.chat-widget.js +1 -0
  5. package/dist/521.chat-widget.js +1 -0
  6. package/dist/538.chat-widget.js +1 -1
  7. package/dist/572.chat-widget.js +1 -0
  8. package/dist/694.chat-widget.js +1 -0
  9. package/dist/chat-widget.js +1 -1
  10. package/lib/commonjs/api.js +4 -3
  11. package/lib/commonjs/api.js.map +1 -1
  12. package/lib/commonjs/components/BetaNotice.js +38 -0
  13. package/lib/commonjs/components/BetaNotice.js.map +1 -0
  14. package/lib/commonjs/components/ChatHeader.js +27 -20
  15. package/lib/commonjs/components/ChatHeader.js.map +1 -1
  16. package/lib/commonjs/components/ChatInput.js +20 -21
  17. package/lib/commonjs/components/ChatInput.js.map +1 -1
  18. package/lib/commonjs/components/ChatWidget.js +165 -92
  19. package/lib/commonjs/components/ChatWidget.js.map +1 -1
  20. package/lib/commonjs/components/LazyProductsFetcher.js +47 -0
  21. package/lib/commonjs/components/LazyProductsFetcher.js.map +1 -0
  22. package/lib/commonjs/components/MessageBubble.js +26 -90
  23. package/lib/commonjs/components/MessageBubble.js.map +1 -1
  24. package/lib/commonjs/components/MessageMetaRow.js +113 -0
  25. package/lib/commonjs/components/MessageMetaRow.js.map +1 -0
  26. package/lib/commonjs/components/ProductsGrid.js +139 -0
  27. package/lib/commonjs/components/ProductsGrid.js.map +1 -0
  28. package/lib/commonjs/components/ProductsList.js +22 -126
  29. package/lib/commonjs/components/ProductsList.js.map +1 -1
  30. package/lib/commonjs/components/ProductsListView.js +139 -0
  31. package/lib/commonjs/components/ProductsListView.js.map +1 -0
  32. package/lib/commonjs/components/SuggestionsRow.js +50 -27
  33. package/lib/commonjs/components/SuggestionsRow.js.map +1 -1
  34. package/lib/commonjs/components/utils.js +4 -3
  35. package/lib/commonjs/components/utils.js.map +1 -1
  36. package/lib/commonjs/hooks/useInViewport.js +42 -0
  37. package/lib/commonjs/hooks/useInViewport.js.map +1 -0
  38. package/lib/commonjs/index.web.js +86 -29
  39. package/lib/commonjs/index.web.js.map +1 -1
  40. package/lib/commonjs/theme.js +4 -4
  41. package/lib/commonjs/theme.js.map +1 -1
  42. package/lib/module/api.js +4 -3
  43. package/lib/module/api.js.map +1 -1
  44. package/lib/module/components/BetaNotice.js +30 -0
  45. package/lib/module/components/BetaNotice.js.map +1 -0
  46. package/lib/module/components/ChatHeader.js +27 -20
  47. package/lib/module/components/ChatHeader.js.map +1 -1
  48. package/lib/module/components/ChatInput.js +20 -21
  49. package/lib/module/components/ChatInput.js.map +1 -1
  50. package/lib/module/components/ChatWidget.js +166 -93
  51. package/lib/module/components/ChatWidget.js.map +1 -1
  52. package/lib/module/components/LazyProductsFetcher.js +40 -0
  53. package/lib/module/components/LazyProductsFetcher.js.map +1 -0
  54. package/lib/module/components/MessageBubble.js +26 -92
  55. package/lib/module/components/MessageBubble.js.map +1 -1
  56. package/lib/module/components/MessageMetaRow.js +105 -0
  57. package/lib/module/components/MessageMetaRow.js.map +1 -0
  58. package/lib/module/components/ProductsGrid.js +133 -0
  59. package/lib/module/components/ProductsGrid.js.map +1 -0
  60. package/lib/module/components/ProductsList.js +21 -126
  61. package/lib/module/components/ProductsList.js.map +1 -1
  62. package/lib/module/components/ProductsListView.js +132 -0
  63. package/lib/module/components/ProductsListView.js.map +1 -0
  64. package/lib/module/components/SuggestionsRow.js +51 -28
  65. package/lib/module/components/SuggestionsRow.js.map +1 -1
  66. package/lib/module/components/utils.js +4 -3
  67. package/lib/module/components/utils.js.map +1 -1
  68. package/lib/module/hooks/useInViewport.js +36 -0
  69. package/lib/module/hooks/useInViewport.js.map +1 -0
  70. package/lib/module/index.web.js +86 -29
  71. package/lib/module/index.web.js.map +1 -1
  72. package/lib/module/theme.js +4 -4
  73. package/lib/module/theme.js.map +1 -1
  74. package/lib/typescript/api.d.ts.map +1 -1
  75. package/lib/typescript/components/BetaNotice.d.ts +5 -0
  76. package/lib/typescript/components/BetaNotice.d.ts.map +1 -0
  77. package/lib/typescript/components/ChatHeader.d.ts +5 -2
  78. package/lib/typescript/components/ChatHeader.d.ts.map +1 -1
  79. package/lib/typescript/components/ChatInput.d.ts.map +1 -1
  80. package/lib/typescript/components/ChatWidget.d.ts.map +1 -1
  81. package/lib/typescript/components/LazyProductsFetcher.d.ts +9 -0
  82. package/lib/typescript/components/LazyProductsFetcher.d.ts.map +1 -0
  83. package/lib/typescript/components/MessageBubble.d.ts +7 -3
  84. package/lib/typescript/components/MessageBubble.d.ts.map +1 -1
  85. package/lib/typescript/components/MessageMetaRow.d.ts +14 -0
  86. package/lib/typescript/components/MessageMetaRow.d.ts.map +1 -0
  87. package/lib/typescript/components/ProductsGrid.d.ts +10 -0
  88. package/lib/typescript/components/ProductsGrid.d.ts.map +1 -0
  89. package/lib/typescript/components/ProductsList.d.ts +4 -2
  90. package/lib/typescript/components/ProductsList.d.ts.map +1 -1
  91. package/lib/typescript/components/ProductsListView.d.ts +10 -0
  92. package/lib/typescript/components/ProductsListView.d.ts.map +1 -0
  93. package/lib/typescript/components/SuggestionsRow.d.ts +2 -0
  94. package/lib/typescript/components/SuggestionsRow.d.ts.map +1 -1
  95. package/lib/typescript/components/utils.d.ts +1 -0
  96. package/lib/typescript/components/utils.d.ts.map +1 -1
  97. package/lib/typescript/hooks/useInViewport.d.ts +5 -0
  98. package/lib/typescript/hooks/useInViewport.d.ts.map +1 -0
  99. package/lib/typescript/index.web.d.ts +1 -1
  100. package/lib/typescript/index.web.d.ts.map +1 -1
  101. package/lib/typescript/types.d.ts +3 -1
  102. package/lib/typescript/types.d.ts.map +1 -1
  103. package/package.json +1 -1
  104. package/src/api.ts +4 -3
  105. package/src/components/BetaNotice.tsx +32 -0
  106. package/src/components/ChatHeader.tsx +32 -18
  107. package/src/components/ChatInput.tsx +23 -21
  108. package/src/components/ChatWidget.tsx +249 -159
  109. package/src/components/LazyProductsFetcher.tsx +41 -0
  110. package/src/components/MessageBubble.tsx +46 -148
  111. package/src/components/MessageMetaRow.tsx +199 -0
  112. package/src/components/ProductsGrid.tsx +163 -0
  113. package/src/components/ProductsList.tsx +20 -146
  114. package/src/components/ProductsListView.tsx +149 -0
  115. package/src/components/SuggestionsRow.tsx +61 -32
  116. package/src/components/utils.ts +6 -4
  117. package/src/hooks/useInViewport.ts +38 -0
  118. package/src/index.web.tsx +87 -32
  119. package/src/theme.ts +4 -4
  120. package/src/types.ts +3 -2
  121. package/dist/751.chat-widget.js +0 -1
  122. package/lib/commonjs/contexts/SegmentClientContext.js +0 -19
  123. package/lib/commonjs/contexts/SegmentClientContext.js.map +0 -1
  124. package/lib/module/contexts/SegmentClientContext.js +0 -10
  125. package/lib/module/contexts/SegmentClientContext.js.map +0 -1
  126. package/lib/typescript/contexts/SegmentClientContext.d.ts +0 -9
  127. package/lib/typescript/contexts/SegmentClientContext.d.ts.map +0 -1
  128. package/src/contexts/SegmentClientContext.tsx +0 -20
@@ -13,11 +13,13 @@ import {
13
13
  ScrollView,
14
14
  Text,
15
15
  TextInput,
16
+ KeyboardAvoidingView,
16
17
  DeviceEventEmitter,
17
18
  Platform,
18
19
  } from "react-native";
19
20
  import { ChatInput } from "./ChatInput";
20
21
  import { MessageBubble } from "./MessageBubble";
22
+ import MessageMetaRow from "./MessageMetaRow";
21
23
  import type { ChatMessage, ChatWidgetProps, ChatWidgetRef } from "../types";
22
24
  import { mergeTheme } from "../theme";
23
25
  import {
@@ -29,9 +31,15 @@ import {
29
31
  sendUserMessage,
30
32
  } from "../api";
31
33
  import ChatHeader from "./ChatHeader";
32
- import SuggestionsRow from "./SuggestionsRow";
34
+ import BetaNotice from "./BetaNotice";
33
35
  import ProductsList from "./ProductsList";
34
- import { FeedbackAction, formatToTime, getUserDetails } from "./utils";
36
+ import LazyProductsFetcher from "./LazyProductsFetcher";
37
+ import {
38
+ FeedbackAction,
39
+ formatToTime,
40
+ getUserDetails,
41
+ UserDetails,
42
+ } from "./utils";
35
43
  import { useUserAnalytics } from "../hooks/useAnalytics";
36
44
  import { Storage } from "../storage";
37
45
 
@@ -39,7 +47,6 @@ export const ChatWidget = forwardRef<ChatWidgetRef, ChatWidgetProps>(
39
47
  (
40
48
  {
41
49
  apiUrl,
42
- userId: userIdProp,
43
50
  userToken: userTokenProp,
44
51
  priceMode: priceModeProp,
45
52
  modalHeight,
@@ -50,6 +57,7 @@ export const ChatWidget = forwardRef<ChatWidgetRef, ChatWidgetProps>(
50
57
  onClearChat,
51
58
  onViewAllPress,
52
59
  onItemPress: onItemPressProp,
60
+ isBetaMode: isBetaModeProp,
53
61
  },
54
62
  ref
55
63
  ) => {
@@ -64,6 +72,7 @@ export const ChatWidget = forwardRef<ChatWidgetRef, ChatWidgetProps>(
64
72
  const [assistantResponse, setAssistantResponse] = useState<
65
73
  ChatMessage | undefined
66
74
  >(undefined);
75
+ const [scrollY, setScrollY] = useState(0);
67
76
  const [productsByMsg, setProductsByMsg] = useState<Record<string, any>>({});
68
77
  const [reloadLoadingIds, setReloadLoadingIds] = useState<Set<string>>(
69
78
  new Set()
@@ -71,26 +80,17 @@ export const ChatWidget = forwardRef<ChatWidgetRef, ChatWidgetProps>(
71
80
  const [priceMode, setPriceMode] = useState<string | null>(
72
81
  priceModeProp || null
73
82
  );
74
- const [conversationId, setConversationId] = useState<string | null>(null);
75
- const [userId, setUserId] = useState<string>(userIdProp || "");
76
83
  const [userToken, setUserToken] = useState<string>(userTokenProp || "");
77
84
  const scrollRef = useRef<ScrollView | null>(null);
78
85
  const inputRef = useRef<TextInput | null>(null);
79
86
  const theme = useMemo(() => mergeTheme(themeOverrides), [themeOverrides]);
80
87
  const { _identify } = useUserAnalytics();
81
- // Load user auth data from storage on mount
88
+ const betaActive = Boolean(isBetaModeProp);
89
+ const noResultsText =
90
+ "No results found for your search. Try adjusting your filters or query.";
82
91
  useEffect(() => {
83
92
  const loadAuthData = async () => {
84
93
  try {
85
- if (!userIdProp) {
86
- const userData = await Storage.getJSON<{ id?: string }>(
87
- "userData",
88
- {}
89
- );
90
- if (userData?.id) {
91
- setUserId(userData.id);
92
- }
93
- }
94
94
  if (!userTokenProp) {
95
95
  const token = await Storage.getItem("token");
96
96
  if (token) {
@@ -102,7 +102,7 @@ export const ChatWidget = forwardRef<ChatWidgetRef, ChatWidgetProps>(
102
102
  }
103
103
  };
104
104
  loadAuthData();
105
- }, [userIdProp, userTokenProp]);
105
+ }, [userTokenProp]);
106
106
 
107
107
  const onViewAll = useCallback(() => {
108
108
  const searchPayload = JSON.stringify(assistantResponse?.search_payload);
@@ -116,11 +116,9 @@ export const ChatWidget = forwardRef<ChatWidgetRef, ChatWidgetProps>(
116
116
  payload.lab_grown === "true" ? "lab_grown_diamond" : "diamond"
117
117
  }&fromNewFilterScreen=false&filterSplitStep=1&sectionName=Single%20Stones&breadCrumbLabel=Stone%20Search&enterSecondFlow=false&saved_search=${searchPayload}`;
118
118
 
119
- // If custom handler is provided (React Native), use it
120
119
  if (onViewAllPress) {
121
120
  onViewAllPress(deepLinkUrl, payload);
122
121
  } else if (Platform.OS === "web") {
123
- // Default behavior for web
124
122
  window.open(deepLinkUrl, "_parent");
125
123
  }
126
124
  }, [assistantResponse, apiUrl, priceMode, onViewAllPress]);
@@ -136,27 +134,22 @@ export const ChatWidget = forwardRef<ChatWidgetRef, ChatWidgetProps>(
136
134
  item.type
137
135
  }&priceMode=${priceMode}&breadCrumbLabel=Stone%20Details`;
138
136
 
139
- // If custom handler is provided (React Native), use it
140
137
  if (onItemPressProp) {
141
138
  onItemPressProp(deepLinkUrl, item);
142
139
  } else if (Platform.OS === "web") {
143
- // Default behavior for web
144
140
  window.open(deepLinkUrl, "_parent");
145
141
  }
146
142
  },
147
143
  [apiUrl, priceMode, onItemPressProp]
148
144
  );
149
145
 
150
- const hasAuth = useMemo(
151
- () => Boolean(userId || userToken),
152
- [userId, userToken]
153
- );
146
+ const hasAuth = useMemo(() => Boolean(userToken), [userToken]);
154
147
 
155
148
  const apiParams: ChatApiParams = useMemo(
156
149
  () => ({
157
- conversationId: userToken || userId,
150
+ conversationId: userToken,
158
151
  }),
159
- [userId, userToken]
152
+ [userToken]
160
153
  );
161
154
 
162
155
  const handleFeedbackAction = useCallback(
@@ -166,7 +159,6 @@ export const ChatWidget = forwardRef<ChatWidgetRef, ChatWidgetProps>(
166
159
  message_id: string
167
160
  ) => {
168
161
  try {
169
- // Use functional update to get current messages without dependency
170
162
  let currentReaction = "0";
171
163
  setMessages((prev) => {
172
164
  const msg = prev.find((m) => m.id === message_id);
@@ -197,7 +189,6 @@ export const ChatWidget = forwardRef<ChatWidgetRef, ChatWidgetProps>(
197
189
  []
198
190
  );
199
191
 
200
- // Load initial history on mount
201
192
  useEffect(() => {
202
193
  let cancelled = false;
203
194
 
@@ -210,7 +201,30 @@ export const ChatWidget = forwardRef<ChatWidgetRef, ChatWidgetProps>(
210
201
  priceMode
211
202
  );
212
203
  if (!cancelled) {
213
- setMessages(normaliseMessages(initial).reverse());
204
+ const normalised = normaliseMessages(initial).reverse();
205
+ if (normalised.length === 0) {
206
+ const initialAssistant: ChatMessage = {
207
+ id: "",
208
+ role: "assistant",
209
+ text: "Hello! How can I help you today?",
210
+ createdAt: Date.now(),
211
+ isLoading: false,
212
+ suggestions: [
213
+ "Search Natural Diamonds",
214
+ "Search Lab-Grown Diamonds",
215
+ ],
216
+ reaction: "0",
217
+ };
218
+ setMessages([initialAssistant]);
219
+ setTimeout(() => {
220
+ scrollRef.current?.scrollToEnd({ animated: false });
221
+ }, 0);
222
+ } else {
223
+ setMessages(normalised);
224
+ setTimeout(() => {
225
+ scrollRef.current?.scrollToEnd({ animated: false });
226
+ }, 0);
227
+ }
214
228
  }
215
229
  } catch (error) {
216
230
  console.error("Failed to fetch initial messages", error);
@@ -244,6 +258,7 @@ export const ChatWidget = forwardRef<ChatWidgetRef, ChatWidgetProps>(
244
258
  role: "assistant",
245
259
  text: "Thinking",
246
260
  createdAt: Date.now(),
261
+ reaction: "0",
247
262
  isLoading: true,
248
263
  };
249
264
 
@@ -284,7 +299,6 @@ export const ChatWidget = forwardRef<ChatWidgetRef, ChatWidgetProps>(
284
299
  setAssistantResponse(latestAssistant);
285
300
 
286
301
  if (latestAssistant?.text) {
287
- // If there is an agent_response, try to fetch products first
288
302
  if (
289
303
  latestAssistant?.search_payload &&
290
304
  typeof latestAssistant.search_payload === "object" &&
@@ -302,9 +316,6 @@ export const ChatWidget = forwardRef<ChatWidgetRef, ChatWidgetProps>(
302
316
  productsResult.response.body.diamonds.length > 0;
303
317
 
304
318
  if (!hasDiamonds) {
305
- const noResultsText =
306
- "No results found for your search. Try adjusting your filters or query.";
307
-
308
319
  setLoadingMessageId(null);
309
320
  setTypingMessageId(null);
310
321
  setTypingFullText("");
@@ -317,7 +328,6 @@ export const ChatWidget = forwardRef<ChatWidgetRef, ChatWidgetProps>(
317
328
  id: latestAssistant?.id ?? msg.id,
318
329
  text: noResultsText,
319
330
  isLoading: false,
320
- suggestions: [],
321
331
  }
322
332
  : msg
323
333
  )
@@ -325,10 +335,10 @@ export const ChatWidget = forwardRef<ChatWidgetRef, ChatWidgetProps>(
325
335
  inputRef.current?.focus();
326
336
  return;
327
337
  }
328
- setProductsByMsg({
329
- messageId: latestAssistant.id,
330
- data: productsResult,
331
- });
338
+ setProductsByMsg((prev) => ({
339
+ ...prev,
340
+ [latestAssistant.id]: productsResult,
341
+ }));
332
342
  }
333
343
  setLoadingMessageId(null);
334
344
 
@@ -346,10 +356,10 @@ export const ChatWidget = forwardRef<ChatWidgetRef, ChatWidgetProps>(
346
356
  )
347
357
  );
348
358
 
349
- // Use setTimeout to ensure state is updated before starting typewriter
350
359
  setTimeout(() => {
351
360
  setTypingMessageId(latestAssistant.id);
352
361
  setTypingFullText(latestAssistant.text);
362
+ scrollRef.current?.scrollToEnd({ animated: true });
353
363
  }, 50);
354
364
  } else {
355
365
  setMessages((prev) =>
@@ -434,7 +444,7 @@ export const ChatWidget = forwardRef<ChatWidgetRef, ChatWidgetProps>(
434
444
  "changePriceMode",
435
445
  (data: { priceMode: string }) => changePriceMode(data.priceMode)
436
446
  );
437
- changePriceMode(undefined);
447
+ changePriceMode();
438
448
  identifySegmentUser();
439
449
 
440
450
  return () => {
@@ -444,47 +454,30 @@ export const ChatWidget = forwardRef<ChatWidgetRef, ChatWidgetProps>(
444
454
  }, []);
445
455
 
446
456
  const changePriceMode = async (_priceMode?: any) => {
447
- // Priority: passed argument > prop > storage
448
457
  if (!_priceMode && priceModeProp) {
449
458
  _priceMode = priceModeProp;
450
459
  }
451
460
  if (!_priceMode) {
452
- const userData = await Storage.getJSON<{ price_mode?: string }>(
453
- "userData",
454
- {}
455
- );
456
- _priceMode = userData?.price_mode;
461
+ let userData = await Storage.getJSON("persist:userInfo", {
462
+ user: undefined,
463
+ } as { user?: string });
464
+ userData = userData && userData.user ? JSON.parse(userData.user) : {};
465
+ _priceMode = (userData as UserDetails)?.price_mode;
457
466
  }
458
- setPriceMode(_priceMode || null);
467
+ setPriceMode(_priceMode === undefined ? null : String(_priceMode));
459
468
  };
460
469
 
461
- // Update conversationId when priceMode changes
462
- useEffect(() => {
463
- const loadConversationId = async () => {
464
- if (!priceMode) {
465
- setConversationId(null);
466
- return;
467
- }
468
- const conversations = await Storage.getJSON<Record<string, any>>(
469
- "vdbchat_conversations",
470
- {}
471
- );
472
- const storedId = conversations?.[priceMode]?.conversation_id ?? null;
473
- setConversationId(storedId ? String(storedId) : null);
474
- };
475
- loadConversationId();
476
- }, [priceMode, messages]); // Re-fetch when messages change (new conversation might be created)
477
-
478
470
  const handleClearChat = useCallback(async () => {
479
471
  try {
480
472
  const conversations = await Storage.getJSON<Record<string, any>>(
481
473
  "vdbchat_conversations",
482
474
  {}
483
475
  );
484
- const storedId =
485
- conversations?.[priceMode as string]?.conversation_id ?? null;
476
+ const storedId = conversations
477
+ ? conversations[priceMode as any]?.conversation_id
478
+ : null;
486
479
  if (storedId && priceMode) {
487
- const updatedConversations = conversations || {};
480
+ const updatedConversations = conversations ?? {};
488
481
  delete updatedConversations[priceMode];
489
482
  await Storage.setJSON("vdbchat_conversations", updatedConversations);
490
483
  }
@@ -498,38 +491,44 @@ export const ChatWidget = forwardRef<ChatWidgetRef, ChatWidgetProps>(
498
491
  setTypingFullText("");
499
492
 
500
493
  const fresh = await fetchInitialMessages(apiUrl, undefined, priceMode);
501
- setMessages(normaliseMessages(fresh).reverse());
494
+ const normalised = normaliseMessages(fresh).reverse();
495
+ if (normalised.length === 0) {
496
+ const initialAssistant: ChatMessage = {
497
+ id: "",
498
+ role: "assistant",
499
+ text: "Hello! How can I help you today?",
500
+ createdAt: Date.now(),
501
+ isLoading: false,
502
+ suggestions: [
503
+ "Search Natural Diamonds",
504
+ "Search Lab-Grown Diamonds",
505
+ ],
506
+ reaction: "0",
507
+ };
508
+ setMessages([initialAssistant]);
509
+ } else {
510
+ setMessages(normalised);
511
+ }
512
+ setTimeout(() => {
513
+ scrollRef.current?.scrollToEnd({ animated: false });
514
+ }, 500);
502
515
  onClearChat?.();
503
516
  } catch (err) {
504
517
  console.error("Failed to clear chat", err);
505
518
  }
506
- }, [apiUrl, onClearChat, priceMode]);
507
-
508
- // Expose clearChat method via ref for external use (React Native)
509
- useImperativeHandle(
510
- ref,
511
- () => ({
512
- clearChat: handleClearChat,
513
- }),
514
- [handleClearChat]
515
- );
519
+ }, [apiUrl, priceMode]);
516
520
 
517
- // "Thinking..." dot animation while waiting for the API
518
521
  useEffect(() => {
519
- if (!loadingMessageId || typingMessageId) {
520
- return;
521
- }
522
+ if (!loadingMessageId || typingMessageId) return;
522
523
 
523
524
  let step = 0;
524
525
  const base = "Thinking";
525
- const msgId = loadingMessageId;
526
-
527
526
  const interval = setInterval(() => {
528
527
  step = (step + 1) % 3;
529
528
  const dots = ".".repeat(step + 1);
530
529
  setMessages((prev) =>
531
530
  prev.map((msg) =>
532
- msg.id === msgId && msg.isLoading
531
+ msg.id === loadingMessageId && msg.isLoading
533
532
  ? { ...msg, text: `${base}${dots}` }
534
533
  : msg
535
534
  )
@@ -541,7 +540,14 @@ export const ChatWidget = forwardRef<ChatWidgetRef, ChatWidgetProps>(
541
540
  };
542
541
  }, [loadingMessageId, typingMessageId]);
543
542
 
544
- // Typewriter-style animation for assistant reply once it arrives
543
+ useImperativeHandle(
544
+ ref,
545
+ () => ({
546
+ clearChat: handleClearChat,
547
+ }),
548
+ [handleClearChat]
549
+ );
550
+
545
551
  const typingIndexRef = useRef(0);
546
552
 
547
553
  useEffect(() => {
@@ -593,82 +599,155 @@ export const ChatWidget = forwardRef<ChatWidgetRef, ChatWidgetProps>(
593
599
  style={[styles.container, { backgroundColor: theme.backgroundColor }]}
594
600
  >
595
601
  {Platform.OS === "web" && (
596
- <ChatHeader onClose={onClose} onClearChat={handleClearChat} />
602
+ <ChatHeader
603
+ onClose={onClose}
604
+ onClearChat={handleClearChat}
605
+ isBetaMode={betaActive}
606
+ />
597
607
  )}
598
- <ScrollView
599
- ref={scrollRef}
600
- style={
601
- modalHeight
602
- ? { height: modalHeight, backgroundColor: "#f5f5f5" }
603
- : { backgroundColor: "#f5f5f5" }
604
- }
605
- contentContainerStyle={{
606
- backgroundColor: theme?.listContentBackgroundColor || "#f5f5f5",
607
- ...styles.listContent,
608
- justifyContent: messages.length === 0 ? "center" : "flex-end",
609
- minHeight: modalHeight ? modalHeight : undefined,
610
- }}
611
- onContentSizeChange={() => {
612
- scrollRef.current?.scrollToEnd({ animated: false });
613
- }}
608
+ <KeyboardAvoidingView
609
+ style={{ flex: 1 }}
610
+ behavior={Platform.OS === "ios" ? "padding" : "height"}
611
+ keyboardVerticalOffset={Platform.OS === "ios" ? 100 : -16}
614
612
  >
615
- <View style={styles.emptyContainer}>
616
- <Text style={styles.emptyText}>
617
- {messages.length === 0
618
- ? "Start a conversation to Find the Perfect Diamond"
619
- : `Chat Started at ${formatToTime(messages[0].createdAt)}`}
620
- </Text>
621
- </View>
622
- {(() => {
623
- const ordered = [...messages];
624
- return ordered.map((item, index) => {
625
- const isLatest = index === ordered.length - 1;
626
- return (
627
- <View key={item.id + index}>
628
- <MessageBubble
629
- message={item}
630
- theme={theme}
631
- conversationId={conversationId}
632
- handleFeedbackAction={handleFeedbackAction}
633
- onReloadResults={handleReloadResults}
634
- reloading={reloadLoadingIds.has(item.id)}
635
- hasResults={Boolean(
636
- productsByMsg[item.id]?.response?.body?.diamonds &&
637
- productsByMsg[item.id].response.body.diamonds.length > 0
638
- )}
639
- />
640
- {item.role === "assistant" &&
641
- productsByMsg &&
642
- productsByMsg.messageId === item.id && (
613
+ <ScrollView
614
+ ref={scrollRef}
615
+ keyboardShouldPersistTaps="handled"
616
+ onScroll={(e) => setScrollY(e.nativeEvent.contentOffset.y)}
617
+ scrollEventThrottle={16}
618
+ style={
619
+ modalHeight
620
+ ? { height: modalHeight, backgroundColor: "#FFFFFF" }
621
+ : { backgroundColor: "#FFFFFF" }
622
+ }
623
+ contentContainerStyle={{
624
+ backgroundColor: theme?.listContentBackgroundColor || "#FFFFFF",
625
+ ...styles.listContent,
626
+ justifyContent: messages.length === 0 ? "center" : "flex-end",
627
+ minHeight: modalHeight ? modalHeight : undefined,
628
+ }}
629
+ onContentSizeChange={() => {
630
+ scrollRef.current?.scrollToEnd({ animated: false });
631
+ }}
632
+ >
633
+ <View style={styles.emptyContainer}>
634
+ <Text style={styles.emptyText}>
635
+ {messages.length === 0
636
+ ? "Start a conversation to Find the Perfect Diamond"
637
+ : `Chat Started at ${formatToTime(messages[0].createdAt)}`}
638
+ </Text>
639
+ </View>
640
+ {(() => {
641
+ const ordered = [...messages];
642
+ return ordered.map((item, index) => {
643
+ const isLatest = index === ordered.length - 1;
644
+ const hasDiamonds = Boolean(
645
+ productsByMsg[item.id]?.response?.body?.diamonds &&
646
+ productsByMsg[item.id]?.response?.body?.diamonds.length > 0
647
+ );
648
+ return (
649
+ <View key={item.id + index} style={{ gap: 12 }}>
650
+ <MessageBubble
651
+ message={item}
652
+ theme={theme}
653
+ priceMode={priceMode as string}
654
+ handleFeedbackAction={handleFeedbackAction}
655
+ onReloadResults={handleReloadResults}
656
+ reloading={reloadLoadingIds.has(item.id)}
657
+ hasResults={hasDiamonds}
658
+ totalResults={
659
+ productsByMsg[item.id]?.response?.header
660
+ ?.total_diamonds_found || 0
661
+ }
662
+ shownResults={
663
+ productsByMsg[item.id]?.response?.body?.diamonds
664
+ .length || 0
665
+ }
666
+ onSuggestionSelect={handleSuggestionSelect}
667
+ isLatest={isLatest}
668
+ isTyping={typingMessageId === item.id}
669
+ />
670
+ {/* Trigger product fetch when this area enters viewport */}
671
+ {item.role === "assistant" &&
672
+ item.search_payload &&
673
+ Object.keys(item.search_payload).length > 0 &&
674
+ !productsByMsg[item.id] && (
675
+ <LazyProductsFetcher
676
+ messageId={item.id}
677
+ payload={item.search_payload}
678
+ onFetched={(id, result) => {
679
+ setProductsByMsg((prev) => ({
680
+ ...prev,
681
+ [id]: result,
682
+ }));
683
+ const diamonds = result?.response?.body?.diamonds;
684
+ const hasAny =
685
+ Array.isArray(diamonds) && diamonds.length > 0;
686
+ if (hasAny) {
687
+ try {
688
+ scrollRef.current?.scrollTo({
689
+ x: 0,
690
+ y: scrollY + 120,
691
+ animated: true,
692
+ });
693
+ } catch (_) {}
694
+ } else {
695
+ setMessages((prev) =>
696
+ prev.map((msg) =>
697
+ msg.id === id
698
+ ? {
699
+ ...msg,
700
+ id: msg.id,
701
+ text: noResultsText,
702
+ isLoading: false,
703
+ }
704
+ : msg
705
+ )
706
+ );
707
+ }
708
+ }}
709
+ />
710
+ )}
711
+ {item.role === "assistant" && hasDiamonds && (
643
712
  <ProductsList
644
- data={productsByMsg?.data?.response?.body?.diamonds}
713
+ data={
714
+ productsByMsg[item.id]?.response?.body?.diamonds || []
715
+ }
716
+ totalResults={
717
+ productsByMsg[item.id]?.response?.header
718
+ ?.total_diamonds_found || 0
719
+ }
645
720
  onViewAll={onViewAll}
646
721
  onItemPress={onItemPress}
647
722
  />
648
723
  )}
649
- {item.role === "assistant" &&
650
- item.suggestions &&
651
- item.suggestions.length > 0 &&
652
- isLatest && (
653
- <SuggestionsRow
654
- suggestions={item.suggestions || []}
655
- onSelect={handleSuggestionSelect}
656
- />
657
- )}
658
- </View>
659
- );
660
- });
661
- })()}
662
- </ScrollView>
663
- <ChatInput
664
- value={input}
665
- onChangeText={setInput}
666
- onSend={handleSend}
667
- disabled={loading}
668
- placeholder={placeholder}
669
- theme={theme}
670
- inputRef={inputRef}
671
- />
724
+ {/* Suggestions are now rendered inside MessageBubble */}
725
+ <MessageMetaRow
726
+ message={item}
727
+ priceMode={priceMode || ""}
728
+ handleFeedbackAction={handleFeedbackAction}
729
+ onReloadResults={handleReloadResults}
730
+ reloading={reloadLoadingIds.has(item.id)}
731
+ hasResults={hasDiamonds}
732
+ />
733
+ </View>
734
+ );
735
+ });
736
+ })()}
737
+ </ScrollView>
738
+ <View style={styles.bottomContainer}>
739
+ <ChatInput
740
+ value={input}
741
+ onChangeText={setInput}
742
+ onSend={handleSend}
743
+ disabled={loading}
744
+ placeholder={placeholder}
745
+ theme={theme}
746
+ inputRef={inputRef}
747
+ />
748
+ <BetaNotice active={betaActive} />
749
+ </View>
750
+ </KeyboardAvoidingView>
672
751
  </View>
673
752
  );
674
753
  }
@@ -694,11 +773,22 @@ const styles = StyleSheet.create({
694
773
  padding: 16,
695
774
  alignItems: "center",
696
775
  justifyContent: "center",
697
- backgroundColor: "#f5f5f5",
776
+ backgroundColor: "#FFFFFF",
698
777
  },
699
778
  emptyText: {
700
- fontSize: 14,
701
- fontWeight: "500",
702
- color: "#666666",
779
+ fontSize: 13,
780
+ fontWeight: "400",
781
+ color: "#4F4E57",
782
+ },
783
+ bottomContainer: {
784
+ paddingVertical: 12,
785
+ paddingHorizontal: 16,
786
+ borderTopColor: "#E0E0E0",
787
+ borderTopWidth: 1,
788
+ flexDirection: "column",
789
+ justifyContent: "space-between",
790
+ alignItems: "center",
791
+ alignSelf: "stretch",
792
+ gap: 12,
703
793
  },
704
794
  });
@@ -0,0 +1,41 @@
1
+ import React, { useEffect, useRef } from "react";
2
+ import { View } from "react-native";
3
+ import { getProducts } from "../api";
4
+ import { useInViewport } from "../hooks/useInViewport";
5
+
6
+ interface LazyProductsFetcherProps {
7
+ messageId: string;
8
+ payload: Record<string, any> | undefined | null;
9
+ onFetched: (messageId: string, data: any) => void;
10
+ }
11
+
12
+ const LazyProductsFetcher: React.FC<LazyProductsFetcherProps> = ({
13
+ messageId,
14
+ payload,
15
+ onFetched,
16
+ }) => {
17
+ const { ref, inView } = useInViewport<HTMLDivElement>();
18
+ const fetchedRef = useRef(false);
19
+
20
+ useEffect(() => {
21
+ if (fetchedRef.current) return;
22
+ if (!payload || typeof payload !== "object") return;
23
+ if (!inView) return;
24
+
25
+ fetchedRef.current = true;
26
+ (async () => {
27
+ try {
28
+ const result = await getProducts(payload);
29
+ onFetched(messageId, result);
30
+ } catch (e) {
31
+ // eslint-disable-next-line no-console
32
+ console.error("Lazy fetch products failed", e);
33
+ }
34
+ })();
35
+ }, [inView, messageId, onFetched, payload]);
36
+
37
+ // Sentinel element; no visible UI needed
38
+ return <View ref={ref as any} style={{ height: 1 }} />;
39
+ };
40
+
41
+ export default LazyProductsFetcher;