@nyris/nyris-webapp 0.3.98 → 0.3.99

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 (85) hide show
  1. package/build/asset-manifest.json +7 -6
  2. package/build/index.html +1 -1
  3. package/build/js/settings.example.js +40 -0
  4. package/build/static/css/main.5cb72c09.css +4 -0
  5. package/build/static/css/main.5cb72c09.css.map +1 -0
  6. package/build/static/js/main.2339271b.js +3 -0
  7. package/build/static/js/{main.93fdbd6e.js.map → main.2339271b.js.map} +1 -1
  8. package/build/static/media/plate_info.6beaea36d35053be3a45.png +0 -0
  9. package/package.json +3 -3
  10. package/public/js/settings.example.js +40 -0
  11. package/src/common/assets/images/plate_info.png +0 -0
  12. package/src/components/Cart.tsx +41 -0
  13. package/src/components/GoBackButton.tsx +29 -2
  14. package/src/components/Header.tsx +3 -0
  15. package/src/components/HitsPerPage.tsx +55 -18
  16. package/src/components/ImagePreview.tsx +31 -1
  17. package/src/components/ImageUpload.tsx +12 -1
  18. package/src/components/Inquiry/InquiryModal.tsx +3 -0
  19. package/src/components/Pagination.tsx +33 -6
  20. package/src/components/PostFilter/PostFilter.tsx +8 -1
  21. package/src/components/PostFilter/PostFilterFindApi.test.tsx +249 -0
  22. package/src/components/PostFilter/PostFilterFindApi.tsx +128 -52
  23. package/src/components/PreFilter/PreFilter.tsx +10 -2
  24. package/src/components/Product/Product.tsx +60 -10
  25. package/src/components/Product/ProductDetailView.tsx +23 -1
  26. package/src/components/Product/ProductDetailViewModal.tsx +5 -1
  27. package/src/components/Product/ProductList.tsx +51 -18
  28. package/src/components/Rfq/RfqModal.tsx +3 -0
  29. package/src/components/ScopeSelect/ScopeSelectForm.tsx +514 -0
  30. package/src/components/ScopeSelect/ScopeSelectImageActions.tsx +62 -0
  31. package/src/components/ScopeSelect/ScopeSelectImagePreview.tsx +141 -0
  32. package/src/components/ScopeSelect/ScopeSelectInfo.tsx +68 -0
  33. package/src/components/ScopeSelect/ScopeSelectInput.tsx +123 -0
  34. package/src/components/ScopeSelect/ScopeSelectModal.tsx +310 -0
  35. package/src/components/ScopeSelect/identifyTypePlateImage.test.ts +129 -0
  36. package/src/components/ScopeSelect/identifyTypePlateImage.ts +83 -0
  37. package/src/components/ScopeSelect/parseScopeIdResponse.test.ts +103 -0
  38. package/src/components/ScopeSelect/parseScopeIdResponse.ts +72 -0
  39. package/src/components/ScopeSelect/scopeSelectHelpers.ts +54 -0
  40. package/src/components/SidePanel.tsx +33 -0
  41. package/src/components/TextSearch.tsx +77 -11
  42. package/src/components/Tooltip/TooltipComponent.tsx +12 -4
  43. package/src/components/TypePlateUpload.tsx +132 -0
  44. package/src/hooks/useEffectiveGroundingResults.ts +8 -6
  45. package/src/hooks/useGoodResultsChat.ts +8 -22
  46. package/src/hooks/useImageSearch.ts +290 -12
  47. package/src/hooks/useMachinePartsListingPagination.ts +95 -0
  48. package/src/hooks/useResultEvaluator.ts +43 -19
  49. package/src/index.css +1 -1
  50. package/src/layouts/AppLayout.tsx +43 -3
  51. package/src/pages/Home.tsx +60 -14
  52. package/src/pages/Result.tsx +194 -14
  53. package/src/services/Feedback.ts +223 -35
  54. package/src/services/feedbackSubtypes.ts +63 -0
  55. package/src/services/filter.ts +1 -1
  56. package/src/services/initialMachinePartsText.payload.test.ts +61 -0
  57. package/src/services/initialMachinePartsText.test.ts +33 -0
  58. package/src/services/initialMachinePartsText.ts +11 -0
  59. package/src/services/machinePartsPage.test.ts +74 -0
  60. package/src/services/machinePartsPage.ts +120 -0
  61. package/src/services/normalizeChatResponse.ts +26 -0
  62. package/src/services/scopeLookup.test.ts +67 -0
  63. package/src/services/scopeLookup.ts +39 -0
  64. package/src/services/visualSearch.listingOptions.test.ts +79 -0
  65. package/src/services/visualSearch.ts +71 -3
  66. package/src/services/vizo.ts +4 -3
  67. package/src/stores/machinePartsListing/machinePartsListingStore.ts +35 -0
  68. package/src/stores/request/filter/filter.initialState.ts +1 -0
  69. package/src/stores/request/filter/filter.slice.ts +2 -0
  70. package/src/stores/result/prodcuts/products.initialState.ts +1 -0
  71. package/src/stores/result/prodcuts/products.slice.ts +4 -0
  72. package/src/stores/scope/scopeStore.ts +67 -4
  73. package/src/stores/types.ts +19 -14
  74. package/src/translations.ts +213 -0
  75. package/src/types.ts +40 -0
  76. package/src/utils/fetchProductImage.ts +1 -0
  77. package/src/utils/normalizeChatAssistantResponse.ts +227 -0
  78. package/src/utils/productCardImageUrl.test.ts +41 -0
  79. package/src/utils/productCardImageUrl.ts +49 -0
  80. package/src/utils/textSearchFilter.test.ts +41 -0
  81. package/src/utils/textSearchFilter.ts +13 -6
  82. package/build/static/css/main.81216c5d.css +0 -4
  83. package/build/static/css/main.81216c5d.css.map +0 -1
  84. package/build/static/js/main.93fdbd6e.js +0 -3
  85. /package/build/static/js/{main.93fdbd6e.js.LICENSE.txt → main.2339271b.js.LICENSE.txt} +0 -0
