@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,141 @@
1
+ import { Icon } from '@nyris/nyris-react-components';
2
+ import { AlertCircle, Check, Factory } from 'lucide-react';
3
+ import { useTranslation } from 'react-i18next';
4
+ import Tooltip from 'components/Tooltip/TooltipComponent';
5
+ import { twMerge } from 'tailwind-merge';
6
+
7
+ interface Props {
8
+ previewUrl?: string | null;
9
+ extractedScopeId: string;
10
+ isExtracting: boolean;
11
+ isSuccess?: boolean;
12
+ errorMessage?: string | null;
13
+ thumbnail?: 'image' | 'machine';
14
+ onClearImage?: () => void;
15
+ }
16
+
17
+ export function ScopeSelectImagePreview({
18
+ previewUrl,
19
+ extractedScopeId,
20
+ isExtracting,
21
+ errorMessage = null,
22
+ thumbnail = 'image',
23
+ onClearImage,
24
+ }: Props) {
25
+ const { t } = useTranslation();
26
+ const serialNumberLabel =
27
+ window.settings.scopes?.typePlateSerialNumberLabel || 'Serial number';
28
+ const thumbnailClassName =
29
+ 'flex h-20 w-20 shrink-0 items-center justify-center overflow-hidden rounded-md bg-[#FAFAFA]';
30
+
31
+ return (
32
+ <div className="relative flex w-full items-center gap-3.5 rounded-xl border border-solid border-[#DDDEE7] bg-white p-2.5 pr-8">
33
+ {thumbnail === 'machine' || !previewUrl ? (
34
+ <div className={thumbnailClassName} aria-hidden="true">
35
+ <Factory className="h-8 w-8 text-[#2B2C46]" strokeWidth={1.75} />
36
+ </div>
37
+ ) : (
38
+ <Tooltip
39
+ content={
40
+ <img
41
+ src={previewUrl}
42
+ alt=""
43
+ className="h-[350px] w-[350px] max-h-[70vh] max-w-[80vw] object-contain"
44
+ />
45
+ }
46
+ side="top"
47
+ sideOffset={8}
48
+ >
49
+ <div className={twMerge(thumbnailClassName, 'cursor-pointer')}>
50
+ <img
51
+ src={previewUrl}
52
+ alt=""
53
+ className="h-full w-full object-contain"
54
+ />
55
+ </div>
56
+ </Tooltip>
57
+ )}
58
+
59
+ <div className="min-w-0 flex-1">
60
+ {isExtracting ? (
61
+ <div>
62
+ <p className="text-xs font-normal leading-none text-[#989BB7]">
63
+ {t(serialNumberLabel)}
64
+ </p>
65
+ <div className="mt-1.5 flex items-center gap-2">
66
+ <svg
67
+ width={16}
68
+ height={16}
69
+ viewBox="0 0 50 50"
70
+ fill="none"
71
+ xmlns="http://www.w3.org/2000/svg"
72
+ className="loading-spinner shrink-0"
73
+ >
74
+ <circle
75
+ cx="25"
76
+ cy="25"
77
+ r="20"
78
+ stroke="#3E36DC"
79
+ strokeWidth="4"
80
+ strokeLinecap="round"
81
+ strokeDasharray="90 30"
82
+ />
83
+ </svg>
84
+ <span className="text-sm text-[#989BB7]">{t('loading')}</span>
85
+ </div>
86
+ </div>
87
+ ) : (
88
+ <>
89
+ {extractedScopeId ? (
90
+ <>
91
+ <p className="text-xs font-normal leading-none text-[#989BB7]">
92
+ {t(serialNumberLabel)}
93
+ </p>
94
+ <div className="mt-1 flex items-center gap-1.5">
95
+ <span
96
+ className="truncate text-lg font-bold leading-tight text-[#2B2C46]"
97
+ title={extractedScopeId}
98
+ >
99
+ {extractedScopeId}
100
+ </span>
101
+ {!errorMessage && (
102
+ <Check
103
+ className="h-[18px] w-[18px] shrink-0 text-green-600"
104
+ strokeWidth={2.5}
105
+ aria-hidden="true"
106
+ />
107
+ )}
108
+ </div>
109
+ </>
110
+ ) : null}
111
+ {errorMessage ? (
112
+ <div
113
+ className={twMerge(
114
+ 'flex items-center gap-1.5 text-sm font-normal text-[#2B2C46]',
115
+ extractedScopeId && 'mt-1.5',
116
+ )}
117
+ >
118
+ <AlertCircle className="h-4 w-4 shrink-0 text-[#2B2C46]" />
119
+ <span>{errorMessage}</span>
120
+ </div>
121
+ ) : null}
122
+ </>
123
+ )}
124
+ </div>
125
+
126
+ {onClearImage ? (
127
+ <button
128
+ type="button"
129
+ className="absolute top-2 right-2 flex h-6 w-6 cursor-pointer items-center justify-center rounded-full text-[#55566B] transition-colors hover:bg-gray-100 hover:text-[#2B2C46]"
130
+ aria-label={t('Clear image search')}
131
+ onClick={e => {
132
+ e.stopPropagation();
133
+ onClearImage();
134
+ }}
135
+ >
136
+ <Icon name="close" width={11} height={11} />
137
+ </button>
138
+ ) : null}
139
+ </div>
140
+ );
141
+ }
@@ -0,0 +1,68 @@
1
+ import { useState } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { twMerge } from 'tailwind-merge';
4
+ import plateInfoImage from 'common/assets/images/plate_info.png';
5
+ import { Dialog, DialogContent, DialogTitle } from '../Modal/Dialog';
6
+
7
+ interface Props {
8
+ isEmptyState?: boolean;
9
+ }
10
+
11
+ export function ScopeSelectInfo({ isEmptyState = true }: Props) {
12
+ const { t } = useTranslation();
13
+ const [isPreviewOpen, setIsPreviewOpen] = useState(false);
14
+ const whereToFindTitle =
15
+ window.settings.scopes?.typePlateWhereToFindTitle || 'Where to find it';
16
+ const whereToFindDescription =
17
+ window.settings.scopes?.typePlateWhereToFindDescription ||
18
+ 'A metal or foil label on the machine housing, usually near the motor or the main panel. Make sure the serial number is sharp and readable.';
19
+
20
+ return (
21
+ <>
22
+ <div className="flex flex-col items-start gap-4 min-[640px]:flex-row min-[640px]:items-center">
23
+ <button
24
+ type="button"
25
+ className="shrink-0 cursor-pointer border-0 bg-transparent p-0"
26
+ onClick={() => setIsPreviewOpen(true)}
27
+ aria-label={t(whereToFindTitle)}
28
+ >
29
+ <img
30
+ src={plateInfoImage}
31
+ alt=""
32
+ className="h-auto w-full max-w-[150px] min-w-[150px] object-contain bg-primary px-2 py-6"
33
+ />
34
+ </button>
35
+ <div className="min-w-0 flex-1">
36
+ <h3
37
+ className={twMerge(
38
+ 'font-bold text-[#2B2C46]',
39
+ isEmptyState ? 'text-base' : 'text-sm',
40
+ )}
41
+ >
42
+ {t(whereToFindTitle)}
43
+ </h3>
44
+ <p
45
+ className={twMerge(
46
+ 'mt-1 font-normal text-[#989BB7]',
47
+ isEmptyState ? 'text-sm leading-5' : 'text-xs leading-4',
48
+ )}
49
+ >
50
+ {t(whereToFindDescription)}
51
+ </p>
52
+ </div>
53
+ </div>
54
+ <Dialog open={isPreviewOpen} onOpenChange={setIsPreviewOpen}>
55
+ <DialogContent className="max-w-[90vw] w-fit bg-white p-4">
56
+ <DialogTitle className="h-0 w-0 hidden">
57
+ {t(whereToFindTitle)}
58
+ </DialogTitle>
59
+ <img
60
+ src={plateInfoImage}
61
+ alt=""
62
+ className="max-h-[80vh] max-w-[min(640px,80vw)] object-contain"
63
+ />
64
+ </DialogContent>
65
+ </Dialog>
66
+ </>
67
+ );
68
+ }
@@ -0,0 +1,123 @@
1
+ import { Icon } from '@nyris/nyris-react-components';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { twMerge } from 'tailwind-merge';
4
+
5
+ interface Props {
6
+ manualScopeId: string;
7
+ hasManualId: boolean;
8
+ isResolving: boolean;
9
+ isApplying?: boolean;
10
+ submitLabel?: string;
11
+ onManualScopeIdChange: (value: string) => void;
12
+ onClearText: () => void;
13
+ onSubmit: () => void;
14
+ }
15
+
16
+ export function ScopeSelectInput({
17
+ manualScopeId,
18
+ hasManualId,
19
+ isResolving,
20
+ isApplying = false,
21
+ submitLabel,
22
+ onManualScopeIdChange,
23
+ onClearText,
24
+ onSubmit,
25
+ }: Props) {
26
+ const { t } = useTranslation();
27
+ const ctaLabel =
28
+ submitLabel || window.settings.scopes?.typePlatePopupCtaButton || 'Apply';
29
+ const manualInputLabel =
30
+ window.settings.scopes?.typePlateManualInputLabel ||
31
+ "Can't scan? Enter serial number manually.";
32
+ const placeholder =
33
+ window.settings.scopes?.typePlateManualInputPlaceholder || 'e.g. 2234GH';
34
+ const applyingLabel =
35
+ window.settings.scopes?.typePlatePopupApplyingLabel || 'Applying...';
36
+
37
+ return (
38
+ <div className="w-full max-w-[636px]">
39
+ <p className="mb-2 text-xs font-normal text-[#2B2C46]">
40
+ {t(manualInputLabel)}
41
+ </p>
42
+ <div className="flex items-center gap-2">
43
+ <div
44
+ className={twMerge(
45
+ 'bg-white border border-solid border-[#DDDEE7] shadow-ds-2',
46
+ 'flex h-12 min-w-0 flex-1 items-center justify-between pr-1.5',
47
+ 'rounded-[32px]',
48
+ )}
49
+ >
50
+ <span className="flex h-full min-w-0 flex-1 items-center rounded-[32px]">
51
+ <input
52
+ type="text"
53
+ value={manualScopeId}
54
+ disabled={isResolving}
55
+ placeholder={t(placeholder)}
56
+ className={twMerge(
57
+ 'h-full w-full border-0 bg-transparent pl-4 outline-none',
58
+ 'rounded-[32px] text-[14px] text-[#2B2C46]',
59
+ 'disabled:bg-[#F3F3F5] disabled:text-[#BBBDCF]',
60
+ )}
61
+ onChange={event => onManualScopeIdChange(event.target.value)}
62
+ onKeyDown={event => {
63
+ if (event.key === 'Enter') {
64
+ event.preventDefault();
65
+ if (hasManualId && !isResolving) {
66
+ onSubmit();
67
+ }
68
+ }
69
+ }}
70
+ />
71
+ </span>
72
+ {hasManualId && (
73
+ <button
74
+ type="button"
75
+ className="flex min-h-10 min-w-10 items-center justify-center rounded-full hover:bg-gray-100 cursor-pointer"
76
+ aria-label={t('Clear text search')}
77
+ onClick={onClearText}
78
+ >
79
+ <Icon name="close" className="h-3 w-3 text-primary" />
80
+ </button>
81
+ )}
82
+ </div>
83
+ <button
84
+ type="button"
85
+ disabled={!hasManualId || isResolving}
86
+ className={twMerge(
87
+ 'h-12 shrink-0 rounded-[32px] px-6 text-[14px] font-semibold text-white',
88
+ 'flex items-center justify-center gap-2 transition-colors',
89
+ 'bg-primary hover:bg-[#2B2C46] disabled:hover:bg-primary',
90
+ 'disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer',
91
+ )}
92
+ onClick={onSubmit}
93
+ >
94
+ {isApplying ? (
95
+ <>
96
+ <svg
97
+ width={16}
98
+ height={16}
99
+ viewBox="0 0 50 50"
100
+ fill="none"
101
+ xmlns="http://www.w3.org/2000/svg"
102
+ className="loading-spinner shrink-0"
103
+ >
104
+ <circle
105
+ cx="25"
106
+ cy="25"
107
+ r="20"
108
+ stroke="#FFFFFF"
109
+ strokeWidth="4"
110
+ strokeLinecap="round"
111
+ strokeDasharray="90 30"
112
+ />
113
+ </svg>
114
+ <span>{t(applyingLabel)}</span>
115
+ </>
116
+ ) : (
117
+ t(ctaLabel)
118
+ )}
119
+ </button>
120
+ </div>
121
+ </div>
122
+ );
123
+ }
@@ -0,0 +1,310 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { Dialog, DialogContent, DialogTitle } from '../Modal/Dialog';
3
+ import { ScopeSelectForm } from './ScopeSelectForm';
4
+ import { useMediaQuery } from 'react-responsive';
5
+ import {
6
+ Drawer,
7
+ DrawerContent,
8
+ DrawerHeader,
9
+ DrawerTitle,
10
+ } from 'components/Drawer/Drawer';
11
+ import { useTranslation } from 'react-i18next';
12
+ import CustomCamera from 'components/CustomCameraDrawer';
13
+ import {
14
+ extractScopeIdFromTypePlate,
15
+ useImageSearch,
16
+ } from 'hooks/useImageSearch';
17
+ import { lookupScopeById } from 'services/scopeLookup';
18
+ import { identifyTypePlateImage } from './identifyTypePlateImage';
19
+ import { useLocation, useNavigate } from 'react-router';
20
+ import useRequestStore from 'stores/request/requestStore';
21
+ import { firstMachinePartsListingText } from 'stores/machinePartsListing/machinePartsListingStore';
22
+ import {
23
+ AppliedScopeInput,
24
+ ScopeSource,
25
+ toImageFile,
26
+ } from './scopeSelectHelpers';
27
+
28
+ interface Props {
29
+ openModal: boolean;
30
+ handleClose: () => void;
31
+ }
32
+
33
+ export function ScopeSelectModal({
34
+ openModal = false,
35
+ handleClose,
36
+ }: Props): JSX.Element {
37
+ const isMobile = useMediaQuery({ query: '(max-width: 776px)' });
38
+ const { t } = useTranslation();
39
+ const [manualScopeId, setManualScopeId] = useState('');
40
+ const [pendingFile, setPendingFile] = useState<File | null>(null);
41
+ const [previewUrl, setPreviewUrl] = useState<string | null>(null);
42
+ const [activeSource, setActiveSource] = useState<ScopeSource | null>(null);
43
+ const [lastAppliedInput, setLastAppliedInput] =
44
+ useState<AppliedScopeInput | null>(null);
45
+ const [lastExtractedFromImage, setLastExtractedFromImage] = useState('');
46
+ const [isManualMode, setIsManualMode] = useState(false);
47
+ const [isCameraOpen, setIsCameraOpen] = useState(false);
48
+ const [isExtracting, setIsExtracting] = useState(false);
49
+ const [scopeError, setScopeError] = useState<string | null>(null);
50
+ const extractGenerationRef = useRef(0);
51
+ const activeSourceRef = useRef<ScopeSource | null>(null);
52
+ const isOpenRef = useRef(openModal);
53
+ const discardPendingScopeRef = useRef<(() => void) | null>(null);
54
+
55
+ const resetFormState = useCallback(() => {
56
+ extractGenerationRef.current += 1;
57
+ setIsExtracting(false);
58
+ setScopeError(null);
59
+ setIsManualMode(false);
60
+ setManualScopeId('');
61
+ setPendingFile(null);
62
+ setLastExtractedFromImage('');
63
+ setActiveSource(null);
64
+ activeSourceRef.current = null;
65
+ setLastAppliedInput(null);
66
+ setPreviewUrl(prev => {
67
+ if (prev) {
68
+ try {
69
+ URL.revokeObjectURL(prev);
70
+ } catch {}
71
+ }
72
+ return null;
73
+ });
74
+ }, []);
75
+
76
+ const closeModal = useCallback(() => {
77
+ discardPendingScopeRef.current?.();
78
+ discardPendingScopeRef.current = null;
79
+ setIsCameraOpen(false);
80
+ resetFormState();
81
+ handleClose();
82
+ }, [handleClose, resetFormState]);
83
+
84
+ useEffect(() => {
85
+ isOpenRef.current = openModal;
86
+ if (!openModal) {
87
+ resetFormState();
88
+ }
89
+ }, [openModal, resetFormState]);
90
+
91
+ const handleActiveSourceChange = useCallback(
92
+ (source: ScopeSource | null) => {
93
+ activeSourceRef.current = source;
94
+ setActiveSource(source);
95
+ },
96
+ [],
97
+ );
98
+
99
+ const location = useLocation();
100
+ const navigate = useNavigate();
101
+ const { singleImageSearch, searchMachinePartsListing } = useImageSearch();
102
+
103
+ const handleScopeSuccess = useCallback(() => {
104
+ closeModal();
105
+ const { query, requestImages, regions } = useRequestStore.getState();
106
+ const keyword = (query || '').trim();
107
+ const searchImage = requestImages[0];
108
+ const onResults = location.pathname.startsWith('/result');
109
+
110
+ if (searchImage) {
111
+ if (!onResults) {
112
+ navigate('/result');
113
+ }
114
+ void singleImageSearch({
115
+ image: searchImage,
116
+ imageRegion: regions[0],
117
+ settings: window.settings,
118
+ showFeedback: true,
119
+ newSearch: false,
120
+ });
121
+ return;
122
+ }
123
+
124
+ if (keyword) {
125
+ if (!onResults) {
126
+ navigate('/result');
127
+ }
128
+ void singleImageSearch({
129
+ text: keyword,
130
+ settings: window.settings,
131
+ showFeedback: true,
132
+ newSearch: false,
133
+ });
134
+ return;
135
+ }
136
+
137
+ const text = firstMachinePartsListingText();
138
+ if (!text) {
139
+ return;
140
+ }
141
+
142
+ if (!onResults) {
143
+ navigate('/result');
144
+ }
145
+ void searchMachinePartsListing({
146
+ text,
147
+ showFeedback: true,
148
+ clearPostFilters: true,
149
+ });
150
+ }, [
151
+ closeModal,
152
+ location.pathname,
153
+ navigate,
154
+ searchMachinePartsListing,
155
+ singleImageSearch,
156
+ ]);
157
+
158
+ const setSelectedImage = useCallback(
159
+ (file: File) => {
160
+ discardPendingScopeRef.current?.();
161
+ setIsManualMode(false);
162
+ setManualScopeId('');
163
+ const nextPreviewUrl = URL.createObjectURL(file);
164
+ setPreviewUrl(prev => {
165
+ if (prev && prev !== nextPreviewUrl) {
166
+ try {
167
+ URL.revokeObjectURL(prev);
168
+ } catch {}
169
+ }
170
+ return nextPreviewUrl;
171
+ });
172
+ setPendingFile(file);
173
+ setLastExtractedFromImage('');
174
+ setScopeError(null);
175
+ handleActiveSourceChange('image');
176
+ const extractGeneration = extractGenerationRef.current + 1;
177
+ extractGenerationRef.current = extractGeneration;
178
+ setIsExtracting(true);
179
+ const defaultErrorMessage =
180
+ window.settings.scopes?.typePlatePopupErrorMessage ||
181
+ 'The serial number was not found. Please check again.';
182
+ void identifyTypePlateImage({
183
+ file,
184
+ isCurrent: () =>
185
+ extractGeneration === extractGenerationRef.current &&
186
+ isOpenRef.current &&
187
+ activeSourceRef.current === 'image',
188
+ extract: extractScopeIdFromTypePlate,
189
+ lookup: lookupScopeById,
190
+ })
191
+ .then(result => {
192
+ if (result.status === 'ignored') {
193
+ return;
194
+ }
195
+ if (result.status !== 'identified') {
196
+ setLastExtractedFromImage('');
197
+ setScopeError(t(defaultErrorMessage));
198
+ return;
199
+ }
200
+
201
+ setLastExtractedFromImage(result.serialNumber);
202
+ setScopeError(null);
203
+ })
204
+ .finally(() => {
205
+ if (
206
+ extractGeneration === extractGenerationRef.current &&
207
+ isOpenRef.current
208
+ ) {
209
+ setIsExtracting(false);
210
+ }
211
+ });
212
+ },
213
+ [handleActiveSourceChange, t],
214
+ );
215
+
216
+ const clearPreview = useCallback(() => {
217
+ extractGenerationRef.current += 1;
218
+ setIsExtracting(false);
219
+ setScopeError(null);
220
+ setPreviewUrl(prev => {
221
+ if (prev) {
222
+ try {
223
+ URL.revokeObjectURL(prev);
224
+ } catch {}
225
+ }
226
+ return null;
227
+ });
228
+ setPendingFile(null);
229
+ setLastExtractedFromImage('');
230
+ }, []);
231
+
232
+ const handleSwitchToManual = useCallback(() => {
233
+ setIsManualMode(true);
234
+ setScopeError(null);
235
+ handleActiveSourceChange('manual');
236
+ if (previewUrl) {
237
+ clearPreview();
238
+ }
239
+ }, [clearPreview, handleActiveSourceChange, previewUrl]);
240
+
241
+ const isSuccess =
242
+ Boolean(lastExtractedFromImage) && !scopeError && !isExtracting;
243
+ const popupTitle = isSuccess
244
+ ? window.settings.scopes?.typePlatePopupTitleSuccess || 'Machine identified'
245
+ : window.settings.scopes?.typePlatePopupTitle || 'Identify your machine';
246
+
247
+ const form = (
248
+ <ScopeSelectForm
249
+ handleClose={closeModal}
250
+ manualScopeId={manualScopeId}
251
+ pendingFile={pendingFile}
252
+ previewUrl={previewUrl}
253
+ activeSource={activeSource}
254
+ lastAppliedInput={lastAppliedInput}
255
+ lastExtractedFromImage={lastExtractedFromImage}
256
+ isExtracting={isExtracting}
257
+ scopeError={scopeError}
258
+ isManualMode={isManualMode}
259
+ onManualScopeIdChange={setManualScopeId}
260
+ onSelectedImage={setSelectedImage}
261
+ onClearPreview={clearPreview}
262
+ onOpenCamera={() => setIsCameraOpen(true)}
263
+ onApplied={setLastAppliedInput}
264
+ onActiveSourceChange={handleActiveSourceChange}
265
+ onExtractedFromImage={setLastExtractedFromImage}
266
+ onScopeErrorChange={setScopeError}
267
+ onSwitchToManual={handleSwitchToManual}
268
+ onSuccess={handleScopeSuccess}
269
+ discardPendingScopeRef={discardPendingScopeRef}
270
+ />
271
+ );
272
+
273
+ return (
274
+ <>
275
+ {!isMobile ? (
276
+ <Dialog open={openModal} onOpenChange={open => !open && closeModal()}>
277
+ <DialogContent
278
+ className="bg-white p-0 max-w-[640px] w-[90%] max-h-[95vh] m-auto overflow-y-hidden rounded-lg pt-2.5"
279
+ closeButton={false}
280
+ >
281
+ <DialogTitle className="h-0 w-0 hidden">
282
+ {t(popupTitle)}
283
+ </DialogTitle>
284
+ {form}
285
+ </DialogContent>
286
+ </Dialog>
287
+ ) : (
288
+ <Drawer open={openModal} onOpenChange={open => !open && closeModal()}>
289
+ <DrawerContent className="bg-white p-0 m-auto overflow-y-hidden pt-2.5 h-full outline-none rounded-none">
290
+ <DrawerHeader className="h-0 w-0 hidden">
291
+ <DrawerTitle>{t(popupTitle)}</DrawerTitle>
292
+ </DrawerHeader>
293
+ {form}
294
+ </DrawerContent>
295
+ </Drawer>
296
+ )}
297
+ <CustomCamera
298
+ show={isCameraOpen}
299
+ onClose={() => setIsCameraOpen(false)}
300
+ handleImageUpload={(image: File | string) => {
301
+ void toImageFile(image).then(file => {
302
+ if (file) {
303
+ setSelectedImage(file);
304
+ }
305
+ });
306
+ }}
307
+ />
308
+ </>
309
+ );
310
+ }