@nyris/nyris-webapp 0.3.97 → 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 (94) hide show
  1. package/build/asset-manifest.json +7 -6
  2. package/build/index.html +1 -1
  3. package/build/js/settings.example.js +41 -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.9e7fb38a.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 +41 -0
  11. package/src/common/assets/images/plate_info.png +0 -0
  12. package/src/components/AuthApiKeySync.tsx +34 -0
  13. package/src/components/Cart.tsx +41 -0
  14. package/src/components/ChatAssistant/useChatAssistantLogic.ts +3 -2
  15. package/src/components/GoBackButton.tsx +29 -2
  16. package/src/components/Header.tsx +3 -0
  17. package/src/components/HitsPerPage.tsx +55 -18
  18. package/src/components/ImagePreview.tsx +31 -1
  19. package/src/components/ImageUpload.tsx +14 -3
  20. package/src/components/Inquiry/InquiryModal.tsx +3 -0
  21. package/src/components/LogoutModal.tsx +2 -0
  22. package/src/components/Pagination.tsx +33 -6
  23. package/src/components/PostFilter/PostFilter.tsx +8 -1
  24. package/src/components/PostFilter/PostFilterComponent.tsx +8 -22
  25. package/src/components/PostFilter/PostFilterFindApi.test.tsx +249 -0
  26. package/src/components/PostFilter/PostFilterFindApi.tsx +128 -52
  27. package/src/components/PreFilter/PreFilter.tsx +10 -2
  28. package/src/components/Product/Product.tsx +60 -10
  29. package/src/components/Product/ProductDetailView.tsx +23 -1
  30. package/src/components/Product/ProductDetailViewModal.tsx +5 -1
  31. package/src/components/Product/ProductList.tsx +51 -16
  32. package/src/components/Rfq/RfqModal.tsx +3 -0
  33. package/src/components/ScopeSelect/ScopeSelectForm.tsx +514 -0
  34. package/src/components/ScopeSelect/ScopeSelectImageActions.tsx +62 -0
  35. package/src/components/ScopeSelect/ScopeSelectImagePreview.tsx +141 -0
  36. package/src/components/ScopeSelect/ScopeSelectInfo.tsx +68 -0
  37. package/src/components/ScopeSelect/ScopeSelectInput.tsx +123 -0
  38. package/src/components/ScopeSelect/ScopeSelectModal.tsx +310 -0
  39. package/src/components/ScopeSelect/identifyTypePlateImage.test.ts +129 -0
  40. package/src/components/ScopeSelect/identifyTypePlateImage.ts +83 -0
  41. package/src/components/ScopeSelect/parseScopeIdResponse.test.ts +103 -0
  42. package/src/components/ScopeSelect/parseScopeIdResponse.ts +72 -0
  43. package/src/components/ScopeSelect/scopeSelectHelpers.ts +54 -0
  44. package/src/components/SidePanel.tsx +43 -0
  45. package/src/components/TextSearch.tsx +80 -14
  46. package/src/components/Tooltip/TooltipComponent.tsx +12 -4
  47. package/src/components/TypePlateUpload.tsx +132 -0
  48. package/src/hooks/useEffectiveGroundingResults.ts +8 -6
  49. package/src/hooks/useGoodResultsChat.ts +8 -22
  50. package/src/hooks/useImageSearch.ts +325 -20
  51. package/src/hooks/useMachinePartsListingPagination.ts +95 -0
  52. package/src/hooks/useResultEvaluator.ts +43 -19
  53. package/src/index.css +1 -1
  54. package/src/index.tsx +4 -0
  55. package/src/layouts/AppLayout.tsx +95 -9
  56. package/src/pages/Home.tsx +60 -14
  57. package/src/pages/Logout.tsx +2 -0
  58. package/src/pages/Result.tsx +206 -20
  59. package/src/providers/AuthProvider.tsx +5 -0
  60. package/src/services/Feedback.ts +224 -35
  61. package/src/services/feedbackSubtypes.ts +63 -0
  62. package/src/services/filter.ts +4 -3
  63. package/src/services/initialMachinePartsText.payload.test.ts +61 -0
  64. package/src/services/initialMachinePartsText.test.ts +33 -0
  65. package/src/services/initialMachinePartsText.ts +11 -0
  66. package/src/services/machinePartsPage.test.ts +74 -0
  67. package/src/services/machinePartsPage.ts +120 -0
  68. package/src/services/normalizeChatResponse.ts +26 -0
  69. package/src/services/scopeLookup.test.ts +67 -0
  70. package/src/services/scopeLookup.ts +39 -0
  71. package/src/services/visualSearch.listingOptions.test.ts +79 -0
  72. package/src/services/visualSearch.ts +76 -7
  73. package/src/services/vizo.ts +17 -5
  74. package/src/stores/machinePartsListing/machinePartsListingStore.ts +35 -0
  75. package/src/stores/request/filter/filter.initialState.ts +1 -0
  76. package/src/stores/request/filter/filter.slice.ts +2 -0
  77. package/src/stores/result/prodcuts/products.initialState.ts +1 -0
  78. package/src/stores/result/prodcuts/products.slice.ts +4 -0
  79. package/src/stores/scope/scopeStore.ts +67 -4
  80. package/src/stores/types.ts +19 -14
  81. package/src/translations.ts +213 -0
  82. package/src/types.ts +40 -0
  83. package/src/utils/derivePostFilterFacets.ts +68 -0
  84. package/src/utils/fetchProductImage.ts +1 -0
  85. package/src/utils/normalizeChatAssistantResponse.ts +227 -0
  86. package/src/utils/nyrisApiKey.ts +115 -0
  87. package/src/utils/productCardImageUrl.test.ts +41 -0
  88. package/src/utils/productCardImageUrl.ts +49 -0
  89. package/src/utils/textSearchFilter.test.ts +41 -0
  90. package/src/utils/textSearchFilter.ts +13 -6
  91. package/build/static/css/main.7c7f5447.css +0 -4
  92. package/build/static/css/main.7c7f5447.css.map +0 -1
  93. package/build/static/js/main.9e7fb38a.js +0 -3
  94. /package/build/static/js/{main.9e7fb38a.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,16 +16,21 @@ 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,
20
23
  );
