@athoscommerce/snap-preact 1.5.1-beta.112 → 1.5.1-beta.116

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 (33) hide show
  1. package/dist/cjs/components/src/components/Atoms/Dropdown/Dropdown.d.ts +3 -0
  2. package/dist/cjs/components/src/components/Atoms/Dropdown/Dropdown.d.ts.map +1 -1
  3. package/dist/cjs/components/src/components/Atoms/Dropdown/Dropdown.js +28 -7
  4. package/dist/cjs/components/src/components/Molecules/ChatAttachmentContext/ChatAttachmentContext.d.ts.map +1 -1
  5. package/dist/cjs/components/src/components/Molecules/ChatAttachmentContext/ChatAttachmentContext.js +3 -2
  6. package/dist/cjs/components/src/components/Molecules/ChatProductComparisonMessage/ChatProductComparisonMessage.d.ts.map +1 -1
  7. package/dist/cjs/components/src/components/Molecules/ChatProductComparisonMessage/ChatProductComparisonMessage.js +22 -2
  8. package/dist/cjs/components/src/components/Molecules/ChatProductQueryMessage/ChatProductQueryMessage.d.ts.map +1 -1
  9. package/dist/cjs/components/src/components/Molecules/ChatProductQueryMessage/ChatProductQueryMessage.js +96 -66
  10. package/dist/cjs/components/src/components/Organisms/Chat/Chat.d.ts.map +1 -1
  11. package/dist/cjs/components/src/components/Organisms/Chat/Chat.js +276 -76
  12. package/dist/cjs/components/src/components/Organisms/Chat/Chat.styles.d.ts.map +1 -1
  13. package/dist/cjs/components/src/components/Organisms/Chat/Chat.styles.js +217 -178
  14. package/dist/cjs/components/src/components/Organisms/Chat/hooks/useChatFileUpload.js +4 -4
  15. package/dist/cjs/src/Templates/SnapTemplates.d.ts.map +1 -1
  16. package/dist/cjs/src/Templates/SnapTemplates.js +1 -1
  17. package/dist/esm/components/src/components/Atoms/Dropdown/Dropdown.d.ts +3 -0
  18. package/dist/esm/components/src/components/Atoms/Dropdown/Dropdown.d.ts.map +1 -1
  19. package/dist/esm/components/src/components/Atoms/Dropdown/Dropdown.js +26 -8
  20. package/dist/esm/components/src/components/Molecules/ChatAttachmentContext/ChatAttachmentContext.d.ts.map +1 -1
  21. package/dist/esm/components/src/components/Molecules/ChatAttachmentContext/ChatAttachmentContext.js +3 -2
  22. package/dist/esm/components/src/components/Molecules/ChatProductComparisonMessage/ChatProductComparisonMessage.d.ts.map +1 -1
  23. package/dist/esm/components/src/components/Molecules/ChatProductComparisonMessage/ChatProductComparisonMessage.js +22 -2
  24. package/dist/esm/components/src/components/Molecules/ChatProductQueryMessage/ChatProductQueryMessage.d.ts.map +1 -1
  25. package/dist/esm/components/src/components/Molecules/ChatProductQueryMessage/ChatProductQueryMessage.js +94 -65
  26. package/dist/esm/components/src/components/Organisms/Chat/Chat.d.ts.map +1 -1
  27. package/dist/esm/components/src/components/Organisms/Chat/Chat.js +240 -45
  28. package/dist/esm/components/src/components/Organisms/Chat/Chat.styles.d.ts.map +1 -1
  29. package/dist/esm/components/src/components/Organisms/Chat/Chat.styles.js +73 -8
  30. package/dist/esm/components/src/components/Organisms/Chat/hooks/useChatFileUpload.js +3 -3
  31. package/dist/esm/src/Templates/SnapTemplates.d.ts.map +1 -1
  32. package/dist/esm/src/Templates/SnapTemplates.js +4 -1
  33. package/package.json +12 -12
