@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,67 @@
1
+ import { getScopes } from 'services/filter';
2
+ import { lookupScopeById } from './scopeLookup';
3
+
4
+ jest.mock('services/filter', () => ({
5
+ getScopes: jest.fn(),
6
+ }));
7
+
8
+ jest.mock('utils/nyrisApiKey', () => ({
9
+ isNyrisApiKeyReady: () => true,
10
+ }));
11
+
12
+ const getScopesMock = getScopes as jest.MockedFunction<typeof getScopes>;
13
+
14
+ describe('lookupScopeById', () => {
15
+ beforeEach(() => {
16
+ getScopesMock.mockReset();
17
+ window.settings = {
18
+ scopes: { scopeID: true },
19
+ apiKey: 'test-key',
20
+ } as typeof window.settings;
21
+ });
22
+
23
+ it('sends the serial number unchanged', async () => {
24
+ getScopesMock.mockResolvedValue({
25
+ itemKeys: ['sku-1'],
26
+ filterAttributes: [],
27
+ });
28
+
29
+ const result = await lookupScopeById('Ab-12/c 9');
30
+
31
+ expect(getScopesMock).toHaveBeenCalledWith(
32
+ 'Ab-12/c 9',
33
+ window.settings,
34
+ );
35
+ expect(result).toEqual({
36
+ status: 'found',
37
+ itemKeys: ['sku-1'],
38
+ filterAttributes: [],
39
+ });
40
+ });
41
+
42
+ it('does not call the scopes API for a blank id', async () => {
43
+ const result = await lookupScopeById(' ');
44
+
45
+ expect(getScopesMock).not.toHaveBeenCalled();
46
+ expect(result).toEqual({ status: 'not_found' });
47
+ });
48
+
49
+ it('returns empty when the machine has no item keys', async () => {
50
+ getScopesMock.mockResolvedValue({
51
+ itemKeys: [],
52
+ filterAttributes: [],
53
+ });
54
+
55
+ await expect(lookupScopeById('2234GH')).resolves.toEqual({
56
+ status: 'empty',
57
+ });
58
+ });
59
+
60
+ it('returns not found when the scopes request fails', async () => {
61
+ getScopesMock.mockRejectedValue(new Error('unavailable'));
62
+
63
+ await expect(lookupScopeById('2234GH')).resolves.toEqual({
64
+ status: 'not_found',
65
+ });
66
+ });
67
+ });
@@ -0,0 +1,39 @@
1
+ import { getScopes } from 'services/filter';
2
+ import { isNyrisApiKeyReady } from 'utils/nyrisApiKey';
3
+ import { TypePlateLookupResult } from 'components/ScopeSelect/identifyTypePlateImage';
4
+
5
+ /**
6
+ * GET /v1/scopes?scopeIds=... using the existing client encoding.
7
+ * Does not write the scope store. Callers apply a machine only after confirm.
8
+ * The id is sent unchanged; trimming only rejects a blank value.
9
+ */
10
+ export async function lookupScopeById(
11
+ scopeId: string,
12
+ ): Promise<TypePlateLookupResult> {
13
+ const settings = window.settings;
14
+ if (!settings.scopes?.scopeID) {
15
+ return { status: 'unavailable' };
16
+ }
17
+
18
+ if (!isNyrisApiKeyReady()) {
19
+ return { status: 'unavailable' };
20
+ }
21
+
22
+ if (scopeId.trim().length === 0 || scopeId.trim() === 'NOT_FOUND') {
23
+ return { status: 'not_found' };
24
+ }
25
+
26
+ try {
27
+ const scopesResponse = await getScopes(scopeId, settings);
28
+ const itemKeys = (scopesResponse.itemKeys || []).filter(Boolean);
29
+ const filterAttributes = scopesResponse.filterAttributes || [];
30
+
31
+ if (itemKeys.length === 0) {
32
+ return { status: 'empty' };
33
+ }
34
+
35
+ return { status: 'found', itemKeys, filterAttributes };
36
+ } catch {
37
+ return { status: 'not_found' };
38
+ }
39
+ }
@@ -0,0 +1,79 @@
1
+ import { machinePartsPageText } from './machinePartsPage';
2
+ import { machinePartsListingXOptions } from './visualSearch';
3
+
4
+ const X_OPTIONS = 'hybrid_search.text.reranking=false';
5
+
6
+ function keys(count: number) {
7
+ return Array.from({ length: count }, (_, index) => `ITEM-${index + 1}`);
8
+ }
9
+
10
+ const listing = {
11
+ hasSearchImage: false,
12
+ hasFilters: false,
13
+ scopeId: 'TestScope2',
14
+ itemKeys: keys(15),
15
+ keyword: '',
16
+ pageSize: 10,
17
+ };
18
+
19
+ describe('machinePartsListingXOptions', () => {
20
+ it('sends the override for each scope-only item key page', () => {
21
+ expect(
22
+ machinePartsListingXOptions({
23
+ ...listing,
24
+ pageIndex: 0,
25
+ text: machinePartsPageText(listing.itemKeys, 0, 10),
26
+ }),
27
+ ).toBe(X_OPTIONS);
28
+ expect(
29
+ machinePartsListingXOptions({
30
+ ...listing,
31
+ pageIndex: 1,
32
+ text: machinePartsPageText(listing.itemKeys, 1, 10),
33
+ }),
34
+ ).toBe(X_OPTIONS);
35
+ });
36
+
37
+ it('omits the override for a keyword, image, filters, or another page', () => {
38
+ const firstPage = machinePartsPageText(listing.itemKeys, 0, 10);
39
+ expect(
40
+ machinePartsListingXOptions({
41
+ ...listing,
42
+ pageIndex: 1,
43
+ text: firstPage,
44
+ }),
45
+ ).toBeUndefined();
46
+ expect(
47
+ machinePartsListingXOptions({
48
+ ...listing,
49
+ pageIndex: 0,
50
+ text: 'pump',
51
+ keyword: 'pump',
52
+ }),
53
+ ).toBeUndefined();
54
+ expect(
55
+ machinePartsListingXOptions({
56
+ ...listing,
57
+ pageIndex: 0,
58
+ text: firstPage,
59
+ hasSearchImage: true,
60
+ }),
61
+ ).toBeUndefined();
62
+ expect(
63
+ machinePartsListingXOptions({
64
+ ...listing,
65
+ pageIndex: 0,
66
+ text: firstPage,
67
+ hasFilters: true,
68
+ }),
69
+ ).toBeUndefined();
70
+ expect(
71
+ machinePartsListingXOptions({
72
+ ...listing,
73
+ pageIndex: 0,
74
+ text: firstPage,
75
+ scopeId: null,
76
+ }),
77
+ ).toBeUndefined();
78
+ });
79
+ });
@@ -10,6 +10,45 @@ import { isEqual } from 'lodash';
10
10
  import { DEFAULT_REGION } from '../constants';
