@visns-studio/visns-components 5.9.5 → 5.9.7
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.
- package/package.json +2 -1
- package/src/components/crm/BusinessCardOcr.jsx +1089 -0
- package/src/components/crm/DataGrid.jsx +21 -8
- package/src/components/crm/Navigation.jsx +37 -0
- package/src/components/crm/styles/BusinessCardOcr.module.scss +646 -0
- package/src/components/crm/styles/Notification.module.scss +0 -1
- package/src/components/crm/styles/global-datagrid.css +1 -1
- package/src/components/test/TooltipTest.jsx +104 -0
- package/src/index.js +2 -0
|
@@ -0,0 +1,1089 @@
|
|
|
1
|
+
import '../styles/global.css';
|
|
2
|
+
|
|
3
|
+
import { useState, useRef, useCallback, useEffect } from 'react';
|
|
4
|
+
import { motion } from 'framer-motion';
|
|
5
|
+
import { toast } from 'react-toastify';
|
|
6
|
+
import Tesseract from 'tesseract.js';
|
|
7
|
+
import {
|
|
8
|
+
Camera,
|
|
9
|
+
Image as ImageIcon,
|
|
10
|
+
CircleX,
|
|
11
|
+
ArrowCycle,
|
|
12
|
+
CircleCheck,
|
|
13
|
+
ArrowCounterClockwise,
|
|
14
|
+
Envelope,
|
|
15
|
+
Network,
|
|
16
|
+
Edit,
|
|
17
|
+
Plus,
|
|
18
|
+
} from 'akar-icons';
|
|
19
|
+
|
|
20
|
+
import ocrStyles from './styles/BusinessCardOcr.module.scss';
|
|
21
|
+
|
|
22
|
+
const BusinessCardOcr = ({
|
|
23
|
+
onContactSaved,
|
|
24
|
+
onCancel,
|
|
25
|
+
isOpen = false,
|
|
26
|
+
onClose,
|
|
27
|
+
fields = [],
|
|
28
|
+
}) => {
|
|
29
|
+
const [currentStep, setCurrentStep] = useState('camera'); // camera, preview, processing, results
|
|
30
|
+
const [capturedImage, setCapturedImage] = useState(null);
|
|
31
|
+
const [cameraStream, setCameraStream] = useState(null);
|
|
32
|
+
const [cameraFacing, setCameraFacing] = useState('environment'); // environment (back), user (front)
|
|
33
|
+
const [availableCameras, setAvailableCameras] = useState([]);
|
|
34
|
+
const [currentCameraIndex, setCurrentCameraIndex] = useState(0);
|
|
35
|
+
const [isProcessing, setIsProcessing] = useState(false);
|
|
36
|
+
const [extractedData, setExtractedData] = useState({});
|
|
37
|
+
const [ocrProgress, setOcrProgress] = useState(0);
|
|
38
|
+
const [showAssignmentDropdown, setShowAssignmentDropdown] = useState(false);
|
|
39
|
+
const [selectedUnusedText, setSelectedUnusedText] = useState('');
|
|
40
|
+
|
|
41
|
+
const videoRef = useRef(null);
|
|
42
|
+
const canvasRef = useRef(null);
|
|
43
|
+
const fileInputRef = useRef(null);
|
|
44
|
+
|
|
45
|
+
// Detect available cameras when component opens
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (isOpen) {
|
|
48
|
+
detectCameras();
|
|
49
|
+
}
|
|
50
|
+
}, [isOpen]);
|
|
51
|
+
|
|
52
|
+
// Initialize camera when component opens
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (isOpen && currentStep === 'camera') {
|
|
55
|
+
initializeCamera();
|
|
56
|
+
}
|
|
57
|
+
return () => {
|
|
58
|
+
stopCamera();
|
|
59
|
+
};
|
|
60
|
+
}, [isOpen, currentStep, cameraFacing, currentCameraIndex, availableCameras]);
|
|
61
|
+
|
|
62
|
+
const detectCameras = async () => {
|
|
63
|
+
try {
|
|
64
|
+
// Request permission first to get proper device labels
|
|
65
|
+
await navigator.mediaDevices.getUserMedia({ video: true });
|
|
66
|
+
|
|
67
|
+
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
68
|
+
const videoDevices = devices.filter(
|
|
69
|
+
(device) => device.kind === 'videoinput' && device.deviceId
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
// Filter out devices that might not work
|
|
73
|
+
const workingCameras = [];
|
|
74
|
+
for (const device of videoDevices) {
|
|
75
|
+
try {
|
|
76
|
+
const testStream = await navigator.mediaDevices.getUserMedia({
|
|
77
|
+
video: { deviceId: device.deviceId }
|
|
78
|
+
});
|
|
79
|
+
testStream.getTracks().forEach(track => track.stop());
|
|
80
|
+
workingCameras.push(device);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
console.warn(`Camera ${device.label || device.deviceId} not accessible:`, err);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
setAvailableCameras(workingCameras);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
console.error('Error detecting cameras:', error);
|
|
89
|
+
setAvailableCameras([]);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const initializeCamera = async () => {
|
|
94
|
+
try {
|
|
95
|
+
let constraints;
|
|
96
|
+
|
|
97
|
+
// Use specific device if available cameras are detected
|
|
98
|
+
if (availableCameras.length > 0 && availableCameras[currentCameraIndex]) {
|
|
99
|
+
constraints = {
|
|
100
|
+
video: {
|
|
101
|
+
deviceId: { exact: availableCameras[currentCameraIndex].deviceId },
|
|
102
|
+
width: { ideal: 1280 },
|
|
103
|
+
height: { ideal: 720 },
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
} else {
|
|
107
|
+
// Fallback to facing mode
|
|
108
|
+
constraints = {
|
|
109
|
+
video: {
|
|
110
|
+
facingMode: cameraFacing,
|
|
111
|
+
width: { ideal: 1280 },
|
|
112
|
+
height: { ideal: 720 },
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
|
118
|
+
setCameraStream(stream);
|
|
119
|
+
|
|
120
|
+
if (videoRef.current) {
|
|
121
|
+
videoRef.current.srcObject = stream;
|
|
122
|
+
}
|
|
123
|
+
} catch (error) {
|
|
124
|
+
console.error('Error accessing camera:', error);
|
|
125
|
+
toast.error('Unable to access camera. Please check permissions.');
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const stopCamera = () => {
|
|
130
|
+
if (cameraStream) {
|
|
131
|
+
cameraStream.getTracks().forEach((track) => track.stop());
|
|
132
|
+
setCameraStream(null);
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const capturePhoto = useCallback(() => {
|
|
137
|
+
if (!videoRef.current || !canvasRef.current) return;
|
|
138
|
+
|
|
139
|
+
const video = videoRef.current;
|
|
140
|
+
const canvas = canvasRef.current;
|
|
141
|
+
const context = canvas.getContext('2d');
|
|
142
|
+
|
|
143
|
+
canvas.width = video.videoWidth;
|
|
144
|
+
canvas.height = video.videoHeight;
|
|
145
|
+
context.drawImage(video, 0, 0);
|
|
146
|
+
|
|
147
|
+
canvas.toBlob(
|
|
148
|
+
(blob) => {
|
|
149
|
+
setCapturedImage(blob);
|
|
150
|
+
setCurrentStep('preview');
|
|
151
|
+
stopCamera();
|
|
152
|
+
},
|
|
153
|
+
'image/jpeg',
|
|
154
|
+
0.9
|
|
155
|
+
);
|
|
156
|
+
}, []);
|
|
157
|
+
|
|
158
|
+
const switchCamera = () => {
|
|
159
|
+
if (availableCameras.length > 1) {
|
|
160
|
+
setCurrentCameraIndex((prev) =>
|
|
161
|
+
(prev + 1) % availableCameras.length
|
|
162
|
+
);
|
|
163
|
+
} else {
|
|
164
|
+
// Fallback to facing mode switch
|
|
165
|
+
setCameraFacing((prev) =>
|
|
166
|
+
prev === 'environment' ? 'user' : 'environment'
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const retakePhoto = () => {
|
|
172
|
+
setCapturedImage(null);
|
|
173
|
+
setCurrentStep('camera');
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const handleFileUpload = (event) => {
|
|
177
|
+
const file = event.target.files[0];
|
|
178
|
+
|
|
179
|
+
if (!file) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Validate file type
|
|
184
|
+
if (!file.type.startsWith('image/')) {
|
|
185
|
+
toast.error('Please select a valid image file (JPG, PNG, etc.)');
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Validate file size (max 10MB)
|
|
190
|
+
const maxSize = 10 * 1024 * 1024; // 10MB
|
|
191
|
+
if (file.size > maxSize) {
|
|
192
|
+
toast.error('Image file is too large. Please select a file smaller than 10MB.');
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Check if it's a reasonable image size
|
|
197
|
+
const img = new Image();
|
|
198
|
+
img.onload = function() {
|
|
199
|
+
if (this.width < 100 || this.height < 100) {
|
|
200
|
+
toast.error('Image is too small. Please select a larger image.');
|
|
201
|
+
URL.revokeObjectURL(this.src); // Clean up memory
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
URL.revokeObjectURL(this.src); // Clean up memory
|
|
206
|
+
setCapturedImage(file);
|
|
207
|
+
setCurrentStep('preview');
|
|
208
|
+
stopCamera();
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
img.onerror = function() {
|
|
212
|
+
toast.error('Unable to load the selected image. Please try another file.');
|
|
213
|
+
URL.revokeObjectURL(this.src); // Clean up memory
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const objectUrl = URL.createObjectURL(file);
|
|
217
|
+
img.src = objectUrl;
|
|
218
|
+
|
|
219
|
+
// Clear the input to allow re-selecting the same file
|
|
220
|
+
event.target.value = '';
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const processBusinessCard = async () => {
|
|
224
|
+
if (!capturedImage) return;
|
|
225
|
+
|
|
226
|
+
setIsProcessing(true);
|
|
227
|
+
setCurrentStep('processing');
|
|
228
|
+
setOcrProgress(0);
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
const result = await Tesseract.recognize(capturedImage, 'eng', {
|
|
232
|
+
logger: (m) => {
|
|
233
|
+
if (m.status === 'recognizing text') {
|
|
234
|
+
const progress = Math.round(m.progress * 100);
|
|
235
|
+
setOcrProgress(progress);
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const text = result.data.text;
|
|
241
|
+
const parsedData = parseExtractedText(text);
|
|
242
|
+
setExtractedData({ ...parsedData, rawText: text });
|
|
243
|
+
setCurrentStep('results');
|
|
244
|
+
toast.success('Business card processed successfully!');
|
|
245
|
+
} catch (error) {
|
|
246
|
+
console.error('OCR Error:', error);
|
|
247
|
+
toast.error('Failed to process business card. Please try again.');
|
|
248
|
+
setCurrentStep('preview');
|
|
249
|
+
} finally {
|
|
250
|
+
setIsProcessing(false);
|
|
251
|
+
setOcrProgress(0);
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
// Helper functions for data quality validation
|
|
256
|
+
const isValidName = (name) => {
|
|
257
|
+
if (!name || typeof name !== 'string') return false;
|
|
258
|
+
|
|
259
|
+
const trimmed = name.trim();
|
|
260
|
+
|
|
261
|
+
// Must be at least 2 characters for first name, 1 for last name
|
|
262
|
+
if (trimmed.length < 1) return false;
|
|
263
|
+
|
|
264
|
+
// No single character first names except common ones
|
|
265
|
+
const commonSingleChars = ['J', 'D', 'R', 'M', 'K', 'T', 'S', 'B', 'C', 'A'];
|
|
266
|
+
if (trimmed.length === 1 && !commonSingleChars.includes(trimmed.toUpperCase())) {
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Must contain mostly letters
|
|
271
|
+
const letterCount = (trimmed.match(/[a-zA-Z]/g) || []).length;
|
|
272
|
+
const letterRatio = letterCount / trimmed.length;
|
|
273
|
+
if (letterRatio < 0.6) return false;
|
|
274
|
+
|
|
275
|
+
// No operators or excessive special characters
|
|
276
|
+
const invalidChars = /[=+\-*/<>|\\#$%^&@!~`{}[\]]/;
|
|
277
|
+
if (invalidChars.test(trimmed)) return false;
|
|
278
|
+
|
|
279
|
+
// Allow common name characters
|
|
280
|
+
const validNamePattern = /^[a-zA-Z\s'.,-]+$/;
|
|
281
|
+
return validNamePattern.test(trimmed);
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const isLikelyOCRArtifact = (line) => {
|
|
285
|
+
if (!line || typeof line !== 'string') return true;
|
|
286
|
+
|
|
287
|
+
const trimmed = line.trim();
|
|
288
|
+
|
|
289
|
+
// Too short or too long
|
|
290
|
+
if (trimmed.length < 2 || trimmed.length > 50) return true;
|
|
291
|
+
|
|
292
|
+
// Calculate character type ratios
|
|
293
|
+
const letters = (trimmed.match(/[a-zA-Z]/g) || []).length;
|
|
294
|
+
const numbers = (trimmed.match(/[0-9]/g) || []).length;
|
|
295
|
+
const symbols = (trimmed.match(/[^a-zA-Z0-9\s.,;:!?'-]/g) || []).length;
|
|
296
|
+
const spaces = (trimmed.match(/\s/g) || []).length;
|
|
297
|
+
const total = trimmed.length;
|
|
298
|
+
const letterRatio = letters / total;
|
|
299
|
+
const symbolRatio = symbols / total;
|
|
300
|
+
const spaceRatio = spaces / total;
|
|
301
|
+
|
|
302
|
+
// Skip if mostly symbols
|
|
303
|
+
if (symbolRatio > 0.3) return true;
|
|
304
|
+
|
|
305
|
+
// Skip if too many consecutive spaces or weird spacing
|
|
306
|
+
if (spaceRatio > 0.4) return true;
|
|
307
|
+
|
|
308
|
+
// Skip if it's all numbers or mostly numbers (unless it looks like a phone)
|
|
309
|
+
if (numbers > 0 && letterRatio < 0.2 && !trimmed.match(/^\+?[\d\s\-()]+$/)) return true;
|
|
310
|
+
|
|
311
|
+
// Skip lines that look like formatting artifacts
|
|
312
|
+
const formatArtifacts = /^[.\-_|]+$|^[^a-zA-Z]*[|]+[^a-zA-Z]*$|^\s*[.]\s*[A-Z]?\s*$/;
|
|
313
|
+
if (formatArtifacts.test(trimmed)) return true;
|
|
314
|
+
|
|
315
|
+
// Skip if it has random character combinations
|
|
316
|
+
const randomPattern = /^[a-zA-Z]{1,2}\s+[a-zA-Z]{1,2}(\s+[a-zA-Z]{1,2})*$/;
|
|
317
|
+
if (randomPattern.test(trimmed) && trimmed.length < 8) return true;
|
|
318
|
+
|
|
319
|
+
// Enhanced gibberish detection
|
|
320
|
+
if (isGibberish(trimmed)) return true;
|
|
321
|
+
|
|
322
|
+
return false;
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
// Advanced gibberish detection
|
|
326
|
+
const isGibberish = (text) => {
|
|
327
|
+
if (!text || text.length < 3) return false;
|
|
328
|
+
|
|
329
|
+
const words = text.split(/\s+/).filter(word => word.length > 0);
|
|
330
|
+
|
|
331
|
+
// Check for excessive mixed case patterns like "Li FOR go > ips"
|
|
332
|
+
let mixedCaseWords = 0;
|
|
333
|
+
let gibberishWords = 0;
|
|
334
|
+
let totalWords = words.length;
|
|
335
|
+
|
|
336
|
+
for (const word of words) {
|
|
337
|
+
// Skip very short words
|
|
338
|
+
if (word.length < 2) continue;
|
|
339
|
+
|
|
340
|
+
// Check for excessive symbol contamination in words
|
|
341
|
+
const symbolsInWord = (word.match(/[^a-zA-Z0-9]/g) || []).length;
|
|
342
|
+
if (symbolsInWord / word.length > 0.3) {
|
|
343
|
+
gibberishWords++;
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Check for random mixed case patterns
|
|
348
|
+
const hasRandomCase = /[a-z][A-Z]|[A-Z][a-z][A-Z]/.test(word);
|
|
349
|
+
if (hasRandomCase && word.length > 2) {
|
|
350
|
+
mixedCaseWords++;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Check for consonant/vowel patterns that don't make sense
|
|
354
|
+
const consonantClusters = word.match(/[bcdfghjklmnpqrstvwxzBCDFGHJKLMNPQRSTVWXZ]{4,}/g);
|
|
355
|
+
if (consonantClusters && consonantClusters.length > 0) {
|
|
356
|
+
gibberishWords++;
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Check for excessive single character "words"
|
|
361
|
+
if (word.length === 1 && /[a-zA-Z]/.test(word)) {
|
|
362
|
+
gibberishWords++;
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Check for patterns like "FOR", "ips", "ED", "RE" in sequence (common OCR errors)
|
|
367
|
+
if (word.length <= 3 && word.match(/^[A-Z]{2,3}$|^[a-z]{2,3}$/)) {
|
|
368
|
+
// Count as potential gibberish if there are many of these short words
|
|
369
|
+
gibberishWords += 0.5;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// If more than 60% of words are gibberish or mixed case, consider it gibberish
|
|
374
|
+
const gibberishRatio = gibberishWords / Math.max(totalWords, 1);
|
|
375
|
+
const mixedCaseRatio = mixedCaseWords / Math.max(totalWords, 1);
|
|
376
|
+
|
|
377
|
+
if (gibberishRatio > 0.6) return true;
|
|
378
|
+
if (mixedCaseRatio > 0.5 && totalWords > 2) return true;
|
|
379
|
+
|
|
380
|
+
// Check for specific gibberish patterns
|
|
381
|
+
const gibberishPatterns = [
|
|
382
|
+
// Patterns like ": Re : Bei oc oC"
|
|
383
|
+
/^[:;]\s*[A-Za-z]{1,3}\s*[:;]\s*[A-Za-z]{1,4}\s+[a-z]{1,2}\s+[a-zA-Z]{1,2}$/,
|
|
384
|
+
// Patterns like "HE — SE TL Beant 2 Es Re Se"
|
|
385
|
+
/^[A-Z]{1,3}\s*[—\-]\s*[A-Z]{1,3}\s+[A-Z]{1,3}\s+[A-Za-z]{1,6}\s+\d+\s+[A-Za-z]{1,3}\s+[A-Za-z]{1,3}\s+[A-Za-z]{1,3}$/,
|
|
386
|
+
// Patterns with excessive brackets, symbols mixed with short words
|
|
387
|
+
/[>\[\]]{2,}|[A-Za-z]{1,3}\s+[>\[\]<]+\s+[A-Za-z]{1,3}/,
|
|
388
|
+
// Excessive punctuation mixed with short letter sequences
|
|
389
|
+
/^[^a-zA-Z]*[a-zA-Z]{1,2}[^a-zA-Z]+[a-zA-Z]{1,3}[^a-zA-Z]+.*$/
|
|
390
|
+
];
|
|
391
|
+
|
|
392
|
+
for (const pattern of gibberishPatterns) {
|
|
393
|
+
if (pattern.test(text)) return true;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Check for excessive spacing patterns
|
|
397
|
+
if (/\s{3,}/.test(text)) return true;
|
|
398
|
+
|
|
399
|
+
// Check for lines with too many single characters
|
|
400
|
+
const singleChars = text.match(/\b[a-zA-Z]\b/g);
|
|
401
|
+
if (singleChars && singleChars.length > 3 && text.length < 30) return true;
|
|
402
|
+
|
|
403
|
+
return false;
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
const hasValidNameStructure = (words) => {
|
|
407
|
+
if (!words || words.length === 0) return false;
|
|
408
|
+
|
|
409
|
+
// Check each word for name validity
|
|
410
|
+
for (const word of words) {
|
|
411
|
+
if (!isValidName(word)) return false;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// First word should be at least 2 characters (unless common single char)
|
|
415
|
+
const firstName = words[0];
|
|
416
|
+
const commonSingleChars = ['J', 'D', 'R', 'M', 'K', 'T', 'S', 'B', 'C', 'A'];
|
|
417
|
+
if (firstName.length === 1 && !commonSingleChars.includes(firstName.toUpperCase())) {
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return true;
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
// Helper function to clean and validate field values
|
|
425
|
+
const cleanFieldValue = (value, fieldType = 'general') => {
|
|
426
|
+
if (!value || typeof value !== 'string') return '';
|
|
427
|
+
|
|
428
|
+
let cleaned = value.trim();
|
|
429
|
+
|
|
430
|
+
// Remove common OCR artifacts and invalid characters
|
|
431
|
+
switch (fieldType) {
|
|
432
|
+
case 'name':
|
|
433
|
+
// For names: only letters, spaces, apostrophes, periods, hyphens
|
|
434
|
+
cleaned = cleaned.replace(/[^a-zA-Z\s'.-]/g, '');
|
|
435
|
+
// Remove excessive spaces
|
|
436
|
+
cleaned = cleaned.replace(/\s+/g, ' ');
|
|
437
|
+
// Remove leading/trailing punctuation
|
|
438
|
+
cleaned = cleaned.replace(/^[^a-zA-Z]+|[^a-zA-Z]+$/g, '');
|
|
439
|
+
break;
|
|
440
|
+
|
|
441
|
+
case 'company':
|
|
442
|
+
// For companies: letters, numbers, spaces, common punctuation
|
|
443
|
+
cleaned = cleaned.replace(/[^a-zA-Z0-9\s&.,'-]/g, '');
|
|
444
|
+
// Remove pipe characters and equals signs that OCR often mistakes
|
|
445
|
+
cleaned = cleaned.replace(/[|="]/g, '');
|
|
446
|
+
// Remove excessive spaces
|
|
447
|
+
cleaned = cleaned.replace(/\s+/g, ' ');
|
|
448
|
+
// Remove leading/trailing punctuation except periods
|
|
449
|
+
cleaned = cleaned.replace(/^[^a-zA-Z0-9]+|[^a-zA-Z0-9.]+$/g, '');
|
|
450
|
+
break;
|
|
451
|
+
|
|
452
|
+
case 'position':
|
|
453
|
+
// For positions: letters, spaces, common punctuation
|
|
454
|
+
cleaned = cleaned.replace(/[^a-zA-Z\s&.,'-]/g, '');
|
|
455
|
+
// Remove pipe characters and equals signs
|
|
456
|
+
cleaned = cleaned.replace(/[|="]/g, '');
|
|
457
|
+
// Remove excessive spaces
|
|
458
|
+
cleaned = cleaned.replace(/\s+/g, ' ');
|
|
459
|
+
// Remove leading/trailing punctuation
|
|
460
|
+
cleaned = cleaned.replace(/^[^a-zA-Z]+|[^a-zA-Z]+$/g, '');
|
|
461
|
+
break;
|
|
462
|
+
|
|
463
|
+
case 'email':
|
|
464
|
+
// For emails: standard email characters only
|
|
465
|
+
cleaned = cleaned.replace(/[^a-zA-Z0-9@._+-]/g, '');
|
|
466
|
+
break;
|
|
467
|
+
|
|
468
|
+
case 'phone':
|
|
469
|
+
// For phones: numbers, spaces, parentheses, hyphens, plus
|
|
470
|
+
cleaned = cleaned.replace(/[^0-9\s()+-]/g, '');
|
|
471
|
+
break;
|
|
472
|
+
|
|
473
|
+
case 'website':
|
|
474
|
+
// For websites: standard URL characters
|
|
475
|
+
cleaned = cleaned.replace(/[^a-zA-Z0-9./:?#@!$&'()*+,;=-]/g, '');
|
|
476
|
+
break;
|
|
477
|
+
|
|
478
|
+
default:
|
|
479
|
+
// General cleaning: remove obvious artifacts
|
|
480
|
+
cleaned = cleaned.replace(/[|="]/g, '');
|
|
481
|
+
cleaned = cleaned.replace(/\s+/g, ' ');
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Final cleanup
|
|
485
|
+
cleaned = cleaned.trim();
|
|
486
|
+
|
|
487
|
+
// Return empty string if result is too short or invalid
|
|
488
|
+
if (cleaned.length < 1) return '';
|
|
489
|
+
|
|
490
|
+
return cleaned;
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
const parseExtractedText = (text) => {
|
|
494
|
+
const lines = text.split('\n').filter((line) => line.trim());
|
|
495
|
+
const parsed = {};
|
|
496
|
+
const unusedLines = [];
|
|
497
|
+
const usedLineIndices = new Set();
|
|
498
|
+
|
|
499
|
+
// Enhanced regex patterns
|
|
500
|
+
const emailRegex = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/;
|
|
501
|
+
const phoneRegex = /(?:\+?1[-.\s]?)?\(?([0-9]{3})\)?[-.\s]?([0-9]{3})[-.\s]?([0-9]{4})/;
|
|
502
|
+
const websiteRegex = /(?:https?:\/\/)?(?:www\.)?[a-zA-Z0-9-]+\.[a-zA-Z]{2,}(?:\.[a-zA-Z]{2,})?/;
|
|
503
|
+
|
|
504
|
+
// Job title keywords for better position detection
|
|
505
|
+
const jobTitleKeywords = ['ceo', 'cto', 'cfo', 'manager', 'director', 'president', 'vice', 'senior', 'lead', 'head', 'chief', 'supervisor', 'coordinator', 'specialist', 'analyst', 'engineer', 'developer', 'designer', 'consultant', 'sales', 'marketing', 'hr', 'owner', 'founder'];
|
|
506
|
+
|
|
507
|
+
// Company indicators
|
|
508
|
+
const companyIndicators = ['inc', 'corp', 'ltd', 'llc', 'company', 'co.', 'corporation', 'incorporated', 'limited', 'enterprises', 'group', 'solutions', 'services', 'systems', 'technologies', 'tech', 'consulting'];
|
|
509
|
+
|
|
510
|
+
// Smart field extraction with confidence scoring
|
|
511
|
+
lines.forEach((line, index) => {
|
|
512
|
+
const trimmedLine = line.trim();
|
|
513
|
+
const lowerLine = trimmedLine.toLowerCase();
|
|
514
|
+
|
|
515
|
+
// Skip obvious OCR artifacts early
|
|
516
|
+
if (isLikelyOCRArtifact(trimmedLine)) {
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Extract email (high confidence)
|
|
521
|
+
const emailMatch = trimmedLine.match(emailRegex);
|
|
522
|
+
if (emailMatch && !parsed.email) {
|
|
523
|
+
const cleanedEmail = cleanFieldValue(emailMatch[0], 'email');
|
|
524
|
+
if (cleanedEmail) {
|
|
525
|
+
parsed.email = cleanedEmail;
|
|
526
|
+
usedLineIndices.add(index);
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Extract phone/mobile (high confidence)
|
|
532
|
+
const phoneMatch = trimmedLine.match(phoneRegex);
|
|
533
|
+
if (phoneMatch && !parsed.mobile && !parsed.phone) {
|
|
534
|
+
const cleanedPhone = cleanFieldValue(phoneMatch[0], 'phone');
|
|
535
|
+
if (cleanedPhone) {
|
|
536
|
+
parsed.mobile = cleanedPhone;
|
|
537
|
+
parsed.phone = cleanedPhone; // For backward compatibility
|
|
538
|
+
usedLineIndices.add(index);
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Extract website (high confidence)
|
|
544
|
+
const websiteMatch = trimmedLine.match(websiteRegex);
|
|
545
|
+
if (websiteMatch && !parsed.website && !emailMatch) {
|
|
546
|
+
const cleanedWebsite = cleanFieldValue(websiteMatch[0], 'website');
|
|
547
|
+
if (cleanedWebsite) {
|
|
548
|
+
parsed.website = cleanedWebsite;
|
|
549
|
+
usedLineIndices.add(index);
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Smart name detection with enhanced validation
|
|
555
|
+
if (!parsed.firstname && !emailMatch && !phoneMatch && !websiteMatch) {
|
|
556
|
+
const words = trimmedLine.split(/\s+/).filter(word => word.length > 0);
|
|
557
|
+
const hasJobTitle = jobTitleKeywords.some(keyword => lowerLine.includes(keyword));
|
|
558
|
+
const hasCompanyIndicator = companyIndicators.some(indicator => lowerLine.includes(indicator));
|
|
559
|
+
|
|
560
|
+
// Enhanced name validation
|
|
561
|
+
if (words.length >= 1 && words.length <= 3 &&
|
|
562
|
+
!hasJobTitle &&
|
|
563
|
+
!hasCompanyIndicator &&
|
|
564
|
+
!(trimmedLine === trimmedLine.toUpperCase() && trimmedLine.length > 3) &&
|
|
565
|
+
hasValidNameStructure(words)) {
|
|
566
|
+
|
|
567
|
+
const firstName = words[0];
|
|
568
|
+
const lastName = words.slice(1).join(' ');
|
|
569
|
+
|
|
570
|
+
// Final validation for names
|
|
571
|
+
if (isValidName(firstName) && (lastName === '' || isValidName(lastName))) {
|
|
572
|
+
const cleanedFirstName = cleanFieldValue(firstName, 'name');
|
|
573
|
+
const cleanedLastName = cleanFieldValue(lastName, 'name');
|
|
574
|
+
|
|
575
|
+
if (cleanedFirstName) {
|
|
576
|
+
parsed.firstname = cleanedFirstName;
|
|
577
|
+
parsed.lastname = cleanedLastName;
|
|
578
|
+
// For backward compatibility
|
|
579
|
+
parsed.first_name = parsed.firstname;
|
|
580
|
+
parsed.last_name = parsed.lastname;
|
|
581
|
+
usedLineIndices.add(index);
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Smart position/job title detection with validation
|
|
589
|
+
if (!parsed.position) {
|
|
590
|
+
const hasJobTitle = jobTitleKeywords.some(keyword => lowerLine.includes(keyword));
|
|
591
|
+
const words = trimmedLine.split(/\s+/);
|
|
592
|
+
const letterRatio = (trimmedLine.match(/[a-zA-Z]/g) || []).length / trimmedLine.length;
|
|
593
|
+
|
|
594
|
+
// Must have good letter ratio and reasonable length
|
|
595
|
+
if (letterRatio > 0.6 && trimmedLine.length >= 3 && trimmedLine.length <= 40 &&
|
|
596
|
+
(hasJobTitle || (words.length >= 2 && words.length <= 6 &&
|
|
597
|
+
!companyIndicators.some(indicator => lowerLine.includes(indicator))))) {
|
|
598
|
+
const cleanedPosition = cleanFieldValue(trimmedLine, 'position');
|
|
599
|
+
if (cleanedPosition) {
|
|
600
|
+
parsed.position = cleanedPosition;
|
|
601
|
+
usedLineIndices.add(index);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Smart company detection with validation
|
|
608
|
+
if (!parsed.company) {
|
|
609
|
+
const hasCompanyIndicator = companyIndicators.some(indicator => lowerLine.includes(indicator));
|
|
610
|
+
const isAllCaps = trimmedLine === trimmedLine.toUpperCase() && trimmedLine.length > 2;
|
|
611
|
+
const words = trimmedLine.split(/\s+/);
|
|
612
|
+
const letterRatio = (trimmedLine.match(/[a-zA-Z]/g) || []).length / trimmedLine.length;
|
|
613
|
+
|
|
614
|
+
// Must have good letter ratio and reasonable structure
|
|
615
|
+
if (letterRatio > 0.5 && trimmedLine.length >= 2 && trimmedLine.length <= 50 &&
|
|
616
|
+
(hasCompanyIndicator || isAllCaps ||
|
|
617
|
+
(words.length >= 1 && words.length <= 4 && index > 0 &&
|
|
618
|
+
!jobTitleKeywords.some(keyword => lowerLine.includes(keyword))))) {
|
|
619
|
+
const cleanedCompany = cleanFieldValue(trimmedLine, 'company');
|
|
620
|
+
if (cleanedCompany) {
|
|
621
|
+
parsed.company = cleanedCompany;
|
|
622
|
+
usedLineIndices.add(index);
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
// Collect unused lines for manual assignment (excluding artifacts)
|
|
630
|
+
lines.forEach((line, index) => {
|
|
631
|
+
if (!usedLineIndices.has(index) && !isLikelyOCRArtifact(line.trim())) {
|
|
632
|
+
const cleanedLine = cleanFieldValue(line.trim(), 'general');
|
|
633
|
+
if (cleanedLine && cleanedLine.length > 1) {
|
|
634
|
+
unusedLines.push({
|
|
635
|
+
text: cleanedLine,
|
|
636
|
+
index: index
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
parsed._unusedData = unusedLines;
|
|
643
|
+
return parsed;
|
|
644
|
+
};
|
|
645
|
+
|
|
646
|
+
// Helper function to get field input type
|
|
647
|
+
const getFieldInputType = (fieldId) => {
|
|
648
|
+
const fieldTypeMap = {
|
|
649
|
+
email: 'email',
|
|
650
|
+
mobile: 'tel',
|
|
651
|
+
phone: 'tel',
|
|
652
|
+
website: 'url',
|
|
653
|
+
position: 'text',
|
|
654
|
+
company: 'text',
|
|
655
|
+
firstname: 'text',
|
|
656
|
+
lastname: 'text',
|
|
657
|
+
};
|
|
658
|
+
return fieldTypeMap[fieldId] || 'text';
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
// Helper function to get field icon
|
|
662
|
+
const getFieldIcon = (fieldId) => {
|
|
663
|
+
const iconMap = {
|
|
664
|
+
email: <Envelope size={16} />,
|
|
665
|
+
mobile: null,
|
|
666
|
+
phone: null,
|
|
667
|
+
website: <Network size={16} />,
|
|
668
|
+
position: null,
|
|
669
|
+
company: <Network size={16} />,
|
|
670
|
+
firstname: <Edit size={16} />,
|
|
671
|
+
lastname: <Edit size={16} />,
|
|
672
|
+
};
|
|
673
|
+
return iconMap[fieldId];
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
// Helper function to get field placeholder
|
|
677
|
+
const getFieldPlaceholder = (fieldId, label) => {
|
|
678
|
+
const placeholderMap = {
|
|
679
|
+
email: 'email@company.com',
|
|
680
|
+
mobile: '(555) 123-4567',
|
|
681
|
+
phone: '(555) 123-4567',
|
|
682
|
+
website: 'https://company.com',
|
|
683
|
+
position: 'Job Title',
|
|
684
|
+
company: 'Company Name',
|
|
685
|
+
firstname: 'First Name',
|
|
686
|
+
lastname: 'Last Name',
|
|
687
|
+
};
|
|
688
|
+
return placeholderMap[fieldId] || label;
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
// Helper function to check if form has minimum required data
|
|
692
|
+
const hasMinimumRequiredData = () => {
|
|
693
|
+
if (fields.length > 0) {
|
|
694
|
+
// Check if at least one field has data
|
|
695
|
+
return fields.some(field => extractedData[field.id]?.trim());
|
|
696
|
+
} else {
|
|
697
|
+
// Fallback for hardcoded fields
|
|
698
|
+
return extractedData.first_name?.trim() || extractedData.email?.trim();
|
|
699
|
+
}
|
|
700
|
+
};
|
|
701
|
+
|
|
702
|
+
const handleInputChange = (field, value) => {
|
|
703
|
+
setExtractedData((prev) => ({ ...prev, [field]: value }));
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
const handleUnusedDataClick = (text) => {
|
|
707
|
+
setSelectedUnusedText(text);
|
|
708
|
+
setShowAssignmentDropdown(true);
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
const assignToField = (fieldId, text) => {
|
|
712
|
+
// Update the field with the assigned text
|
|
713
|
+
setExtractedData((prev) => ({
|
|
714
|
+
...prev,
|
|
715
|
+
[fieldId]: text,
|
|
716
|
+
_unusedData: prev._unusedData?.filter(item => item.text !== text) || []
|
|
717
|
+
}));
|
|
718
|
+
setShowAssignmentDropdown(false);
|
|
719
|
+
setSelectedUnusedText('');
|
|
720
|
+
};
|
|
721
|
+
|
|
722
|
+
const handleSave = async () => {
|
|
723
|
+
try {
|
|
724
|
+
// For now, just simulate saving and pass the data to the callback
|
|
725
|
+
// Real implementation would make API call to save contact
|
|
726
|
+
toast.success('Contact information extracted successfully!');
|
|
727
|
+
|
|
728
|
+
if (onContactSaved) {
|
|
729
|
+
onContactSaved(extractedData);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
handleClose();
|
|
733
|
+
} catch (error) {
|
|
734
|
+
console.error('Save error:', error);
|
|
735
|
+
toast.error('Failed to save contact. Please try again.');
|
|
736
|
+
}
|
|
737
|
+
};
|
|
738
|
+
|
|
739
|
+
const handleClose = () => {
|
|
740
|
+
stopCamera();
|
|
741
|
+
setCapturedImage(null);
|
|
742
|
+
setExtractedData({});
|
|
743
|
+
setCurrentStep('camera');
|
|
744
|
+
setOcrProgress(0);
|
|
745
|
+
setShowAssignmentDropdown(false);
|
|
746
|
+
setSelectedUnusedText('');
|
|
747
|
+
setCurrentCameraIndex(0);
|
|
748
|
+
|
|
749
|
+
if (onClose) onClose();
|
|
750
|
+
if (onCancel) onCancel();
|
|
751
|
+
};
|
|
752
|
+
|
|
753
|
+
if (!isOpen) return null;
|
|
754
|
+
|
|
755
|
+
return (
|
|
756
|
+
<motion.div
|
|
757
|
+
className={ocrStyles.modalOverlay}
|
|
758
|
+
initial={{ opacity: 0 }}
|
|
759
|
+
animate={{ opacity: 1 }}
|
|
760
|
+
exit={{ opacity: 0 }}
|
|
761
|
+
>
|
|
762
|
+
<motion.div
|
|
763
|
+
className={ocrStyles.modalContent}
|
|
764
|
+
initial={{ scale: 0.9, opacity: 0 }}
|
|
765
|
+
animate={{ scale: 1, opacity: 1 }}
|
|
766
|
+
exit={{ scale: 0.9, opacity: 0 }}
|
|
767
|
+
>
|
|
768
|
+
<div className={ocrStyles.modalHeader}>
|
|
769
|
+
<h2>
|
|
770
|
+
{currentStep === 'camera' && 'Capture Business Card'}
|
|
771
|
+
{currentStep === 'preview' && 'Review Capture'}
|
|
772
|
+
{currentStep === 'processing' && 'Processing...'}
|
|
773
|
+
{currentStep === 'results' && 'Contact Information'}
|
|
774
|
+
</h2>
|
|
775
|
+
<button
|
|
776
|
+
onClick={handleClose}
|
|
777
|
+
className={ocrStyles.closeButton}
|
|
778
|
+
>
|
|
779
|
+
<CircleX size={24} />
|
|
780
|
+
</button>
|
|
781
|
+
</div>
|
|
782
|
+
|
|
783
|
+
<div className={ocrStyles.modalBody}>
|
|
784
|
+
{currentStep === 'camera' && (
|
|
785
|
+
<div className={ocrStyles.cameraContainer}>
|
|
786
|
+
<div className={ocrStyles.captureGuide}>
|
|
787
|
+
<p>
|
|
788
|
+
Position business card within the frame
|
|
789
|
+
</p>
|
|
790
|
+
</div>
|
|
791
|
+
<div className={ocrStyles.videoWrapper}>
|
|
792
|
+
<video
|
|
793
|
+
ref={videoRef}
|
|
794
|
+
autoPlay
|
|
795
|
+
playsInline
|
|
796
|
+
muted
|
|
797
|
+
className={ocrStyles.cameraVideo}
|
|
798
|
+
/>
|
|
799
|
+
<div className={ocrStyles.cameraOverlay}>
|
|
800
|
+
</div>
|
|
801
|
+
</div>
|
|
802
|
+
|
|
803
|
+
<div className={ocrStyles.cameraControls}>
|
|
804
|
+
{availableCameras.length > 1 && (
|
|
805
|
+
<button
|
|
806
|
+
onClick={switchCamera}
|
|
807
|
+
className={ocrStyles.secondaryButton}
|
|
808
|
+
>
|
|
809
|
+
<ArrowCycle size={20} />
|
|
810
|
+
Switch Camera
|
|
811
|
+
</button>
|
|
812
|
+
)}
|
|
813
|
+
|
|
814
|
+
<button
|
|
815
|
+
onClick={capturePhoto}
|
|
816
|
+
className={ocrStyles.captureButton}
|
|
817
|
+
>
|
|
818
|
+
<Camera size={24} />
|
|
819
|
+
Capture
|
|
820
|
+
</button>
|
|
821
|
+
|
|
822
|
+
<input
|
|
823
|
+
type="file"
|
|
824
|
+
ref={fileInputRef}
|
|
825
|
+
onChange={handleFileUpload}
|
|
826
|
+
accept="image/jpeg,image/png,image/jpg,image/gif,image/webp"
|
|
827
|
+
style={{ display: 'none' }}
|
|
828
|
+
/>
|
|
829
|
+
<button
|
|
830
|
+
onClick={() =>
|
|
831
|
+
fileInputRef.current?.click()
|
|
832
|
+
}
|
|
833
|
+
className={ocrStyles.secondaryButton}
|
|
834
|
+
>
|
|
835
|
+
<ImageIcon size={24} />
|
|
836
|
+
Upload Image
|
|
837
|
+
</button>
|
|
838
|
+
</div>
|
|
839
|
+
</div>
|
|
840
|
+
)}
|
|
841
|
+
|
|
842
|
+
{currentStep === 'preview' && capturedImage && (
|
|
843
|
+
<div className={ocrStyles.previewContainer}>
|
|
844
|
+
<div className={ocrStyles.imagePreview}>
|
|
845
|
+
<img
|
|
846
|
+
src={URL.createObjectURL(capturedImage)}
|
|
847
|
+
alt="Captured business card"
|
|
848
|
+
className={ocrStyles.previewImage}
|
|
849
|
+
/>
|
|
850
|
+
</div>
|
|
851
|
+
|
|
852
|
+
<div className={ocrStyles.previewControls}>
|
|
853
|
+
<button
|
|
854
|
+
onClick={retakePhoto}
|
|
855
|
+
className={ocrStyles.secondaryButton}
|
|
856
|
+
>
|
|
857
|
+
<ArrowCounterClockwise size={20} />
|
|
858
|
+
Retake
|
|
859
|
+
</button>
|
|
860
|
+
|
|
861
|
+
<button
|
|
862
|
+
onClick={processBusinessCard}
|
|
863
|
+
disabled={isProcessing}
|
|
864
|
+
className={ocrStyles.primaryButton}
|
|
865
|
+
>
|
|
866
|
+
<CircleCheck size={20} />
|
|
867
|
+
Process Card
|
|
868
|
+
</button>
|
|
869
|
+
</div>
|
|
870
|
+
</div>
|
|
871
|
+
)}
|
|
872
|
+
|
|
873
|
+
{currentStep === 'processing' && (
|
|
874
|
+
<div className={ocrStyles.processingContainer}>
|
|
875
|
+
<div className={ocrStyles.loadingSpinner}></div>
|
|
876
|
+
<p>Extracting contact information...</p>
|
|
877
|
+
<div className={ocrStyles.progressBar}>
|
|
878
|
+
<div
|
|
879
|
+
className={ocrStyles.progressFill}
|
|
880
|
+
style={{ width: `${ocrProgress}%` }}
|
|
881
|
+
></div>
|
|
882
|
+
</div>
|
|
883
|
+
<small>{ocrProgress}% complete</small>
|
|
884
|
+
</div>
|
|
885
|
+
)}
|
|
886
|
+
|
|
887
|
+
{currentStep === 'results' && (
|
|
888
|
+
<div className={ocrStyles.contactForm}>
|
|
889
|
+
<div className={ocrStyles.formGrid}>
|
|
890
|
+
{fields.length > 0 ? (
|
|
891
|
+
fields.map((field) => (
|
|
892
|
+
<div key={field.id} className={ocrStyles.fieldGroup}>
|
|
893
|
+
<label>
|
|
894
|
+
{getFieldIcon(field.id)}
|
|
895
|
+
{field.label}
|
|
896
|
+
</label>
|
|
897
|
+
<input
|
|
898
|
+
type={getFieldInputType(field.id)}
|
|
899
|
+
value={extractedData[field.id] || ''}
|
|
900
|
+
onChange={(e) =>
|
|
901
|
+
handleInputChange(
|
|
902
|
+
field.id,
|
|
903
|
+
e.target.value
|
|
904
|
+
)
|
|
905
|
+
}
|
|
906
|
+
placeholder={getFieldPlaceholder(field.id, field.label)}
|
|
907
|
+
className={ocrStyles.inputField}
|
|
908
|
+
/>
|
|
909
|
+
</div>
|
|
910
|
+
))
|
|
911
|
+
) : (
|
|
912
|
+
// Fallback to original hardcoded fields if no fields config provided
|
|
913
|
+
<>
|
|
914
|
+
<div className={ocrStyles.fieldGroup}>
|
|
915
|
+
<label>
|
|
916
|
+
<Edit size={16} />
|
|
917
|
+
First Name
|
|
918
|
+
</label>
|
|
919
|
+
<input
|
|
920
|
+
type="text"
|
|
921
|
+
value={extractedData.first_name || ''}
|
|
922
|
+
onChange={(e) =>
|
|
923
|
+
handleInputChange(
|
|
924
|
+
'first_name',
|
|
925
|
+
e.target.value
|
|
926
|
+
)
|
|
927
|
+
}
|
|
928
|
+
placeholder="First Name"
|
|
929
|
+
className={ocrStyles.inputField}
|
|
930
|
+
/>
|
|
931
|
+
</div>
|
|
932
|
+
<div className={ocrStyles.fieldGroup}>
|
|
933
|
+
<label>
|
|
934
|
+
<Edit size={16} />
|
|
935
|
+
Last Name
|
|
936
|
+
</label>
|
|
937
|
+
<input
|
|
938
|
+
type="text"
|
|
939
|
+
value={extractedData.last_name || ''}
|
|
940
|
+
onChange={(e) =>
|
|
941
|
+
handleInputChange(
|
|
942
|
+
'last_name',
|
|
943
|
+
e.target.value
|
|
944
|
+
)
|
|
945
|
+
}
|
|
946
|
+
placeholder="Last Name"
|
|
947
|
+
className={ocrStyles.inputField}
|
|
948
|
+
/>
|
|
949
|
+
</div>
|
|
950
|
+
<div className={ocrStyles.fieldGroup}>
|
|
951
|
+
<label>
|
|
952
|
+
<Envelope size={16} />
|
|
953
|
+
Email
|
|
954
|
+
</label>
|
|
955
|
+
<input
|
|
956
|
+
type="email"
|
|
957
|
+
value={extractedData.email || ''}
|
|
958
|
+
onChange={(e) =>
|
|
959
|
+
handleInputChange(
|
|
960
|
+
'email',
|
|
961
|
+
e.target.value
|
|
962
|
+
)
|
|
963
|
+
}
|
|
964
|
+
placeholder="email@company.com"
|
|
965
|
+
className={ocrStyles.inputField}
|
|
966
|
+
/>
|
|
967
|
+
</div>
|
|
968
|
+
<div className={ocrStyles.fieldGroup}>
|
|
969
|
+
<label>Phone</label>
|
|
970
|
+
<input
|
|
971
|
+
type="tel"
|
|
972
|
+
value={extractedData.phone || ''}
|
|
973
|
+
onChange={(e) =>
|
|
974
|
+
handleInputChange(
|
|
975
|
+
'phone',
|
|
976
|
+
e.target.value
|
|
977
|
+
)
|
|
978
|
+
}
|
|
979
|
+
placeholder="(555) 123-4567"
|
|
980
|
+
className={ocrStyles.inputField}
|
|
981
|
+
/>
|
|
982
|
+
</div>
|
|
983
|
+
<div className={ocrStyles.fieldGroup}>
|
|
984
|
+
<label>
|
|
985
|
+
<Network size={16} />
|
|
986
|
+
Company
|
|
987
|
+
</label>
|
|
988
|
+
<input
|
|
989
|
+
type="text"
|
|
990
|
+
value={extractedData.company || ''}
|
|
991
|
+
onChange={(e) =>
|
|
992
|
+
handleInputChange(
|
|
993
|
+
'company',
|
|
994
|
+
e.target.value
|
|
995
|
+
)
|
|
996
|
+
}
|
|
997
|
+
placeholder="Company Name"
|
|
998
|
+
className={ocrStyles.inputField}
|
|
999
|
+
/>
|
|
1000
|
+
</div>
|
|
1001
|
+
</>
|
|
1002
|
+
)}
|
|
1003
|
+
</div>
|
|
1004
|
+
|
|
1005
|
+
{extractedData._unusedData && extractedData._unusedData.length > 0 && (
|
|
1006
|
+
<div className={ocrStyles.unusedDataSection}>
|
|
1007
|
+
<h4>Unassigned Data</h4>
|
|
1008
|
+
<p>The following text wasn't automatically assigned. Drag or click to assign to a field:</p>
|
|
1009
|
+
<div className={ocrStyles.unusedDataGrid}>
|
|
1010
|
+
{extractedData._unusedData.map((item, index) => (
|
|
1011
|
+
<div
|
|
1012
|
+
key={`unused-${index}`}
|
|
1013
|
+
className={ocrStyles.unusedDataItem}
|
|
1014
|
+
onClick={() => handleUnusedDataClick(item.text)}
|
|
1015
|
+
>
|
|
1016
|
+
{item.text}
|
|
1017
|
+
</div>
|
|
1018
|
+
))}
|
|
1019
|
+
</div>
|
|
1020
|
+
{showAssignmentDropdown && (
|
|
1021
|
+
<div className={ocrStyles.assignmentDropdown}>
|
|
1022
|
+
<p>Assign "{selectedUnusedText}" to:</p>
|
|
1023
|
+
<div className={ocrStyles.assignmentOptions}>
|
|
1024
|
+
{fields.map((field) => (
|
|
1025
|
+
<button
|
|
1026
|
+
key={field.id}
|
|
1027
|
+
onClick={() => assignToField(field.id, selectedUnusedText)}
|
|
1028
|
+
className={ocrStyles.assignmentOption}
|
|
1029
|
+
>
|
|
1030
|
+
{field.label}
|
|
1031
|
+
</button>
|
|
1032
|
+
))}
|
|
1033
|
+
</div>
|
|
1034
|
+
<button
|
|
1035
|
+
onClick={() => setShowAssignmentDropdown(false)}
|
|
1036
|
+
className={ocrStyles.cancelAssignment}
|
|
1037
|
+
>
|
|
1038
|
+
Cancel
|
|
1039
|
+
</button>
|
|
1040
|
+
</div>
|
|
1041
|
+
)}
|
|
1042
|
+
</div>
|
|
1043
|
+
)}
|
|
1044
|
+
|
|
1045
|
+
{extractedData.rawText && (
|
|
1046
|
+
<details className={ocrStyles.rawTextSection}>
|
|
1047
|
+
<summary className={ocrStyles.rawTextSummary}>
|
|
1048
|
+
<span className={ocrStyles.summaryText}>
|
|
1049
|
+
📄 Raw Extracted Text
|
|
1050
|
+
</span>
|
|
1051
|
+
<span className={ocrStyles.summaryHint}>
|
|
1052
|
+
(Click to expand)
|
|
1053
|
+
</span>
|
|
1054
|
+
<span className={ocrStyles.expandIcon}>▶</span>
|
|
1055
|
+
</summary>
|
|
1056
|
+
<div className={ocrStyles.rawTextContent}>
|
|
1057
|
+
{extractedData.rawText}
|
|
1058
|
+
</div>
|
|
1059
|
+
</details>
|
|
1060
|
+
)}
|
|
1061
|
+
|
|
1062
|
+
<div className={ocrStyles.formActions}>
|
|
1063
|
+
<button
|
|
1064
|
+
onClick={retakePhoto}
|
|
1065
|
+
className={ocrStyles.secondaryButton}
|
|
1066
|
+
>
|
|
1067
|
+
<ImageIcon size={20} />
|
|
1068
|
+
Scan Another
|
|
1069
|
+
</button>
|
|
1070
|
+
|
|
1071
|
+
<button
|
|
1072
|
+
onClick={handleSave}
|
|
1073
|
+
className={ocrStyles.primaryButton}
|
|
1074
|
+
disabled={!hasMinimumRequiredData()}
|
|
1075
|
+
>
|
|
1076
|
+
Save Contact
|
|
1077
|
+
</button>
|
|
1078
|
+
</div>
|
|
1079
|
+
</div>
|
|
1080
|
+
)}
|
|
1081
|
+
</div>
|
|
1082
|
+
|
|
1083
|
+
<canvas ref={canvasRef} style={{ display: 'none' }} />
|
|
1084
|
+
</motion.div>
|
|
1085
|
+
</motion.div>
|
|
1086
|
+
);
|
|
1087
|
+
};
|
|
1088
|
+
|
|
1089
|
+
export default BusinessCardOcr;
|