@visns-studio/visns-components 5.9.6 → 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.
@@ -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;