11
11
  import { getUserLocation } from 'helpers/getGeoLocation';
12
12
  import { getNyrisSettings } from 'utils/nyrisApiKey';
13
+ import useRequestStore from 'stores/request/requestStore';
14
+ import useScopeStore from 'stores/scope/scopeStore';
15
+ import { machinePartsPageText } from './machinePartsPage';
16
+ import useMachinePartsListingStore from 'stores/machinePartsListing/machinePartsListingStore';
17
+
18
+ const MACHINE_PARTS_LISTING_X_OPTIONS = 'hybrid_search.text.reranking=false';
19
+
20
+ /** Scope-only listing: generated item-key text, no keyword, image, or filters. */
21
+ export function machinePartsListingXOptions({
22
+ text,
23
+ hasSearchImage,
24
+ hasFilters,
25
+ scopeId,
26
+ itemKeys,
27
+ keyword,
28
+ pageIndex,
29
+ pageSize,
30
+ }: {
31
+ text?: string;
32
+ hasSearchImage: boolean;
33
+ hasFilters: boolean;
34
+ scopeId: string | null;
35
+ itemKeys: readonly string[];
36
+ keyword: string;
37
+ pageIndex: number;
38
+ pageSize: number;
39
+ }): string | undefined {
40
+ if (!scopeId || hasSearchImage || hasFilters) {
41
+ return undefined;
42
+ }
43
+ const listingText = machinePartsPageText(itemKeys, pageIndex, pageSize);
44
+ if (!listingText || text !== listingText) {
45
+ return undefined;
46
+ }
47
+ if (keyword.trim().length > 0) {
48
+ return undefined;
49
+ }
50
+ return MACHINE_PARTS_LISTING_X_OPTIONS;
51
+ }
13
52
 
14
53
  export interface Filter {
15
54
  key?: string;
@@ -53,6 +92,18 @@ export const findRegions = async (
53
92
  };
54
93
  };
55
94
 