@@ -29,7 +29,7 @@ export const ChatOrganism = observer((properties) => {
29
29
  logo: 'https://cdn.athoscommerce.net/snap/images/Athos%20Commerce_Icon_white.svg',
30
30
  title: 'Athos Conversational Assistant',
31
31
  subtitle: 'Your Guided Discovery Expert',
32
- multiselectFacets: true,
32
+ multiselectFacets: false,
33
33
  disableBubbleSuggestedQuestions: isMobile ? true : false,
34
34
  primaryColorBg: '#253B80',
35
35
  primaryColorFg: '#fff',
@@ -137,10 +137,14 @@ export const ChatOrganism = observer((properties) => {
137
137
  // Re-focus the chat input on desktop after a search completes.
138
138
  // The input is disabled while loading, which causes the browser to drop focus.
139
139
  // On mobile we intentionally skip this so the virtual keyboard dismisses.
140
+ // Only fire on the loading true → false transition — running on mount steals
141
+ // focus away from the Storybook controls whenever the story re-renders.
142
+ const prevLoadingRef = useRef(store.loading);
140
143
  useEffect(() => {
141
- if (!store.loading && !isMobile && store.open) {
144
+ if (prevLoadingRef.current && !store.loading && !isMobile && store.open) {
142
145
  controller.focusInput();
143
146
  }
147
+ prevLoadingRef.current = store.loading;
144
148
  }, [store.loading]);
145
149
  // const subProps: ChatSubProps = {};
146
150
  const activeMessage = store.currentChat?.activeMessage;
@@ -161,10 +165,11 @@ export const ChatOrganism = observer((properties) => {
161
165
  ? ['inspirationResult', ...(isFirstMobileComparison ? ['productComparison'] : []), ...(mobileProductInfoOpen ? ['productQuery'] : [])]
162
166
  : ['inspirationResult', 'productComparison', 'productQuery'];
163
167
  const shouldShowSideChat = activeMessage && sideChatTypes.includes(activeMessage?.messageType) && store.currentChat?.dismissedSideChatMessageId !== activeMessage.id;
164
- const styling = mergeStyles({
168
+ const hasSideChat = !!shouldShowSideChat;
169
+ const styling = useMemo(() => mergeStyles({
165
170
  mobile: isMobile,
166
171
  offset,
167
- hasSideChat: !!shouldShowSideChat,
172
+ hasSideChat,
168
173
  footerHeight,
169
174
  primaryColorBg: colorPrimary,
170
175
  primaryColorFg: colorPrimaryText,
@@ -173,7 +178,19 @@ export const ChatOrganism = observer((properties) => {
173
178
  secondaryAccentColorBg: colorSecondaryAccent,
174
179
  secondaryAccentColorFg: colorSecondaryAccentText,
175
180
  position: chatPosition,
176
- }, chatDefaultStyles);
181
+ }, chatDefaultStyles), [
182
+ isMobile,
183
+ offset,
184
+ hasSideChat,
185
+ footerHeight,
186
+ colorPrimary,
187
+ colorPrimaryText,
188
+ colorPrimaryAccent,
189
+ colorPrimaryAccentText,
190
+ colorSecondaryAccent,
191
+ colorSecondaryAccentText,
192
+ chatPosition,
193
+ ]);
177
194
  // Lock body scroll while chat is open so touch/wheel scrolls don't leak to the page behind
178
195
  useEffect(() => {
179
196
  if (!store.open)
@@ -218,6 +235,116 @@ export const ChatOrganism = observer((properties) => {
218
235
  observer.observe(el);
219
236
  return () => observer.disconnect();
220
237
  }, [store.open, store.currentChat?.isExpired]);
238
+ // Prevent the background page from scrolling while the chat is open on touch
239
+ // devices. iOS Safari (real iPad/iPhone) rubber-band-scrolls the body when a
240
+ // touchmove inside `.ss__chat__messages` reaches the top/bottom — and
241
+ // `overscroll-behavior: contain` is ignored there.
242
+ //
243
+ // We don't touch body styles (locking via `position: fixed` on `<body>` leaves
244
+ // iPad Safari in a stuck viewport state where the page can't scroll after the
245
+ // lock is released). Instead we install a `touchmove` handler that:
246
+ // - allows the touchmove if it's inside a scrollable region of the chat
247
+ // and that region isn't at its scroll edge in the swipe direction;
248
+ // - calls `preventDefault()` otherwise — which is what blocks both
249
+ // background scrolling and iOS rubber-band overscroll.
250
+ // Cleanup just removes the listeners, so nothing can leak into the page.
251
+ useEffect(() => {
252
+ if (!store.open)
253
+ return;
254
+ if (typeof document === 'undefined')
255
+ return;
256
+ let startX = 0;
257
+ let startY = 0;
258
+ let scrollEl = null;
259
+ // Walk up from the touch target to find the first ancestor inside the chat
260
+ // that is actually scrollable in either axis. Stop at the chat root so we
261
+ // never consider elements outside the chat.
262
+ const findScrollableInChat = (start) => {
263
+ const chatRoot = chatRef.current;
264
+ if (!chatRoot)
265
+ return null;
266
+ let el = start;
267
+ while (el && chatRoot.contains(el)) {
268
+ const style = getComputedStyle(el);
269
+ const scrollableY = (style.overflowY === 'auto' || style.overflowY === 'scroll') && el.scrollHeight > el.clientHeight;
270
+ const scrollableX = (style.overflowX === 'auto' || style.overflowX === 'scroll') && el.scrollWidth > el.clientWidth;
271
+ if (scrollableY || scrollableX) {
272
+ return el;
273
+ }
274
+ if (el === chatRoot)
275
+ break;
276
+ el = el.parentElement;
277
+ }
278
+ return null;
279
+ };
280
+ const onTouchStart = (e) => {
281
+ if (!e.touches[0])
282
+ return;
283
+ startX = e.touches[0].clientX;
284
+ startY = e.touches[0].clientY;
285
+ scrollEl = findScrollableInChat(e.target);
286
+ };
287
+ const onTouchMove = (e) => {
288
+ const target = e.target;
289
+ const chatRoot = chatRef.current;
290
+ const inChat = !!(chatRoot && target && chatRoot.contains(target));
291
+ // Touches outside the chat shouldn't scroll the background.
292
+ if (!inChat) {
293
+ if (e.cancelable)
294
+ e.preventDefault();
295
+ return;
296
+ }
297
+ // Inside the chat but no scrollable container — block to prevent body scroll.
298
+ if (!scrollEl) {
299
+ if (e.cancelable)
300
+ e.preventDefault();
301
+ return;
302
+ }
303
+ // Inside a scrollable container — only block when at the edge in the
304
+ // swipe direction, so internal scrolling continues to work. Detect the
305
+ // dominant axis so horizontally-scrollable rows (e.g. the facets row)
306
+ // also get to handle their own touch gestures.
307
+ const currentX = e.touches[0]?.clientX ?? startX;
308
+ const currentY = e.touches[0]?.clientY ?? startY;
309
+ const deltaX = currentX - startX;
310
+ const deltaY = currentY - startY;
311
+ const horizontal = Math.abs(deltaX) > Math.abs(deltaY);
312
+ if (horizontal) {
313
+ const scrollsX = scrollEl.scrollWidth > scrollEl.clientWidth;
314
+ if (!scrollsX) {
315
+ if (e.cancelable)
316
+ e.preventDefault();
317
+ return;
318
+ }
319
+ const atLeft = scrollEl.scrollLeft <= 0;
320
+ const atRight = scrollEl.scrollLeft + scrollEl.clientWidth >= scrollEl.scrollWidth - 1;
321
+ if ((atLeft && deltaX > 0) || (atRight && deltaX < 0)) {
322
+ if (e.cancelable)
323
+ e.preventDefault();
324
+ }
325
+ }
326
+ else {
327
+ const scrollsY = scrollEl.scrollHeight > scrollEl.clientHeight;
328
+ if (!scrollsY) {
329
+ if (e.cancelable)
330
+ e.preventDefault();
331
+ return;
332
+ }
333
+ const atTop = scrollEl.scrollTop <= 0;
334
+ const atBottom = scrollEl.scrollTop + scrollEl.clientHeight >= scrollEl.scrollHeight - 1;
335
+ if ((atTop && deltaY > 0) || (atBottom && deltaY < 0)) {
336
+ if (e.cancelable)
337
+ e.preventDefault();
338
+ }
339
+ }
340
+ };
341
+ document.addEventListener('touchstart', onTouchStart, { passive: true });
342
+ document.addEventListener('touchmove', onTouchMove, { passive: false });
343
+ return () => {
344
+ document.removeEventListener('touchstart', onTouchStart);
345
+ document.removeEventListener('touchmove', onTouchMove);
346
+ };
347
+ }, [store.open]);
221
348
  const HistoryButton = (props) => (_jsx(Button, { className: "ss__chat__header__button--history", disabled: props.disabled, icon: {
222
349
  icon: 'history',
223
350
  title: 'Chat History',
@@ -225,7 +352,10 @@ export const ChatOrganism = observer((properties) => {
225
352
  const HistoryPopup = (props) => (_jsxs("div", { className: "ss__chat__history", children: [_jsxs("div", { className: "ss__chat__history__header", children: [_jsx("h4", { children: "History" }), _jsx("div", { className: "ss__chat__history__header__buttons", children: _jsx(Button, { content: "clear", onClick: () => {
226
353
  controller.store.clearHistory();
227
354
  props.toggleOpen && props.toggleOpen();
228
- } }) })] }), store.chats.length > 0 ? (_jsx("div", { className: "ss__chat__history__chats", children: store.chats.map((chat) => (_jsx("div", { className: "ss__chat__history__chats__chat", children: _jsxs(Button, { className: "ss__chat__history__chat__button", onClick: () => {
355
+ } }) })] }), store.chats.length > 0 ? (_jsx("div", { className: "ss__chat__history__chats", children: store.chats
356
+ .slice()
357
+ .reverse()
358
+ .map((chat) => (_jsx("div", { className: "ss__chat__history__chats__chat", children: _jsxs(Button, { className: "ss__chat__history__chat__button", onClick: () => {
229
359
  controller.store.switchChat(chat.id);
230
360
  }, disabled: chat.id === store.currentChatId, children: [_jsx("div", { className: "ss__chat__history__chat__button__text", children: (() => {
231
361
  const lastUserMessage = [...chat.chat].reverse().find((message) => message.messageType === 'user');
@@ -292,7 +422,18 @@ export const ChatOrganism = observer((properties) => {
292
422
  boxShadow: '0px 3px 6px 0px rgba(0, 0, 0, 0.2)',
293
423
  borderRadius: '6px',
294
424
  },
295
- }, children: _jsx("div", { className: "ss__chat__actions__facet__options", children: _jsx("div", { className: "ss__chat__actions__facet__slider", style: { padding: '12px 16px' }, children: _jsx(FacetSlider, { facet: props.facet }) }) }) }) }));
425
+ }, children: _jsx("div", { className: "ss__chat__actions__facet__options", children: _jsx("div", { className: "ss__chat__actions__facet__slider", style: { padding: '12px 16px' }, children: _jsx(FacetSlider, { facet: props.facet, onChange: () => {
426
+ if (!multiselectFacets) {
427
+ // FacetSlider fires onChange before its urlManager update; defer
428
+ // so controller.search() reads the updated filter state. Force
429
+ // productSearch with the current filters so a slider tweak doesn't
430
+ // fall through to `general` when hasPendingFacetChanges happens to
431
+ // be false (e.g. the slider returned to the seeded range).
432
+ queueMicrotask(() => controller.search({
433
+ data: { requestType: 'productSearch', searchFilters: controller.store.searchFilters },
434
+ }));
435
+ }
436
+ } }) }) }) }) }));
296
437
  };
297
438
  // TODO: if starting a new chat and it's expired, this button would then disappear
298
439
  if (!controller.store.chatEnabled) {
@@ -330,10 +471,12 @@ export const ChatOrganism = observer((properties) => {
330
471
  return undefined;
331
472
  }
332
473
  }, [requestType]);
474
+ const isWelcomeState = (!store.currentChat?.chat || store.currentChat.chat.length === 0) && !!store.welcomeMessage && !store.currentChat?.isExpired;
333
475
  return (_jsx(CacheProvider, { children: _jsxs(_Fragment, { children: [_jsxs("div", { ref: chatRef, className: classnames('ss__chat', {
334
476
  'ss__chat--open': store.open,
335
477
  'ss__chat--minimized': !store.open,
336
478
  'ss__chat--mobile': isMobile,
479
+ 'ss__chat--welcome': isWelcomeState,
337
480
  }, className, internalClassName), ...styling, children: [!disableBubbleSuggestedQuestions && !store.open && !store.currentChat && store.suggestedQuestions?.length > 0 && (_jsx("div", { className: "ss__chat__suggested-questions", children: store.suggestedQuestions.map((question, index) => (_jsx("div", { className: "ss__chat__suggested-questions__item", onClick: () => {
338
481
  controller.openChat(question);
339
482
  }, children: question }, index))) })), _jsx("button", { type: "button", className: 'ss__chat__bubble', "aria-label": store.open ? 'Close chat' : 'Open chat', "aria-expanded": store.open, onClick: () => controller.handlers.button.click(), children: _jsx(Icon, { icon: "chat", title: "Open Chat" }) }), store.open && shouldShowSideChat && activeMessage ? (_jsxs("div", { ref: secondaryRef, className: classnames('ss__chat__secondary'), role: "region", "aria-label": "Conversation details", style: isMobile
@@ -433,14 +576,14 @@ export const ChatOrganism = observer((properties) => {
433
576
  controller.store.createChat();
434
577
  }, children: "New Session" })] })), store.facets && store.facets.length > 0 && (_jsx("div", { className: 'ss__chat__actions', children: _jsxs("div", { className: "ss__chat__actions__wrap", children: [_jsxs("div", { className: "ss__chat__actions__facets-header", children: [_jsx("div", { className: "ss__chat__actions--title", children: "Filter by:" }), multiselectFacets && store.hasPendingFacetChanges && (_jsx("div", { className: 'ss__chat__actions__facets-apply ss__chat__actions__facets-apply--active', children: _jsx(Button, { onClick: () => {
435
578
  controller.search();
436
- }, children: "Apply" }) }))] }), _jsx("div", { className: "ss__chat__actions--facets", children: store.facets.slice(0, 10).map((facet, idx) => {
579
+ }, children: store.pendingFacetCount > 0 ? `Apply (${store.pendingFacetCount})` : 'Clear facets' }) }))] }), _jsx("div", { className: "ss__chat__actions--facets", children: store.facets.slice(0, 10).map((facet, idx) => {
437
580
  // range/slider facets render via FacetSlider; everything else needs at least one value to be useful
438
581
  if (facet.type !== 'range' && !facet.values?.length)
439
582
  return null;
440
583
  if (facet.type === 'range') {
441
584
  return _jsx(SliderFacetDropdown, { facet: facet, label: getFacetButtonLabel(facet) }, idx);
442
585
  }
443
- return (_jsx("div", { className: `ss__chat__actions__facet ss__chat__actions__facet--${facet.type}`, children: _jsx(Dropdown, { usePortal: true, dropUp: true, button: _jsx(FacetButton, { label: getFacetButtonLabel(facet) }), style: {
586
+ return (_jsx("div", { className: `ss__chat__actions__facet ss__chat__actions__facet--${facet.type}`, children: _jsx(Dropdown, { usePortal: true, dropUp: true, boundaryRef: chatRef, button: _jsx(FacetButton, { label: getFacetButtonLabel(facet) }), style: {
444
587
  '.ss__dropdown__content': {
445
588
  width: '150px',
446
589
  maxHeight: '200px',
@@ -504,6 +647,9 @@ export const ChatOrganism = observer((properties) => {
504
647
  '&:hover': {
505
648
  background: new Colour(colorPrimary).lightenHex(0.95),
506
649
  },
650
+ '&.ss__chat__actions__facet__option--selected': {
651
+ fontWeight: 'bold',
652
+ },
507
653
  },
508
654
  }),
509
655
  },
@@ -527,12 +673,28 @@ export const ChatOrganism = observer((properties) => {
527
673
  'ss__chat__actions__facet__option__checkbox--checked': isSelected,
528
674
  }), children: isSelected && _jsx(Icon, { icon: "check-thin", size: "10px" }) }), _jsx("span", { className: "ss__chat__actions__facet__option__label", children: rangeOption.label })] }, optionValue));
529
675
  }
530
- return (_jsx(Button, { onClick: () => {
531
- controller.store.addFacet({
532
- key: facet.field,
533
- value: optionValue,
676
+ return (_jsx(Button, { className: classnames({
677
+ 'ss__chat__actions__facet__option--selected': controller.store.isFacetSelected(facet.field, optionValue),
678
+ }), onClick: () => {
679
+ // Non-multiselect: clicking an already-selected value toggles it off.
680
+ // `addFacet` is idempotent against an existing urlManager value, so without
681
+ // the toggle the state wouldn't change, `hasPendingFacetChanges` would stay
682
+ // false, and the params getter would fall through to `general`.
683
+ if (controller.store.isFacetSelected(facet.field, optionValue)) {
684
+ controller.store.removeFacet(facet.field, optionValue);
685
+ }
686
+ else {
687
+ controller.store.addFacet({
688
+ key: facet.field,
689
+ value: optionValue,
690
+ });
691
+ }
692
+ // Force productSearch with the current filter state rather than relying
693
+ // on the params getter's hasPendingFacetChanges promotion — a facet click
694
+ // in the chat is unambiguously a search intent.
695
+ controller.search({
696
+ data: { requestType: 'productSearch', searchFilters: controller.store.searchFilters },
534
697
  });
535
- controller.search();
536
698
  }, children: rangeOption.label }, optionValue));
537
699
  })
538
700
  : facet.values.map((option) => {
@@ -554,12 +716,28 @@ export const ChatOrganism = observer((properties) => {
554
716
  'ss__chat__actions__facet__option__checkbox--checked': isSelected,
555
717
  }), children: isSelected && _jsx(Icon, { icon: "check-thin", size: "10px" }) }), _jsx("span", { className: "ss__chat__actions__facet__option__label", children: valueOption.label })] }, optionValue));
556
718
  }
557
- return (_jsx(Button, { onClick: () => {
558
- controller.store.addFacet({
559
- key: facet.field,
560
- value: optionValue,
719
+ return (_jsx(Button, { className: classnames({
720
+ 'ss__chat__actions__facet__option--selected': controller.store.isFacetSelected(facet.field, optionValue),
721
+ }), onClick: () => {
722
+ // Non-multiselect: clicking an already-selected value toggles it off.
723
+ // `addFacet` is idempotent against an existing urlManager value, so without
724
+ // the toggle the state wouldn't change, `hasPendingFacetChanges` would stay
725
+ // false, and the params getter would fall through to `general`.
726
+ if (controller.store.isFacetSelected(facet.field, optionValue)) {
727
+ controller.store.removeFacet(facet.field, optionValue);
728
+ }
729
+ else {
730
+ controller.store.addFacet({
731
+ key: facet.field,
732
+ value: optionValue,
733
+ });
734
+ }
735
+ // Force productSearch with the current filter state rather than relying
736
+ // on the params getter's hasPendingFacetChanges promotion — a facet click
737
+ // in the chat is unambiguously a search intent.
738
+ controller.search({
739
+ data: { requestType: 'productSearch', searchFilters: controller.store.searchFilters },
561
740
  });
562
- controller.search();
563
741
  }, children: valueOption.label }, optionValue));
564
742
  }) }) }, facet.field) }, idx));
565
743
  }) })] }) })), (() => {
@@ -584,12 +762,11 @@ export const ChatOrganism = observer((properties) => {
584
762
  id: result?.id,
585
763
  name: d?.mappings?.core?.name || '',
586
764
  imageUrl: d?.mappings?.core?.thumbnailImageUrl || d?.mappings?.core?.imageUrl || d?.mappings?.core?.parentImageUrl || '',
587
- onClick: isMobile
588
- ? () => {
589
- controller.productQuickView(result);
765
+ onClick: () => {
766
+ controller.productQuickView(result);
767
+ if (isMobile)
590
768
  setMobileProductInfoOpen(true);
591
- }
592
- : undefined,
769
+ },
593
770
  };
594
771
  })
595
772
  : showCommittedComparisons
@@ -599,32 +776,28 @@ export const ChatOrganism = observer((properties) => {
599
776
  id: comparisonItem.result?.id,
600
777
  name: d?.mappings?.core?.name || '',
601
778
  imageUrl: d?.mappings?.core?.thumbnailImageUrl || d?.mappings?.core?.imageUrl || d?.mappings?.core?.parentImageUrl || '',
602
- onClick: isMobile
603
- ? () => {
604
- controller.productQuickView(comparisonItem.result);
779
+ onClick: () => {
780
+ controller.productQuickView(comparisonItem.result);
781
+ if (isMobile)
605
782
  setMobileProductInfoOpen(true);
606
- }
607
- : undefined,
783
+ },
608
784
  };
609
785
  })
610
786
  : [];
611
- const productItems = productAttachments.map((item) => ({
612
- id: item.id,
613
- name: item.name || '',
614
- imageUrl: item.thumbnailUrl || '',
615
- onClick: isMobile
616
- ? () => {
617
- setMobileProductInfoOpen(true);
618
- }
619
- : () => {
620
- store.currentChat?.setActiveMessage(store.currentChat?.activeMessage?.id || '');
621
- },
622
- onRemove: () => {
787
+ const productItems = productAttachments.map((item) => {
788
+ const remove = () => {
623
789
  store.currentChat?.attachments.remove(item.id);
624
790
  setMobileProductInfoOpen(false);
625
791
  store.currentChat?.dismissSideChat();
626
- },
627
- }));
792
+ };
793
+ return {
794
+ id: item.id,
795
+ name: item.name || '',
796
+ imageUrl: item.thumbnailUrl || '',
797
+ onClick: remove,
798
+ onRemove: remove,
799
+ };
800
+ });
628
801
  const imageItems = imageAttachments.map((item) => ({
629
802
  id: item.id,
630
803
  name: item.fileName || 'Image',
@@ -634,7 +807,7 @@ export const ChatOrganism = observer((properties) => {
634
807
  errorMessage: item.error?.message,
635
808
  onRemove: () => store.currentChat?.attachments.remove(item.id),
636
809
  }));
637
- const productTitle = productItems.length > 1 ? 'Compare these products' : 'Ask about this product' + (isMobile ? '' : ' (click for details)');
810
+ const productTitle = productItems.length > 1 ? 'Compare these products' : 'Ask about this product';
638
811
  const hasImageError = imageItems.some((item) => item.hasError);
639
812
  const imageTitle = imageItems.length > 0 && !hasImageError ? 'Find products similar to this image:' : '';
640
813
  return (_jsxs(_Fragment, { children: [comparisonItems.length > 0 && (_jsx(ChatAttachmentContext, { ...subProps.attachmentContext, title: 'Compare these products', items: comparisonItems, onClose: () => {
@@ -664,9 +837,31 @@ export const ChatOrganism = observer((properties) => {
664
837
  store.currentChat?.dismissTopicDrift();
665
838
  return;
666
839
  }
840
+ // Capture image attachments from the current chat before handleTopicDrift /
841
+ // createChat tear down the conversation — an imageSearch that drifted needs
842
+ // the same image attached to the carried-over message in the new session,
843
+ // otherwise params would fall back to a `general` request without the image.
844
+ const previousImageAttachments = (store.currentChat?.attachments.attached || [])
845
+ .filter((item) => item.type === 'image' && item.state !== 'error')
846
+ .map((item) => {
847
+ const img = item;
848
+ return {
849
+ type: 'image',
850
+ base64: img.base64,
851
+ fileName: img.fileName,
852
+ imageId: img.imageId,
853
+ imageUrl: img.imageUrl,
854
+ thumbnailUrl: img.thumbnailUrl,
855
+ // Already uploaded — skip the 'loading' default state.
856
+ state: 'attached',
857
+ };
858
+ });
667
859
  const messageText = store.currentChat?.handleTopicDrift();
668
860
  if (messageText) {
669
861
  controller.store.createChat();
862
+ previousImageAttachments.forEach((cfg) => {
863
+ controller.store.currentChat?.attachments.add(cfg);
864
+ });
670
865
  controller.search({ data: { message: messageText } });
671
866
  }
672
867
  }, children: buttonText })), _jsx("span", { onClick: () => {
@@ -703,7 +898,7 @@ export const ChatOrganism = observer((properties) => {
703
898
  await controller.upload(e.target.files);
704
899
  // reset value
705
900
  e.target.value = '';
706
- }, multiple: true, type: "file", accept: "image/*", className: "ss__chat__input__input__file" })] }))] }), _jsx("div", { className: 'ss__chat__input__actions', children: (() => {
901
+ }, type: "file", accept: "image/*", className: "ss__chat__input__input__file" })] }))] }), _jsx("div", { className: 'ss__chat__input__actions', children: (() => {
707
902
  const hasImageAttachment = (store.currentChat?.attachments.attached || []).some((item) => item.type === 'image' && !item.error);
708
903
  const messageEmpty = !controller.store.inputValue.trim();
709
904
  const sendDisabled = store.blocked || store.currentChat?.sessionLimitReached || (hasImageAttachment && messageEmpty) || messageEmpty;
@@ -1 +1 @@
1
- {"version":3,"file":"Chat.styles.d.ts","sourceRoot":"","sources":["../../../../../../../components/src/components/Organisms/Chat/Chat.styles.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAE7C,MAAM,MAAM,cAAc,GAAG;IAC5B,MAAM,EAAE,OAAO,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACzB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,YAAY,EAAE,MAAM,CAAC;IACrB,cAAc,EAAE,MAAM,CAAC;IACvB,cAAc,EAAE,MAAM,CAAC;IACvB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,oBAAoB,EAAE,MAAM,CAAC;IAC7B,sBAAsB,EAAE,MAAM,CAAC;IAC/B,sBAAsB,EAAE,MAAM,CAAC;IAC/B,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC;CAC3B,CAAC;AAEF,eAAO,MAAM,iBAAiB,EAAE,WAAW,CAAC,cAAc,CA0mCzD,CAAC"}
1
+ {"version":3,"file":"Chat.styles.d.ts","sourceRoot":"","sources":["../../../../../../../components/src/components/Organisms/Chat/Chat.styles.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAE7C,MAAM,MAAM,cAAc,GAAG;IAC5B,MAAM,EAAE,OAAO,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACzB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,YAAY,EAAE,MAAM,CAAC;IACrB,cAAc,EAAE,MAAM,CAAC;IACvB,cAAc,EAAE,MAAM,CAAC;IACvB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,oBAAoB,EAAE,MAAM,CAAC;IAC7B,sBAAsB,EAAE,MAAM,CAAC;IAC/B,sBAAsB,EAAE,MAAM,CAAC;IAC/B,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC;CAC3B,CAAC;AAEF,eAAO,MAAM,iBAAiB,EAAE,WAAW,CAAC,cAAc,CA2qCzD,CAAC"}
@@ -208,7 +208,66 @@ export const chatDefaultStyles = ({ mobile, offset, hasSideChat, footerHeight, p
208
208
  '.ss__chat__bubble': {
209
209
  display: 'none',
210
210
  },
211
+ // At constrained viewport heights (e.g. browser zoom-in) the fixed 70vh panels
212
+ // leave too little room for the messages view once sticky header/footer content
213
+ // is accounted for. Stretch the chat to fill the window minus the offset.
214
+ ...(mobile
215
+ ? {}
216
+ : {
217
+ '@media (max-height: 800px)': {
218
+ top: offsetValue || 0,
219
+ bottom: 0,
220
+ height: offsetValue ? `calc(100vh - ${offsetValue})` : '100vh',
221
+ maxHeight: offsetValue ? `calc(100vh - ${offsetValue})` : '100vh',
222
+ '.ss__chat__primary': {
223
+ height: '100%',
224
+ },
225
+ '.ss__chat__secondary': {
226
+ height: '100%',
227
+ maxHeight: '100%',
228
+ },
229
+ // The default 90vh cap on chat content would leave unfilled space when
230
+ // the chat stretches to 100vh; primary's justifyContent: flex-end then
231
+ // pushes its content down while secondary stays anchored to the top,
232
+ // making the panels appear misaligned.
233
+ '.ss__chat__content': {
234
+ maxHeight: '100%',
235
+ },
236
+ },
237
+ }),
211
238
  },
239
+ // Welcome state: only the greeting + suggested questions are shown, so let the
240
+ // chat hug its content instead of taking the full 70vh / 100vh used once a
241
+ // conversation has started. Mobile stays fullscreen by design.
242
+ ...(mobile
243
+ ? {}
244
+ : {
245
+ '&.ss__chat--welcome.ss__chat--open': {
246
+ '.ss__chat__primary': {
247
+ height: 'auto',
248
+ },
249
+ // With an auto-sized panel, the messages container's overflow/maxHeight
250
+ // constraints (and the welcome's minHeight: 100%) resolve against an
251
+ // undefined parent height and produce a stray scrollbar / clipped layout.
252
+ // Let everything flow at its natural size.
253
+ '.ss__chat__messages': {
254
+ overflow: 'visible',
255
+ maxHeight: 'none',
256
+ },
257
+ '.ss__chat__welcome': {
258
+ minHeight: 0,
259
+ },
260
+ '@media (max-height: 800px)': {
261
+ top: 'auto',
262
+ bottom: '20px',
263
+ height: 'auto',
264
+ maxHeight: 'calc(100vh - 40px)',
265
+ '.ss__chat__primary': {
266
+ height: 'auto',
267
+ },
268
+ },
269
+ },
270
+ }),
212
271
  '.ss__chat__header': {
213
272
  display: 'flex',
214
273
  justifyContent: 'space-between',
@@ -275,7 +334,7 @@ export const chatDefaultStyles = ({ mobile, offset, hasSideChat, footerHeight, p
275
334
  top: 6,
276
335
  right: 0,
277
336
  background: 'white',
278
- zIndex: 2,
337
+ zIndex: 21,
279
338
  boxShadow: '0px 3px 6px 0px rgba(0, 0, 0, 0.2)',
280
339
  borderRadius: '6px',
281
340
  display: 'flex',
@@ -304,7 +363,7 @@ export const chatDefaultStyles = ({ mobile, offset, hasSideChat, footerHeight, p
304
363
  },
305
364
  '.ss__chat__history__chats': {
306
365
  display: 'flex',
307
- flexDirection: 'column-reverse',
366
+ flexDirection: 'column',
308
367
  gap: 5,
309
368
  maxHeight: '300px',
310
369
  overflowY: 'auto',
@@ -428,9 +487,9 @@ export const chatDefaultStyles = ({ mobile, offset, hasSideChat, footerHeight, p
428
487
  '.ss__chat__content__header__comparisons': {
429
488
  display: 'flex',
430
489
  flexDirection: 'column',
431
- gap: '1em',
490
+ gap: '0.5em',
432
491
  background: new Colour(colorPrimary).lightenHex(0.95),
433
- padding: '1em',
492
+ padding: '0.5em 1em',
434
493
  '.ss__chat__content__header__comparisons__header': {
435
494
  display: 'flex',
436
495
  justifyContent: 'space-between',
@@ -534,7 +593,7 @@ export const chatDefaultStyles = ({ mobile, offset, hasSideChat, footerHeight, p
534
593
  overscrollBehavior: 'contain',
535
594
  margin: 0,
536
595
  maxHeight: '100%',
537
- background: '#f9fafc',
596
+ background: new Colour(colorPrimary).lightenHex(0.95),
538
597
  '.ss__chat__messages__end': {
539
598
  height: '1px',
540
599
  },
@@ -596,10 +655,13 @@ export const chatDefaultStyles = ({ mobile, offset, hasSideChat, footerHeight, p
596
655
  lineHeight: 1.5,
597
656
  color: '#333',
598
657
  padding: '2em',
599
- textAlign: 'center',
658
+ textAlign: 'left',
600
659
  },
601
- '.ss__chat__suggestions': {
660
+ '.ss__chat-suggested-questions': {
602
661
  marginTop: 'auto',
662
+ '.ss__chat-suggested-questions__item': {
663
+ padding: '8px 16px',
664
+ },
603
665
  },
604
666
  },
605
667
  },
@@ -649,7 +711,10 @@ export const chatDefaultStyles = ({ mobile, offset, hasSideChat, footerHeight, p
649
711
  '.ss__chat__actions--suggested': {
650
712
  overflowX: 'scroll',
651
713
  },
652
- '.ss__chat__actions__facet': {},
714
+ '.ss__chat__actions__facet': {
715
+ padding: '1px', // make room for outline when focused
716
+ flexShrink: 0,
717
+ },
653
718
  '.ss__chat__actions__facets-dropdown': {
654
719
  '.ss__dropdown__content': {
655
720
  bottom: '0',
@@ -38,12 +38,12 @@ export const useChatFileUpload = ({ enabled, onUpload }) => {
38
38
  dragCounterRef.current = 0;
39
39
  setIsDraggingFile(false);
40
40
  const dropped = Array.from(e.dataTransfer?.files || []);
41
- const images = dropped.filter((f) => f.type.startsWith('image/'));
42
- if (images.length === 0)
41
+ const firstImage = dropped.find((f) => f.type.startsWith('image/'));
42
+ if (!firstImage)
43
43
  return;
44
44
  // Reconstruct a FileList — consumers (e.g. controller.upload) expect FileList | null
45
45
  const dt = new DataTransfer();
46
- images.forEach((f) => dt.items.add(f));
46
+ dt.items.add(firstImage);
47
47
  await onUpload(dt.files);
48
48
  };
49
49
  return {
@@ -1 +1 @@
1
- {"version":3,"file":"SnapTemplates.d.ts","sourceRoot":"","sources":["../../../../src/Templates/SnapTemplates.tsx"],"names":[],"mappings":"AAGA,OAAO,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AAI/B,OAAO,EAAkB,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAGxE,OAAO,EAAE,KAAK,+BAA+B,EAAE,MAAM,gCAAgC,CAAC;AACtF,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,iCAAiC,CAAC;AAC3E,OAAO,KAAK,EAA0C,cAAc,EAAoB,MAAM,gCAAgC,CAAC;AAC/H,OAAO,KAAK,EAAE,6BAA6B,EAAoC,MAAM,6CAA6C,CAAC;AACnI,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AAC7C,OAAO,KAAK,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAC1D,OAAO,KAAK,EAOX,0BAA0B,EAC1B,4BAA4B,EAE5B,MAAM,wBAAwB,CAAC;AAoChC,eAAO,MAAM,sBAAsB,gBAAgB,CAAC;AACpD,eAAO,MAAM,wBAAwB,iBAAiB,CAAC;AAEvD,MAAM,MAAM,mBAAmB,GAAG,yBAAyB,CAAC;AAC5D,MAAM,MAAM,2BAA2B,GAAG,4BAA4B,GAAG;IACxE,QAAQ,EAAE,IAAI,CAAC;IACf,GAAG,CAAC,EAAE,mBAAmB,CAAC;IAC1B,QAAQ,CAAC,EAAE,YAAY,CAAC;CACxB,CAAC;AAEF,MAAM,MAAM,yBAAyB,GAAG,0BAA0B,GAAG;IACpE,GAAG,CAAC,EAAE,mBAAmB,CAAC;IAC1B,QAAQ,CAAC,EAAE,YAAY,CAAC;CACxB,CAAC;AAwBF,eAAO,MAAM,gBAAgB,EAAE,YAAiB,CAAC;AAEjD,eAAO,MAAM,wCAAwC,EAAE,+BAItD,CAAC;AAEF,qBAAa,aAAc,SAAQ,IAAI;IACtC,SAAS,EAAE,cAAc,CAAC;gBACd,MAAM,EAAE,mBAAmB,GAAG,2BAA2B;CAoGrE;AAED,wBAAgB,cAAc,CAAC,wBAAwB,EACtD,eAAe,EAAE,MAAM,EAAE,EACzB,kBAAkB,EAAE,wBAAwB,EAAE,GAC5C;IAAE,CAAC,GAAG,EAAE,MAAM,GAAG,wBAAwB,GAAG,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAA;CAAE,CAKrE;AAED,eAAO,MAAM,qBAAqB,mBAAoB,mBAAmB,kBAAkB,cAAc,KAAG,cAAc,EAoCzH,CAAC;AAEF,eAAO,MAAM,mBAAmB,mBAAoB,mBAAmB,kBAAkB,cAAc,KAAG,cAAc,EA2CvH,CAAC;AAEF,wBAAgB,2BAA2B,CAAC,cAAc,EAAE,mBAAmB,EAAE,cAAc,EAAE,cAAc,GAAG,cAAc,EAAE,CAqCjI;AAED,wBAAgB,oCAAoC,CACnD,cAAc,EAAE,mBAAmB,GAAG,2BAA2B,EACjE,cAAc,EAAE,cAAc,GAC5B;IAAE,CAAC,IAAI,EAAE,MAAM,GAAG,6BAA6B,CAAA;CAAE,CAiDnD;AAED,wBAAgB,gBAAgB,CAAC,cAAc,EAAE,mBAAmB,GAAG,2BAA2B,EAAE,cAAc,EAAE,cAAc,GAAG,UAAU,CA0J9I;AAED,wBAAgB,aAAa,CAC5B,cAAc,EAAE,mBAAmB,GAAG,2BAA2B,EACjE,cAAc,EAAE,cAAc,EAC9B,cAAc,CAAC,EAAE,cAAc,GAAG,QAAQ,GAAG,gBAAgB,GAAG,MAAM,GACpE,cAAc,EAAE,CAmFlB"}
1
+ {"version":3,"file":"SnapTemplates.d.ts","sourceRoot":"","sources":["../../../../src/Templates/SnapTemplates.tsx"],"names":[],"mappings":"AAGA,OAAO,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AAI/B,OAAO,EAAkB,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAGxE,OAAO,EAAE,KAAK,+BAA+B,EAAE,MAAM,gCAAgC,CAAC;AACtF,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,iCAAiC,CAAC;AAC3E,OAAO,KAAK,EAA0C,cAAc,EAAoB,MAAM,gCAAgC,CAAC;AAC/H,OAAO,KAAK,EAAE,6BAA6B,EAAoC,MAAM,6CAA6C,CAAC;AACnI,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AAC7C,OAAO,KAAK,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAC1D,OAAO,KAAK,EAOX,0BAA0B,EAC1B,4BAA4B,EAE5B,MAAM,wBAAwB,CAAC;AAoChC,eAAO,MAAM,sBAAsB,gBAAgB,CAAC;AACpD,eAAO,MAAM,wBAAwB,iBAAiB,CAAC;AAEvD,MAAM,MAAM,mBAAmB,GAAG,yBAAyB,CAAC;AAC5D,MAAM,MAAM,2BAA2B,GAAG,4BAA4B,GAAG;IACxE,QAAQ,EAAE,IAAI,CAAC;IACf,GAAG,CAAC,EAAE,mBAAmB,CAAC;IAC1B,QAAQ,CAAC,EAAE,YAAY,CAAC;CACxB,CAAC;AAEF,MAAM,MAAM,yBAAyB,GAAG,0BAA0B,GAAG;IACpE,GAAG,CAAC,EAAE,mBAAmB,CAAC;IAC1B,QAAQ,CAAC,EAAE,YAAY,CAAC;CACxB,CAAC;AAwBF,eAAO,MAAM,gBAAgB,EAAE,YAAiB,CAAC;AAEjD,eAAO,MAAM,wCAAwC,EAAE,+BAItD,CAAC;AAEF,qBAAa,aAAc,SAAQ,IAAI;IACtC,SAAS,EAAE,cAAc,CAAC;gBACd,MAAM,EAAE,mBAAmB,GAAG,2BAA2B;CAoGrE;AAED,wBAAgB,cAAc,CAAC,wBAAwB,EACtD,eAAe,EAAE,MAAM,EAAE,EACzB,kBAAkB,EAAE,wBAAwB,EAAE,GAC5C;IAAE,CAAC,GAAG,EAAE,MAAM,GAAG,wBAAwB,GAAG,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAA;CAAE,CAKrE;AAED,eAAO,MAAM,qBAAqB,mBAAoB,mBAAmB,kBAAkB,cAAc,KAAG,cAAc,EAoCzH,CAAC;AAEF,eAAO,MAAM,mBAAmB,mBAAoB,mBAAmB,kBAAkB,cAAc,KAAG,cAAc,EA2CvH,CAAC;AAEF,wBAAgB,2BAA2B,CAAC,cAAc,EAAE,mBAAmB,EAAE,cAAc,EAAE,cAAc,GAAG,cAAc,EAAE,CAqCjI;AAED,wBAAgB,oCAAoC,CACnD,cAAc,EAAE,mBAAmB,GAAG,2BAA2B,EACjE,cAAc,EAAE,cAAc,GAC5B;IAAE,CAAC,IAAI,EAAE,MAAM,GAAG,6BAA6B,CAAA;CAAE,CAiDnD;AAED,wBAAgB,gBAAgB,CAAC,cAAc,EAAE,mBAAmB,GAAG,2BAA2B,EAAE,cAAc,EAAE,cAAc,GAAG,UAAU,CA6J9I;AAED,wBAAgB,aAAa,CAC5B,cAAc,EAAE,mBAAmB,GAAG,2BAA2B,EACjE,cAAc,EAAE,cAAc,EAC9B,cAAc,CAAC,EAAE,cAAc,GAAG,QAAQ,GAAG,gBAAgB,GAAG,MAAM,GACpE,cAAc,EAAE,CAmFlB"}
@@ -377,7 +377,10 @@ export function createSnapConfig(templateConfig, templatesStore) {
377
377
  config: {
378
378
  id: 'chat',
379
379
  plugins: createPlugins(templateConfig, templatesStore, 'chat'),
380
- settings: templateConfig.chat.settings || {},
380
+ settings: {
381
+ ...(templateConfig.chat.settings || {}),
382
+ languageCode: templatesStore.language,
383
+ },
381
384
  },
382
385
  targeters: createChatTargeters(templateConfig, templatesStore),
383
386
  };