@nyris/nyris-webapp 0.3.99 → 0.4.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 (30) hide show
  1. package/build/asset-manifest.json +6 -6
  2. package/build/index.html +1 -1
  3. package/build/js/settings.example.js +3 -1
  4. package/build/static/css/main.a7ff7a7f.css +4 -0
  5. package/build/static/css/main.a7ff7a7f.css.map +1 -0
  6. package/build/static/js/main.916c157c.js +3 -0
  7. package/build/static/js/{main.2339271b.js.map → main.916c157c.js.map} +1 -1
  8. package/package.json +3 -3
  9. package/public/js/settings.example.js +3 -1
  10. package/src/components/ChatAssistant/ChatAssistant.test.tsx +213 -0
  11. package/src/components/ChatAssistant/ChatAssistant.tsx +18 -1
  12. package/src/components/ChatAssistant/MobileChatAssistant.tsx +18 -1
  13. package/src/components/ChatAssistant/OptionChip.tsx +23 -1
  14. package/src/components/ChatAssistant/useChatAssistantLogic.ts +35 -14
  15. package/src/components/Inquiry/InquiryModal.tsx +58 -26
  16. package/src/hooks/chatProductMetadata.test.ts +307 -0
  17. package/src/hooks/useBadResultsRecovery.ts +26 -49
  18. package/src/hooks/useGoodResultsChat.ts +20 -17
  19. package/src/hooks/useResultEvaluator.ts +21 -55
  20. package/src/services/Feedback.ts +5 -2
  21. package/src/stores/chat/conversationStore.test.ts +133 -0
  22. package/src/stores/chat/conversationStore.ts +55 -14
  23. package/src/types.ts +1 -0
  24. package/src/utils/chatProductMetadata.test.ts +130 -0
  25. package/src/utils/chatProductMetadata.ts +93 -0
  26. package/src/utils/compressImage.ts +3 -3
  27. package/build/static/css/main.5cb72c09.css +0 -4
  28. package/build/static/css/main.5cb72c09.css.map +0 -1
  29. package/build/static/js/main.2339271b.js +0 -3
  30. /package/build/static/js/{main.2339271b.js.LICENSE.txt → main.916c157c.js.LICENSE.txt} +0 -0
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@nyris/nyris-webapp",
3
- "version": "0.3.99",
3
+ "version": "0.4.0",
4
4
  "dependencies": {
5
5
  "@auth0/auth0-react": "^2.2.4",
6
6
  "@emailjs/browser": "^4.3.3",
7
7
  "@google/genai": "^1.46.0",
8
- "@nyris/nyris-api": "^0.3.99",
9
- "@nyris/nyris-react-components": "^0.3.99",
8
+ "@nyris/nyris-api": "^0.4.0",
9
+ "@nyris/nyris-react-components": "^0.4.0",
10
10
  "@radix-ui/react-accordion": "^1.2.2",
11
11
  "@radix-ui/react-dialog": "^1.1.4",
12
12
  "@radix-ui/react-popover": "^1.1.4",
@@ -49,6 +49,7 @@ var settings = {
49
49
  fallbackToElasticSearch: false,
50
50
  chat: true,
51
51
  findRelatedParts: true,
52
+ requestSupport: false,
52
53
  },
53
54
  // UI - theme
54
55
  theme: {
@@ -144,7 +145,8 @@ var settings = {
144
145
  typePlateResultsNoScopeLabel:
145
146
  'Scan your type plate to see only parts for your equipment.',
146
147
  typePlateResultsClearScopeLabel: 'Search all products',
147
- typePlatePopupErrorMessage: 'The serial number was not found. Please check again.',
148
+ typePlatePopupErrorMessage:
149
+ 'The serial number was not found. Please check again.',
148
150
  typePlateManualInputLabel: "Can't scan? Enter serial number manually.",
149
151
  typePlateManualInputPlaceholder: 'e.g. 2234GH',
150
152
  typePlateWhereToFindTitle: 'Where to find it',
@@ -0,0 +1,213 @@
1
+ import {
2
+ fireEvent,
3
+ render,
4
+ screen,
5
+ waitFor,
6
+ within,
7
+ } from '@testing-library/react';
8
+ import useConversationStore from 'stores/chat/conversationStore';
9
+ import ChatAssistant from './ChatAssistant';
10
+ import MobileChatAssistant from './MobileChatAssistant';
11
+
12
+ const mockSendMessage = jest.fn();
13
+ const mockProducts = {
14
+ productsFromAlgolia: [],
15
+ productsFromFindApi: [],
16
+ firstSearchResults: [],
17
+ imageAnalysis: {} as any,
18
+ setFindApiProducts: jest.fn(),
19
+ setFullResultPool: jest.fn(),
20
+ };
21
+ const mockRequest = { requestImages: [], regions: [] };
22
+
23
+ jest.mock('hooks/useGoodResultsChat', () => ({
24
+ useGoodResultsChat: () => ({ sendMessage: mockSendMessage }),
25
+ }));
26
+ jest.mock('hooks/useBadResultsRecovery', () => ({
27
+ useBadResultsRecovery: () => ({ sendMessage: mockSendMessage }),
28
+ }));
29
+ jest.mock('services/vizo', () => ({ chatAnalysis: jest.fn() }));
30
+ jest.mock('utils/compressImage', () => ({ compressImage: jest.fn() }));
31
+ jest.mock('utils/relatedParts', () => ({
32
+ loadRelatedPartsTable: jest.fn(),
33
+ lookupRelatedParts: jest.fn(),
34
+ }));
35
+ jest.mock('utils/fetchProductImage', () => ({
36
+ fetchProductImageBase64: jest.fn(),
37
+ getProductImageUrl: jest.fn(),
38
+ }));
39
+ jest.mock('utils/nyrisApiKey', () => ({ getEffectiveApiKey: jest.fn() }));
40
+ jest.mock('stores/result/resultStore', () => ({
41
+ __esModule: true,
42
+ default: Object.assign((selector: any) => selector(mockProducts), {
43
+ getState: () => mockProducts,
44
+ }),
45
+ }));
46
+ jest.mock('stores/request/requestStore', () => ({
47
+ __esModule: true,
48
+ default: Object.assign((selector: any) => selector(mockRequest), {
49
+ getState: () => mockRequest,
50
+ }),
51
+ }));
52
+ jest.mock('stores/ui/uiStore', () => ({
53
+ __esModule: true,
54
+ default: (selector: any) =>
55
+ selector({
56
+ toggleAiMode: jest.fn(),
57
+ setIsAiModeOpen: jest.fn(),
58
+ }),
59
+ }));
60
+ jest.mock('@nyris/nyris-react-components', () => ({ Icon: () => null }));
61
+ jest.mock('react-i18next', () => ({
62
+ useTranslation: () => ({ t: (value: string) => value }),
63
+ }));
64
+ jest.mock('components/Tooltip/TooltipComponent', () => ({
65
+ __esModule: true,
66
+ default: ({ children }: any) => children,
67
+ }));
68
+ jest.mock('components/CustomCameraDrawer', () => ({
69
+ __esModule: true,
70
+ default: () => null,
71
+ }));
72
+ jest.mock('components/ImageUpload', () => ({
73
+ __esModule: true,
74
+ default: () => null,
75
+ }));
76
+ jest.mock('components/Inquiry/InquiryModal', () => ({
77
+ __esModule: true,
78
+ default: ({ setIsInquiryModalOpen }: any) => (
79
+ <div role="dialog" aria-label="Support inquiry">
80
+ <button onClick={() => setIsInquiryModalOpen(false)}>
81
+ Close inquiry
82
+ </button>
83
+ </div>
84
+ ),
85
+ }));
86
+
87
+ beforeEach(() => {
88
+ jest.clearAllMocks();
89
+ HTMLElement.prototype.scrollIntoView = jest.fn();
90
+ window.settings = {
91
+ algolia: { enabled: false },
92
+ vizo: { findRelatedParts: false, requestSupport: true },
93
+ support: { emailInquiry: false, emailTemplateId: '' },
94
+ } as typeof window.settings;
95
+ mockProducts.firstSearchResults = [];
96
+ mockProducts.imageAnalysis = {};
97
+ useConversationStore.getState().reset();
98
+ mockSendMessage.mockImplementation(async (content: string) => {
99
+ const store = useConversationStore.getState();
100
+ store.addMessage({ role: 'user', content });
101
+ store.setIsLoading(true);
102
+ store.addMessage(
103
+ {
104
+ role: 'assistant',
105
+ content: 'Let us keep looking.',
106
+ options: [{ label: 'Continue search', action_type: 'filter' }],
107
+ },
108
+ { isApiResponse: true },
109
+ );
110
+ store.setIsLoading(false);
111
+ });
112
+ });
113
+
114
+ test.each([
115
+ { name: 'desktop', Component: ChatAssistant },
116
+ { name: 'mobile', Component: MobileChatAssistant },
117
+ ])(
118
+ '$name offers an inquiry without blocking continued chat',
119
+ async ({ Component }) => {
120
+ render(<Component />);
121
+ const input = screen.getByRole('textbox');
122
+ const sendMessage = async () => {
123
+ fireEvent.change(input, { target: { value: 'Still looking' } });
124
+ // eslint-disable-next-line testing-library/no-node-access
125
+ fireEvent.submit(input.closest('form')!);
126
+ await waitFor(() => expect(input).toHaveValue(''));
127
+ };
128
+
129
+ await sendMessage();
130
+ await sendMessage();
131
+ expect(
132
+ screen.queryByRole('button', { name: 'Request support' }),
133
+ ).toBeNull();
134
+ await sendMessage();
135
+
136
+ const supportButton = await screen.findByRole('button', {
137
+ name: 'Request support',
138
+ });
139
+ expect(HTMLElement.prototype.scrollIntoView).toHaveBeenLastCalledWith({
140
+ behavior: 'auto',
141
+ block: 'end',
142
+ });
143
+ const supportSection = screen.getByRole('complementary', {
144
+ name: 'Human support',
145
+ });
146
+ expect(
147
+ within(supportSection).getByText(
148
+ 'Still not finding it? Our team can help. Send us a quick email',
149
+ ),
150
+ ).toBeInTheDocument();
151
+ expect(
152
+ within(supportSection).getByRole('button', { name: 'Request support' }),
153
+ ).toBe(supportButton);
154
+ const replyButtons = screen.getAllByRole('button', {
155
+ name: 'Continue search',
156
+ });
157
+ expect(replyButtons[replyButtons.length - 1]).toBeEnabled();
158
+ fireEvent.click(supportButton);
159
+ expect(
160
+ screen.getByRole('dialog', { name: 'Support inquiry' }),
161
+ ).toBeInTheDocument();
162
+ fireEvent.click(screen.getByRole('button', { name: 'Close inquiry' }));
163
+
164
+ await sendMessage();
165
+ expect(mockSendMessage).toHaveBeenCalledTimes(4);
166
+ expect(
167
+ screen.getAllByRole('button', { name: 'Request support' }),
168
+ ).toHaveLength(1);
169
+ expect(supportButton).toBeEnabled();
170
+ fireEvent.click(supportButton);
171
+ expect(
172
+ screen.getByRole('dialog', { name: 'Support inquiry' }),
173
+ ).toBeInTheDocument();
174
+ },
175
+ );
176
+
177
+ test('offers related parts again after restoring original results when enabled', async () => {
178
+ const originalResults: any = [{ id: 'original-result' }];
179
+ mockProducts.firstSearchResults = originalResults;
180
+ mockProducts.imageAnalysis = {
181
+ specification: { product_category: 'pump' },
182
+ };
183
+ window.settings.vizo!.findRelatedParts = true;
184
+
185
+ const store = useConversationStore.getState();
186
+ store.setRelatedPartsUsed(true);
187
+ store.setRelatedPartsTableSuggestions([
188
+ { label: 'Motor', description: 'Replacement motor' },
189
+ ]);
190
+ store.addMessage({
191
+ role: 'assistant',
192
+ content: 'Filtered results',
193
+ options: [
194
+ {
195
+ label: 'Go back to original results',
196
+ action_type: 'restore_original',
197
+ value: 'restore',
198
+ },
199
+ ],
200
+ });
201
+
202
+ render(<ChatAssistant />);
203
+ fireEvent.click(
204
+ screen.getByRole('button', { name: 'Go back to original results' }),
205
+ );
206
+
207
+ expect(
208
+ await screen.findByRole('button', {
209
+ name: 'Find parts related to pump',
210
+ }),
211
+ ).toBeInTheDocument();
212
+ expect(useConversationStore.getState().relatedPartsUsed).toBe(false);
213
+ });
@@ -7,6 +7,7 @@ import { Icon } from '@nyris/nyris-react-components';
7
7
  import useUiStore from 'stores/ui/uiStore';
8
8
  import OptionChip, { ChatOption } from './OptionChip';
9
9
  import { useChatAssistantLogic } from './useChatAssistantLogic';
10
+ import InquiryModal from 'components/Inquiry/InquiryModal';
10
11
 
11
12
  function ChatAssistant() {
12
13
  const inputRef = useRef<HTMLTextAreaElement>(null);
@@ -26,8 +27,14 @@ function ChatAssistant() {
26
27
  handleSendMessage,
27
28
  handleOptionClick,
28
29
  handleImageUpload,
30
+ isSupportInquiryOpen,
31
+ setIsSupportInquiryOpen,
32
+ requestImage,
33
+ selectedRegion,
29
34
  } = useChatAssistantLogic();
30
35
 
36
+ const latestChatMessageId = messages[messages.length - 1]?.id;
37
+
31
38
  const setIsOpen = useConversationStore(s => s.setIsOpen);
32
39
 
33
40
  const toggleAiMode = useUiStore(state => state.toggleAiMode);
@@ -56,6 +63,15 @@ function ChatAssistant() {
56
63
  'bg-[radial-gradient(ellipse_at_70%_7%,_#F3F4F8_50%,_#E4E3FF_100%)]',
57
64
  ])}
58
65
  >
66
+ {isSupportInquiryOpen && (
67
+ <InquiryModal
68
+ requestImage={requestImage}
69
+ selectedRegion={selectedRegion}
70
+ isInquiryModalOpen={isSupportInquiryOpen}
71
+ setIsInquiryModalOpen={setIsSupportInquiryOpen}
72
+ allowEmailFallback
73
+ />
74
+ )}
59
75
  <div
60
76
  className="absolute top-3 right-4 bg-[#FAFAFA] w-7 h-7 flex justify-center items-center rounded-lg cursor-pointer"
61
77
  onClick={() => {
@@ -144,7 +160,8 @@ function ChatAssistant() {
144
160
  option={option}
145
161
  onClick={wrappedHandleOptionClick}
146
162
  disabled={
147
- isLoading || i !== messages.length - 1
163
+ option.action_type !== 'request_support' &&
164
+ (isLoading || msg.id !== latestChatMessageId)
148
165
  }
149
166
  />
150
167
  ))}
@@ -9,6 +9,7 @@ import CustomCamera from 'components/CustomCameraDrawer';
9
9
  import ImageUpload from 'components/ImageUpload';
10
10
  import OptionChip, { ChatOption } from './OptionChip';
11
11
  import { useChatAssistantLogic } from './useChatAssistantLogic';
12
+ import InquiryModal from 'components/Inquiry/InquiryModal';
12
13
 
13
14
  function MobileChatAssistant() {
14
15
  const { t } = useTranslation();
@@ -30,8 +31,14 @@ function MobileChatAssistant() {
30
31
  handleSendMessage,
31
32
  handleOptionClick,
32
33
  handleImageUpload,
34
+ isSupportInquiryOpen,
35
+ setIsSupportInquiryOpen,
36
+ requestImage,
37
+ selectedRegion,
33
38
  } = useChatAssistantLogic();
34
39
 
40
+ const latestChatMessageId = messages[messages.length - 1]?.id;
41
+
35
42
  const setIsAiModeOpen = useUiStore(state => state.setIsAiModeOpen);
36
43
 
37
44
  const [isOpenModalCamera, setOpenModalCamera] = useState<boolean>(false);
@@ -52,6 +59,15 @@ function MobileChatAssistant() {
52
59
 
53
60
  return (
54
61
  <div className="fixed inset-x-0 bottom-0 z-[50] flex flex-col justify-end max-h-[50vh]">
62
+ {isSupportInquiryOpen && (
63
+ <InquiryModal
64
+ requestImage={requestImage}
65
+ selectedRegion={selectedRegion}
66
+ isInquiryModalOpen={isSupportInquiryOpen}
67
+ setIsInquiryModalOpen={setIsSupportInquiryOpen}
68
+ allowEmailFallback
69
+ />
70
+ )}
55
71
  {/* Chat container — anchored to bottom, content-based height, max 50vh */}
56
72
  <div
57
73
  className={twMerge(
@@ -143,7 +159,8 @@ function MobileChatAssistant() {
143
159
  option={option}
144
160
  onClick={wrappedHandleOptionClick}
145
161
  disabled={
146
- isLoading || i !== messages.length - 1
162
+ option.action_type !== 'request_support' &&
163
+ (isLoading || msg.id !== latestChatMessageId)
147
164
  }
148
165
  />
149
166
  ))}
@@ -1,4 +1,4 @@
1
- import { RotateCcw, Search, Wrench } from 'lucide-react';
1
+ import { Mail, RotateCcw, Search, Wrench } from 'lucide-react';
2
2
  import { twMerge } from 'tailwind-merge';
3
3
 
4
4
  import Tooltip from 'components/Tooltip/TooltipComponent';
@@ -27,6 +27,28 @@ function OptionChip({ option, onClick, disabled = false }: OptionChipProps) {
27
27
  const displayText =
28
28
  isFilter && option.value?.trim() ? option.value : option.label;
29
29
 
30
+ if (option.action_type === 'request_support') {
31
+ return (
32
+ <aside
33
+ aria-label="Human support"
34
+ className="w-full mt-2 border-t border-solid border-[#B9D5CE] pt-3"
35
+ >
36
+ <p className="text-sm leading-5 text-[#27443D] mb-3">
37
+ Still not finding it? Our team can help. Send us a quick email
38
+ </p>
39
+ <button
40
+ type="button"
41
+ onClick={() => onClick(option)}
42
+ disabled={disabled}
43
+ className="inline-flex items-center justify-center gap-2 min-h-[36px] px-3 py-2 rounded-md bg-[#0F766E] text-white text-xs font-semibold hover:bg-[#115E59] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0F766E] disabled:opacity-50 disabled:cursor-not-allowed"
44
+ >
45
+ <Mail size={14} className="shrink-0" aria-hidden="true" />
46
+ {displayText}
47
+ </button>
48
+ </aside>
49
+ );
50
+ }
51
+
30
52
  return (
31
53
  <Tooltip content={displayText} disabled={displayText.length <= 32}>
32
54
  <button
@@ -6,6 +6,7 @@ import useRequestStore from 'stores/request/requestStore';
6
6
  import { useGoodResultsChat } from 'hooks/useGoodResultsChat';
7
7
  import { useBadResultsRecovery } from 'hooks/useBadResultsRecovery';
8
8
  import { chatAnalysis } from 'services/vizo';
9
+ import { getChatProductMetadata } from 'utils/chatProductMetadata';
9
10
  import { compressImage } from 'utils/compressImage';
10
11
  import { loadRelatedPartsTable, lookupRelatedParts } from 'utils/relatedParts';
11
12
  import {
@@ -20,6 +21,7 @@ export function useChatAssistantLogic() {
20
21
  const imageInputRef = useRef<HTMLInputElement>(null);
21
22
 
22
23
  const [findingRelatedParts, setFindingRelatedParts] = useState(false);
24
+ const [isSupportInquiryOpen, setIsSupportInquiryOpen] = useState(false);
23
25
 
24
26
  // Conversation store
25
27
  const isEvaluated = useConversationStore(s => s.isEvaluated);
@@ -27,6 +29,7 @@ export function useChatAssistantLogic() {
27
29
  const messages = useConversationStore(s => s.messages);
28
30
  const isLoading = useConversationStore(s => s.isLoading);
29
31
  const error = useConversationStore(s => s.error);
32
+ const sessionId = useConversationStore(s => s.sessionId);
30
33
  const catalogSearchReady = useConversationStore(s => s.catalogSearchReady);
31
34
  const setCatalogSearchReady = useConversationStore(
32
35
  s => s.setCatalogSearchReady,
@@ -54,6 +57,7 @@ export function useChatAssistantLogic() {
54
57
 
55
58
  // Request store
56
59
  const requestImages = useRequestStore(state => state.requestImages);
60
+ const regions = useRequestStore(state => state.regions);
57
61
 
58
62
  const p2TriggeredRef = useRef(false);
59
63
 
@@ -69,6 +73,10 @@ export function useChatAssistantLogic() {
69
73
  // Catalog search state
70
74
  const [catalogSearching, setCatalogSearching] = useState(false);
71
75
 
76
+ useEffect(() => {
77
+ setIsSupportInquiryOpen(false);
78
+ }, [sessionId]);
79
+
72
80
  // Helper to get the correct product setter
73
81
  const getSetProducts = useCallback(() => {
74
82
  return setFindApiProducts;
@@ -100,7 +108,7 @@ export function useChatAssistantLogic() {
100
108
 
101
109
  // Auto-scroll
102
110
  useEffect(() => {
103
- messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
111
+ messagesEndRef.current?.scrollIntoView({ behavior: 'auto', block: 'end' });
104
112
  }, [messages]);
105
113
 
106
114
  // ── Handlers ──
@@ -131,6 +139,11 @@ export function useChatAssistantLogic() {
131
139
 
132
140
  const handleOptionClick = useCallback(
133
141
  async (option: any) => {
142
+ if (option.action_type === 'request_support') {
143
+ setIsSupportInquiryOpen(true);
144
+ return;
145
+ }
146
+
134
147
  if (option.action_type === 'text_search_prompt') {
135
148
  setInputValue('');
136
149
  const store = useConversationStore.getState();
@@ -187,9 +200,23 @@ export function useChatAssistantLogic() {
187
200
  setProducts(restoreResults);
188
201
  setFullResultPool(restoreResults);
189
202
  store.setPreCatalogResults(null);
203
+ store.setRelatedPartsUsed(false);
204
+ const relatedPartsOption =
205
+ window.settings.vizo?.findRelatedParts &&
206
+ store.relatedPartsTableSuggestions.length > 0
207
+ ? [
208
+ {
209
+ label: `Find parts related to ${imageAnalysis?.specification?.product_category || 'request image'}`,
210
+ action_type: 'find_related_parts' as const,
211
+ key: 'find_related',
212
+ value: 'find_related_parts',
213
+ },
214
+ ]
215
+ : [];
190
216
  store.addMessage({
191
217
  role: 'assistant',
192
218
  content: `Restored your original ${restoreResults.length} result${restoreResults.length === 1 ? '' : 's'} from the initial image search. Feel free to keep filtering or run a new catalog search.`,
219
+ options: relatedPartsOption,
193
220
  });
194
221
  } else {
195
222
  store.addMessage({
@@ -300,12 +327,7 @@ export function useChatAssistantLogic() {
300
327
  const visibleProducts = currentProducts.slice(0, 30);
301
328
  const productList = visibleProducts.map((p: any, i: number) => ({
302
329
  rank: i + 1,
303
- sku: p.sku || p.objectID || `product-${i}`,
304
- title: p.title || p.name || 'Untitled',
305
- brand: p.brand || '',
306
- description: (p.description || '').substring(0, 150),
307
- categories: p.categories || [],
308
- mpn: p.customIds?.mpn || p.ids?.mpn || '',
330
+ metadata: getChatProductMetadata(p),
309
331
  }));
310
332
 
311
333
  const productImages = await Promise.all(
@@ -350,17 +372,12 @@ export function useChatAssistantLogic() {
350
372
  ${productContextNotice}
351
373
 
352
374
  PRODUCT LIST:
353
- Each result includes a unique SKU in brackets. Use the result thumbnails and metadata together when deciding which current results match the uploaded image. Return matched_indices as 1-based ranks from this list.
375
+ Each JSON result includes a 1-based rank and complete product metadata, including identifiers when available. Use the result thumbnails and metadata together when deciding which current results match the uploaded image. Return matched_indices as 1-based rank values from this list.
354
376
  `,
355
377
  });
356
378
 
357
379
  productList.forEach((product, index) => {
358
- const currentProduct = visibleProducts[index];
359
- const tags = currentProduct?._tags;
360
- const tagStr = tags
361
- ? ` | TAGS: type=${tags.productType}, color=${tags.color}, shape=${tags.shape}, material=${tags.material}`
362
- : '';
363
- const metadata = `${product.rank}. [${product.sku}] ${product.title} | brand: ${product.brand} | mpn: ${product.mpn} | categories: ${JSON.stringify(product.categories)} | desc: ${product.description}${tagStr}`;
380
+ const metadata = JSON.stringify(product);
364
381
 
365
382
  if (hasVisualContext && productImages[index]?.base64) {
366
383
  parts.push({
@@ -730,6 +747,10 @@ Each result includes a unique SKU in brackets. Use the result thumbnails and met
730
747
  activeProducts,
731
748
  productsFromAlgolia,
732
749
  findingRelatedParts,
750
+ isSupportInquiryOpen,
751
+ setIsSupportInquiryOpen,
752
+ requestImage: requestImages[0],
753
+ selectedRegion: regions[0],
733
754
 
734
755
  // Refs
735
756
  messagesEndRef,
@@ -8,7 +8,7 @@ import { Icon } from '@nyris/nyris-react-components';
8
8
  import useRequestStore from 'stores/request/requestStore';
9
9
  import { getCroppedCanvas } from 'utils/misc';
10
10
  import { ToastHelper } from 'helpers/ToastHelper';
11
- import { Dialog, DialogContent } from 'components/Modal/Dialog';
11
+ import { Dialog, DialogContent, DialogTitle } from 'components/Modal/Dialog';
12
12
  import { AutosizeTextarea } from 'components/AutosizeTextArea';
13
13
  import Tooltip from 'components/Tooltip/TooltipComponent';
14
14
 
@@ -21,6 +21,7 @@ interface Props {
21
21
  selectedRegion: any;
22
22
  setIsInquiryModalOpen: any;
23
23
  isInquiryModalOpen?: any;
24
+ allowEmailFallback?: boolean;
24
25
  }
25
26
  // eslint-disable-next-line
26
27
  const emailRegex = /.+\@.+\..+$/;
@@ -44,6 +45,7 @@ export default function InquiryModal({
44
45
  selectedRegion,
45
46
  setIsInquiryModalOpen,
46
47
  isInquiryModalOpen,
48
+ allowEmailFallback = false,
47
49
  }: Props) {
48
50
  const settings = window.settings;
49
51
  const preFilter = useRequestStore(state => state.preFilter);
@@ -56,6 +58,27 @@ export default function InquiryModal({
56
58
  const [emailValid, setEmailValid] = useState<boolean | undefined>(undefined);
57
59
 
58
60
  const [information, setInformation] = useState('');
61
+ const useEmailClient =
62
+ allowEmailFallback && !settings.support?.emailTemplateId;
63
+ // encodeURIComponent, not URLSearchParams: mailto (RFC 6068) needs spaces as %20, not +
64
+ const supportEmailHref = `mailto:support@nyris.io?subject=${encodeURIComponent(
65
+ 'Vizo support request',
66
+ )}&body=${encodeURIComponent(`Email: ${email.trim()}\n\n${information}`)}`;
67
+ const submitStyle: React.CSSProperties = {
68
+ height: '66px',
69
+ display: 'flex',
70
+ alignItems: 'center',
71
+ width: '50%',
72
+ backgroundColor: emailValid
73
+ ? settings.theme?.primaryColor || '#0F766E'
74
+ : '#E9E9EC',
75
+ color: emailValid ? '#fff' : '#AAABB5',
76
+ fontSize: '14px',
77
+ paddingLeft: '16px',
78
+ border: 'none',
79
+ cursor: emailValid ? 'pointer' : 'default',
80
+ textDecoration: 'none',
81
+ };
59
82
 
60
83
  const setFormattedContent = React.useCallback(
61
84
  (text: string) => {
@@ -189,7 +212,8 @@ export default function InquiryModal({
189
212
  >
190
213
  <DialogContent
191
214
  closeButton={false}
192
- className="w-[360px] desktop:w-[378px] flex flex-col bg-white p-0"
215
+ aria-describedby={undefined}
216
+ className="w-[360px] desktop:w-[378px] max-w-[calc(100vw-32px)] max-h-[calc(100dvh-32px)] overflow-y-auto flex flex-col bg-white p-0"
193
217
  >
194
218
  <div>
195
219
  <div
@@ -200,7 +224,8 @@ export default function InquiryModal({
200
224
  justifyContent: 'space-between',
201
225
  }}
202
226
  >
203
- <p
227
+ <DialogTitle
228
+ className="text-sm leading-normal tracking-normal"
204
229
  style={{
205
230
  color: '#2B2C46',
206
231
  fontWeight: 'bold',
@@ -209,8 +234,10 @@ export default function InquiryModal({
209
234
  {requestImage
210
235
  ? t('Submit your image for inquiry')
211
236
  : t('Submit your inquiry')}
212
- </p>
213
- <div
237
+ </DialogTitle>
238
+ <button
239
+ type="button"
240
+ aria-label={t('Close')}
214
241
  onClick={() => setIsInquiryModalOpen(false)}
215
242
  style={{ display: 'flex', padding: '1px' }}
216
243
  >
@@ -222,7 +249,7 @@ export default function InquiryModal({
222
249
  cursor: 'pointer',
223
250
  }}
224
251
  />
225
- </div>
252
+ </button>
226
253
  </div>
227
254
  {requestImage && (
228
255
  <div
@@ -381,26 +408,31 @@ export default function InquiryModal({
381
408
  >
382
409
  {t('Cancel')}
383
410
  </button>
384
- <button
385
- style={{
386
- height: '66px',
387
- display: 'flex',
388
- alignItems: 'center',
389
- width: '50%',
390
- backgroundColor: emailValid
391
- ? settings.theme?.primaryColor
392
- : '#E9E9EC',
393
- color: emailValid ? '#fff' : '#AAABB5',
394
- fontSize: '14px',
395
- paddingLeft: '16px',
396
- border: 'none',
397
- cursor: emailValid ? 'pointer' : 'normal',
398
- }}
399
- disabled={!emailValid}
400
- onClick={handleInquiry}
401
- >
402
- {t('Send')}
403
- </button>
411
+ {useEmailClient ? (
412
+ <a
413
+ style={submitStyle}
414
+ href={emailValid ? supportEmailHref : undefined}
415
+ aria-disabled={!emailValid}
416
+ tabIndex={emailValid ? 0 : -1}
417
+ onClick={event => {
418
+ if (!emailValid) {
419
+ event.preventDefault();
420
+ return;
421
+ }
422
+ trackClick(CLICK_SUBTYPE.RAISE_INQUIRY);
423
+ }}
424
+ >
425
+ {t('Open email')}
426
+ </a>
427
+ ) : (
428
+ <button
429
+ style={submitStyle}
430
+ disabled={!emailValid}
431
+ onClick={handleInquiry}
432
+ >
433
+ {t('Send')}
434
+ </button>
435
+ )}
404
436
  </div>
405
437
  </div>
406
438
  </DialogContent>