95
+ /** Validated machine scope id. Item keys from /v1/scopes are not used. */
96
+ function appliedMachineScopeId(): string | null {
97
+ if (!window.settings?.scopes?.scopeID) {
98
+ return null;
99
+ }
100
+ const { scopeId } = useScopeStore.getState();
101
+ if (typeof scopeId !== 'string' || scopeId.trim().length === 0) {
102
+ return null;
103
+ }
104
+ return scopeId;
105
+ }
106
+
56
107
  export const find = async ({
57
108
  image,
58
109
  settings,
@@ -69,7 +120,8 @@ export const find = async ({
69
120
  scopeIds?: string;
70
121
  }) => {
71
122
  const nyrisApi = new NyrisAPI(getNyrisSettings(settings));
72
- let options: ImageSearchOptions = text ? { text } : {};
123
+ let options: ImageSearchOptions =
124
+ typeof text === 'string' && text.length > 0 ? { text } : {};
73
125
 
74
126
  if (window.settings?.geoLocation) {
75
127
  try {
@@ -85,13 +137,29 @@ export const find = async ({
85
137
  if (region) {
86
138
  options = { ...options, cropRect: region };
87
139
  }
140
+ const machineScopeId = appliedMachineScopeId();
141
+ const listingPage = useMachinePartsListingStore.getState();
142
+ const xOptions = machinePartsListingXOptions({
143
+ text,
144
+ hasSearchImage: Boolean(image),
145
+ hasFilters: Boolean(filters && filters.length > 0),
146
+ scopeId: machineScopeId,
147
+ itemKeys: useScopeStore.getState().itemKeys || [],
148
+ keyword: useRequestStore.getState().query || '',
149
+ pageIndex: listingPage.pageIndex,
150
+ pageSize: listingPage.pageSize,
151
+ });
88
152
  return nyrisApi.find(
89
153
  options,
90
154
  image,
91
155
  filters,
92
156
  undefined,
93
- undefined,
94
- scopeIds ? [scopeIds] : [],
157
+ xOptions,
158
+ machineScopeId
159
+ ? [machineScopeId]
160
+ : scopeIds
161
+ ? [scopeIds]
162
+ : [],
95
163
  );
96
164
  };
97
165
 
@@ -2,6 +2,7 @@ import { RectCoords } from '@nyris/nyris-api';
2
2
  import axios from 'axios';
3
3
  import { cropImage } from 'utils/cropImageToBase64';
4
4
  import { getEffectiveApiKey } from 'utils/nyrisApiKey';
5
+ import { normalizeChatResponse } from './normalizeChatResponse';
5
6
 
6
7
  function resolveAiOrNyrisApiKey(explicitApiKey?: string): string {
7
8
  if (explicitApiKey) {
@@ -330,20 +331,20 @@ export async function chatAnalysis({
330
331
  }: ChatAnalysisParams): Promise<any> {
331
332
  try {
332
333
  const response = await axios.post(
333
- 'https://api.nyris.io/api/ai-analysis/chat',
334
+ `${window.settings.baseUrl}/api/ai-analysis/chat`,
334
335
  {
335
336
  promptKey,
336
337
  multimodalInputs,
337
338
  },
338
339
  {
339
340
  headers: {
340
- 'x-api-key': apiKey || window.settings.aiApiKey || '',
341
+ 'x-api-key': resolveAiOrNyrisApiKey(apiKey),
341
342
  'Content-Type': 'application/json',
342
343
  },
343
344
  },
344
345
  );
345
346
 
346
- return response.data;
347
+ return normalizeChatResponse(response.data);
347
348
  } catch (error: any) {
348
349
  if (error.response) {
349
350
  throw new Error(`API error: ${error.response.statusText}`);
@@ -0,0 +1,35 @@
1
+ import { create } from 'zustand';
2
+ import {
3
+ DEFAULT_MACHINE_PARTS_PAGE_SIZE,
4
+ machinePartsPageText,
5
+ } from 'services/machinePartsPage';
6
+ import useScopeStore from 'stores/scope/scopeStore';
7
+
8
+ interface MachinePartsListingState {
9
+ pageSize: number;
10
+ pageIndex: number;
11
+ setPageSize: (pageSize: number) => void;
12
+ setPageIndex: (pageIndex: number) => void;
13
+ resetPage: () => void;
14
+ }
15
+
16
+ const useMachinePartsListingStore = create<MachinePartsListingState>()(set => ({
17
+ pageSize: DEFAULT_MACHINE_PARTS_PAGE_SIZE,
18
+ pageIndex: 0,
19
+ setPageSize: pageSize => set({ pageSize, pageIndex: 0 }),
20
+ setPageIndex: pageIndex => set({ pageIndex }),
21
+ resetPage: () => set({ pageIndex: 0 }),
22
+ }));
23
+
24
+ /** Scope-only listing restarts at page 1 and keeps the selected page size. */
25
+ export function firstMachinePartsListingText(): string | undefined {
26
+ useMachinePartsListingStore.getState().resetPage();
27
+ const { pageSize } = useMachinePartsListingStore.getState();
28
+ return machinePartsPageText(
29
+ useScopeStore.getState().itemKeys || [],
30
+ 0,
31
+ pageSize,
32
+ );
33
+ }
34
+
35
+ export default useMachinePartsListingStore;
@@ -8,4 +8,5 @@ export const initialState: FilterState = {
8
8
  specificationFilter: {},
9
9
  postFilterSelections: {},
10
10
  preFilterLoading: false,
11
+ typePlatePreFilterValues: [],
11
12
  };
@@ -13,6 +13,8 @@ const filterSlice: StateCreator<FilterState & FilterAction> = set => ({
13
13
  setPreFilterList: filter => set(() => ({ preFilterList: filter })),
14
14
  setPreFilterLoading: isLoading =>
15
15
  set(() => ({ preFilterLoading: isLoading })),
16
+ setTypePlatePreFilterValues: values =>
17
+ set(() => ({ typePlatePreFilterValues: values })),
16
18
  setPostFilterSelections: filters =>
17
19
  set(() => ({ postFilterSelections: filters })),
18
20
  togglePostFilterSelection: (attribute, value) =>
@@ -31,6 +31,7 @@ export const initialState: ProductsState = {
31
31
  rerankStatus: 'idle',
32
32
  errorMessage: null,
33
33
  },
34
+ requestQuality: null,
34
35
  postFilterFacets: [],
35
36
  initialFindSearchSnapshot: null,
36
37
  };
@@ -61,6 +61,10 @@ const productsSlice: StateCreator<ProductsState & ProductsAction> = set => ({
61
61
  set(() => ({
62
62
  smartFilters,
63
63
  })),
64
+ setRequestQuality: requestQuality =>
65
+ set(() => ({
66
+ requestQuality,
67
+ })),
64
68
  setPostFilterFacets: facets =>
65
69
  set(() => ({
66
70
  postFilterFacets: facets,
@@ -1,35 +1,98 @@
1
1
  import { create } from 'zustand';
2
2
  import { Filter } from '@nyris/nyris-api';
3
3
 
4
+ export type ScopeSource = 'manual' | 'image';
5
+
6
+ export type AppliedScopeInput =
7
+ | { source: 'manual'; value: string }
8
+ | { source: 'image'; fileKey: string };
9
+
4
10
  interface ScopeState {
5
11
  itemKeys: string[];
6
12
  filterAttributes: Filter[];
13
+ scopeId: string | null;
7
14
  isLoading: boolean;
8
15
  hasFetched: boolean;
16
+ formResetKey: number;
17
+ manualScopeId: string;
18
+ pendingFile: File | null;
19
+ previewUrl: string | null;
20
+ activeSource: ScopeSource | null;
21
+ lastAppliedInput: AppliedScopeInput | null;
22
+ lastExtractedFromImage: string;
9
23
  }
10
24
 
11
25
  interface ScopeAction {
12
- setScope: (itemKeys: string[], filterAttributes: Filter[]) => void;
26
+ setScope: (
27
+ itemKeys: string[],
28
+ filterAttributes: Filter[],
29
+ scopeId?: string | null,
30
+ ) => void;
13
31
  setScopeLoading: (isLoading: boolean) => void;
14
32
  setScopeFetched: (hasFetched: boolean) => void;
33
+ setManualScopeId: (manualScopeId: string) => void;
34
+ setPendingFile: (pendingFile: File | null) => void;
35
+ setPreviewUrl: (previewUrl: string | null) => void;
36
+ setActiveSource: (activeSource: ScopeSource | null) => void;
37
+ setLastAppliedInput: (lastAppliedInput: AppliedScopeInput | null) => void;
38
+ setLastExtractedFromImage: (lastExtractedFromImage: string) => void;
15
39
  resetScope: () => void;
16
40
  }
17
41
 
18
42
  const initialState: ScopeState = {
19
43
  itemKeys: [],
20
44
  filterAttributes: [],
45
+ scopeId: null,
21
46
  isLoading: false,
22
47
  hasFetched: false,
48
+ formResetKey: 0,
49
+ manualScopeId: '',
50
+ pendingFile: null,
51
+ previewUrl: null,
52
+ activeSource: null,
53
+ lastAppliedInput: null,
54
+ lastExtractedFromImage: '',
23
55
  };
24
56
 
25
57
  const useScopeStore = create<ScopeState & ScopeAction>()(set => ({
26
58
  ...initialState,
27
59
 
28
- setScope: (itemKeys, filterAttributes) =>
29
- set({ itemKeys, filterAttributes, hasFetched: true }),
60
+ setScope: (itemKeys, filterAttributes, scopeId = null) =>
61
+ set({ itemKeys, filterAttributes, scopeId, hasFetched: true }),
30
62
  setScopeLoading: isLoading => set({ isLoading }),
31
63
  setScopeFetched: hasFetched => set({ hasFetched }),
32
- resetScope: () => set(initialState),
64
+ setManualScopeId: manualScopeId => set({ manualScopeId }),
65
+ setPendingFile: pendingFile => set({ pendingFile }),
66
+ setPreviewUrl: previewUrl => set({ previewUrl }),
67
+ setActiveSource: activeSource => set({ activeSource }),
68
+ setLastAppliedInput: lastAppliedInput => set({ lastAppliedInput }),
69
+ setLastExtractedFromImage: lastExtractedFromImage =>
70
+ set({ lastExtractedFromImage }),
71
+ resetScope: () =>
72
+ set(state => {
73
+ if (state.previewUrl) {
74
+ try {
75
+ URL.revokeObjectURL(state.previewUrl);
76
+ } catch {}
77
+ }
78
+ return {
79
+ ...initialState,
80
+ formResetKey: state.formResetKey + 1,
81
+ };
82
+ }),
33
83
  }));
34
84
 
85
+ /** Machine scope that should constrain the results currently on screen. */
86
+ export function hasActiveMachineScope(): boolean {
87
+ if (!window.settings?.scopes?.scopeID) {
88
+ return false;
89
+ }
90
+ const { scopeId, itemKeys } = useScopeStore.getState();
91
+ return (
92
+ typeof scopeId === 'string' &&
93
+ scopeId.trim().length > 0 &&
94
+ itemKeys.length > 0
95
+ );
96
+ }
97
+
35
98
  export default useScopeStore;
@@ -71,20 +71,21 @@ export interface ProductsState {
71
71
  groundingError: string | null;
72
72
  firstAlgoliaProducts: any[];
73
73
  fullResultPool: any[];
74
- smartFilters: {
75
- status: 'idle' | 'loading' | 'success' | 'error';
76
- filters: Array<{
77
- key: string;
78
- label: string;
79
- value: string;
80
- selected?: boolean;
81
- }>;
82
- selectedFilterKey: string | null;
83
- selectedFilterValue: string | null;
84
- rerankedSkus: string[];
85
- rerankStatus: 'idle' | 'loading' | 'success' | 'error';
86
- errorMessage: string | null;
87
- };
74
+ smartFilters: {
75
+ status: 'idle' | 'loading' | 'success' | 'error';
76
+ filters: Array<{
77
+ key: string;
78
+ label: string;
79
+ value: string;
80
+ selected?: boolean;
81
+ }>;
82
+ selectedFilterKey: string | null;
83
+ selectedFilterValue: string | null;
84
+ rerankedSkus: string[];
85
+ rerankStatus: 'idle' | 'loading' | 'success' | 'error';
86
+ errorMessage: string | null;
87
+ };
88
+ requestQuality: string | null;
88
89
  postFilterFacets: any[];
89
90
  initialFindSearchSnapshot: {
90
91
  results: any[];
@@ -110,6 +111,7 @@ export interface ProductsAction {
110
111
  setFullResultPool: (products: any[]) => void;
111
112
  resetGrounding: () => void;
112
113
  setSmartFilters: (smartFilters: ProductsState['smartFilters']) => void;
114
+ setRequestQuality: (requestQuality: string | null) => void;
113
115
  setPostFilterFacets: (facets: any[]) => void;
114
116
  setInitialFindSearchSnapshot: (
115
117
  snapshot: ProductsState['initialFindSearchSnapshot'],
@@ -132,6 +134,8 @@ export interface FilterState {
132
134
  specificationFilter: Record<string, string>;
133
135
  postFilterSelections: Record<string, string[]>;
134
136
  preFilterLoading: boolean;
137
+ // Values applied from type-plate upload; cleared independently of manual picks.
138
+ typePlatePreFilterValues: string[];
135
139
  }
136
140
 
137
141
  export interface FilterAction {
@@ -144,6 +148,7 @@ export interface FilterAction {
144
148
  togglePostFilterSelection: (attribute: string, value: string) => void;
145
149
  clearPostFilterSelections: () => void;
146
150
  setPreFilterLoading: (isLoading: boolean) => void;
151
+ setTypePlatePreFilterValues: (values: string[]) => void;
147
152
  }
148
153
 
149
154
  export interface MiscState {