24
+ const postFilterSelections = useRequestStore(
25
+ state => state.postFilterSelections,
26
+ );
21
27
 
22
28
  const findApiProducts = useResultStore(state => state.productsFromFindApi);
23
29
 
24
30
  const showSidePanel = useUiStore(state => state.showSidePanel);
25
31
 
26
32
  const imageAnalysis = useResultStore(state => state.imageAnalysis);
33
+ const requestQuality = useResultStore(state => state.requestQuality);
27
34
 
28
35
  const showPostFilter = window.settings?.postFilterOption;
29
36
 
@@ -45,13 +52,20 @@ export default function SidePanel({ className }: { className?: string }) {
45
52
 
46
53
  const effectiveGroundingResults = useEffectiveGroundingResults();
47
54
 
55
+ const hasPostFilterSelections = Object.values(
56
+ postFilterSelections || {},
57
+ ).some(values => values?.length > 0);
58
+
48
59
  if (!showPostFilter && requestImages.length === 0) {
49
60
  return <></>;
50
61
  }
51
62
 
63
+ // Keep the panel when post-filters are applied so the user can unselect
64
+ // after a filtered find returns zero results.
52
65
  if (
53
66
  requestImages.length === 0 &&
54
67
  findApiProducts.length === 0 &&
68
+ !hasPostFilterSelections &&
55
69
  !window.settings.algolia.enabled
56
70
  ) {
57
71
  return <></>;
@@ -143,6 +157,7 @@ export default function SidePanel({ className }: { className?: string }) {
143
157
  }
144
158
  return (
145
159
  <IdentifiedAttributes
160
+ key={key}
146
161
  attr={key}
147
162
  value={value}
148
163
  specificationFilter={specificationFilter}
@@ -161,6 +176,34 @@ export default function SidePanel({ className }: { className?: string }) {
161
176
  />
162
177
  )}
163
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
+
164
207
  {(isGoogleGroundingLoading ||
165
208
  googleGroundingResponse ||
166
209
  groundingError) && (