@visns-studio/visns-components 5.9.6 → 5.9.8

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,2376 @@
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 CustomFetch from './Fetch';
21
+ import ocrStyles from './styles/BusinessCardOcr.module.scss';
22
+
23
+ const BusinessCardOcr = ({
24
+ onContactSaved,
25
+ onCancel,
26
+ isOpen = false,
27
+ onClose,
28
+ fields = [],
29
+ fetchConfig = null, // { url, method } from Navigation settings
30
+ }) => {
31
+ const [currentStep, setCurrentStep] = useState('camera'); // camera, preview, processing, results, consultation, decisions
32
+ const [capturedImage, setCapturedImage] = useState(null);
33
+ const [cameraStream, setCameraStream] = useState(null);
34
+ const [cameraFacing, setCameraFacing] = useState('environment'); // environment (back), user (front)
35
+ const [availableCameras, setAvailableCameras] = useState([]);
36
+ const [currentCameraIndex, setCurrentCameraIndex] = useState(0);
37
+ const [isProcessing, setIsProcessing] = useState(false);
38
+ const [extractedData, setExtractedData] = useState({});
39
+ const [ocrProgress, setOcrProgress] = useState(0);
40
+ const [showAssignmentDropdown, setShowAssignmentDropdown] = useState(false);
41
+ const [selectedUnusedText, setSelectedUnusedText] = useState('');
42
+ const [backendResponse, setBackendResponse] = useState(null);
43
+ const [selectedClient, setSelectedClient] = useState(null);
44
+ const [contactDecision, setContactDecision] = useState(null); // 'create', 'update', 'merge'
45
+ const [isConsulting, setIsConsulting] = useState(false);
46
+ const [isSaving, setIsSaving] = useState(false);
47
+
48
+ const videoRef = useRef(null);
49
+ const canvasRef = useRef(null);
50
+ const fileInputRef = useRef(null);
51
+
52
+ // Detect available cameras when component opens
53
+ useEffect(() => {
54
+ if (isOpen) {
55
+ detectCameras();
56
+ }
57
+ }, [isOpen]);
58
+
59
+
60
+ // Initialize camera when component opens
61
+ useEffect(() => {
62
+ if (isOpen && currentStep === 'camera') {
63
+ initializeCamera();
64
+ }
65
+ return () => {
66
+ stopCamera();
67
+ };
68
+ }, [
69
+ isOpen,
70
+ currentStep,
71
+ cameraFacing,
72
+ currentCameraIndex,
73
+ availableCameras,
74
+ ]);
75
+
76
+ const detectCameras = async () => {
77
+ try {
78
+ // Request permission first to get proper device labels
79
+ await navigator.mediaDevices.getUserMedia({ video: true });
80
+
81
+ const devices = await navigator.mediaDevices.enumerateDevices();
82
+ const videoDevices = devices.filter(
83
+ (device) => device.kind === 'videoinput' && device.deviceId
84
+ );
85
+
86
+ // Filter out devices that might not work
87
+ const workingCameras = [];
88
+ for (const device of videoDevices) {
89
+ try {
90
+ const testStream =
91
+ await navigator.mediaDevices.getUserMedia({
92
+ video: { deviceId: device.deviceId },
93
+ });
94
+ testStream.getTracks().forEach((track) => track.stop());
95
+ workingCameras.push(device);
96
+ } catch (err) {
97
+ console.warn(
98
+ `Camera ${
99
+ device.label || device.deviceId
100
+ } not accessible:`,
101
+ err
102
+ );
103
+ }
104
+ }
105
+
106
+ setAvailableCameras(workingCameras);
107
+ } catch (error) {
108
+ console.error('Error detecting cameras:', error);
109
+ setAvailableCameras([]);
110
+ }
111
+ };
112
+
113
+ const initializeCamera = async () => {
114
+ try {
115
+ let constraints;
116
+
117
+ // Use specific device if available cameras are detected
118
+ if (
119
+ availableCameras.length > 0 &&
120
+ availableCameras[currentCameraIndex]
121
+ ) {
122
+ constraints = {
123
+ video: {
124
+ deviceId: {
125
+ exact: availableCameras[currentCameraIndex]
126
+ .deviceId,
127
+ },
128
+ width: { ideal: 1280 },
129
+ height: { ideal: 720 },
130
+ },
131
+ };
132
+ } else {
133
+ // Fallback to facing mode
134
+ constraints = {
135
+ video: {
136
+ facingMode: cameraFacing,
137
+ width: { ideal: 1280 },
138
+ height: { ideal: 720 },
139
+ },
140
+ };
141
+ }
142
+
143
+ const stream = await navigator.mediaDevices.getUserMedia(
144
+ constraints
145
+ );
146
+ setCameraStream(stream);
147
+
148
+ if (videoRef.current) {
149
+ videoRef.current.srcObject = stream;
150
+ }
151
+ } catch (error) {
152
+ console.error('Error accessing camera:', error);
153
+ toast.error('Unable to access camera. Please check permissions.');
154
+ }
155
+ };
156
+
157
+ const stopCamera = () => {
158
+ if (cameraStream) {
159
+ cameraStream.getTracks().forEach((track) => track.stop());
160
+ setCameraStream(null);
161
+ }
162
+ };
163
+
164
+ const capturePhoto = useCallback(() => {
165
+ if (!videoRef.current || !canvasRef.current) return;
166
+
167
+ const video = videoRef.current;
168
+ const canvas = canvasRef.current;
169
+ const context = canvas.getContext('2d');
170
+
171
+ canvas.width = video.videoWidth;
172
+ canvas.height = video.videoHeight;
173
+ context.drawImage(video, 0, 0);
174
+
175
+ canvas.toBlob(
176
+ (blob) => {
177
+ setCapturedImage(blob);
178
+ setCurrentStep('preview');
179
+ stopCamera();
180
+ },
181
+ 'image/jpeg',
182
+ 0.9
183
+ );
184
+ }, []);
185
+
186
+ const switchCamera = () => {
187
+ if (availableCameras.length > 1) {
188
+ setCurrentCameraIndex(
189
+ (prev) => (prev + 1) % availableCameras.length
190
+ );
191
+ } else {
192
+ // Fallback to facing mode switch
193
+ setCameraFacing((prev) =>
194
+ prev === 'environment' ? 'user' : 'environment'
195
+ );
196
+ }
197
+ };
198
+
199
+ const retakePhoto = () => {
200
+ setCapturedImage(null);
201
+ setCurrentStep('camera');
202
+ };
203
+
204
+ const handleFileUpload = (event) => {
205
+ const file = event.target.files[0];
206
+
207
+ if (!file) {
208
+ return;
209
+ }
210
+
211
+ // Validate file type
212
+ if (!file.type.startsWith('image/')) {
213
+ toast.error('Please select a valid image file (JPG, PNG, etc.)');
214
+ return;
215
+ }
216
+
217
+ // Validate file size (max 10MB)
218
+ const maxSize = 10 * 1024 * 1024; // 10MB
219
+ if (file.size > maxSize) {
220
+ toast.error(
221
+ 'Image file is too large. Please select a file smaller than 10MB.'
222
+ );
223
+ return;
224
+ }
225
+
226
+ // Check if it's a reasonable image size
227
+ const img = new Image();
228
+ img.onload = function () {
229
+ if (this.width < 100 || this.height < 100) {
230
+ toast.error(
231
+ 'Image is too small. Please select a larger image.'
232
+ );
233
+ URL.revokeObjectURL(this.src); // Clean up memory
234
+ return;
235
+ }
236
+
237
+ URL.revokeObjectURL(this.src); // Clean up memory
238
+ setCapturedImage(file);
239
+ setCurrentStep('preview');
240
+ stopCamera();
241
+ };
242
+
243
+ img.onerror = function () {
244
+ toast.error(
245
+ 'Unable to load the selected image. Please try another file.'
246
+ );
247
+ URL.revokeObjectURL(this.src); // Clean up memory
248
+ };
249
+
250
+ const objectUrl = URL.createObjectURL(file);
251
+ img.src = objectUrl;
252
+
253
+ // Clear the input to allow re-selecting the same file
254
+ event.target.value = '';
255
+ };
256
+
257
+ const processBusinessCard = async () => {
258
+ if (!capturedImage) return;
259
+
260
+ setIsProcessing(true);
261
+ setCurrentStep('processing');
262
+ setOcrProgress(0);
263
+
264
+ try {
265
+ const result = await Tesseract.recognize(capturedImage, 'eng', {
266
+ logger: (m) => {
267
+ if (m.status === 'recognizing text') {
268
+ const progress = Math.round(m.progress * 100);
269
+ setOcrProgress(progress);
270
+ }
271
+ },
272
+ });
273
+
274
+ const text = result.data.text;
275
+ const parsedData = parseExtractedText(text);
276
+ setExtractedData({ ...parsedData, rawText: text });
277
+ setCurrentStep('results');
278
+ toast.success('Business card processed successfully!');
279
+ } catch (error) {
280
+ console.error('OCR Error:', error);
281
+ toast.error('Failed to process business card. Please try again.');
282
+ setCurrentStep('preview');
283
+ } finally {
284
+ setIsProcessing(false);
285
+ setOcrProgress(0);
286
+ }
287
+ };
288
+
289
+ // Helper functions for data quality validation
290
+ const isValidName = (name) => {
291
+ if (!name || typeof name !== 'string') return false;
292
+
293
+ const trimmed = name.trim();
294
+
295
+ // Must be at least 2 characters for first name, 1 for last name
296
+ if (trimmed.length < 1) return false;
297
+
298
+ // No single character first names except common ones
299
+ const commonSingleChars = [
300
+ 'J',
301
+ 'D',
302
+ 'R',
303
+ 'M',
304
+ 'K',
305
+ 'T',
306
+ 'S',
307
+ 'B',
308
+ 'C',
309
+ 'A',
310
+ ];
311
+ if (
312
+ trimmed.length === 1 &&
313
+ !commonSingleChars.includes(trimmed.toUpperCase())
314
+ ) {
315
+ return false;
316
+ }
317
+
318
+ // Must contain mostly letters
319
+ const letterCount = (trimmed.match(/[a-zA-Z]/g) || []).length;
320
+ const letterRatio = letterCount / trimmed.length;
321
+ if (letterRatio < 0.6) return false;
322
+
323
+ // No operators or excessive special characters
324
+ const invalidChars = /[=+\-*/<>|\\#$%^&@!~`{}[\]]/;
325
+ if (invalidChars.test(trimmed)) return false;
326
+
327
+ // Allow common name characters
328
+ const validNamePattern = /^[a-zA-Z\s'.,-]+$/;
329
+ return validNamePattern.test(trimmed);
330
+ };
331
+
332
+ const isLikelyOCRArtifact = (line) => {
333
+ if (!line || typeof line !== 'string') return true;
334
+
335
+ const trimmed = line.trim();
336
+
337
+ // Too short or too long
338
+ if (trimmed.length < 2 || trimmed.length > 50) return true;
339
+
340
+ // Calculate character type ratios
341
+ const letters = (trimmed.match(/[a-zA-Z]/g) || []).length;
342
+ const numbers = (trimmed.match(/[0-9]/g) || []).length;
343
+ const symbols = (trimmed.match(/[^a-zA-Z0-9\s.,;:!?'-]/g) || []).length;
344
+ const spaces = (trimmed.match(/\s/g) || []).length;
345
+ const total = trimmed.length;
346
+ const letterRatio = letters / total;
347
+ const symbolRatio = symbols / total;
348
+ const spaceRatio = spaces / total;
349
+
350
+ // Skip if mostly symbols
351
+ if (symbolRatio > 0.3) return true;
352
+
353
+ // Skip if too many consecutive spaces or weird spacing
354
+ if (spaceRatio > 0.4) return true;
355
+
356
+ // Skip if it's all numbers or mostly numbers (unless it looks like a phone)
357
+ if (
358
+ numbers > 0 &&
359
+ letterRatio < 0.2 &&
360
+ !trimmed.match(/^\+?[\d\s\-()]+$/)
361
+ )
362
+ return true;
363
+
364
+ // Skip lines that look like formatting artifacts
365
+ const formatArtifacts =
366
+ /^[.\-_|]+$|^[^a-zA-Z]*[|]+[^a-zA-Z]*$|^\s*[.]\s*[A-Z]?\s*$/;
367
+ if (formatArtifacts.test(trimmed)) return true;
368
+
369
+ // Skip if it has random character combinations
370
+ const randomPattern =
371
+ /^[a-zA-Z]{1,2}\s+[a-zA-Z]{1,2}(\s+[a-zA-Z]{1,2})*$/;
372
+ if (randomPattern.test(trimmed) && trimmed.length < 8) return true;
373
+
374
+ // Enhanced gibberish detection
375
+ if (isGibberish(trimmed)) return true;
376
+
377
+ return false;
378
+ };
379
+
380
+ // Advanced gibberish detection
381
+ const isGibberish = (text) => {
382
+ if (!text || text.length < 3) return false;
383
+
384
+ const words = text.split(/\s+/).filter((word) => word.length > 0);
385
+
386
+ // Check for excessive mixed case patterns like "Li FOR go > ips"
387
+ let mixedCaseWords = 0;
388
+ let gibberishWords = 0;
389
+ let totalWords = words.length;
390
+
391
+ for (const word of words) {
392
+ // Skip very short words
393
+ if (word.length < 2) continue;
394
+
395
+ // Check for excessive symbol contamination in words
396
+ const symbolsInWord = (word.match(/[^a-zA-Z0-9]/g) || []).length;
397
+ if (symbolsInWord / word.length > 0.3) {
398
+ gibberishWords++;
399
+ continue;
400
+ }
401
+
402
+ // Check for random mixed case patterns
403
+ const hasRandomCase = /[a-z][A-Z]|[A-Z][a-z][A-Z]/.test(word);
404
+ if (hasRandomCase && word.length > 2) {
405
+ mixedCaseWords++;
406
+ }
407
+
408
+ // Check for consonant/vowel patterns that don't make sense
409
+ const consonantClusters = word.match(
410
+ /[bcdfghjklmnpqrstvwxzBCDFGHJKLMNPQRSTVWXZ]{4,}/g
411
+ );
412
+ if (consonantClusters && consonantClusters.length > 0) {
413
+ gibberishWords++;
414
+ continue;
415
+ }
416
+
417
+ // Check for excessive single character "words"
418
+ if (word.length === 1 && /[a-zA-Z]/.test(word)) {
419
+ gibberishWords++;
420
+ continue;
421
+ }
422
+
423
+ // Check for patterns like "FOR", "ips", "ED", "RE" in sequence (common OCR errors)
424
+ if (word.length <= 3 && word.match(/^[A-Z]{2,3}$|^[a-z]{2,3}$/)) {
425
+ // Count as potential gibberish if there are many of these short words
426
+ gibberishWords += 0.5;
427
+ }
428
+ }
429
+
430
+ // If more than 60% of words are gibberish or mixed case, consider it gibberish
431
+ const gibberishRatio = gibberishWords / Math.max(totalWords, 1);
432
+ const mixedCaseRatio = mixedCaseWords / Math.max(totalWords, 1);
433
+
434
+ if (gibberishRatio > 0.6) return true;
435
+ if (mixedCaseRatio > 0.5 && totalWords > 2) return true;
436
+
437
+ // Check for specific gibberish patterns
438
+ const gibberishPatterns = [
439
+ // Patterns like ": Re : Bei oc oC"
440
+ /^[:;]\s*[A-Za-z]{1,3}\s*[:;]\s*[A-Za-z]{1,4}\s+[a-z]{1,2}\s+[a-zA-Z]{1,2}$/,
441
+ // Patterns like "HE — SE TL Beant 2 Es Re Se"
442
+ /^[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}$/,
443
+ // Patterns with excessive brackets, symbols mixed with short words
444
+ /[>\[\]]{2,}|[A-Za-z]{1,3}\s+[>\[\]<]+\s+[A-Za-z]{1,3}/,
445
+ // Excessive punctuation mixed with short letter sequences
446
+ /^[^a-zA-Z]*[a-zA-Z]{1,2}[^a-zA-Z]+[a-zA-Z]{1,3}[^a-zA-Z]+.*$/,
447
+ ];
448
+
449
+ for (const pattern of gibberishPatterns) {
450
+ if (pattern.test(text)) return true;
451
+ }
452
+
453
+ // Check for excessive spacing patterns
454
+ if (/\s{3,}/.test(text)) return true;
455
+
456
+ // Check for lines with too many single characters
457
+ const singleChars = text.match(/\b[a-zA-Z]\b/g);
458
+ if (singleChars && singleChars.length > 3 && text.length < 30)
459
+ return true;
460
+
461
+ return false;
462
+ };
463
+
464
+ const hasValidNameStructure = (words) => {
465
+ if (!words || words.length === 0) return false;
466
+
467
+ // Check each word for name validity
468
+ for (const word of words) {
469
+ if (!isValidName(word)) return false;
470
+ }
471
+
472
+ // First word should be at least 2 characters (unless common single char)
473
+ const firstName = words[0];
474
+ const commonSingleChars = [
475
+ 'J',
476
+ 'D',
477
+ 'R',
478
+ 'M',
479
+ 'K',
480
+ 'T',
481
+ 'S',
482
+ 'B',
483
+ 'C',
484
+ 'A',
485
+ ];
486
+ if (
487
+ firstName.length === 1 &&
488
+ !commonSingleChars.includes(firstName.toUpperCase())
489
+ ) {
490
+ return false;
491
+ }
492
+
493
+ return true;
494
+ };
495
+
496
+ // Helper function to clean and validate field values
497
+ const cleanFieldValue = (value, fieldType = 'general') => {
498
+ if (!value || typeof value !== 'string') return '';
499
+
500
+ let cleaned = value.trim();
501
+
502
+ // Remove common OCR artifacts and invalid characters
503
+ switch (fieldType) {
504
+ case 'name':
505
+ // For names: only letters, spaces, apostrophes, periods, hyphens
506
+ cleaned = cleaned.replace(/[^a-zA-Z\s'.-]/g, '');
507
+ // Remove excessive spaces
508
+ cleaned = cleaned.replace(/\s+/g, ' ');
509
+ // Remove leading/trailing punctuation
510
+ cleaned = cleaned.replace(/^[^a-zA-Z]+|[^a-zA-Z]+$/g, '');
511
+ break;
512
+
513
+ case 'company':
514
+ // For companies: letters, numbers, spaces, common punctuation
515
+ cleaned = cleaned.replace(/[^a-zA-Z0-9\s&.,'-]/g, '');
516
+ // Remove pipe characters and equals signs that OCR often mistakes
517
+ cleaned = cleaned.replace(/[|="]/g, '');
518
+ // Remove excessive spaces
519
+ cleaned = cleaned.replace(/\s+/g, ' ');
520
+ // Remove leading/trailing punctuation except periods
521
+ cleaned = cleaned.replace(
522
+ /^[^a-zA-Z0-9]+|[^a-zA-Z0-9.]+$/g,
523
+ ''
524
+ );
525
+ break;
526
+
527
+ case 'position':
528
+ // For positions: letters, spaces, common punctuation
529
+ cleaned = cleaned.replace(/[^a-zA-Z\s&.,'-]/g, '');
530
+ // Remove pipe characters and equals signs
531
+ cleaned = cleaned.replace(/[|="]/g, '');
532
+ // Remove excessive spaces
533
+ cleaned = cleaned.replace(/\s+/g, ' ');
534
+ // Remove leading/trailing punctuation
535
+ cleaned = cleaned.replace(/^[^a-zA-Z]+|[^a-zA-Z]+$/g, '');
536
+ break;
537
+
538
+ case 'email':
539
+ // For emails: standard email characters only
540
+ cleaned = cleaned.replace(/[^a-zA-Z0-9@._+-]/g, '');
541
+ break;
542
+
543
+ case 'phone':
544
+ // For phones: numbers, spaces, parentheses, hyphens, plus
545
+ cleaned = cleaned.replace(/[^0-9\s()+-]/g, '');
546
+ break;
547
+
548
+ case 'website':
549
+ // For websites: standard URL characters
550
+ cleaned = cleaned.replace(
551
+ /[^a-zA-Z0-9./:?#@!$&'()*+,;=-]/g,
552
+ ''
553
+ );
554
+ break;
555
+
556
+ default:
557
+ // General cleaning: remove obvious artifacts
558
+ cleaned = cleaned.replace(/[|="]/g, '');
559
+ cleaned = cleaned.replace(/\s+/g, ' ');
560
+ }
561
+
562
+ // Final cleanup
563
+ cleaned = cleaned.trim();
564
+
565
+ // Return empty string if result is too short or invalid
566
+ if (cleaned.length < 1) return '';
567
+
568
+ return cleaned;
569
+ };
570
+
571
+ const parseExtractedText = (text) => {
572
+ const lines = text.split('\n').filter((line) => line.trim());
573
+ const parsed = {};
574
+ const unusedLines = [];
575
+ const usedLineIndices = new Set();
576
+
577
+ // Enhanced regex patterns
578
+ const emailRegex =
579
+ /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/;
580
+ const phoneRegex =
581
+ /(?:\+?1[-.\s]?)?\(?([0-9]{3})\)?[-.\s]?([0-9]{3})[-.\s]?([0-9]{4})/;
582
+ const websiteRegex =
583
+ /(?:https?:\/\/)?(?:www\.)?[a-zA-Z0-9-]+\.[a-zA-Z]{2,}(?:\.[a-zA-Z]{2,})?/;
584
+
585
+ // Job title keywords for better position detection
586
+ const jobTitleKeywords = [
587
+ 'ceo',
588
+ 'cto',
589
+ 'cfo',
590
+ 'manager',
591
+ 'director',
592
+ 'president',
593
+ 'vice',
594
+ 'senior',
595
+ 'lead',
596
+ 'head',
597
+ 'chief',
598
+ 'supervisor',
599
+ 'coordinator',
600
+ 'specialist',
601
+ 'analyst',
602
+ 'engineer',
603
+ 'developer',
604
+ 'designer',
605
+ 'consultant',
606
+ 'sales',
607
+ 'marketing',
608
+ 'hr',
609
+ 'owner',
610
+ 'founder',
611
+ ];
612
+
613
+ // Company indicators
614
+ const companyIndicators = [
615
+ 'inc',
616
+ 'corp',
617
+ 'ltd',
618
+ 'llc',
619
+ 'company',
620
+ 'co.',
621
+ 'corporation',
622
+ 'incorporated',
623
+ 'limited',
624
+ 'enterprises',
625
+ 'group',
626
+ 'solutions',
627
+ 'services',
628
+ 'systems',
629
+ 'technologies',
630
+ 'tech',
631
+ 'consulting',
632
+ ];
633
+
634
+ // Smart field extraction with confidence scoring
635
+ lines.forEach((line, index) => {
636
+ const trimmedLine = line.trim();
637
+ const lowerLine = trimmedLine.toLowerCase();
638
+
639
+ // Skip obvious OCR artifacts early
640
+ if (isLikelyOCRArtifact(trimmedLine)) {
641
+ return;
642
+ }
643
+
644
+ // Extract email (high confidence)
645
+ const emailMatch = trimmedLine.match(emailRegex);
646
+ if (emailMatch && !parsed.email) {
647
+ const cleanedEmail = cleanFieldValue(emailMatch[0], 'email');
648
+ if (cleanedEmail) {
649
+ parsed.email = cleanedEmail;
650
+ usedLineIndices.add(index);
651
+ return;
652
+ }
653
+ }
654
+
655
+ // Extract phone/mobile (high confidence)
656
+ const phoneMatch = trimmedLine.match(phoneRegex);
657
+ if (phoneMatch && !parsed.mobile && !parsed.phone) {
658
+ const cleanedPhone = cleanFieldValue(phoneMatch[0], 'phone');
659
+ if (cleanedPhone) {
660
+ parsed.mobile = cleanedPhone;
661
+ parsed.phone = cleanedPhone; // For backward compatibility
662
+ usedLineIndices.add(index);
663
+ return;
664
+ }
665
+ }
666
+
667
+ // Extract website (high confidence)
668
+ const websiteMatch = trimmedLine.match(websiteRegex);
669
+ if (websiteMatch && !parsed.website && !emailMatch) {
670
+ const cleanedWebsite = cleanFieldValue(
671
+ websiteMatch[0],
672
+ 'website'
673
+ );
674
+ if (cleanedWebsite) {
675
+ parsed.website = cleanedWebsite;
676
+ usedLineIndices.add(index);
677
+ return;
678
+ }
679
+ }
680
+
681
+ // Smart name detection with enhanced validation
682
+ if (
683
+ !parsed.firstname &&
684
+ !emailMatch &&
685
+ !phoneMatch &&
686
+ !websiteMatch
687
+ ) {
688
+ const words = trimmedLine
689
+ .split(/\s+/)
690
+ .filter((word) => word.length > 0);
691
+ const hasJobTitle = jobTitleKeywords.some((keyword) =>
692
+ lowerLine.includes(keyword)
693
+ );
694
+ const hasCompanyIndicator = companyIndicators.some(
695
+ (indicator) => lowerLine.includes(indicator)
696
+ );
697
+
698
+ // Enhanced name validation
699
+ if (
700
+ words.length >= 1 &&
701
+ words.length <= 3 &&
702
+ !hasJobTitle &&
703
+ !hasCompanyIndicator &&
704
+ !(
705
+ trimmedLine === trimmedLine.toUpperCase() &&
706
+ trimmedLine.length > 3
707
+ ) &&
708
+ hasValidNameStructure(words)
709
+ ) {
710
+ const firstName = words[0];
711
+ const lastName = words.slice(1).join(' ');
712
+
713
+ // Final validation for names
714
+ if (
715
+ isValidName(firstName) &&
716
+ (lastName === '' || isValidName(lastName))
717
+ ) {
718
+ const cleanedFirstName = cleanFieldValue(
719
+ firstName,
720
+ 'name'
721
+ );
722
+ const cleanedLastName = cleanFieldValue(
723
+ lastName,
724
+ 'name'
725
+ );
726
+
727
+ if (cleanedFirstName) {
728
+ parsed.firstname = cleanedFirstName;
729
+ parsed.lastname = cleanedLastName;
730
+ // For backward compatibility
731
+ parsed.first_name = parsed.firstname;
732
+ parsed.last_name = parsed.lastname;
733
+ usedLineIndices.add(index);
734
+ return;
735
+ }
736
+ }
737
+ }
738
+ }
739
+
740
+ // Smart position/job title detection with validation
741
+ if (!parsed.position) {
742
+ const hasJobTitle = jobTitleKeywords.some((keyword) =>
743
+ lowerLine.includes(keyword)
744
+ );
745
+ const words = trimmedLine.split(/\s+/);
746
+ const letterRatio =
747
+ (trimmedLine.match(/[a-zA-Z]/g) || []).length /
748
+ trimmedLine.length;
749
+
750
+ // Must have good letter ratio and reasonable length
751
+ if (
752
+ letterRatio > 0.6 &&
753
+ trimmedLine.length >= 3 &&
754
+ trimmedLine.length <= 40 &&
755
+ (hasJobTitle ||
756
+ (words.length >= 2 &&
757
+ words.length <= 6 &&
758
+ !companyIndicators.some((indicator) =>
759
+ lowerLine.includes(indicator)
760
+ )))
761
+ ) {
762
+ const cleanedPosition = cleanFieldValue(
763
+ trimmedLine,
764
+ 'position'
765
+ );
766
+ if (cleanedPosition) {
767
+ parsed.position = cleanedPosition;
768
+ usedLineIndices.add(index);
769
+ return;
770
+ }
771
+ }
772
+ }
773
+
774
+ // Smart company detection with validation
775
+ if (!parsed.company) {
776
+ const hasCompanyIndicator = companyIndicators.some(
777
+ (indicator) => lowerLine.includes(indicator)
778
+ );
779
+ const isAllCaps =
780
+ trimmedLine === trimmedLine.toUpperCase() &&
781
+ trimmedLine.length > 2;
782
+ const words = trimmedLine.split(/\s+/);
783
+ const letterRatio =
784
+ (trimmedLine.match(/[a-zA-Z]/g) || []).length /
785
+ trimmedLine.length;
786
+
787
+ // Must have good letter ratio and reasonable structure
788
+ if (
789
+ letterRatio > 0.5 &&
790
+ trimmedLine.length >= 2 &&
791
+ trimmedLine.length <= 50 &&
792
+ (hasCompanyIndicator ||
793
+ isAllCaps ||
794
+ (words.length >= 1 &&
795
+ words.length <= 4 &&
796
+ index > 0 &&
797
+ !jobTitleKeywords.some((keyword) =>
798
+ lowerLine.includes(keyword)
799
+ )))
800
+ ) {
801
+ const cleanedCompany = cleanFieldValue(
802
+ trimmedLine,
803
+ 'company'
804
+ );
805
+ if (cleanedCompany) {
806
+ parsed.company = cleanedCompany;
807
+ usedLineIndices.add(index);
808
+ return;
809
+ }
810
+ }
811
+ }
812
+ });
813
+
814
+ // Collect unused lines for manual assignment (excluding artifacts)
815
+ lines.forEach((line, index) => {
816
+ if (
817
+ !usedLineIndices.has(index) &&
818
+ !isLikelyOCRArtifact(line.trim())
819
+ ) {
820
+ const cleanedLine = cleanFieldValue(line.trim(), 'general');
821
+ if (cleanedLine && cleanedLine.length > 1) {
822
+ unusedLines.push({
823
+ text: cleanedLine,
824
+ index: index,
825
+ });
826
+ }
827
+ }
828
+ });
829
+
830
+ parsed._unusedData = unusedLines;
831
+ return parsed;
832
+ };
833
+
834
+ // Helper function to get field input type
835
+ const getFieldInputType = (fieldId) => {
836
+ const fieldTypeMap = {
837
+ email: 'email',
838
+ mobile: 'tel',
839
+ phone: 'tel',
840
+ website: 'url',
841
+ position: 'text',
842
+ company: 'text',
843
+ firstname: 'text',
844
+ lastname: 'text',
845
+ };
846
+ return fieldTypeMap[fieldId] || 'text';
847
+ };
848
+
849
+ // Helper function to get field icon
850
+ const getFieldIcon = (fieldId) => {
851
+ const iconMap = {
852
+ email: <Envelope size={16} />,
853
+ mobile: null,
854
+ phone: null,
855
+ website: <Network size={16} />,
856
+ position: null,
857
+ company: <Network size={16} />,
858
+ firstname: <Edit size={16} />,
859
+ lastname: <Edit size={16} />,
860
+ };
861
+ return iconMap[fieldId];
862
+ };
863
+
864
+ // Helper function to get field placeholder
865
+ const getFieldPlaceholder = (fieldId, label) => {
866
+ const placeholderMap = {
867
+ email: 'email@company.com',
868
+ mobile: '(555) 123-4567',
869
+ phone: '(555) 123-4567',
870
+ website: 'https://company.com',
871
+ position: 'Job Title',
872
+ company: 'Company Name',
873
+ firstname: 'First Name',
874
+ lastname: 'Last Name',
875
+ };
876
+ return placeholderMap[fieldId] || label;
877
+ };
878
+
879
+ // Helper function to check if form has minimum required data
880
+ const hasMinimumRequiredData = () => {
881
+ if (fields.length > 0) {
882
+ // Check if at least one field has data
883
+ return fields.some((field) => extractedData[field.id]?.trim());
884
+ } else {
885
+ // Fallback for hardcoded fields
886
+ return (
887
+ extractedData.first_name?.trim() || extractedData.email?.trim()
888
+ );
889
+ }
890
+ };
891
+
892
+ const handleInputChange = (field, value) => {
893
+ setExtractedData((prev) => ({ ...prev, [field]: value }));
894
+ };
895
+
896
+ const handleUnusedDataClick = (text) => {
897
+ setSelectedUnusedText(text);
898
+ setShowAssignmentDropdown(true);
899
+ };
900
+
901
+ const assignToField = (fieldId, text) => {
902
+ // Update the field with the assigned text
903
+ setExtractedData((prev) => ({
904
+ ...prev,
905
+ [fieldId]: text,
906
+ _unusedData:
907
+ prev._unusedData?.filter((item) => item.text !== text) || [],
908
+ }));
909
+ setShowAssignmentDropdown(false);
910
+ setSelectedUnusedText('');
911
+ };
912
+
913
+ // Backend consultation step
914
+ const consultBackend = async () => {
915
+ if (!fetchConfig?.url) {
916
+ // Fallback to direct save if no backend configuration
917
+ return handleDirectSave();
918
+ }
919
+
920
+ setIsConsulting(true);
921
+ setCurrentStep('consultation');
922
+
923
+ try {
924
+ const response = await CustomFetch(
925
+ fetchConfig.url,
926
+ fetchConfig.method || 'POST',
927
+ extractedData
928
+ );
929
+
930
+ if (!response.data.success) {
931
+ throw new Error(
932
+ response.data.message || 'Backend consultation failed'
933
+ );
934
+ }
935
+
936
+ setBackendResponse(response.data.data);
937
+
938
+ // Determine next step based on recommendations
939
+ const recommendations = response.data.data.recommendations || [];
940
+ if (recommendations.length > 0) {
941
+ setCurrentStep('decisions');
942
+ } else {
943
+ // No conflicts, proceed to direct save
944
+ await handleFinalSave();
945
+ }
946
+ } catch (error) {
947
+ console.error('Backend consultation error:', error);
948
+ toast.error(
949
+ 'Failed to check for existing contacts. Proceeding with direct save.'
950
+ );
951
+ await handleDirectSave();
952
+ } finally {
953
+ setIsConsulting(false);
954
+ }
955
+ };
956
+
957
+ // Direct save for fallback scenarios
958
+ const handleDirectSave = async () => {
959
+ try {
960
+ toast.success('Contact information extracted successfully!');
961
+
962
+ if (onContactSaved) {
963
+ onContactSaved(extractedData);
964
+ }
965
+
966
+ handleClose();
967
+ } catch (error) {
968
+ console.error('Save error:', error);
969
+ toast.error('Failed to save contact. Please try again.');
970
+ }
971
+ };
972
+
973
+ // Final save after user decisions
974
+ const handleFinalSave = async () => {
975
+ setIsSaving(true);
976
+
977
+ try {
978
+ // Prepare final data based on user decisions
979
+ const finalData = {
980
+ ...extractedData,
981
+ _clientDecision: selectedClient,
982
+ _contactDecision: contactDecision,
983
+ _backendResponse: backendResponse,
984
+ };
985
+
986
+ if (
987
+ fetchConfig?.url &&
988
+ fetchConfig?.url.includes('processBusinessCard')
989
+ ) {
990
+ // Make actual API call to save contact
991
+ const saveUrl = fetchConfig.url.replace(
992
+ 'processBusinessCard',
993
+ 'saveContact'
994
+ );
995
+
996
+ const response = await CustomFetch(saveUrl, 'POST', finalData);
997
+
998
+ if (!response.data.success) {
999
+ throw new Error(response.data.message || 'Failed to save contact');
1000
+ }
1001
+
1002
+ toast.success(response.data.message || 'Contact saved successfully!');
1003
+
1004
+ if (onContactSaved) {
1005
+ onContactSaved({
1006
+ ...finalData,
1007
+ _savedContact: response.data.data.contact,
1008
+ _actionTaken: response.data.data.action_taken,
1009
+ _clientAction: response.data.data.client_action,
1010
+ });
1011
+ }
1012
+ } else {
1013
+ // Fallback to callback only
1014
+ toast.success('Contact saved successfully!');
1015
+
1016
+ if (onContactSaved) {
1017
+ onContactSaved(finalData);
1018
+ }
1019
+ }
1020
+
1021
+ handleClose();
1022
+ } catch (error) {
1023
+ console.error('Final save error:', error);
1024
+ toast.error('Failed to save contact. Please try again.');
1025
+ } finally {
1026
+ setIsSaving(false);
1027
+ }
1028
+ };
1029
+
1030
+ // Main save handler - "Next" triggers consultation, "Save Contact" does direct save
1031
+ const handleSave = async () => {
1032
+ if (fetchConfig?.url) {
1033
+ // With backend: "Next" → Consultation → Decisions → "Confirm & Save"
1034
+ await consultBackend();
1035
+ } else {
1036
+ // Without backend: "Save Contact" → Direct save
1037
+ await handleDirectSave();
1038
+ }
1039
+ };
1040
+
1041
+ const handleClose = () => {
1042
+ stopCamera();
1043
+ setCapturedImage(null);
1044
+ setExtractedData({});
1045
+ setCurrentStep('camera');
1046
+ setOcrProgress(0);
1047
+ setShowAssignmentDropdown(false);
1048
+ setSelectedUnusedText('');
1049
+ setCurrentCameraIndex(0);
1050
+ setBackendResponse(null);
1051
+ setSelectedClient(null);
1052
+ setContactDecision(null);
1053
+ setIsConsulting(false);
1054
+ setIsSaving(false);
1055
+
1056
+ if (onClose) onClose();
1057
+ if (onCancel) onCancel();
1058
+ };
1059
+
1060
+ if (!isOpen) return null;
1061
+
1062
+ return (
1063
+ <motion.div
1064
+ className={ocrStyles.modalOverlay}
1065
+ initial={{ opacity: 0 }}
1066
+ animate={{ opacity: 1 }}
1067
+ exit={{ opacity: 0 }}
1068
+ >
1069
+ <motion.div
1070
+ className={ocrStyles.modalContent}
1071
+ initial={{ scale: 0.9, opacity: 0 }}
1072
+ animate={{ scale: 1, opacity: 1 }}
1073
+ exit={{ scale: 0.9, opacity: 0 }}
1074
+ >
1075
+ <div className={ocrStyles.modalHeader}>
1076
+ <h2>
1077
+ {currentStep === 'camera' && 'Capture Business Card'}
1078
+ {currentStep === 'preview' && 'Review Capture'}
1079
+ {currentStep === 'processing' && 'Processing...'}
1080
+ {currentStep === 'results' && 'Contact Information'}
1081
+ {currentStep === 'consultation' &&
1082
+ 'Checking for Duplicates...'}
1083
+ {currentStep === 'decisions' && 'Resolve Conflicts'}
1084
+ </h2>
1085
+ <button
1086
+ onClick={handleClose}
1087
+ className={ocrStyles.closeButton}
1088
+ >
1089
+ <CircleX size={24} />
1090
+ </button>
1091
+ </div>
1092
+
1093
+ <div className={ocrStyles.modalBody}>
1094
+ {currentStep === 'camera' && (
1095
+ <div className={ocrStyles.cameraContainer}>
1096
+ <div className={ocrStyles.captureGuide}>
1097
+ <p>Position business card within the frame</p>
1098
+ </div>
1099
+ <div className={ocrStyles.videoWrapper}>
1100
+ <video
1101
+ ref={videoRef}
1102
+ autoPlay
1103
+ playsInline
1104
+ muted
1105
+ className={ocrStyles.cameraVideo}
1106
+ />
1107
+ <div className={ocrStyles.cameraOverlay}></div>
1108
+ </div>
1109
+
1110
+ <div className={ocrStyles.cameraControls}>
1111
+ {availableCameras.length > 1 && (
1112
+ <button
1113
+ onClick={switchCamera}
1114
+ className={ocrStyles.secondaryButton}
1115
+ >
1116
+ <ArrowCycle size={20} />
1117
+ Switch Camera
1118
+ </button>
1119
+ )}
1120
+
1121
+ <button
1122
+ onClick={capturePhoto}
1123
+ className={ocrStyles.captureButton}
1124
+ >
1125
+ <Camera size={24} />
1126
+ Capture
1127
+ </button>
1128
+
1129
+ <input
1130
+ type="file"
1131
+ ref={fileInputRef}
1132
+ onChange={handleFileUpload}
1133
+ accept="image/jpeg,image/png,image/jpg,image/gif,image/webp"
1134
+ style={{ display: 'none' }}
1135
+ />
1136
+ <button
1137
+ onClick={() =>
1138
+ fileInputRef.current?.click()
1139
+ }
1140
+ className={ocrStyles.secondaryButton}
1141
+ >
1142
+ <ImageIcon size={24} />
1143
+ Upload Image
1144
+ </button>
1145
+ </div>
1146
+ </div>
1147
+ )}
1148
+
1149
+ {currentStep === 'preview' && capturedImage && (
1150
+ <div className={ocrStyles.previewContainer}>
1151
+ <div className={ocrStyles.imagePreview}>
1152
+ <img
1153
+ src={URL.createObjectURL(capturedImage)}
1154
+ alt="Captured business card"
1155
+ className={ocrStyles.previewImage}
1156
+ />
1157
+ </div>
1158
+
1159
+ <div className={ocrStyles.previewControls}>
1160
+ <button
1161
+ onClick={retakePhoto}
1162
+ className={ocrStyles.secondaryButton}
1163
+ >
1164
+ <ArrowCounterClockwise size={20} />
1165
+ Retake
1166
+ </button>
1167
+
1168
+ <button
1169
+ onClick={processBusinessCard}
1170
+ disabled={isProcessing}
1171
+ className={ocrStyles.primaryButton}
1172
+ >
1173
+ <CircleCheck size={20} />
1174
+ Process Card
1175
+ </button>
1176
+ </div>
1177
+ </div>
1178
+ )}
1179
+
1180
+ {currentStep === 'processing' && (
1181
+ <div className={ocrStyles.processingContainer}>
1182
+ <div className={ocrStyles.loadingSpinner}></div>
1183
+ <p>Extracting contact information...</p>
1184
+ <div className={ocrStyles.progressBar}>
1185
+ <div
1186
+ className={ocrStyles.progressFill}
1187
+ style={{ width: `${ocrProgress}%` }}
1188
+ ></div>
1189
+ </div>
1190
+ <small>{ocrProgress}% complete</small>
1191
+ </div>
1192
+ )}
1193
+
1194
+ {currentStep === 'results' && (
1195
+ <div className={ocrStyles.contactForm}>
1196
+ <div className={ocrStyles.formGrid}>
1197
+ {fields.length > 0 ? (
1198
+ fields.map((field) => (
1199
+ <div
1200
+ key={field.id}
1201
+ className={ocrStyles.fieldGroup}
1202
+ >
1203
+ <label>
1204
+ {getFieldIcon(field.id)}
1205
+ {field.label}
1206
+ </label>
1207
+ <input
1208
+ type={getFieldInputType(
1209
+ field.id
1210
+ )}
1211
+ value={
1212
+ extractedData[field.id] ||
1213
+ ''
1214
+ }
1215
+ onChange={(e) =>
1216
+ handleInputChange(
1217
+ field.id,
1218
+ e.target.value
1219
+ )
1220
+ }
1221
+ placeholder={getFieldPlaceholder(
1222
+ field.id,
1223
+ field.label
1224
+ )}
1225
+ className={ocrStyles.inputField}
1226
+ />
1227
+ </div>
1228
+ ))
1229
+ ) : (
1230
+ // Fallback to original hardcoded fields if no fields config provided
1231
+ <>
1232
+ <div className={ocrStyles.fieldGroup}>
1233
+ <label>
1234
+ <Edit size={16} />
1235
+ First Name
1236
+ </label>
1237
+ <input
1238
+ type="text"
1239
+ value={
1240
+ extractedData.first_name ||
1241
+ ''
1242
+ }
1243
+ onChange={(e) =>
1244
+ handleInputChange(
1245
+ 'first_name',
1246
+ e.target.value
1247
+ )
1248
+ }
1249
+ placeholder="First Name"
1250
+ className={ocrStyles.inputField}
1251
+ />
1252
+ </div>
1253
+ <div className={ocrStyles.fieldGroup}>
1254
+ <label>
1255
+ <Edit size={16} />
1256
+ Last Name
1257
+ </label>
1258
+ <input
1259
+ type="text"
1260
+ value={
1261
+ extractedData.last_name ||
1262
+ ''
1263
+ }
1264
+ onChange={(e) =>
1265
+ handleInputChange(
1266
+ 'last_name',
1267
+ e.target.value
1268
+ )
1269
+ }
1270
+ placeholder="Last Name"
1271
+ className={ocrStyles.inputField}
1272
+ />
1273
+ </div>
1274
+ <div className={ocrStyles.fieldGroup}>
1275
+ <label>
1276
+ <Envelope size={16} />
1277
+ Email
1278
+ </label>
1279
+ <input
1280
+ type="email"
1281
+ value={
1282
+ extractedData.email || ''
1283
+ }
1284
+ onChange={(e) =>
1285
+ handleInputChange(
1286
+ 'email',
1287
+ e.target.value
1288
+ )
1289
+ }
1290
+ placeholder="email@company.com"
1291
+ className={ocrStyles.inputField}
1292
+ />
1293
+ </div>
1294
+ <div className={ocrStyles.fieldGroup}>
1295
+ <label>Phone</label>
1296
+ <input
1297
+ type="tel"
1298
+ value={
1299
+ extractedData.phone || ''
1300
+ }
1301
+ onChange={(e) =>
1302
+ handleInputChange(
1303
+ 'phone',
1304
+ e.target.value
1305
+ )
1306
+ }
1307
+ placeholder="(555) 123-4567"
1308
+ className={ocrStyles.inputField}
1309
+ />
1310
+ </div>
1311
+ <div className={ocrStyles.fieldGroup}>
1312
+ <label>
1313
+ <Network size={16} />
1314
+ Company
1315
+ </label>
1316
+ <input
1317
+ type="text"
1318
+ value={
1319
+ extractedData.company || ''
1320
+ }
1321
+ onChange={(e) =>
1322
+ handleInputChange(
1323
+ 'company',
1324
+ e.target.value
1325
+ )
1326
+ }
1327
+ placeholder="Company Name"
1328
+ className={ocrStyles.inputField}
1329
+ />
1330
+ </div>
1331
+ </>
1332
+ )}
1333
+ </div>
1334
+
1335
+ {extractedData._unusedData &&
1336
+ extractedData._unusedData.length > 0 && (
1337
+ <div
1338
+ className={ocrStyles.unusedDataSection}
1339
+ >
1340
+ <h4>Unassigned Data</h4>
1341
+ <p>
1342
+ The following text wasn't
1343
+ automatically assigned. Drag or
1344
+ click to assign to a field:
1345
+ </p>
1346
+ <div
1347
+ className={ocrStyles.unusedDataGrid}
1348
+ >
1349
+ {extractedData._unusedData.map(
1350
+ (item, index) => (
1351
+ <div
1352
+ key={`unused-${index}`}
1353
+ className={
1354
+ ocrStyles.unusedDataItem
1355
+ }
1356
+ onClick={() =>
1357
+ handleUnusedDataClick(
1358
+ item.text
1359
+ )
1360
+ }
1361
+ >
1362
+ {item.text}
1363
+ </div>
1364
+ )
1365
+ )}
1366
+ </div>
1367
+ {showAssignmentDropdown && (
1368
+ <div
1369
+ className={
1370
+ ocrStyles.assignmentDropdown
1371
+ }
1372
+ >
1373
+ <p>
1374
+ Assign "{selectedUnusedText}
1375
+ " to:
1376
+ </p>
1377
+ <div
1378
+ className={
1379
+ ocrStyles.assignmentOptions
1380
+ }
1381
+ >
1382
+ {fields.map((field) => (
1383
+ <button
1384
+ key={field.id}
1385
+ onClick={() =>
1386
+ assignToField(
1387
+ field.id,
1388
+ selectedUnusedText
1389
+ )
1390
+ }
1391
+ className={
1392
+ ocrStyles.assignmentOption
1393
+ }
1394
+ >
1395
+ {field.label}
1396
+ </button>
1397
+ ))}
1398
+ </div>
1399
+ <button
1400
+ onClick={() =>
1401
+ setShowAssignmentDropdown(
1402
+ false
1403
+ )
1404
+ }
1405
+ className={
1406
+ ocrStyles.cancelAssignment
1407
+ }
1408
+ >
1409
+ Cancel
1410
+ </button>
1411
+ </div>
1412
+ )}
1413
+ </div>
1414
+ )}
1415
+
1416
+ {extractedData.rawText && (
1417
+ <details className={ocrStyles.rawTextSection}>
1418
+ <summary
1419
+ className={ocrStyles.rawTextSummary}
1420
+ >
1421
+ <span className={ocrStyles.summaryText}>
1422
+ 📄 Raw Extracted Text
1423
+ </span>
1424
+ <span className={ocrStyles.summaryHint}>
1425
+ (Click to expand)
1426
+ </span>
1427
+ <span className={ocrStyles.expandIcon}>
1428
+
1429
+ </span>
1430
+ </summary>
1431
+ <div className={ocrStyles.rawTextContent}>
1432
+ {extractedData.rawText}
1433
+ </div>
1434
+ </details>
1435
+ )}
1436
+
1437
+ <div className={ocrStyles.formActions}>
1438
+ <button
1439
+ onClick={retakePhoto}
1440
+ className={ocrStyles.secondaryButton}
1441
+ >
1442
+ <ImageIcon size={20} />
1443
+ Scan Another
1444
+ </button>
1445
+
1446
+ <button
1447
+ onClick={handleSave}
1448
+ className={ocrStyles.primaryButton}
1449
+ disabled={
1450
+ !hasMinimumRequiredData() ||
1451
+ isConsulting
1452
+ }
1453
+ >
1454
+ {isConsulting
1455
+ ? 'Checking...'
1456
+ : fetchConfig?.url
1457
+ ? 'Next'
1458
+ : 'Save Contact'}
1459
+ </button>
1460
+ </div>
1461
+ </div>
1462
+ )}
1463
+
1464
+ {currentStep === 'consultation' && (
1465
+ <div className={ocrStyles.processingContainer}>
1466
+ <div className={ocrStyles.loadingSpinner}></div>
1467
+ <p>
1468
+ Checking for existing companies and contacts...
1469
+ </p>
1470
+ <small>
1471
+ Searching for potential matches to avoid
1472
+ duplicates
1473
+ </small>
1474
+ </div>
1475
+ )}
1476
+
1477
+ {currentStep === 'decisions' && backendResponse && (
1478
+ <div className={ocrStyles.decisionsContainer}>
1479
+ {backendResponse.recommendations?.map(
1480
+ (recommendation, index) => (
1481
+ <div
1482
+ key={index}
1483
+ className={ocrStyles.recommendationCard}
1484
+ >
1485
+ {recommendation.type ===
1486
+ 'existing_contact' && (
1487
+ <div
1488
+ className={
1489
+ ocrStyles.existingContactCard
1490
+ }
1491
+ >
1492
+ <div
1493
+ className={
1494
+ ocrStyles.cardHeader
1495
+ }
1496
+ >
1497
+ <h3>
1498
+ ⚠️ Contact Already
1499
+ Exists
1500
+ </h3>
1501
+ <p>
1502
+ {recommendation.message}
1503
+ </p>
1504
+ </div>
1505
+ <div
1506
+ className={
1507
+ ocrStyles.contactDetails
1508
+ }
1509
+ >
1510
+ <div
1511
+ className={
1512
+ ocrStyles.existingContact
1513
+ }
1514
+ >
1515
+ <h4>
1516
+ Existing Contact:
1517
+ </h4>
1518
+ <p>
1519
+ <strong>
1520
+ Name:
1521
+ </strong>{' '}
1522
+ {
1523
+ recommendation
1524
+ .data
1525
+ .firstname
1526
+ }{' '}
1527
+ {
1528
+ recommendation
1529
+ .data
1530
+ .surname
1531
+ }
1532
+ </p>
1533
+ <p>
1534
+ <strong>
1535
+ Email:
1536
+ </strong>{' '}
1537
+ {
1538
+ recommendation
1539
+ .data.email
1540
+ }
1541
+ </p>
1542
+ <p>
1543
+ <strong>
1544
+ Mobile:
1545
+ </strong>{' '}
1546
+ {recommendation.data
1547
+ .mobile ||
1548
+ 'N/A'}
1549
+ </p>
1550
+ <p>
1551
+ <strong>
1552
+ Position:
1553
+ </strong>{' '}
1554
+ {recommendation.data
1555
+ .position ||
1556
+ 'N/A'}
1557
+ </p>
1558
+ {recommendation.data
1559
+ .client && (
1560
+ <p>
1561
+ <strong>
1562
+ Company:
1563
+ </strong>{' '}
1564
+ {
1565
+ recommendation
1566
+ .data
1567
+ .client
1568
+ .name
1569
+ }
1570
+ </p>
1571
+ )}
1572
+ </div>
1573
+ <div
1574
+ className={
1575
+ ocrStyles.newContact
1576
+ }
1577
+ >
1578
+ <h4>New Data:</h4>
1579
+ <p>
1580
+ <strong>
1581
+ Name:
1582
+ </strong>{' '}
1583
+ {
1584
+ extractedData.firstname
1585
+ }{' '}
1586
+ {
1587
+ extractedData.surname
1588
+ }
1589
+ </p>
1590
+ <p>
1591
+ <strong>
1592
+ Email:
1593
+ </strong>{' '}
1594
+ {
1595
+ extractedData.email
1596
+ }
1597
+ </p>
1598
+ <p>
1599
+ <strong>
1600
+ Mobile:
1601
+ </strong>{' '}
1602
+ {extractedData.mobile ||
1603
+ 'N/A'}
1604
+ </p>
1605
+ <p>
1606
+ <strong>
1607
+ Position:
1608
+ </strong>{' '}
1609
+ {extractedData.position ||
1610
+ 'N/A'}
1611
+ </p>
1612
+ <p>
1613
+ <strong>
1614
+ Company:
1615
+ </strong>{' '}
1616
+ {extractedData.company ||
1617
+ 'N/A'}
1618
+ </p>
1619
+ </div>
1620
+ </div>
1621
+ <div
1622
+ className={
1623
+ ocrStyles.decisionButtons
1624
+ }
1625
+ >
1626
+ <button
1627
+ className={`${
1628
+ ocrStyles.decisionButton
1629
+ } ${
1630
+ contactDecision ===
1631
+ 'update'
1632
+ ? ocrStyles.selected
1633
+ : ''
1634
+ }`}
1635
+ onClick={() =>
1636
+ setContactDecision(
1637
+ 'update'
1638
+ )
1639
+ }
1640
+ >
1641
+ Update Existing Contact
1642
+ </button>
1643
+ <button
1644
+ className={`${
1645
+ ocrStyles.decisionButton
1646
+ } ${
1647
+ contactDecision ===
1648
+ 'create'
1649
+ ? ocrStyles.selected
1650
+ : ''
1651
+ }`}
1652
+ onClick={() =>
1653
+ setContactDecision(
1654
+ 'create'
1655
+ )
1656
+ }
1657
+ >
1658
+ Create New Contact
1659
+ </button>
1660
+ </div>
1661
+ </div>
1662
+ )}
1663
+
1664
+ {recommendation.type ===
1665
+ 'similar_clients' && (
1666
+ <div
1667
+ className={
1668
+ ocrStyles.clientSuggestionsCard
1669
+ }
1670
+ >
1671
+ <div
1672
+ className={
1673
+ ocrStyles.cardHeader
1674
+ }
1675
+ >
1676
+ <h3>
1677
+ 🔍 Similar Companies
1678
+ Found
1679
+ </h3>
1680
+ <p>
1681
+ {recommendation.message}
1682
+ </p>
1683
+ </div>
1684
+ <div
1685
+ className={
1686
+ ocrStyles.clientSuggestions
1687
+ }
1688
+ >
1689
+ {recommendation.suggestions.map(
1690
+ (
1691
+ client,
1692
+ clientIndex
1693
+ ) => (
1694
+ <div
1695
+ key={
1696
+ clientIndex
1697
+ }
1698
+ className={`${
1699
+ ocrStyles.clientOption
1700
+ } ${
1701
+ selectedClient?.id ===
1702
+ client.id
1703
+ ? ocrStyles.selected
1704
+ : ''
1705
+ }`}
1706
+ onClick={() =>
1707
+ setSelectedClient(
1708
+ client
1709
+ )
1710
+ }
1711
+ >
1712
+ <div
1713
+ className={
1714
+ ocrStyles.clientInfo
1715
+ }
1716
+ >
1717
+ <strong>
1718
+ {
1719
+ client.name
1720
+ }
1721
+ </strong>
1722
+ <span
1723
+ className={
1724
+ ocrStyles.similarity
1725
+ }
1726
+ >
1727
+ {Math.round(
1728
+ client.similarity
1729
+ )}
1730
+ % match
1731
+ </span>
1732
+ {client.abn && (
1733
+ <small>
1734
+ ABN:{' '}
1735
+ {
1736
+ client.abn
1737
+ }
1738
+ </small>
1739
+ )}
1740
+ </div>
1741
+ </div>
1742
+ )
1743
+ )}
1744
+ <div
1745
+ className={`${
1746
+ ocrStyles.clientOption
1747
+ } ${
1748
+ ocrStyles.newClientOption
1749
+ } ${
1750
+ selectedClient?.isNew
1751
+ ? ocrStyles.selected
1752
+ : ''
1753
+ }`}
1754
+ onClick={() =>
1755
+ setSelectedClient({
1756
+ isNew: true,
1757
+ name: extractedData.company,
1758
+ })
1759
+ }
1760
+ >
1761
+ <div
1762
+ className={
1763
+ ocrStyles.clientInfo
1764
+ }
1765
+ >
1766
+ <strong>
1767
+ Create New
1768
+ Company: "
1769
+ {
1770
+ extractedData.company
1771
+ }
1772
+ "
1773
+ </strong>
1774
+ <span
1775
+ className={
1776
+ ocrStyles.similarity
1777
+ }
1778
+ >
1779
+ New
1780
+ </span>
1781
+ </div>
1782
+ </div>
1783
+ </div>
1784
+ </div>
1785
+ )}
1786
+
1787
+ {recommendation.type ===
1788
+ 'new_contact_existing_client' && (
1789
+ <div
1790
+ className={
1791
+ ocrStyles.existingClientCard
1792
+ }
1793
+ >
1794
+ <div
1795
+ className={
1796
+ ocrStyles.cardHeader
1797
+ }
1798
+ >
1799
+ <h3>
1800
+ ✅ Perfect Company Match
1801
+ </h3>
1802
+ <p>
1803
+ Create new contact for
1804
+ existing company:{' '}
1805
+ <strong>
1806
+ {
1807
+ backendResponse
1808
+ .client_exact_match
1809
+ ?.name
1810
+ }
1811
+ </strong>
1812
+ </p>
1813
+ </div>
1814
+ <div
1815
+ className={
1816
+ ocrStyles.autoSelected
1817
+ }
1818
+ >
1819
+ Company automatically
1820
+ selected. Contact will be
1821
+ created.
1822
+ </div>
1823
+ </div>
1824
+ )}
1825
+
1826
+ {recommendation.type ===
1827
+ 'new_client_and_contact' && (
1828
+ <div
1829
+ className={
1830
+ ocrStyles.newBothCard
1831
+ }
1832
+ >
1833
+ <div
1834
+ className={
1835
+ ocrStyles.cardHeader
1836
+ }
1837
+ >
1838
+ <h3>
1839
+ 🆕 New Company & Contact
1840
+ </h3>
1841
+ <p>
1842
+ No matches found. Will
1843
+ create new company and
1844
+ contact.
1845
+ </p>
1846
+ </div>
1847
+ <div
1848
+ className={
1849
+ ocrStyles.autoSelected
1850
+ }
1851
+ >
1852
+ New company "
1853
+ {extractedData.company}" and
1854
+ contact will be created.
1855
+ </div>
1856
+ </div>
1857
+ )}
1858
+
1859
+ {recommendation.type ===
1860
+ 'contact_exists_different_client' && (
1861
+ <div
1862
+ className={
1863
+ ocrStyles.conflictCard
1864
+ }
1865
+ >
1866
+ <div
1867
+ className={
1868
+ ocrStyles.cardHeader
1869
+ }
1870
+ >
1871
+ <h3>
1872
+ ⚠️ Contact & Company Conflict
1873
+ </h3>
1874
+ <p>
1875
+ {recommendation.message}
1876
+ </p>
1877
+ </div>
1878
+ <div
1879
+ className={
1880
+ ocrStyles.contactDetails
1881
+ }
1882
+ >
1883
+ <div
1884
+ className={
1885
+ ocrStyles.existingContact
1886
+ }
1887
+ >
1888
+ <h4>
1889
+ Existing Contact:
1890
+ </h4>
1891
+ <p>
1892
+ <strong>Name:</strong>{' '}
1893
+ {recommendation.contact.firstname} {recommendation.contact.surname}
1894
+ </p>
1895
+ <p>
1896
+ <strong>Company:</strong>{' '}
1897
+ {recommendation.contact.client?.name || 'No company'}
1898
+ </p>
1899
+ <p>
1900
+ <strong>Email:</strong>{' '}
1901
+ {recommendation.contact.email}
1902
+ </p>
1903
+ </div>
1904
+ <div
1905
+ className={
1906
+ ocrStyles.newContact
1907
+ }
1908
+ >
1909
+ <h4>
1910
+ Matched Company:
1911
+ </h4>
1912
+ <p>
1913
+ <strong>Company:</strong>{' '}
1914
+ {recommendation.matched_client.name}
1915
+ </p>
1916
+ <p>
1917
+ <strong>Scanned Email:</strong>{' '}
1918
+ {extractedData.email}
1919
+ </p>
1920
+ </div>
1921
+ </div>
1922
+ <div
1923
+ className={
1924
+ ocrStyles.decisionButtons
1925
+ }
1926
+ >
1927
+ <button
1928
+ className={`${
1929
+ ocrStyles.decisionButton
1930
+ } ${
1931
+ contactDecision ===
1932
+ 'update'
1933
+ ? ocrStyles.selected
1934
+ : ''
1935
+ }`}
1936
+ onClick={() => {
1937
+ setContactDecision('update');
1938
+ setSelectedClient(recommendation.matched_client);
1939
+ }}
1940
+ >
1941
+ Move Contact to Matched Company
1942
+ </button>
1943
+ <button
1944
+ className={`${
1945
+ ocrStyles.decisionButton
1946
+ } ${
1947
+ contactDecision ===
1948
+ 'create'
1949
+ ? ocrStyles.selected
1950
+ : ''
1951
+ }`}
1952
+ onClick={() => {
1953
+ setContactDecision('create');
1954
+ setSelectedClient(recommendation.matched_client);
1955
+ }}
1956
+ >
1957
+ Create New Contact for Matched Company
1958
+ </button>
1959
+ </div>
1960
+ </div>
1961
+ )}
1962
+
1963
+ {recommendation.type ===
1964
+ 'contact_with_suggested_clients' && (
1965
+ <div
1966
+ className={
1967
+ ocrStyles.existingContactCard
1968
+ }
1969
+ >
1970
+ <div
1971
+ className={
1972
+ ocrStyles.cardHeader
1973
+ }
1974
+ >
1975
+ <h3>
1976
+ ✅ Contact Found in Suggested Companies
1977
+ </h3>
1978
+ <p>
1979
+ {recommendation.message}
1980
+ </p>
1981
+ </div>
1982
+ <div
1983
+ className={
1984
+ ocrStyles.contactDetails
1985
+ }
1986
+ >
1987
+ <div
1988
+ className={
1989
+ ocrStyles.existingContact
1990
+ }
1991
+ >
1992
+ <h4>
1993
+ Existing Contact:
1994
+ </h4>
1995
+ <p>
1996
+ <strong>Name:</strong>{' '}
1997
+ {recommendation.contact.firstname} {recommendation.contact.surname}
1998
+ </p>
1999
+ <p>
2000
+ <strong>Company:</strong>{' '}
2001
+ {recommendation.contact.client?.name}
2002
+ </p>
2003
+ <p>
2004
+ <strong>Email:</strong>{' '}
2005
+ {recommendation.contact.email}
2006
+ </p>
2007
+ </div>
2008
+ <div
2009
+ className={
2010
+ ocrStyles.newContact
2011
+ }
2012
+ >
2013
+ <h4>New Data:</h4>
2014
+ <p>
2015
+ <strong>Name:</strong>{' '}
2016
+ {extractedData.firstname} {extractedData.surname}
2017
+ </p>
2018
+ <p>
2019
+ <strong>Position:</strong>{' '}
2020
+ {extractedData.position || 'N/A'}
2021
+ </p>
2022
+ <p>
2023
+ <strong>Mobile:</strong>{' '}
2024
+ {extractedData.mobile || 'N/A'}
2025
+ </p>
2026
+ </div>
2027
+ </div>
2028
+ <div
2029
+ className={
2030
+ ocrStyles.decisionButtons
2031
+ }
2032
+ >
2033
+ <button
2034
+ className={`${
2035
+ ocrStyles.decisionButton
2036
+ } ${
2037
+ contactDecision ===
2038
+ 'update'
2039
+ ? ocrStyles.selected
2040
+ : ''
2041
+ }`}
2042
+ onClick={() => {
2043
+ setContactDecision('update');
2044
+ setSelectedClient(recommendation.contact.client);
2045
+ }}
2046
+ >
2047
+ Update Existing Contact
2048
+ </button>
2049
+ <button
2050
+ className={`${
2051
+ ocrStyles.decisionButton
2052
+ } ${
2053
+ contactDecision ===
2054
+ 'create'
2055
+ ? ocrStyles.selected
2056
+ : ''
2057
+ }`}
2058
+ onClick={() => {
2059
+ setContactDecision('create');
2060
+ }}
2061
+ >
2062
+ Create New Contact
2063
+ </button>
2064
+ </div>
2065
+
2066
+ {/* Show client suggestions below */}
2067
+ <div className={ocrStyles.clientSuggestions} style={{marginTop: '1rem'}}>
2068
+ <h4>Company Options:</h4>
2069
+ {recommendation.suggestions.map((client, clientIndex) => (
2070
+ <div
2071
+ key={clientIndex}
2072
+ className={`${ocrStyles.clientOption} ${selectedClient?.id === client.id ? ocrStyles.selected : ''}`}
2073
+ onClick={() => setSelectedClient(client)}
2074
+ >
2075
+ <div className={ocrStyles.clientInfo}>
2076
+ <strong>{client.name}</strong>
2077
+ <span className={ocrStyles.similarity}>{Math.round(client.similarity)}% match</span>
2078
+ {client.abn && <small>ABN: {client.abn}</small>}
2079
+ </div>
2080
+ </div>
2081
+ ))}
2082
+ </div>
2083
+ </div>
2084
+ )}
2085
+
2086
+ {recommendation.type ===
2087
+ 'contact_exists_with_client_suggestions' && (
2088
+ <div
2089
+ className={
2090
+ ocrStyles.conflictCard
2091
+ }
2092
+ >
2093
+ <div
2094
+ className={
2095
+ ocrStyles.cardHeader
2096
+ }
2097
+ >
2098
+ <h3>
2099
+ ⚠️ Complex Contact & Company Conflict
2100
+ </h3>
2101
+ <p>
2102
+ {recommendation.message}
2103
+ </p>
2104
+ </div>
2105
+ <div
2106
+ className={
2107
+ ocrStyles.contactDetails
2108
+ }
2109
+ >
2110
+ <div
2111
+ className={
2112
+ ocrStyles.existingContact
2113
+ }
2114
+ >
2115
+ <h4>
2116
+ Existing Contact:
2117
+ </h4>
2118
+ <p>
2119
+ <strong>Name:</strong>{' '}
2120
+ {recommendation.contact.firstname} {recommendation.contact.surname}
2121
+ </p>
2122
+ <p>
2123
+ <strong>Company:</strong>{' '}
2124
+ {recommendation.contact.client?.name || 'No company'}
2125
+ </p>
2126
+ <p>
2127
+ <strong>Email:</strong>{' '}
2128
+ {recommendation.contact.email}
2129
+ </p>
2130
+ </div>
2131
+ <div
2132
+ className={
2133
+ ocrStyles.newContact
2134
+ }
2135
+ >
2136
+ <h4>Scanned Card Data:</h4>
2137
+ <p>
2138
+ <strong>Name:</strong>{' '}
2139
+ {extractedData.firstname} {extractedData.surname}
2140
+ </p>
2141
+ <p>
2142
+ <strong>Company:</strong>{' '}
2143
+ {extractedData.company}
2144
+ </p>
2145
+ <p>
2146
+ <strong>Email:</strong>{' '}
2147
+ {extractedData.email}
2148
+ </p>
2149
+ </div>
2150
+ </div>
2151
+
2152
+ {/* Show client suggestions */}
2153
+ <div className={ocrStyles.clientSuggestions} style={{margin: '1rem 0'}}>
2154
+ <h4>Similar Companies Found:</h4>
2155
+ {recommendation.suggestions.map((client, clientIndex) => (
2156
+ <div
2157
+ key={clientIndex}
2158
+ className={`${ocrStyles.clientOption} ${selectedClient?.id === client.id ? ocrStyles.selected : ''}`}
2159
+ onClick={() => setSelectedClient(client)}
2160
+ >
2161
+ <div className={ocrStyles.clientInfo}>
2162
+ <strong>{client.name}</strong>
2163
+ <span className={ocrStyles.similarity}>{Math.round(client.similarity)}% match</span>
2164
+ {client.abn && <small>ABN: {client.abn}</small>}
2165
+ </div>
2166
+ </div>
2167
+ ))}
2168
+ <div
2169
+ className={`${ocrStyles.clientOption} ${ocrStyles.newClientOption} ${selectedClient?.isNew ? ocrStyles.selected : ''}`}
2170
+ onClick={() => setSelectedClient({ isNew: true, name: extractedData.company })}
2171
+ >
2172
+ <div className={ocrStyles.clientInfo}>
2173
+ <strong>Create New Company: "{extractedData.company}"</strong>
2174
+ <span className={ocrStyles.similarity}>New</span>
2175
+ </div>
2176
+ </div>
2177
+ </div>
2178
+
2179
+ <div
2180
+ className={
2181
+ ocrStyles.decisionButtons
2182
+ }
2183
+ >
2184
+ <button
2185
+ className={`${
2186
+ ocrStyles.decisionButton
2187
+ } ${
2188
+ contactDecision ===
2189
+ 'update'
2190
+ ? ocrStyles.selected
2191
+ : ''
2192
+ }`}
2193
+ onClick={() => setContactDecision('update')}
2194
+ >
2195
+ Update Contact's Company
2196
+ </button>
2197
+ <button
2198
+ className={`${
2199
+ ocrStyles.decisionButton
2200
+ } ${
2201
+ contactDecision ===
2202
+ 'create'
2203
+ ? ocrStyles.selected
2204
+ : ''
2205
+ }`}
2206
+ onClick={() => setContactDecision('create')}
2207
+ >
2208
+ Create New Contact
2209
+ </button>
2210
+ </div>
2211
+ </div>
2212
+ )}
2213
+
2214
+ {recommendation.type ===
2215
+ 'contact_exists_no_client_match' && (
2216
+ <div
2217
+ className={
2218
+ ocrStyles.conflictCard
2219
+ }
2220
+ >
2221
+ <div
2222
+ className={
2223
+ ocrStyles.cardHeader
2224
+ }
2225
+ >
2226
+ <h3>
2227
+ ⚠️ Contact Exists with
2228
+ Different Company
2229
+ </h3>
2230
+ <p>
2231
+ {recommendation.message}
2232
+ </p>
2233
+ </div>
2234
+ <div
2235
+ className={
2236
+ ocrStyles.contactDetails
2237
+ }
2238
+ >
2239
+ <div
2240
+ className={
2241
+ ocrStyles.existingContact
2242
+ }
2243
+ >
2244
+ <h4>
2245
+ Existing Contact:
2246
+ </h4>
2247
+ <p>
2248
+ <strong>
2249
+ Company:
2250
+ </strong>{' '}
2251
+ {recommendation
2252
+ .contact.client
2253
+ ?.name ||
2254
+ 'No company'}
2255
+ </p>
2256
+ <p>
2257
+ <strong>
2258
+ Email:
2259
+ </strong>{' '}
2260
+ {
2261
+ recommendation
2262
+ .contact
2263
+ .email
2264
+ }
2265
+ </p>
2266
+ </div>
2267
+ <div
2268
+ className={
2269
+ ocrStyles.newContact
2270
+ }
2271
+ >
2272
+ <h4>
2273
+ Scanned Card Says:
2274
+ </h4>
2275
+ <p>
2276
+ <strong>
2277
+ Company:
2278
+ </strong>{' '}
2279
+ {
2280
+ extractedData.company
2281
+ }
2282
+ </p>
2283
+ <p>
2284
+ <strong>
2285
+ Email:
2286
+ </strong>{' '}
2287
+ {
2288
+ extractedData.email
2289
+ }
2290
+ </p>
2291
+ </div>
2292
+ </div>
2293
+ <div
2294
+ className={
2295
+ ocrStyles.decisionButtons
2296
+ }
2297
+ >
2298
+ <button
2299
+ className={`${
2300
+ ocrStyles.decisionButton
2301
+ } ${
2302
+ contactDecision ===
2303
+ 'update'
2304
+ ? ocrStyles.selected
2305
+ : ''
2306
+ }`}
2307
+ onClick={() =>
2308
+ setContactDecision(
2309
+ 'update'
2310
+ )
2311
+ }
2312
+ >
2313
+ Update Contact's Company
2314
+ </button>
2315
+ <button
2316
+ className={`${
2317
+ ocrStyles.decisionButton
2318
+ } ${
2319
+ contactDecision ===
2320
+ 'create'
2321
+ ? ocrStyles.selected
2322
+ : ''
2323
+ }`}
2324
+ onClick={() =>
2325
+ setContactDecision(
2326
+ 'create'
2327
+ )
2328
+ }
2329
+ >
2330
+ Create New Contact
2331
+ </button>
2332
+ </div>
2333
+ </div>
2334
+ )}
2335
+ </div>
2336
+ )
2337
+ )}
2338
+
2339
+ <div className={ocrStyles.decisionActions}>
2340
+ <button
2341
+ onClick={() => setCurrentStep('results')}
2342
+ className={ocrStyles.secondaryButton}
2343
+ >
2344
+ Back to Edit
2345
+ </button>
2346
+ <button
2347
+ onClick={handleFinalSave}
2348
+ className={ocrStyles.primaryButton}
2349
+ disabled={
2350
+ isSaving ||
2351
+ (!selectedClient &&
2352
+ !contactDecision &&
2353
+ backendResponse.recommendations?.some(
2354
+ (r) =>
2355
+ [
2356
+ 'similar_clients',
2357
+ 'existing_contact',
2358
+ 'contact_exists_no_client_match',
2359
+ ].includes(r.type)
2360
+ ))
2361
+ }
2362
+ >
2363
+ {isSaving ? 'Saving...' : 'Confirm & Save'}
2364
+ </button>
2365
+ </div>
2366
+ </div>
2367
+ )}
2368
+ </div>
2369
+
2370
+ <canvas ref={canvasRef} style={{ display: 'none' }} />
2371
+ </motion.div>
2372
+ </motion.div>
2373
+ );
2374
+ };
2375
+
2376
+ export default BusinessCardOcr;