@visns-studio/visns-components 5.9.7 → 5.9.9

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.
@@ -17,6 +17,7 @@ import {
17
17
  Plus,
18
18
  } from 'akar-icons';
19
19
 
20
+ import CustomFetch from './Fetch';
20
21
  import ocrStyles from './styles/BusinessCardOcr.module.scss';
21
22
 
22
23
  const BusinessCardOcr = ({
@@ -25,8 +26,9 @@ const BusinessCardOcr = ({
25
26
  isOpen = false,
26
27
  onClose,
27
28
  fields = [],
29
+ fetchConfig = null, // { url, method } from Navigation settings
28
30
  }) => {
29
- const [currentStep, setCurrentStep] = useState('camera'); // camera, preview, processing, results
31
+ const [currentStep, setCurrentStep] = useState('camera'); // camera, preview, processing, results, consultation, decisions
30
32
  const [capturedImage, setCapturedImage] = useState(null);
31
33
  const [cameraStream, setCameraStream] = useState(null);
32
34
  const [cameraFacing, setCameraFacing] = useState('environment'); // environment (back), user (front)
@@ -37,6 +39,11 @@ const BusinessCardOcr = ({
37
39
  const [ocrProgress, setOcrProgress] = useState(0);
38
40
  const [showAssignmentDropdown, setShowAssignmentDropdown] = useState(false);
39
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);
40
47
 
41
48
  const videoRef = useRef(null);
42
49
  const canvasRef = useRef(null);
@@ -49,6 +56,7 @@ const BusinessCardOcr = ({
49
56
  }
50
57
  }, [isOpen]);
51
58
 
59
+
52
60
  // Initialize camera when component opens
53
61
  useEffect(() => {
54
62
  if (isOpen && currentStep === 'camera') {
@@ -57,32 +65,44 @@ const BusinessCardOcr = ({
57
65
  return () => {
58
66
  stopCamera();
59
67
  };
60
- }, [isOpen, currentStep, cameraFacing, currentCameraIndex, availableCameras]);
68
+ }, [
69
+ isOpen,
70
+ currentStep,
71
+ cameraFacing,
72
+ currentCameraIndex,
73
+ availableCameras,
74
+ ]);
61
75
 
62
76
  const detectCameras = async () => {
63
77
  try {
64
78
  // Request permission first to get proper device labels
65
79
  await navigator.mediaDevices.getUserMedia({ video: true });
66
-
80
+
67
81
  const devices = await navigator.mediaDevices.enumerateDevices();
68
82
  const videoDevices = devices.filter(
69
83
  (device) => device.kind === 'videoinput' && device.deviceId
70
84
  );
71
-
85
+
72
86
  // Filter out devices that might not work
73
87
  const workingCameras = [];
74
88
  for (const device of videoDevices) {
75
89
  try {
76
- const testStream = await navigator.mediaDevices.getUserMedia({
77
- video: { deviceId: device.deviceId }
78
- });
79
- testStream.getTracks().forEach(track => track.stop());
90
+ const testStream =
91
+ await navigator.mediaDevices.getUserMedia({
92
+ video: { deviceId: device.deviceId },
93
+ });
94
+ testStream.getTracks().forEach((track) => track.stop());
80
95
  workingCameras.push(device);
81
96
  } catch (err) {
82
- console.warn(`Camera ${device.label || device.deviceId} not accessible:`, err);
97
+ console.warn(
98
+ `Camera ${
99
+ device.label || device.deviceId
100
+ } not accessible:`,
101
+ err
102
+ );
83
103
  }
84
104
  }
85
-
105
+
86
106
  setAvailableCameras(workingCameras);
87
107
  } catch (error) {
88
108
  console.error('Error detecting cameras:', error);
@@ -93,12 +113,18 @@ const BusinessCardOcr = ({
93
113
  const initializeCamera = async () => {
94
114
  try {
95
115
  let constraints;
96
-
116
+
97
117
  // Use specific device if available cameras are detected
98
- if (availableCameras.length > 0 && availableCameras[currentCameraIndex]) {
118
+ if (
119
+ availableCameras.length > 0 &&
120
+ availableCameras[currentCameraIndex]
121
+ ) {
99
122
  constraints = {
100
123
  video: {
101
- deviceId: { exact: availableCameras[currentCameraIndex].deviceId },
124
+ deviceId: {
125
+ exact: availableCameras[currentCameraIndex]
126
+ .deviceId,
127
+ },
102
128
  width: { ideal: 1280 },
103
129
  height: { ideal: 720 },
104
130
  },
@@ -114,7 +140,9 @@ const BusinessCardOcr = ({
114
140
  };
115
141
  }
116
142
 
117
- const stream = await navigator.mediaDevices.getUserMedia(constraints);
143
+ const stream = await navigator.mediaDevices.getUserMedia(
144
+ constraints
145
+ );
118
146
  setCameraStream(stream);
119
147
 
120
148
  if (videoRef.current) {
@@ -157,8 +185,8 @@ const BusinessCardOcr = ({
157
185
 
158
186
  const switchCamera = () => {
159
187
  if (availableCameras.length > 1) {
160
- setCurrentCameraIndex((prev) =>
161
- (prev + 1) % availableCameras.length
188
+ setCurrentCameraIndex(
189
+ (prev) => (prev + 1) % availableCameras.length
162
190
  );
163
191
  } else {
164
192
  // Fallback to facing mode switch
@@ -175,47 +203,53 @@ const BusinessCardOcr = ({
175
203
 
176
204
  const handleFileUpload = (event) => {
177
205
  const file = event.target.files[0];
178
-
206
+
179
207
  if (!file) {
180
208
  return;
181
209
  }
182
-
210
+
183
211
  // Validate file type
184
212
  if (!file.type.startsWith('image/')) {
185
213
  toast.error('Please select a valid image file (JPG, PNG, etc.)');
186
214
  return;
187
215
  }
188
-
216
+
189
217
  // Validate file size (max 10MB)
190
218
  const maxSize = 10 * 1024 * 1024; // 10MB
191
219
  if (file.size > maxSize) {
192
- toast.error('Image file is too large. Please select a file smaller than 10MB.');
220
+ toast.error(
221
+ 'Image file is too large. Please select a file smaller than 10MB.'
222
+ );
193
223
  return;
194
224
  }
195
-
225
+
196
226
  // Check if it's a reasonable image size
197
227
  const img = new Image();
198
- img.onload = function() {
228
+ img.onload = function () {
199
229
  if (this.width < 100 || this.height < 100) {
200
- toast.error('Image is too small. Please select a larger image.');
230
+ toast.error(
231
+ 'Image is too small. Please select a larger image.'
232
+ );
201
233
  URL.revokeObjectURL(this.src); // Clean up memory
202
234
  return;
203
235
  }
204
-
236
+
205
237
  URL.revokeObjectURL(this.src); // Clean up memory
206
238
  setCapturedImage(file);
207
239
  setCurrentStep('preview');
208
240
  stopCamera();
209
241
  };
210
-
211
- img.onerror = function() {
212
- toast.error('Unable to load the selected image. Please try another file.');
242
+
243
+ img.onerror = function () {
244
+ toast.error(
245
+ 'Unable to load the selected image. Please try another file.'
246
+ );
213
247
  URL.revokeObjectURL(this.src); // Clean up memory
214
248
  };
215
-
249
+
216
250
  const objectUrl = URL.createObjectURL(file);
217
251
  img.src = objectUrl;
218
-
252
+
219
253
  // Clear the input to allow re-selecting the same file
220
254
  event.target.value = '';
221
255
  };
@@ -255,40 +289,54 @@ const BusinessCardOcr = ({
255
289
  // Helper functions for data quality validation
256
290
  const isValidName = (name) => {
257
291
  if (!name || typeof name !== 'string') return false;
258
-
292
+
259
293
  const trimmed = name.trim();
260
-
294
+
261
295
  // Must be at least 2 characters for first name, 1 for last name
262
296
  if (trimmed.length < 1) return false;
263
-
297
+
264
298
  // 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())) {
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
+ ) {
267
315
  return false;
268
316
  }
269
-
317
+
270
318
  // Must contain mostly letters
271
319
  const letterCount = (trimmed.match(/[a-zA-Z]/g) || []).length;
272
320
  const letterRatio = letterCount / trimmed.length;
273
321
  if (letterRatio < 0.6) return false;
274
-
322
+
275
323
  // No operators or excessive special characters
276
324
  const invalidChars = /[=+\-*/<>|\\#$%^&@!~`{}[\]]/;
277
325
  if (invalidChars.test(trimmed)) return false;
278
-
326
+
279
327
  // Allow common name characters
280
328
  const validNamePattern = /^[a-zA-Z\s'.,-]+$/;
281
329
  return validNamePattern.test(trimmed);
282
330
  };
283
-
331
+
284
332
  const isLikelyOCRArtifact = (line) => {
285
333
  if (!line || typeof line !== 'string') return true;
286
-
334
+
287
335
  const trimmed = line.trim();
288
-
336
+
289
337
  // Too short or too long
290
338
  if (trimmed.length < 2 || trimmed.length > 50) return true;
291
-
339
+
292
340
  // Calculate character type ratios
293
341
  const letters = (trimmed.match(/[a-zA-Z]/g) || []).length;
294
342
  const numbers = (trimmed.match(/[0-9]/g) || []).length;
@@ -298,85 +346,94 @@ const BusinessCardOcr = ({
298
346
  const letterRatio = letters / total;
299
347
  const symbolRatio = symbols / total;
300
348
  const spaceRatio = spaces / total;
301
-
349
+
302
350
  // Skip if mostly symbols
303
351
  if (symbolRatio > 0.3) return true;
304
-
352
+
305
353
  // Skip if too many consecutive spaces or weird spacing
306
354
  if (spaceRatio > 0.4) return true;
307
-
355
+
308
356
  // 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
-
357
+ if (
358
+ numbers > 0 &&
359
+ letterRatio < 0.2 &&
360
+ !trimmed.match(/^\+?[\d\s\-()]+$/)
361
+ )
362
+ return true;
363
+
311
364
  // Skip lines that look like formatting artifacts
312
- const formatArtifacts = /^[.\-_|]+$|^[^a-zA-Z]*[|]+[^a-zA-Z]*$|^\s*[.]\s*[A-Z]?\s*$/;
365
+ const formatArtifacts =
366
+ /^[.\-_|]+$|^[^a-zA-Z]*[|]+[^a-zA-Z]*$|^\s*[.]\s*[A-Z]?\s*$/;
313
367
  if (formatArtifacts.test(trimmed)) return true;
314
-
368
+
315
369
  // 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})*$/;
370
+ const randomPattern =
371
+ /^[a-zA-Z]{1,2}\s+[a-zA-Z]{1,2}(\s+[a-zA-Z]{1,2})*$/;
317
372
  if (randomPattern.test(trimmed) && trimmed.length < 8) return true;
318
-
373
+
319
374
  // Enhanced gibberish detection
320
375
  if (isGibberish(trimmed)) return true;
321
-
376
+
322
377
  return false;
323
378
  };
324
-
379
+
325
380
  // Advanced gibberish detection
326
381
  const isGibberish = (text) => {
327
382
  if (!text || text.length < 3) return false;
328
-
329
- const words = text.split(/\s+/).filter(word => word.length > 0);
330
-
383
+
384
+ const words = text.split(/\s+/).filter((word) => word.length > 0);
385
+
331
386
  // Check for excessive mixed case patterns like "Li FOR go > ips"
332
387
  let mixedCaseWords = 0;
333
388
  let gibberishWords = 0;
334
389
  let totalWords = words.length;
335
-
390
+
336
391
  for (const word of words) {
337
392
  // Skip very short words
338
393
  if (word.length < 2) continue;
339
-
394
+
340
395
  // Check for excessive symbol contamination in words
341
396
  const symbolsInWord = (word.match(/[^a-zA-Z0-9]/g) || []).length;
342
397
  if (symbolsInWord / word.length > 0.3) {
343
398
  gibberishWords++;
344
399
  continue;
345
400
  }
346
-
401
+
347
402
  // Check for random mixed case patterns
348
403
  const hasRandomCase = /[a-z][A-Z]|[A-Z][a-z][A-Z]/.test(word);
349
404
  if (hasRandomCase && word.length > 2) {
350
405
  mixedCaseWords++;
351
406
  }
352
-
407
+
353
408
  // Check for consonant/vowel patterns that don't make sense
354
- const consonantClusters = word.match(/[bcdfghjklmnpqrstvwxzBCDFGHJKLMNPQRSTVWXZ]{4,}/g);
409
+ const consonantClusters = word.match(
410
+ /[bcdfghjklmnpqrstvwxzBCDFGHJKLMNPQRSTVWXZ]{4,}/g
411
+ );
355
412
  if (consonantClusters && consonantClusters.length > 0) {
356
413
  gibberishWords++;
357
414
  continue;
358
415
  }
359
-
360
- // Check for excessive single character "words"
416
+
417
+ // Check for excessive single character "words"
361
418
  if (word.length === 1 && /[a-zA-Z]/.test(word)) {
362
419
  gibberishWords++;
363
420
  continue;
364
421
  }
365
-
422
+
366
423
  // Check for patterns like "FOR", "ips", "ED", "RE" in sequence (common OCR errors)
367
424
  if (word.length <= 3 && word.match(/^[A-Z]{2,3}$|^[a-z]{2,3}$/)) {
368
425
  // Count as potential gibberish if there are many of these short words
369
426
  gibberishWords += 0.5;
370
427
  }
371
428
  }
372
-
429
+
373
430
  // If more than 60% of words are gibberish or mixed case, consider it gibberish
374
431
  const gibberishRatio = gibberishWords / Math.max(totalWords, 1);
375
432
  const mixedCaseRatio = mixedCaseWords / Math.max(totalWords, 1);
376
-
433
+
377
434
  if (gibberishRatio > 0.6) return true;
378
435
  if (mixedCaseRatio > 0.5 && totalWords > 2) return true;
379
-
436
+
380
437
  // Check for specific gibberish patterns
381
438
  const gibberishPatterns = [
382
439
  // Patterns like ": Re : Bei oc oC"
@@ -386,47 +443,62 @@ const BusinessCardOcr = ({
386
443
  // Patterns with excessive brackets, symbols mixed with short words
387
444
  /[>\[\]]{2,}|[A-Za-z]{1,3}\s+[>\[\]<]+\s+[A-Za-z]{1,3}/,
388
445
  // 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]+.*$/
446
+ /^[^a-zA-Z]*[a-zA-Z]{1,2}[^a-zA-Z]+[a-zA-Z]{1,3}[^a-zA-Z]+.*$/,
390
447
  ];
391
-
448
+
392
449
  for (const pattern of gibberishPatterns) {
393
450
  if (pattern.test(text)) return true;
394
451
  }
395
-
452
+
396
453
  // Check for excessive spacing patterns
397
454
  if (/\s{3,}/.test(text)) return true;
398
-
455
+
399
456
  // Check for lines with too many single characters
400
457
  const singleChars = text.match(/\b[a-zA-Z]\b/g);
401
- if (singleChars && singleChars.length > 3 && text.length < 30) return true;
402
-
458
+ if (singleChars && singleChars.length > 3 && text.length < 30)
459
+ return true;
460
+
403
461
  return false;
404
462
  };
405
-
463
+
406
464
  const hasValidNameStructure = (words) => {
407
465
  if (!words || words.length === 0) return false;
408
-
466
+
409
467
  // Check each word for name validity
410
468
  for (const word of words) {
411
469
  if (!isValidName(word)) return false;
412
470
  }
413
-
471
+
414
472
  // First word should be at least 2 characters (unless common single char)
415
473
  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())) {
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
+ ) {
418
490
  return false;
419
491
  }
420
-
492
+
421
493
  return true;
422
494
  };
423
495
 
424
496
  // Helper function to clean and validate field values
425
497
  const cleanFieldValue = (value, fieldType = 'general') => {
426
498
  if (!value || typeof value !== 'string') return '';
427
-
499
+
428
500
  let cleaned = value.trim();
429
-
501
+
430
502
  // Remove common OCR artifacts and invalid characters
431
503
  switch (fieldType) {
432
504
  case 'name':
@@ -437,7 +509,7 @@ const BusinessCardOcr = ({
437
509
  // Remove leading/trailing punctuation
438
510
  cleaned = cleaned.replace(/^[^a-zA-Z]+|[^a-zA-Z]+$/g, '');
439
511
  break;
440
-
512
+
441
513
  case 'company':
442
514
  // For companies: letters, numbers, spaces, common punctuation
443
515
  cleaned = cleaned.replace(/[^a-zA-Z0-9\s&.,'-]/g, '');
@@ -446,9 +518,12 @@ const BusinessCardOcr = ({
446
518
  // Remove excessive spaces
447
519
  cleaned = cleaned.replace(/\s+/g, ' ');
448
520
  // Remove leading/trailing punctuation except periods
449
- cleaned = cleaned.replace(/^[^a-zA-Z0-9]+|[^a-zA-Z0-9.]+$/g, '');
521
+ cleaned = cleaned.replace(
522
+ /^[^a-zA-Z0-9]+|[^a-zA-Z0-9.]+$/g,
523
+ ''
524
+ );
450
525
  break;
451
-
526
+
452
527
  case 'position':
453
528
  // For positions: letters, spaces, common punctuation
454
529
  cleaned = cleaned.replace(/[^a-zA-Z\s&.,'-]/g, '');
@@ -459,34 +534,37 @@ const BusinessCardOcr = ({
459
534
  // Remove leading/trailing punctuation
460
535
  cleaned = cleaned.replace(/^[^a-zA-Z]+|[^a-zA-Z]+$/g, '');
461
536
  break;
462
-
537
+
463
538
  case 'email':
464
539
  // For emails: standard email characters only
465
540
  cleaned = cleaned.replace(/[^a-zA-Z0-9@._+-]/g, '');
466
541
  break;
467
-
542
+
468
543
  case 'phone':
469
544
  // For phones: numbers, spaces, parentheses, hyphens, plus
470
545
  cleaned = cleaned.replace(/[^0-9\s()+-]/g, '');
471
546
  break;
472
-
547
+
473
548
  case 'website':
474
549
  // For websites: standard URL characters
475
- cleaned = cleaned.replace(/[^a-zA-Z0-9./:?#@!$&'()*+,;=-]/g, '');
550
+ cleaned = cleaned.replace(
551
+ /[^a-zA-Z0-9./:?#@!$&'()*+,;=-]/g,
552
+ ''
553
+ );
476
554
  break;
477
-
555
+
478
556
  default:
479
557
  // General cleaning: remove obvious artifacts
480
558
  cleaned = cleaned.replace(/[|="]/g, '');
481
559
  cleaned = cleaned.replace(/\s+/g, ' ');
482
560
  }
483
-
561
+
484
562
  // Final cleanup
485
563
  cleaned = cleaned.trim();
486
-
564
+
487
565
  // Return empty string if result is too short or invalid
488
566
  if (cleaned.length < 1) return '';
489
-
567
+
490
568
  return cleaned;
491
569
  };
492
570
 
@@ -497,15 +575,61 @@ const BusinessCardOcr = ({
497
575
  const usedLineIndices = new Set();
498
576
 
499
577
  // 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
-
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
+
504
585
  // 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
-
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
+
507
613
  // Company indicators
508
- const companyIndicators = ['inc', 'corp', 'ltd', 'llc', 'company', 'co.', 'corporation', 'incorporated', 'limited', 'enterprises', 'group', 'solutions', 'services', 'systems', 'technologies', 'tech', 'consulting'];
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
+ ];
509
633
 
510
634
  // Smart field extraction with confidence scoring
511
635
  lines.forEach((line, index) => {
@@ -543,7 +667,10 @@ const BusinessCardOcr = ({
543
667
  // Extract website (high confidence)
544
668
  const websiteMatch = trimmedLine.match(websiteRegex);
545
669
  if (websiteMatch && !parsed.website && !emailMatch) {
546
- const cleanedWebsite = cleanFieldValue(websiteMatch[0], 'website');
670
+ const cleanedWebsite = cleanFieldValue(
671
+ websiteMatch[0],
672
+ 'website'
673
+ );
547
674
  if (cleanedWebsite) {
548
675
  parsed.website = cleanedWebsite;
549
676
  usedLineIndices.add(index);
@@ -552,26 +679,51 @@ const BusinessCardOcr = ({
552
679
  }
553
680
 
554
681
  // 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
-
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
+
560
698
  // 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
-
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
+ ) {
567
710
  const firstName = words[0];
568
711
  const lastName = words.slice(1).join(' ');
569
-
712
+
570
713
  // Final validation for names
571
- if (isValidName(firstName) && (lastName === '' || isValidName(lastName))) {
572
- const cleanedFirstName = cleanFieldValue(firstName, 'name');
573
- const cleanedLastName = cleanFieldValue(lastName, 'name');
574
-
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
+
575
727
  if (cleanedFirstName) {
576
728
  parsed.firstname = cleanedFirstName;
577
729
  parsed.lastname = cleanedLastName;
@@ -587,15 +739,30 @@ const BusinessCardOcr = ({
587
739
 
588
740
  // Smart position/job title detection with validation
589
741
  if (!parsed.position) {
590
- const hasJobTitle = jobTitleKeywords.some(keyword => lowerLine.includes(keyword));
742
+ const hasJobTitle = jobTitleKeywords.some((keyword) =>
743
+ lowerLine.includes(keyword)
744
+ );
591
745
  const words = trimmedLine.split(/\s+/);
592
- const letterRatio = (trimmedLine.match(/[a-zA-Z]/g) || []).length / trimmedLine.length;
593
-
746
+ const letterRatio =
747
+ (trimmedLine.match(/[a-zA-Z]/g) || []).length /
748
+ trimmedLine.length;
749
+
594
750
  // 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');
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
+ );
599
766
  if (cleanedPosition) {
600
767
  parsed.position = cleanedPosition;
601
768
  usedLineIndices.add(index);
@@ -606,17 +773,35 @@ const BusinessCardOcr = ({
606
773
 
607
774
  // Smart company detection with validation
608
775
  if (!parsed.company) {
609
- const hasCompanyIndicator = companyIndicators.some(indicator => lowerLine.includes(indicator));
610
- const isAllCaps = trimmedLine === trimmedLine.toUpperCase() && trimmedLine.length > 2;
776
+ const hasCompanyIndicator = companyIndicators.some(
777
+ (indicator) => lowerLine.includes(indicator)
778
+ );
779
+ const isAllCaps =
780
+ trimmedLine === trimmedLine.toUpperCase() &&
781
+ trimmedLine.length > 2;
611
782
  const words = trimmedLine.split(/\s+/);
612
- const letterRatio = (trimmedLine.match(/[a-zA-Z]/g) || []).length / trimmedLine.length;
613
-
783
+ const letterRatio =
784
+ (trimmedLine.match(/[a-zA-Z]/g) || []).length /
785
+ trimmedLine.length;
786
+
614
787
  // 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');
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
+ );
620
805
  if (cleanedCompany) {
621
806
  parsed.company = cleanedCompany;
622
807
  usedLineIndices.add(index);
@@ -628,12 +813,15 @@ const BusinessCardOcr = ({
628
813
 
629
814
  // Collect unused lines for manual assignment (excluding artifacts)
630
815
  lines.forEach((line, index) => {
631
- if (!usedLineIndices.has(index) && !isLikelyOCRArtifact(line.trim())) {
816
+ if (
817
+ !usedLineIndices.has(index) &&
818
+ !isLikelyOCRArtifact(line.trim())
819
+ ) {
632
820
  const cleanedLine = cleanFieldValue(line.trim(), 'general');
633
821
  if (cleanedLine && cleanedLine.length > 1) {
634
822
  unusedLines.push({
635
823
  text: cleanedLine,
636
- index: index
824
+ index: index,
637
825
  });
638
826
  }
639
827
  }
@@ -692,10 +880,12 @@ const BusinessCardOcr = ({
692
880
  const hasMinimumRequiredData = () => {
693
881
  if (fields.length > 0) {
694
882
  // Check if at least one field has data
695
- return fields.some(field => extractedData[field.id]?.trim());
883
+ return fields.some((field) => extractedData[field.id]?.trim());
696
884
  } else {
697
885
  // Fallback for hardcoded fields
698
- return extractedData.first_name?.trim() || extractedData.email?.trim();
886
+ return (
887
+ extractedData.first_name?.trim() || extractedData.email?.trim()
888
+ );
699
889
  }
700
890
  };
701
891
 
@@ -713,16 +903,60 @@ const BusinessCardOcr = ({
713
903
  setExtractedData((prev) => ({
714
904
  ...prev,
715
905
  [fieldId]: text,
716
- _unusedData: prev._unusedData?.filter(item => item.text !== text) || []
906
+ _unusedData:
907
+ prev._unusedData?.filter((item) => item.text !== text) || [],
717
908
  }));
718
909
  setShowAssignmentDropdown(false);
719
910
  setSelectedUnusedText('');
720
911
  };
721
912
 
722
- const handleSave = async () => {
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 () => {
723
959
  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
960
  toast.success('Contact information extracted successfully!');
727
961
 
728
962
  if (onContactSaved) {
@@ -736,6 +970,74 @@ const BusinessCardOcr = ({
736
970
  }
737
971
  };
738
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
+
739
1041
  const handleClose = () => {
740
1042
  stopCamera();
741
1043
  setCapturedImage(null);
@@ -745,6 +1047,11 @@ const BusinessCardOcr = ({
745
1047
  setShowAssignmentDropdown(false);
746
1048
  setSelectedUnusedText('');
747
1049
  setCurrentCameraIndex(0);
1050
+ setBackendResponse(null);
1051
+ setSelectedClient(null);
1052
+ setContactDecision(null);
1053
+ setIsConsulting(false);
1054
+ setIsSaving(false);
748
1055
 
749
1056
  if (onClose) onClose();
750
1057
  if (onCancel) onCancel();
@@ -771,6 +1078,9 @@ const BusinessCardOcr = ({
771
1078
  {currentStep === 'preview' && 'Review Capture'}
772
1079
  {currentStep === 'processing' && 'Processing...'}
773
1080
  {currentStep === 'results' && 'Contact Information'}
1081
+ {currentStep === 'consultation' &&
1082
+ 'Checking for Duplicates...'}
1083
+ {currentStep === 'decisions' && 'Resolve Conflicts'}
774
1084
  </h2>
775
1085
  <button
776
1086
  onClick={handleClose}
@@ -784,9 +1094,7 @@ const BusinessCardOcr = ({
784
1094
  {currentStep === 'camera' && (
785
1095
  <div className={ocrStyles.cameraContainer}>
786
1096
  <div className={ocrStyles.captureGuide}>
787
- <p>
788
- Position business card within the frame
789
- </p>
1097
+ <p>Position business card within the frame</p>
790
1098
  </div>
791
1099
  <div className={ocrStyles.videoWrapper}>
792
1100
  <video
@@ -796,8 +1104,7 @@ const BusinessCardOcr = ({
796
1104
  muted
797
1105
  className={ocrStyles.cameraVideo}
798
1106
  />
799
- <div className={ocrStyles.cameraOverlay}>
800
- </div>
1107
+ <div className={ocrStyles.cameraOverlay}></div>
801
1108
  </div>
802
1109
 
803
1110
  <div className={ocrStyles.cameraControls}>
@@ -889,21 +1196,32 @@ const BusinessCardOcr = ({
889
1196
  <div className={ocrStyles.formGrid}>
890
1197
  {fields.length > 0 ? (
891
1198
  fields.map((field) => (
892
- <div key={field.id} className={ocrStyles.fieldGroup}>
1199
+ <div
1200
+ key={field.id}
1201
+ className={ocrStyles.fieldGroup}
1202
+ >
893
1203
  <label>
894
1204
  {getFieldIcon(field.id)}
895
1205
  {field.label}
896
1206
  </label>
897
1207
  <input
898
- type={getFieldInputType(field.id)}
899
- value={extractedData[field.id] || ''}
1208
+ type={getFieldInputType(
1209
+ field.id
1210
+ )}
1211
+ value={
1212
+ extractedData[field.id] ||
1213
+ ''
1214
+ }
900
1215
  onChange={(e) =>
901
1216
  handleInputChange(
902
1217
  field.id,
903
1218
  e.target.value
904
1219
  )
905
1220
  }
906
- placeholder={getFieldPlaceholder(field.id, field.label)}
1221
+ placeholder={getFieldPlaceholder(
1222
+ field.id,
1223
+ field.label
1224
+ )}
907
1225
  className={ocrStyles.inputField}
908
1226
  />
909
1227
  </div>
@@ -918,7 +1236,10 @@ const BusinessCardOcr = ({
918
1236
  </label>
919
1237
  <input
920
1238
  type="text"
921
- value={extractedData.first_name || ''}
1239
+ value={
1240
+ extractedData.first_name ||
1241
+ ''
1242
+ }
922
1243
  onChange={(e) =>
923
1244
  handleInputChange(
924
1245
  'first_name',
@@ -936,7 +1257,10 @@ const BusinessCardOcr = ({
936
1257
  </label>
937
1258
  <input
938
1259
  type="text"
939
- value={extractedData.last_name || ''}
1260
+ value={
1261
+ extractedData.last_name ||
1262
+ ''
1263
+ }
940
1264
  onChange={(e) =>
941
1265
  handleInputChange(
942
1266
  'last_name',
@@ -954,7 +1278,9 @@ const BusinessCardOcr = ({
954
1278
  </label>
955
1279
  <input
956
1280
  type="email"
957
- value={extractedData.email || ''}
1281
+ value={
1282
+ extractedData.email || ''
1283
+ }
958
1284
  onChange={(e) =>
959
1285
  handleInputChange(
960
1286
  'email',
@@ -969,7 +1295,9 @@ const BusinessCardOcr = ({
969
1295
  <label>Phone</label>
970
1296
  <input
971
1297
  type="tel"
972
- value={extractedData.phone || ''}
1298
+ value={
1299
+ extractedData.phone || ''
1300
+ }
973
1301
  onChange={(e) =>
974
1302
  handleInputChange(
975
1303
  'phone',
@@ -987,7 +1315,9 @@ const BusinessCardOcr = ({
987
1315
  </label>
988
1316
  <input
989
1317
  type="text"
990
- value={extractedData.company || ''}
1318
+ value={
1319
+ extractedData.company || ''
1320
+ }
991
1321
  onChange={(e) =>
992
1322
  handleInputChange(
993
1323
  'company',
@@ -1002,56 +1332,101 @@ const BusinessCardOcr = ({
1002
1332
  )}
1003
1333
  </div>
1004
1334
 
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)}
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
+ }
1015
1372
  >
1016
- {item.text}
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>
1017
1411
  </div>
1018
- ))}
1412
+ )}
1019
1413
  </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
- )}
1414
+ )}
1044
1415
 
1045
1416
  {extractedData.rawText && (
1046
1417
  <details className={ocrStyles.rawTextSection}>
1047
- <summary className={ocrStyles.rawTextSummary}>
1418
+ <summary
1419
+ className={ocrStyles.rawTextSummary}
1420
+ >
1048
1421
  <span className={ocrStyles.summaryText}>
1049
1422
  📄 Raw Extracted Text
1050
1423
  </span>
1051
1424
  <span className={ocrStyles.summaryHint}>
1052
1425
  (Click to expand)
1053
1426
  </span>
1054
- <span className={ocrStyles.expandIcon}>▶</span>
1427
+ <span className={ocrStyles.expandIcon}>
1428
+
1429
+ </span>
1055
1430
  </summary>
1056
1431
  <div className={ocrStyles.rawTextContent}>
1057
1432
  {extractedData.rawText}
@@ -1071,9 +1446,921 @@ const BusinessCardOcr = ({
1071
1446
  <button
1072
1447
  onClick={handleSave}
1073
1448
  className={ocrStyles.primaryButton}
1074
- disabled={!hasMinimumRequiredData()}
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
+ }
1075
2362
  >
1076
- Save Contact
2363
+ {isSaving ? 'Saving...' : 'Confirm & Save'}
1077
2364
  </button>
1078
2365
  </div>
1079
2366
  </div>