@@ -0,0 +1,129 @@
1
+ import { parseScopeIdChatResponse } from './parseScopeIdResponse';
2
+ import {
3
+ identifyTypePlateImage,
4
+ TypePlateExtractResult,
5
+ TypePlateLookupResult,
6
+ } from './identifyTypePlateImage';
7
+
8
+ const file = new File(['plate'], 'plate.jpg', { type: 'image/jpeg' });
9
+
10
+ function extractFromResponse(response: unknown): TypePlateExtractResult {
11
+ const parsed = parseScopeIdChatResponse(response);
12
+ if (parsed.status !== 'extracted') {
13
+ return { status: 'not_found', extractedScopeId: '' };
14
+ }
15
+ return { status: 'extracted', extractedScopeId: parsed.serialNumber };
16
+ }
17
+
18
+ describe('identifyTypePlateImage', () => {
19
+ it('does not look up a machine when OCR returns null', async () => {
20
+ const lookup = jest.fn();
21
+ const result = await identifyTypePlateImage({
22
+ file,
23
+ isCurrent: () => true,
24
+ extract: async () => extractFromResponse({ serial_number: null }),
25
+ lookup,
26
+ });
27
+
28
+ expect(result).toEqual({ status: 'not_found' });
29
+ expect(lookup).not.toHaveBeenCalled();
30
+ });
31
+
32
+ it('returns not found when the scopes lookup has no item keys', async () => {
33
+ const lookup = jest.fn(
34
+ async (): Promise<TypePlateLookupResult> => ({ status: 'empty' }),
35
+ );
36
+ const result = await identifyTypePlateImage({
37
+ file,
38
+ isCurrent: () => true,
39
+ extract: async () => extractFromResponse({ serial_number: '2234GH' }),
40
+ lookup,
41
+ });
42
+
43
+ expect(lookup).toHaveBeenCalledWith('2234GH');
44
+ expect(result).toEqual({ status: 'not_found' });
45
+ });
46
+
47
+ it('returns not found when the scopes request fails', async () => {
48
+ const result = await identifyTypePlateImage({
49
+ file,
50
+ isCurrent: () => true,
51
+ extract: async () => extractFromResponse({ serial_number: '2234GH' }),
52
+ lookup: async () => {
53
+ throw new Error('network');
54
+ },
55
+ });
56
+
57
+ expect(result).toEqual({ status: 'not_found' });
58
+ });
59
+
60
+ it('identifies a machine only after the lookup succeeds', async () => {
61
+ const result = await identifyTypePlateImage({
62
+ file,
63
+ isCurrent: () => true,
64
+ extract: async () =>
65
+ extractFromResponse({ serial_number: 'Ab-12/c 9' }),
66
+ lookup: async () => ({
67
+ status: 'found',
68
+ itemKeys: ['sku-1'],
69
+ filterAttributes: [],
70
+ }),
71
+ });
72
+
73
+ expect(result).toEqual({
74
+ status: 'identified',
75
+ serialNumber: 'Ab-12/c 9',
76
+ });
77
+ });
78
+
79
+ it('ignores an OCR response after a newer attempt or manual switch', async () => {
80
+ let current = true;
81
+ const lookup = jest.fn();
82
+ const result = await identifyTypePlateImage({
83
+ file,
84
+ isCurrent: () => current,
85
+ extract: async () => {
86
+ current = false;
87
+ return extractFromResponse({ serial_number: 'OLD' });
88
+ },
89
+ lookup,
90
+ });
91
+
92
+ expect(result).toEqual({ status: 'ignored' });
93
+ expect(lookup).not.toHaveBeenCalled();
94
+ });
95
+
96
+ it('ignores a scopes lookup that finishes after the popup closes', async () => {
97
+ let current = true;
98
+ const result = await identifyTypePlateImage({
99
+ file,
100
+ isCurrent: () => current,
101
+ extract: async () => extractFromResponse({ serial_number: '2234GH' }),
102
+ lookup: async () => {
103
+ current = false;
104
+ return {
105
+ status: 'found',
106
+ itemKeys: ['sku-1'],
107
+ filterAttributes: [],
108
+ };
109
+ },
110
+ });
111
+
112
+ expect(result).toEqual({ status: 'ignored' });
113
+ });
114
+
115
+ it('ignores a failed OCR response after the attempt is no longer current', async () => {
116
+ let current = true;
117
+ const result = await identifyTypePlateImage({
118
+ file,
119
+ isCurrent: () => current,
120
+ extract: async () => {
121
+ current = false;
122
+ throw new Error('ai failed');
123
+ },
124
+ lookup: async () => ({ status: 'not_found' }),
125
+ });
126
+
127
+ expect(result).toEqual({ status: 'ignored' });
128
+ });
129
+ });
@@ -0,0 +1,83 @@
1
+ export interface TypePlateExtractResult {
2
+ extractedScopeId: string;
3
+ status: 'extracted' | 'not_found' | 'unavailable';
4
+ }
5
+
6
+ export type TypePlateLookupResult =
7
+ | {
8
+ status: 'found';
9
+ itemKeys: string[];
10
+ filterAttributes: { key?: string; values: string[] }[];
11
+ }
12
+ | { status: 'empty' | 'not_found' | 'unavailable' };
13
+
14
+ export type TypePlateImageIdentification =
15
+ | { status: 'ignored' }
16
+ | { status: 'identified'; serialNumber: string }
17
+ | { status: 'not_found' }
18
+ | { status: 'unavailable' };
19
+
20
+ interface IdentifyTypePlateImageOptions {
21
+ file: File;
22
+ isCurrent: () => boolean;
23
+ extract: (file: File) => Promise<TypePlateExtractResult>;
24
+ lookup: (serialNumber: string) => Promise<TypePlateLookupResult>;
25
+ }
26
+
27
+ /**
28
+ * OCR and the scopes lookup are separate. Confirmation is returned only when
29
+ * the lookup finds item keys. A stale attempt returns `ignored` and must not
30
+ * update the popup.
31
+ */
32
+ export async function identifyTypePlateImage({
33
+ file,
34
+ isCurrent,
35
+ extract,
36
+ lookup,
37
+ }: IdentifyTypePlateImageOptions): Promise<TypePlateImageIdentification> {
38
+ let extracted: TypePlateExtractResult;
39
+ try {
40
+ extracted = await extract(file);
41
+ } catch {
42
+ if (!isCurrent()) {
43
+ return { status: 'ignored' };
44
+ }
45
+ return { status: 'not_found' };
46
+ }
47
+
48
+ if (!isCurrent()) {
49
+ return { status: 'ignored' };
50
+ }
51
+
52
+ if (extracted.status !== 'extracted') {
53
+ return {
54
+ status: extracted.status === 'unavailable' ? 'unavailable' : 'not_found',
55
+ };
56
+ }
57
+
58
+ let lookupResult: TypePlateLookupResult;
59
+ try {
60
+ lookupResult = await lookup(extracted.extractedScopeId);
61
+ } catch {
62
+ if (!isCurrent()) {
63
+ return { status: 'ignored' };
64
+ }
65
+ return { status: 'not_found' };
66
+ }
67
+
68
+ if (!isCurrent()) {
69
+ return { status: 'ignored' };
70
+ }
71
+
72
+ if (lookupResult.status !== 'found') {
73
+ return {
74
+ status:
75
+ lookupResult.status === 'unavailable' ? 'unavailable' : 'not_found',
76
+ };
77
+ }
78
+
79
+ return {
80
+ status: 'identified',
81
+ serialNumber: extracted.extractedScopeId,
82
+ };
83
+ }
@@ -0,0 +1,103 @@
1
+ import { parseScopeIdChatResponse } from './parseScopeIdResponse';
2
+
3
+ describe('parseScopeIdChatResponse', () => {
4
+ it('reads a serial number from the parsed chatAnalysis object', () => {
5
+ expect(parseScopeIdChatResponse({ serial_number: '2234GH' })).toEqual({
6
+ status: 'extracted',
7
+ serialNumber: '2234GH',
8
+ });
9
+ });
10
+
11
+ it('reads a JSON string', () => {
12
+ expect(
13
+ parseScopeIdChatResponse('{"serial_number":"2234GH"}'),
14
+ ).toEqual({
15
+ status: 'extracted',
16
+ serialNumber: '2234GH',
17
+ });
18
+ });
19
+
20
+ it('reads markdown-fenced JSON', () => {
21
+ expect(
22
+ parseScopeIdChatResponse('```json\n{"serial_number":"2234GH"}\n```'),
23
+ ).toEqual({
24
+ status: 'extracted',
25
+ serialNumber: '2234GH',
26
+ });
27
+ });
28
+
29
+ it('preserves case, internal characters, and surrounding spaces', () => {
30
+ expect(
31
+ parseScopeIdChatResponse({ serial_number: ' Ab-12/c 9 ' }),
32
+ ).toEqual({
33
+ status: 'extracted',
34
+ serialNumber: ' Ab-12/c 9 ',
35
+ });
36
+ });
37
+
38
+ it('treats null as not found', () => {
39
+ expect(parseScopeIdChatResponse({ serial_number: null })).toEqual({
40
+ status: 'not_found',
41
+ });
42
+ });
43
+
44
+ it('rejects an empty or whitespace-only value', () => {
45
+ expect(parseScopeIdChatResponse({ serial_number: '' })).toEqual({
46
+ status: 'not_found',
47
+ });
48
+ expect(parseScopeIdChatResponse({ serial_number: ' ' })).toEqual({
49
+ status: 'not_found',
50
+ });
51
+ });
52
+
53
+ it('rejects invalid value types', () => {
54
+ expect(parseScopeIdChatResponse({ serial_number: 12345 })).toEqual({
55
+ status: 'not_found',
56
+ });
57
+ expect(parseScopeIdChatResponse({ serial_number: false })).toEqual({
58
+ status: 'not_found',
59
+ });
60
+ expect(parseScopeIdChatResponse({ serial_number: {} })).toEqual({
61
+ status: 'not_found',
62
+ });
63
+ expect(parseScopeIdChatResponse({ serial_number: [] })).toEqual({
64
+ status: 'not_found',
65
+ });
66
+ });
67
+
68
+ it('rejects a missing serial_number property', () => {
69
+ expect(parseScopeIdChatResponse({})).toEqual({ status: 'not_found' });
70
+ expect(
71
+ parseScopeIdChatResponse({ other: '2234GH' }),
72
+ ).toEqual({ status: 'not_found' });
73
+ });
74
+
75
+ it('does not use malformed JSON as a serial number', () => {
76
+ expect(parseScopeIdChatResponse('{"serial_number":')).toEqual({
77
+ status: 'not_found',
78
+ });
79
+ expect(
80
+ parseScopeIdChatResponse('```json\n{"serial_number":\n```'),
81
+ ).toEqual({ status: 'not_found' });
82
+ expect(
83
+ parseScopeIdChatResponse('Result: {"serial_number":"2234GH"}'),
84
+ ).toEqual({ status: 'not_found' });
85
+ });
86
+
87
+ it('still accepts a legacy plain serial and NOT_FOUND', () => {
88
+ expect(parseScopeIdChatResponse('2234GH')).toEqual({
89
+ status: 'extracted',
90
+ serialNumber: '2234GH',
91
+ });
92
+ expect(parseScopeIdChatResponse(' Ab-12 ')).toEqual({
93
+ status: 'extracted',
94
+ serialNumber: 'Ab-12',
95
+ });
96
+ expect(parseScopeIdChatResponse('NOT_FOUND')).toEqual({
97
+ status: 'not_found',
98
+ });
99
+ expect(parseScopeIdChatResponse(' NOT_FOUND ')).toEqual({
100
+ status: 'not_found',
101
+ });
102
+ });
103
+ });
@@ -0,0 +1,72 @@
1
+ import { normalizeChatResponse } from 'services/normalizeChatResponse';
2
+
3
+ export type ScopeIdParseResult =
4
+ | { status: 'extracted'; serialNumber: string }
5
+ | { status: 'not_found' };
6
+
7
+ const LEGACY_NOT_FOUND = 'NOT_FOUND';
8
+
9
+ function isRecord(value: unknown): value is Record<string, unknown> {
10
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
11
+ }
12
+
13
+ /**
14
+ * Legacy plain-string responses are still accepted during rollout.
15
+ * Anything that looks like JSON, including a failed parse, is not a serial.
16
+ */
17
+ function isUnparsedStructuredText(value: string): boolean {
18
+ const trimmed = value.trim();
19
+ return (
20
+ trimmed.startsWith('{') ||
21
+ trimmed.startsWith('[') ||
22
+ trimmed.startsWith('```') ||
23
+ trimmed.includes('{')
24
+ );
25
+ }
26
+
27
+ function parseSerialNumberValue(value: unknown): ScopeIdParseResult {
28
+ if (typeof value !== 'string') {
29
+ return { status: 'not_found' };
30
+ }
31
+ if (value.trim().length === 0 || value.trim() === LEGACY_NOT_FOUND) {
32
+ return { status: 'not_found' };
33
+ }
34
+ return { status: 'extracted', serialNumber: value };
35
+ }
36
+
37
+ /**
38
+ * `chatAnalysis` returns `normalizeChatResponse(response.data)`.
39
+ * Axios already parses a JSON body into an object. A string body may still be
40
+ * raw JSON or a markdown fence; `normalizeChatResponse` parses those.
41
+ * There is no extra content wrapper around the model output.
42
+ *
43
+ * Only `serial_number` is read. The string is kept exactly, including case
44
+ * and internal characters. Trimming is used only to reject a blank value.
45
+ */
46
+ export function parseScopeIdChatResponse(
47
+ response: unknown,
48
+ ): ScopeIdParseResult {
49
+ const normalized = normalizeChatResponse(response);
50
+
51
+ if (isRecord(normalized)) {
52
+ if (!Object.prototype.hasOwnProperty.call(normalized, 'serial_number')) {
53
+ return { status: 'not_found' };
54
+ }
55
+ return parseSerialNumberValue(normalized.serial_number);
56
+ }
57
+
58
+ if (typeof normalized !== 'string') {
59
+ return { status: 'not_found' };
60
+ }
61
+
62
+ if (isUnparsedStructuredText(normalized)) {
63
+ return { status: 'not_found' };
64
+ }
65
+
66
+ const trimmed = normalized.trim();
67
+ if (trimmed.length === 0 || trimmed === LEGACY_NOT_FOUND) {
68
+ return { status: 'not_found' };
69
+ }
70
+
71
+ return { status: 'extracted', serialNumber: trimmed };
72
+ }
@@ -0,0 +1,54 @@
1
+ import { ToastHelper } from 'helpers/ToastHelper';
2
+ import { TypePlateScopeResult } from 'hooks/useImageSearch';
3
+ import { ScopeSource, AppliedScopeInput } from 'stores/scope/scopeStore';
4
+
5
+ export type { ScopeSource, AppliedScopeInput };
6
+
7
+ export function notifyScopeResult(
8
+ result: TypePlateScopeResult,
9
+ t: (key: string) => string,
10
+ ) {
11
+ if (result.status === 'not_found' || result.status === 'empty') {
12
+ const defaultErrorMessage =
13
+ window.settings.scopes?.typePlatePopupErrorMessage ||
14
+ 'The serial number was not found. Please check again.';
15
+ ToastHelper.error(t(defaultErrorMessage));
16
+ }
17
+ }
18
+
19
+ export function getImageFileKey(file: File): string {
20
+ return `${file.name}:${file.size}:${file.lastModified}`;
21
+ }
22
+
23
+ export function isSameAppliedInput(
24
+ current: AppliedScopeInput,
25
+ lastApplied: AppliedScopeInput,
26
+ ): boolean {
27
+ if (current.source !== lastApplied.source) {
28
+ return false;
29
+ }
30
+ if (current.source === 'manual' && lastApplied.source === 'manual') {
31
+ return current.value === lastApplied.value;
32
+ }
33
+ return (
34
+ current.source === 'image' &&
35
+ lastApplied.source === 'image' &&
36
+ current.fileKey === lastApplied.fileKey
37
+ );
38
+ }
39
+
40
+ export async function toImageFile(
41
+ image: File | string | null | undefined,
42
+ ): Promise<File | null> {
43
+ if (image instanceof File) {
44
+ return image;
45
+ }
46
+ if (typeof image !== 'string' || image.length === 0) {
47
+ return null;
48
+ }
49
+ const response = await fetch(image);
50
+ const blob = await response.blob();
51
+ return new File([blob], 'type-plate.jpg', {
52
+ type: blob.type || 'image/jpeg',
53
+ });
54
+ }
@@ -1,4 +1,6 @@
1
+ import { useState } from 'react';
1
2
  import { twMerge } from 'tailwind-merge';
3
+ import { Icon } from '@nyris/nyris-react-components';
2
4
 
3
5
  import ImagePreview from './ImagePreview';
4
6
  import useRequestStore from 'stores/request/requestStore';
@@ -14,6 +16,7 @@ export default function SidePanel({ className }: { className?: string }) {
14
16
  const settings = window.settings;
15
17
  const isSmartFiltersEnabled = !!settings.useSmartFilters;
16
18
  const requestImages = useRequestStore(state => state.requestImages);
19
+ const [isRequestQualityExpanded, setIsRequestQualityExpanded] = useState(true);
17
20
 
18
21
  const specificationFilter = useRequestStore(
19
22
  state => state.specificationFilter,
@@ -27,6 +30,7 @@ export default function SidePanel({ className }: { className?: string }) {
27
30
  const showSidePanel = useUiStore(state => state.showSidePanel);
28
31
 
29
32
  const imageAnalysis = useResultStore(state => state.imageAnalysis);
33
+ const requestQuality = useResultStore(state => state.requestQuality);
30
34
 
31
35
  const showPostFilter = window.settings?.postFilterOption;
32
36
 
@@ -153,6 +157,7 @@ export default function SidePanel({ className }: { className?: string }) {
153
157
  }
154
158
  return (
155
159
  <IdentifiedAttributes
160
+ key={key}
156
161
  attr={key}
157
162
  value={value}
158
163
  specificationFilter={specificationFilter}
@@ -171,6 +176,34 @@ export default function SidePanel({ className }: { className?: string }) {
171
176
  />
172
177
  )}
173
178
 
179
+ {window.settings.requestQuality && requestQuality && (
180
+ <div className="mx-4 rounded-3xl bg-[#F3F4F8] border border-[#e4e3ff] p-3.5 text-[#3B3E5F]">
181
+ <button
182
+ type="button"
183
+ className="w-full flex items-center justify-between gap-2"
184
+ onClick={() =>
185
+ setIsRequestQualityExpanded(prev => !prev)
186
+ }
187
+ >
188
+ <span className="text-[#3B3E5F] font-bold text-sm leading-4">
189
+ Request Image Quality
190
+ </span>
191
+ <Icon
192
+ name="arrow_down"
193
+ className={twMerge(
194
+ 'fill-[#3B3E5F] w-3 h-3 shrink-0 transition-transform',
195
+ isRequestQualityExpanded && 'rotate-180',
196
+ )}
197
+ />
198
+ </button>
199
+ {isRequestQualityExpanded && (
200
+ <div className="text-[#767A9F] text-sm mt-2.5">
201
+ {requestQuality}
202
+ </div>
203
+ )}
204
+ </div>
205
+ )}
206
+
174
207
  {(isGoogleGroundingLoading ||
175
208
  googleGroundingResponse ||
176
209
  groundingError) && (
@@ -8,11 +8,20 @@ import { Icon } from '@nyris/nyris-react-components';
8
8
  import { useImageSearch } from 'hooks/useImageSearch';
9
9
  import PreFilterModal from './PreFilter/PreFilterModal';
10
10
  import useRequestStore from 'stores/request/requestStore';
11
+ import { hasActiveMachineScope } from 'stores/scope/scopeStore';
12
+ import { firstMachinePartsListingText } from 'stores/machinePartsListing/machinePartsListingStore';
11
13
  import Tooltip from './Tooltip/TooltipComponent';
12
14
  import { useMediaQuery } from 'react-responsive';
13
15
  import ImageUpload from './ImageUpload';
16
+ import { TypePlateUpload } from './TypePlateUpload';
14
17
  import useUiStore from 'stores/ui/uiStore';
15
18
  import useConversationStore from 'stores/chat/conversationStore';
19
+ import { trackClick } from 'services/Feedback';
20
+ import { CLICK_SUBTYPE } from 'services/feedbackSubtypes';
21
+
22
+ function machinePartsListingText() {
23
+ return firstMachinePartsListingText();
24
+ }
16
25
 
17
26
  function TextSearch({
18
27
  className,
@@ -63,7 +72,11 @@ function TextSearch({
63
72
  useState<boolean>(false);
64
73
  const [isInputFocused, setIsInputFocused] = useState(false);
65
74
 
75
+ const isScopeMode = !!settings.scopes?.scopeID;
66
76
  const showPreFilter = useMemo(() => {
77
+ if (settings.scopes?.scopeID) {
78
+ return false;
79
+ }
67
80
  if (settings.shouldUseUserMetadata && user) {
68
81
  if (user['/user_metadata']?.value) {
69
82
  setMetaFilter(user['/user_metadata'].value);
@@ -81,23 +94,38 @@ function TextSearch({
81
94
  }, [
82
95
  setMetaFilter,
83
96
  settings.preFilterOption,
97
+ settings.scopes?.scopeID,
84
98
  settings.shouldUseUserMetadata,
85
99
  user,
86
100
  ]);
87
101
 
88
102
  const visualSearch = useMemo(() => requestImages.length > 0, [requestImages]);
89
- const { singleImageSearch } = useImageSearch();
103
+ const { singleImageSearch, searchMachinePartsListing } = useImageSearch();
90
104
 
91
105
  // eslint-disable-next-line react-hooks/exhaustive-deps
92
106
  const searchOrRedirect = useCallback(
93
107
  debounce((value: any) => {
94
108
  setQuery(value);
95
- if (requestImages.length === 0 && value === '' && !specifications) {
96
- navigate('/');
97
- return;
109
+ if (requestImages.length === 0 && value === '') {
110
+ if (hasActiveMachineScope()) {
111
+ searchMachinePartsListing({
112
+ text: machinePartsListingText() || '',
113
+ showFeedback: true,
114
+ clearPostFilters: true,
115
+ });
116
+ navigate('/result');
117
+ return;
118
+ }
119
+ if (!specifications) {
120
+ navigate('/');
121
+ return;
122
+ }
98
123
  }
99
124
 
100
- if (!window.settings?.algolia.enabled && requestImages.length === 0) {
125
+ if (
126
+ requestImages.length === 0 &&
127
+ (hasActiveMachineScope() || !window.settings?.algolia.enabled)
128
+ ) {
101
129
  singleImageSearch({
102
130
  image: requestImages[0],
103
131
  imageRegion: regions[0],
@@ -114,10 +142,28 @@ function TextSearch({
114
142
  );
115
143
 
116
144
  const onChangeText = (event: any) => {
117
- setValueInput(event.currentTarget.value);
118
- searchOrRedirect(event.currentTarget.value);
145
+ const nextValue = event.currentTarget.value;
146
+ setValueInput(nextValue);
147
+
148
+ if (
149
+ nextValue === '' &&
150
+ requestImages.length === 0 &&
151
+ hasActiveMachineScope()
152
+ ) {
153
+ searchOrRedirect.cancel();
154
+ setQuery('');
155
+ searchMachinePartsListing({
156
+ text: machinePartsListingText() || '',
157
+ showFeedback: true,
158
+ clearPostFilters: true,
159
+ });
160
+ navigate('/result');
161
+ return;
162
+ }
119
163
 
120
- if (event.currentTarget.value === '' && !specifications) {
164
+ searchOrRedirect(nextValue);
165
+
166
+ if (nextValue === '' && !specifications) {
121
167
  setValueInput('');
122
168
  setQuery('');
123
169
  }
@@ -179,6 +225,11 @@ function TextSearch({
179
225
  showPreFilter ? setToggleModalFilterDesktop(true) : false
180
226
  }
181
227
  >
228
+ {isScopeMode && (
229
+ <TypePlateUpload
230
+ iconOnly={location.pathname === '/result'}
231
+ />
232
+ )}
182
233
  {showPreFilter && (
183
234
  <Tooltip
184
235
  content={
@@ -342,7 +393,7 @@ function TextSearch({
342
393
  {nameplateNotificationText}
343
394
  </div>
344
395
  )}
345
- {!showPreFilter && (
396
+ {!isScopeMode && !showPreFilter && (
346
397
  <div className="p-2 hidden desktop:block">
347
398
  <Icon name="search" width={16} height={16} />
348
399
  </div>
@@ -390,7 +441,7 @@ function TextSearch({
390
441
  location.pathname === '/result' &&
391
442
  'desktop:bg-[#E7E8F1] focus:bg-[#FFFFFF]',
392
443
  )}
393
- placeholder={t('Search')}
444
+ placeholder={t('Search part names, SKU, or description...')}
394
445
  value={valueInput}
395
446
  onChange={onChangeText}
396
447
  onFocus={() => setIsInputFocused(true)}
@@ -419,6 +470,7 @@ function TextSearch({
419
470
  event.preventDefault();
420
471
  }}
421
472
  onClick={() => {
473
+ searchOrRedirect.cancel();
422
474
  if (visualSearch) {
423
475
  navigate('/result');
424
476
  setValueInput('');
@@ -438,6 +490,17 @@ function TextSearch({
438
490
  }
439
491
  return;
440
492
  }
493
+ if (hasActiveMachineScope()) {
494
+ setQuery('');
495
+ setValueInput('');
496
+ searchMachinePartsListing({
497
+ text: machinePartsListingText() || '',
498
+ showFeedback: false,
499
+ clearPostFilters: true,
500
+ });
501
+ navigate('/result');
502
+ return;
503
+ }
441
504
  setSpecifications(null);
442
505
  setQuery('');
443
506
  setValueInput('');
@@ -451,7 +514,7 @@ function TextSearch({
451
514
  {showImageUpload && <ImageUpload onCameraClick={onCameraClick} />}
452
515
  {aiMode &&
453
516
  window.settings.vizo?.chat &&
454
- requestImages.length !== 0 &&
517
+ (requestImages.length !== 0 || valueInput) &&
455
518
  !(isMobile && isInputFocused) && (
456
519
  <div className="relative">
457
520
  <button
@@ -467,6 +530,9 @@ function TextSearch({
467
530
  )}
468
531
  onClick={() => {
469
532
  const nextIsAiModeOpen = !isAiModeOpen;
533
+ if (nextIsAiModeOpen) {
534
+ trackClick(CLICK_SUBTYPE.CHAT_WITH_VIZO);
535
+ }
470
536
  toggleAiMode();
471
537
  setIsChatOpen(nextIsAiModeOpen);
472
538
  }}