@sanctum-key/react-native-sdk 1.0.23 → 1.0.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/build/package.json +3 -1
  2. package/build/src/components/EnhancedCameraView.d.ts +7 -1
  3. package/build/src/components/EnhancedCameraView.d.ts.map +1 -1
  4. package/build/src/components/EnhancedCameraView.js +88 -30
  5. package/build/src/components/EnhancedCameraView.js.map +1 -1
  6. package/build/src/components/EnhancedCameraView.web.d.ts +5 -1
  7. package/build/src/components/EnhancedCameraView.web.d.ts.map +1 -1
  8. package/build/src/components/EnhancedCameraView.web.js +80 -295
  9. package/build/src/components/EnhancedCameraView.web.js.map +1 -1
  10. package/build/src/components/KYCElements/CameraCapture.js +1 -1
  11. package/build/src/components/KYCElements/CameraCapture.js.map +1 -1
  12. package/build/src/components/KYCElements/CountrySelection.d.ts.map +1 -1
  13. package/build/src/components/KYCElements/CountrySelection.js +38 -218
  14. package/build/src/components/KYCElements/CountrySelection.js.map +1 -1
  15. package/build/src/components/KYCElements/CountrySelectionTemplate.d.ts.map +1 -1
  16. package/build/src/components/KYCElements/CountrySelectionTemplate.js +119 -26
  17. package/build/src/components/KYCElements/CountrySelectionTemplate.js.map +1 -1
  18. package/build/src/components/KYCElements/IDCardCapture.d.ts.map +1 -1
  19. package/build/src/components/KYCElements/IDCardCapture.js +512 -87
  20. package/build/src/components/KYCElements/IDCardCapture.js.map +1 -1
  21. package/build/src/components/KYCElements/VerificationProgressTemplate.d.ts.map +1 -1
  22. package/build/src/components/KYCElements/VerificationProgressTemplate.js +93 -51
  23. package/build/src/components/KYCElements/VerificationProgressTemplate.js.map +1 -1
  24. package/build/src/components/OverLay/type.d.ts +1 -0
  25. package/build/src/components/OverLay/type.d.ts.map +1 -1
  26. package/build/src/components/OverLay/type.js.map +1 -1
  27. package/build/src/components/WebBarcodeScanner.d.ts +9 -0
  28. package/build/src/components/WebBarcodeScanner.d.ts.map +1 -0
  29. package/build/src/components/WebBarcodeScanner.js +152 -0
  30. package/build/src/components/WebBarcodeScanner.js.map +1 -0
  31. package/build/src/config/KYCConfigs.js +2 -2
  32. package/build/src/config/KYCConfigs.js.map +1 -1
  33. package/build/src/config/region_mapping.js +14 -14
  34. package/build/src/config/region_mapping.js.map +1 -1
  35. package/build/src/config/region_mapping.json +0 -14
  36. package/build/src/modules/api/CardAuthentification.d.ts +2 -0
  37. package/build/src/modules/api/CardAuthentification.d.ts.map +1 -1
  38. package/build/src/modules/api/CardAuthentification.js +23 -40
  39. package/build/src/modules/api/CardAuthentification.js.map +1 -1
  40. package/build/src/modules/api/KYCService.d.ts +7 -2
  41. package/build/src/modules/api/KYCService.d.ts.map +1 -1
  42. package/build/src/modules/api/KYCService.js +163 -57
  43. package/build/src/modules/api/KYCService.js.map +1 -1
  44. package/build/src/types/KYC.types.d.ts +1 -0
  45. package/build/src/types/KYC.types.d.ts.map +1 -1
  46. package/build/src/types/KYC.types.js.map +1 -1
  47. package/build/src/utils/aamvaParser.d.ts +62 -0
  48. package/build/src/utils/aamvaParser.d.ts.map +1 -0
  49. package/build/src/utils/aamvaParser.js +91 -0
  50. package/build/src/utils/aamvaParser.js.map +1 -0
  51. package/build/src/utils/cropByObb.d.ts.map +1 -1
  52. package/build/src/utils/cropByObb.js +4 -3
  53. package/build/src/utils/cropByObb.js.map +1 -1
  54. package/build/src/web/WebKYCEntry.d.ts +1 -1
  55. package/build/src/web/WebKYCEntry.d.ts.map +1 -1
  56. package/build/src/web/WebKYCEntry.js +105 -239
  57. package/build/src/web/WebKYCEntry.js.map +1 -1
  58. package/package.json +3 -1
  59. package/src/components/EnhancedCameraView.tsx +116 -42
  60. package/src/components/EnhancedCameraView.web.tsx +98 -338
  61. package/src/components/KYCElements/CameraCapture.tsx +1 -1
  62. package/src/components/KYCElements/CountrySelection.tsx +60 -240
  63. package/src/components/KYCElements/CountrySelectionTemplate.tsx +148 -29
  64. package/src/components/KYCElements/IDCardCapture.tsx +675 -142
  65. package/src/components/KYCElements/VerificationProgressTemplate.tsx +112 -65
  66. package/src/components/OverLay/type.ts +1 -1
  67. package/src/components/WebBarcodeScanner.tsx +273 -0
  68. package/src/config/KYCConfigs.ts +2 -2
  69. package/src/config/region_mapping.json +0 -14
  70. package/src/config/region_mapping.ts +14 -14
  71. package/src/modules/api/CardAuthentification.ts +44 -58
  72. package/src/modules/api/KYCService.ts +236 -82
  73. package/src/types/KYC.types.ts +1 -0
  74. package/src/utils/aamvaParser.ts +102 -0
  75. package/src/utils/cropByObb.ts +5 -3
  76. package/src/web/WebKYCEntry.tsx +125 -301
@@ -1,4 +1,4 @@
1
- import React, { useEffect, useMemo, useState } from 'react';
1
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { View, Text, StyleSheet, Image, ScrollView, Platform, ActivityIndicator, TouchableOpacity, useWindowDimensions } from 'react-native';
3
3
  import { showAlert } from '../../utils/platformAlert';
4
4
  import { EnhancedCameraView } from '../EnhancedCameraView';
@@ -11,13 +11,26 @@ import { removeDuplicates } from '../../utils/remove-duplicate';
11
11
  import { backVerification, checkTemplateType, frontVerification } from '../../modules/api/CardAuthentification';
12
12
  import { getDocumentTypeInfo } from '../../utils/get-document-type-info';
13
13
  import pathToBase64 from '../../utils/pathToBase64';
14
- import { cropByObb, cropImageWithBBoxWithTolerance, getObbConfidence, OBB_CONFIDENCE_THRESHOLD } from '../../utils/cropByObb';
14
+ import { cropByObb, getObbConfidence, OBB_CONFIDENCE_THRESHOLD } from '../../utils/cropByObb';
15
15
  import REGION_MAPPING from '../../config/region_mapping.json';
16
16
  import QRCode from 'react-native-qrcode-svg';
17
+ import { logger } from '../../utils/logger';
18
+ import { cropImageWithBBoxWithTolerance } from '../../utils/cropByObb';
19
+ import kycService, { authentification } from '../../modules/api/KYCService';
17
20
 
18
- const ISO_TO_COUNTRY_NAME: Record<string, string> = { 'KE': 'Kenya', 'CM': 'Cameroon', 'NG': 'Nigeria', 'CA': 'Canada', 'FR': 'France', 'GH': 'Ghana', 'ZA': 'South Africa', 'GB': 'Britain', 'CI': 'Ivory Coast', 'SN': 'Senegal', 'TG': 'Togo', 'ML': 'Mali' };
21
+ const ISO_TO_COUNTRY_NAME: Record<string, string> = {
22
+ 'KE': 'Kenya', 'CM': 'Cameroon', 'NG': 'Nigeria', 'CA': 'Canada',
23
+ 'FR': 'France', 'GH': 'Ghana', 'ZA': 'South Africa', 'GB': 'Britain',
24
+ 'CI': 'Ivory Coast', 'SN': 'Senegal', 'TG': 'Togo', 'ML': 'Mali'
25
+ };
26
+
27
+ interface IIDCardPayload {
28
+ dir: string;
29
+ file: string;
30
+ mrz: string;
31
+ templatePath?: string;
32
+ }
19
33
 
20
- interface IIDCardPayload { dir: string; file: string; mrz: string; templatePath?: string; }
21
34
  interface IDCardCaptureProps {
22
35
  component: TemplateComponent;
23
36
  value?: Record<string, IIDCardPayload>;
@@ -27,6 +40,14 @@ interface IDCardCaptureProps {
27
40
  currentSide?: string;
28
41
  }
29
42
 
43
+ const SHORT_TO_LONG_REGION: Record<string, string> = {
44
+ "AB": "Alberta", "BC": "British Columbia", "MB": "Manitoba",
45
+ "NB": "New Brunswick", "NL": "Newfoundland and Labrador",
46
+ "NT": "Northwest Territories", "NS": "Nova Scotia", "NU": "Nunavut",
47
+ "ON": "Ontario", "PE": "Prince Edward Island", "QC": "Quebec",
48
+ "SK": "Saskatchewan", "YT": "Yukon"
49
+ };
50
+
30
51
  export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value = {}, onValueChange, error, language = 'en' }) => {
31
52
  const { t, locale } = useI18n();
32
53
  const [showCamera, setShowCamera] = useState(false);
@@ -41,23 +62,42 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
41
62
  const [cameraType, setCameraType] = useState<'back' | 'front'>('back');
42
63
  const [showQRModal, setShowQRModal] = useState(false);
43
64
  const [currentUrl, setCurrentUrl] = useState('');
65
+ const [qrPlatform, setQrPlatform] = useState<'android' | 'ios'>('android');
66
+
67
+ const [scanPhase, setScanPhase] = useState<'image' | 'barcode'>('image');
68
+ const [pendingCAImage, setPendingCAImage] = useState<{ path: string, templateResponse: any, templatePath: string, bbox?: IBbox } | null>(null);
69
+
70
+ const scannedBarcodeRef = useRef<string | null>(null);
44
71
 
45
72
  useEffect(() => {
46
- if (typeof window !== 'undefined' && window.location) {
47
- setCurrentUrl(window.location.href);
48
- }
73
+ if (typeof window !== 'undefined' && window.location) setCurrentUrl(window.location.href);
74
+ console.log(currentUrl)
49
75
  }, []);
50
76
 
51
- const documentTypeMapping: Record<string, GovernmentDocumentType> = { 'nationalId': 'national_id', 'passport': 'passport', 'driversLicense': 'drivers_licence', 'residencePermit': 'permanent_residence', 'healthInsuranceCard': 'health_insurance_card', };
77
+ const documentTypeMapping: Record<string, GovernmentDocumentType> = {
78
+ 'nationalId': 'national_id', 'national_id': 'national_id', 'identity_card': 'national_id',
79
+ 'passport': 'passport', 'driversLicense': 'drivers_licence', 'drivers_licence': 'drivers_licence',
80
+ 'residencePermit': 'permanent_residence', 'permanent_residence': 'permanent_residence',
81
+ 'healthInsuranceCard': 'health_insurance_card', 'health_insurance_card': 'health_insurance_card',
82
+ };
52
83
 
53
84
  const { actions, state, env } = useTemplateKYCFlowContext();
54
85
  const { width: screenWidth, height: screenHeight } = useWindowDimensions();
55
86
 
56
87
  const getLocalizedText = (text: LocalizedText | Record<string, LocalizedText>): string => {
57
- if (text && typeof text[currentSide] === 'object' && text[currentSide][locale]) return text[currentSide][locale] || '';
88
+ if (text && typeof text[currentSide] === 'object' && text[currentSide][locale])
89
+ return text[currentSide][locale] || '';
58
90
  return "";
59
91
  };
60
92
 
93
+ useEffect(() => {
94
+ if (Platform.OS === 'web' && typeof window !== 'undefined') {
95
+ const baseUrl = window.location.origin + window.location.pathname;
96
+ const handoffUrl = `${baseUrl}?session_id=${state.session.session_id}&resume=true`;
97
+ setCurrentUrl(handoffUrl);
98
+ }
99
+ }, [state.session.session_id]);
100
+
61
101
  const countrySelectionData = useMemo(() => {
62
102
  const countrySelectionComponent = state.template.components.find(c => c.type === 'country_selection');
63
103
  return countrySelectionComponent ? state.componentData[countrySelectionComponent.id] : null;
@@ -72,8 +112,54 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
72
112
 
73
113
  const countryData = useMemo(() => countrySelectionData, [countrySelectionData]);
74
114
 
115
+ const isWebScanningRestricted = useMemo(() => {
116
+ if (Platform.OS !== 'web') return false;
117
+ if (countryData?.code !== 'CA') return false;
118
+
119
+ const docType = selectedDocumentType?.type;
120
+ return docType === 'drivers_licence' ||
121
+ docType === 'health_insurance_card' ||
122
+ docType === 'national_id' ||
123
+ docType === 'identity_card';
124
+ }, [countryData?.code, selectedDocumentType?.type]);
125
+
126
+ const targetBarcodeTypes = useMemo<any[]>(() => {
127
+ const docType = selectedDocumentType?.type;
128
+ if (docType === 'health_insurance_card') return ['code-128', 'code-39', 'pdf-417'];
129
+ return ['pdf-417'];
130
+ }, [selectedDocumentType]);
131
+
75
132
  const refreshCamera = () => {
76
133
  setIsRebootingCamera(true);
134
+
135
+ if (scanPhase === 'barcode') {
136
+ setScanPhase('image');
137
+ setPendingCAImage(null);
138
+ scannedBarcodeRef.current = null;
139
+
140
+ setCapturedImages((prev) => {
141
+ const newState = { ...prev };
142
+ delete newState[currentSide];
143
+ return newState;
144
+ });
145
+
146
+ if (value) {
147
+ const newValue = { ...value };
148
+ delete newValue[currentSide];
149
+ onValueChange(newValue);
150
+ }
151
+
152
+ setSilentCaptureResult({
153
+ success: false,
154
+ isAnalyzing: false,
155
+ path: '',
156
+ error: '',
157
+ templatePath: '',
158
+ mrz: '',
159
+ bbox: undefined
160
+ });
161
+ }
162
+
77
163
  setTimeout(() => {
78
164
  setCameraKey(prev => prev + 1);
79
165
  setIsRebootingCamera(false);
@@ -91,9 +177,15 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
91
177
  const updatedImages = value as Record<string, IIDCardPayload>;
92
178
  setCapturedImages(updatedImages);
93
179
  const currentImageData = updatedImages[currentSide];
180
+
94
181
  if (currentImageData?.dir) {
95
182
  setSilentCaptureResult(prev => ({
96
- ...prev, path: currentImageData.dir, success: true, isAnalyzing: false, mrz: currentImageData.mrz || '', templatePath: currentImageData.templatePath || '',
183
+ ...prev,
184
+ path: currentImageData.dir,
185
+ success: true,
186
+ isAnalyzing: false,
187
+ mrz: currentImageData.mrz || '',
188
+ templatePath: currentImageData.templatePath || '',
97
189
  }));
98
190
  }
99
191
  }
@@ -101,7 +193,14 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
101
193
  }, [value, currentSide]);
102
194
 
103
195
  const cameraConfig = useMemo(() => {
104
- const instructions = selectedDocumentType ? (locale === 'en' ? getDocumentTypeInfo(selectedDocumentType.type).instructions.en : getDocumentTypeInfo(selectedDocumentType.type).instructions.fr) : getLocalizedText(component.instructions as Record<string, LocalizedText>);
196
+ let instructions = selectedDocumentType
197
+ ? (locale === 'en' ? getDocumentTypeInfo(selectedDocumentType.type).instructions.en : getDocumentTypeInfo(selectedDocumentType.type).instructions.fr)
198
+ : getLocalizedText(component.instructions as Record<string, LocalizedText>);
199
+
200
+ if (scanPhase === 'barcode') {
201
+ instructions = '';
202
+ }
203
+
105
204
  const isLandscape = screenWidth > screenHeight;
106
205
  let xMin, xMax, yMin, yMax;
107
206
 
@@ -109,39 +208,74 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
109
208
  const boxWidthPercent = isLandscape ? 0.40 : 0.85;
110
209
  xMin = isLandscape ? 30 : 7.5;
111
210
  xMax = xMin + (boxWidthPercent * 100);
211
+
112
212
  const containerWidth = Math.min(screenWidth * 0.95, 1000);
113
213
  const containerHeight = isLandscape ? screenHeight * 0.80 : screenHeight * 0.90;
214
+
114
215
  const boxPixelWidth = containerWidth * boxWidthPercent;
115
216
  const boxPixelHeight = boxPixelWidth / 1.59;
217
+
116
218
  const heightPercent = (boxPixelHeight / containerHeight) * 100;
117
219
  yMin = (100 - heightPercent) / 2.5;
118
220
  yMax = yMin + heightPercent;
119
221
  } else {
120
- xMin = 5; xMax = 95; yMin = 34; yMax = 66;
222
+ xMin = 5;
223
+ xMax = 95;
224
+ yMin = 34;
225
+ yMax = 66;
121
226
  }
122
- const platformBbox = { xMin, yMin, xMax, yMax, borderColor: '#2DBD60', borderWidth: Platform.OS === 'web' ? 2 : 3, cornerRadius: 8 };
227
+
228
+ const platformBbox = {
229
+ xMin,
230
+ yMin,
231
+ xMax,
232
+ yMax,
233
+ borderColor: scanPhase === 'barcode' ? '#EAB308' : '#2DBD60',
234
+ borderWidth: Platform.OS === 'web' ? 2 : 3,
235
+ cornerRadius: 8
236
+ };
237
+
123
238
  return {
124
239
  cameraType: Platform.OS === 'web' ? cameraType : 'back',
125
240
  flashMode: 'auto' as const,
126
241
  overlay: { guideText: instructions, bbox: platformBbox }
127
242
  };
128
- }, [selectedDocumentType, locale, component.instructions, cameraType, screenWidth, screenHeight]);
243
+ }, [selectedDocumentType, locale, component.instructions, cameraType, screenWidth, screenHeight, scanPhase]);
129
244
 
130
245
  const retakePicture = (sideToRetake: 'front' | 'back') => {
131
246
  setIsProcessingCapture(false);
132
247
  setProcessingImagePath(null);
248
+ scannedBarcodeRef.current = null;
249
+ setScanPhase('image');
250
+ setPendingCAImage(null);
133
251
  setSilentCaptureResult({ path: '', success: false, isAnalyzing: false, error: '', templatePath: '', mrz: '', bbox: undefined });
134
252
  setShowCamera(true);
135
253
  refreshCamera();
136
254
  actions.showCustomStepper(false);
137
- setCapturedImages((prev) => { const newState = { ...prev }; delete newState[sideToRetake]; return newState; });
138
- if (value) { const newValue = { ...value }; delete newValue[sideToRetake]; onValueChange(newValue); }
255
+ setCapturedImages((prev) => {
256
+ const newState = { ...prev };
257
+ delete newState[sideToRetake];
258
+ return newState;
259
+ });
260
+
261
+ if (value) {
262
+ const newValue = { ...value };
263
+ delete newValue[sideToRetake];
264
+ onValueChange(newValue);
265
+ }
139
266
  };
140
267
 
141
268
  const getCurrentSideVerification = (currentSide: string, countryKey: string) => {
142
269
  const rawDocType = countrySelectionData?.documentType;
143
- if (!rawDocType || !countryKey) { return { authMethod: [], mrzTypes: [], regionMapping: null, key: 'root' }; }
144
-
270
+ if (!rawDocType || !countryKey) return { authMethod: [], mrzTypes: [], regionMapping: null, key: 'root' };
271
+
272
+ const jsonDocMap: Record<string, string> = {
273
+ 'nationalId': 'identity_card', 'national_id': 'identity_card', 'passport': 'passport',
274
+ 'driversLicense': 'drivers_licence', 'drivers_licence': 'drivers_licence',
275
+ 'residencePermit': 'permanent_residence', 'healthInsuranceCard': 'health_insurance_card'
276
+ };
277
+
278
+ const searchDocType = jsonDocMap[rawDocType] || rawDocType;
145
279
  const rawCountryName = ISO_TO_COUNTRY_NAME[countryData?.code || ''] || countryData?.code || countryKey;
146
280
  const baseMapping = (REGION_MAPPING as any).regionMapping || REGION_MAPPING;
147
281
 
@@ -150,25 +284,24 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
150
284
  const foundKey = Object.keys(baseMapping).find(k => k.toLowerCase() === rawCountryName.toLowerCase() || k.toLowerCase() === countryKey.toLowerCase());
151
285
  if (foundKey) countryMapping = baseMapping[foundKey];
152
286
  }
153
- if (!countryMapping) { return { authMethod: [], mrzTypes: [], regionMapping: null, key: 'root' }; }
154
-
155
- const regionMapping = countryMapping[rawDocType];
156
- if (!regionMapping) { return { authMethod: [], mrzTypes: [], regionMapping: null, key: 'root' }; }
157
287
 
288
+ if (!countryMapping || !countryMapping[searchDocType]) return { authMethod: [], mrzTypes: [], regionMapping: null, key: 'root' };
289
+
158
290
  const authMethod: string[] = [];
159
291
  const mrzTypes: string[] = [];
160
- const key = countrySelectionData.region?.trim()?.length > 0 ? countrySelectionData.region.trim() : 'root';
161
-
292
+ const rawRegionKey = countrySelectionData.region?.trim()?.length > 0 ? countrySelectionData.region.trim() : 'root';
293
+ const key = SHORT_TO_LONG_REGION[rawRegionKey] || rawRegionKey;
294
+ const regionMapping = countryMapping[searchDocType];
295
+
162
296
  if (regionMapping?.[key] && Array.isArray(regionMapping[key])) {
163
297
  regionMapping[key].forEach((item: any) => {
164
- if (item[currentSide]) {
165
- authMethod.push(item[currentSide]);
166
- if (item?.mrz_type) mrzTypes.push(item?.mrz_type);
167
- }
298
+ if (item[currentSide]) authMethod.push(item[currentSide]);
299
+ if (item?.mrz_type) mrzTypes.push(item?.mrz_type);
168
300
  });
169
301
  }
302
+
170
303
  return { authMethod: removeDuplicates(authMethod), mrzTypes: removeDuplicates(mrzTypes), regionMapping, key };
171
- };
304
+ }
172
305
 
173
306
  const getCorrespondingMrzType = (templatePath: string, mapping: any, regionKey: string = "root") => {
174
307
  if (!mapping || !mapping[regionKey]) return null;
@@ -192,33 +325,38 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
192
325
  if (isProcessingCapture) return;
193
326
  setIsProcessingCapture(true);
194
327
  setProcessingImagePath(capturePath);
328
+
195
329
  try {
196
330
  let imagePathForUpload = capturePath;
197
331
  const overlayBbox = cameraConfig.overlay.bbox;
198
332
  const uiCropBbox = {
199
- minX: overlayBbox.xMin / 100,
200
- minY: overlayBbox.yMin / 100,
333
+ minX: overlayBbox.xMin / 100, minY: overlayBbox.yMin / 100,
201
334
  width: (overlayBbox.xMax - overlayBbox.xMin) / 100,
202
335
  height: (overlayBbox.yMax - overlayBbox.yMin) / 100,
203
336
  };
337
+
204
338
  try {
205
339
  imagePathForUpload = await cropImageWithBBoxWithTolerance(capturePath, uiCropBbox, 0.06);
206
340
  } catch (e) {
207
341
  console.warn("Crop failed, falling back to original image", e);
208
- imagePathForUpload = capturePath;
342
+ imagePathForUpload = capturePath;
209
343
  }
344
+
210
345
  const base64 = await pathToBase64(imagePathForUpload);
211
346
  const newImages = {
212
347
  ...capturedImages,
213
348
  [currentSide]: { dir: imagePathForUpload, file: base64, mrz: verified.mrz || "", templatePath: verified.templatePath },
214
349
  };
350
+
215
351
  setCapturedImages(newImages);
216
352
 
217
353
  if (verified.country && verified.documentType) {
354
+ const docType = verified.documentType as string;
355
+ const resolvedDocumentType = GovernmentDocumentTypeBackend[docType] || (Object.values(GovernmentDocumentTypeBackend).includes(docType as GovernmentDocumentType) ? docType : null) || Object.entries(GovernmentDocumentTypeBackend).find(([, v]) => v === docType)?.[1] || docType;
218
356
  onValueChange({
219
357
  ...newImages,
220
358
  country: verified.country,
221
- documentType: GovernmentDocumentTypeBackend[verified.documentType as keyof typeof GovernmentDocumentTypeBackend] || '',
359
+ documentType: resolvedDocumentType,
222
360
  ...(verified.region ? { region: verified.region } : {})
223
361
  });
224
362
  }
@@ -229,6 +367,8 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
229
367
  setSilentCaptureResult(prev => ({ ...prev, isAnalyzing: false }));
230
368
  setIsProcessingCapture(false);
231
369
  setProcessingImagePath(null);
370
+ setScanPhase('image');
371
+ setPendingCAImage(null);
232
372
  }, 600);
233
373
  } catch (e: any) {
234
374
  console.error("Backend Error:", e);
@@ -240,11 +380,100 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
240
380
  }
241
381
  };
242
382
 
243
- const handleSilentCapture = async (result: { success: boolean; path?: string; error?: string }) => {
244
- if (silentCaptureResult.isAnalyzing || isProcessingCapture) return;
383
+ const isBarcodeRequired = (): boolean => {
384
+ if (currentSide !== 'back') return false;
385
+ if (!countryData?.code || !selectedDocumentType?.type) return false;
386
+
387
+ const rawDocType = selectedDocumentType.type;
388
+ const jsonDocMap: Record<string, string> = {
389
+ 'nationalId': 'identity_card', 'national_id': 'identity_card', 'passport': 'passport',
390
+ 'driversLicense': 'drivers_licence', 'drivers_licence': 'drivers_licence',
391
+ 'residencePermit': 'permanent_residence', 'healthInsuranceCard': 'health_insurance_card'
392
+ };
245
393
 
394
+ const searchDocType = jsonDocMap[rawDocType] || rawDocType;
395
+ const rawCountryName = ISO_TO_COUNTRY_NAME[countryData.code] || countryData.code;
396
+ const baseMapping = (REGION_MAPPING as any).regionMapping || REGION_MAPPING;
397
+
398
+ let countryMapping = baseMapping[rawCountryName];
399
+ if (!countryMapping) {
400
+ const foundKey = Object.keys(baseMapping).find(k => k.toLowerCase() === rawCountryName.toLowerCase() || k.toLowerCase() === countryData.code.toLowerCase());
401
+ if (foundKey) countryMapping = baseMapping[foundKey];
402
+ }
403
+
404
+ if (!countryMapping || !countryMapping[searchDocType]) return false;
405
+
406
+ const rawRegionKey = countryData.region?.trim()?.length > 0 ? countryData.region.trim() : 'root';
407
+ const regionKey = SHORT_TO_LONG_REGION[rawRegionKey] || rawRegionKey;
408
+ const regionData = countryMapping[searchDocType][regionKey];
409
+
410
+ if (!regionData || !Array.isArray(regionData)) return false;
411
+
412
+ const requiresBarcode = regionData.some((item: any) => {
413
+ const method = String(item['back'] || '').toUpperCase();
414
+ return method.includes('BARCODE') || method.includes('2D');
415
+ });
416
+
417
+ logger.log(`[Barcode Check] Dynamic check for ${countryData.code} - ${searchDocType} - ${regionKey}: requiresBarcode=${requiresBarcode}`);
418
+ return requiresBarcode;
419
+ };
420
+
421
+ const processCanadianBarcode = async (barcodeString: string) => {
422
+ if (isProcessingCapture || !pendingCAImage) return;
423
+ logger.log('[CA Barcode] Valid barcode decoded. Sending to parser...');
424
+
425
+ setSilentCaptureResult(prev => ({ ...prev, isAnalyzing: true, error: '' }));
426
+
427
+ try {
428
+ const isHIC = selectedDocumentType!.type === 'health_insurance_card';
429
+ let parsedDictionary;
430
+
431
+ if (isHIC && barcodeString.length < 50) {
432
+ parsedDictionary = { data: { card_number: barcodeString, barcode_type: "1D" } };
433
+ } else {
434
+ const token = await authentification();
435
+ parsedDictionary = await kycService.formatBarcodeData({ textInput: barcodeString, token }, env);
436
+ }
437
+
438
+ if (!parsedDictionary) throw new Error("Could not parse the barcode data.");
439
+
440
+ const verifiedResult: ISilentCaptureResult & { region?: string } = {
441
+ path: pendingCAImage.path,
442
+ templatePath: pendingCAImage.templatePath,
443
+ bbox: pendingCAImage.bbox,
444
+ success: true,
445
+ mrz: JSON.stringify(parsedDictionary),
446
+ barcode: barcodeString,
447
+ isAnalyzing: false,
448
+ country: countryData?.code,
449
+ documentType: selectedDocumentType!.type,
450
+ region: selectedDocumentType!.region !== 'root' ? selectedDocumentType!.region : undefined,
451
+ };
452
+
453
+ setSilentCaptureResult(verifiedResult);
454
+ if (pendingCAImage.bbox) {
455
+ const safeBbox = pendingCAImage.bbox as IBbox;
456
+ setBboxBySide(prev => ({ ...prev, ['back']: safeBbox }));
457
+ }
458
+
459
+ await autoCapture(pendingCAImage.path, verifiedResult);
460
+ } catch (error: any) {
461
+ console.error("Barcode processing failed", error);
462
+ setScanPhase('image');
463
+ setPendingCAImage(null);
464
+ scannedBarcodeRef.current = null;
465
+ setSilentCaptureResult(prev => ({ ...prev, isAnalyzing: false, success: false, error: 'Failed to process barcode. Please retake the photo.' }));
466
+ }
467
+ };
468
+
469
+ const handleSilentCapture = async (result: { success: boolean; path?: string; error?: string; barcode?: unknown }) => {
470
+ if (isProcessingCapture) return;
471
+ if (scanPhase === 'barcode') return;
472
+ if (silentCaptureResult.isAnalyzing) return;
473
+
246
474
  if (result.success && result.path) {
247
475
  setSilentCaptureResult((prev) => ({ ...prev, isAnalyzing: true, success: false, error: '' }));
476
+
248
477
  let templatePath = capturedImages[currentSide]?.templatePath || '';
249
478
  let templateBbox: IBbox | undefined;
250
479
  let templateResponse: any;
@@ -253,19 +482,20 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
253
482
  setSilentCaptureResult((prev) => ({ ...prev, isAnalyzing: false, success: false, error: 'Document type not selected' }));
254
483
  return;
255
484
  }
256
-
485
+
257
486
  const issuingSubdivision = selectedDocumentType.region !== 'root' ? selectedDocumentType.region : undefined;
258
-
487
+
259
488
  try {
489
+ // 1. Check & Crop the Template visually first
260
490
  if (!templatePath) {
261
- const templateType = await checkTemplateType({
262
- path: result.path || '',
263
- docType: selectedDocumentType?.type as GovernmentDocumentType,
264
- docRegion: countryData?.code || "",
491
+ const templateType = await checkTemplateType({
492
+ path: result.path || '',
493
+ docType: selectedDocumentType?.type as GovernmentDocumentType,
494
+ docRegion: countryData?.code || "",
265
495
  postfix: currentSide,
266
- issuingSubdivision: issuingSubdivision
496
+ issuingSubdivision: issuingSubdivision
267
497
  }, env);
268
-
498
+
269
499
  templateResponse = templateType;
270
500
  if (templateType.template_path) templatePath = templateType.template_path;
271
501
 
@@ -278,73 +508,95 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
278
508
  try {
279
509
  const crop = await cropByObb(result.path, (templateType as any).card_obb);
280
510
  templateBbox = crop.bbox;
281
- } catch { }
511
+ } catch {}
282
512
  }
283
513
  }
284
-
514
+
285
515
  const extractedCountryKey = templatePath ? templatePath.split('/')[0] : (ISO_TO_COUNTRY_NAME[countryData?.code || ''] || 'root');
286
516
  const regionMappings = getCurrentSideVerification(currentSide, extractedCountryKey);
287
517
  let verificationRes: any;
288
-
518
+
289
519
  if (currentSide === 'front') {
290
520
  const matchedAuthMethod = getCorrespondingAuthMethod(templatePath, regionMappings.regionMapping, regionMappings.key || '', 'front');
291
521
  const mrzType = getCorrespondingMrzType(templatePath, regionMappings.regionMapping, regionMappings.key || '') || 'TD1';
292
522
 
293
- verificationRes = await frontVerification({
294
- path: result.path,
295
- regionMapping: {
296
- authMethod: matchedAuthMethod ? [matchedAuthMethod] : [],
297
- mrzTypes: mrzType ? [mrzType] : []
298
- },
299
- selectedDocumentType: GovernmentDocumentTypeShorted[selectedDocumentType?.type as keyof typeof GovernmentDocumentTypeShorted] || '',
300
- code: countryData?.code || '',
301
- currentSide,
302
- templatePath,
303
- mrzType,
304
- issuingSubdivision: issuingSubdivision
523
+ verificationRes = await frontVerification({
524
+ path: result.path,
525
+ regionMapping: { authMethod: matchedAuthMethod ? [matchedAuthMethod] : [], mrzTypes: mrzType ? [mrzType] : [] },
526
+ selectedDocumentType: GovernmentDocumentTypeShorted[selectedDocumentType?.type as keyof typeof GovernmentDocumentTypeShorted] || '',
527
+ code: countryData?.code || '', currentSide, templatePath, mrzType,
528
+ issuingSubdivision: issuingSubdivision, clientSideBarcodeData: silentCaptureResult.barcode
305
529
  }, env);
306
530
  } else {
531
+ logger.log("Attempting backVerification with:", { path: result.path, templatePath, side: currentSide });
307
532
  const matchedBackAuthMethod = getCorrespondingAuthMethod(templatePath, regionMappings.regionMapping, regionMappings.key || '', 'back');
308
533
  const backMrzType = getCorrespondingMrzType(templatePath, regionMappings.regionMapping, regionMappings.key || '') || '';
309
534
  const frontTemplatePath = capturedImages['front']?.templatePath || '';
310
-
311
- verificationRes = await backVerification({
312
- path: result.path,
313
- regionMapping: {
314
- authMethod: matchedBackAuthMethod ? [matchedBackAuthMethod] : [],
315
- mrzTypes: backMrzType ? [backMrzType] : []
316
- },
317
- selectedDocumentType: GovernmentDocumentTypeShorted[selectedDocumentType.type as keyof typeof GovernmentDocumentTypeShorted] || '',
318
- code: countryData?.code || '',
319
- currentSide,
320
- templatePath,
321
- mrzType: backMrzType,
322
- templateResponse,
323
- issuingSubdivision: issuingSubdivision,
324
- frontTemplatePath: frontTemplatePath
535
+
536
+ // This validates if the back matches the front template!
537
+ verificationRes = await backVerification({
538
+ path: result.path,
539
+ regionMapping: { authMethod: matchedBackAuthMethod ? [matchedBackAuthMethod] : [], mrzTypes: backMrzType ? [backMrzType] : [] },
540
+ selectedDocumentType: GovernmentDocumentTypeShorted[selectedDocumentType.type as keyof typeof GovernmentDocumentTypeShorted] || '',
541
+ code: countryData?.code || '', currentSide, templatePath, mrzType: backMrzType, templateResponse,
542
+ issuingSubdivision: issuingSubdivision, frontTemplatePath: frontTemplatePath
325
543
  }, env);
326
544
  }
327
-
545
+
328
546
  const bbox = verificationRes?.bbox || templateBbox;
329
- const mrz = verificationRes?.mrz ? JSON.stringify(verificationRes.mrz) : "";
330
-
331
- const verifiedResult: ISilentCaptureResult & { region?: string } = {
332
- path: result.path,
333
- templatePath,
334
- bbox,
335
- success: true,
336
- mrz,
337
- isAnalyzing: false,
338
- country: countryData?.code,
339
- documentType: selectedDocumentType.type,
340
- region: issuingSubdivision
547
+ const verifiedMrz = verificationRes?.mrz ? JSON.stringify(verificationRes.mrz) : "";
548
+
549
+ if (isBarcodeRequired()) {
550
+ const barcodeData = scannedBarcodeRef.current;
551
+ const docTypeStr = selectedDocumentType!.type;
552
+ const isHIC = docTypeStr === 'health_insurance_card';
553
+ const isValidScan = typeof barcodeData === 'string' && ((isHIC && barcodeData.length > 0) || barcodeData.length > 50);
554
+
555
+ // If the barcode was caught extremely fast in the same frame
556
+ if (isValidScan) {
557
+ logger.log('[CA Barcode] Image captured, verified & barcode ALREADY scanned! Skipping Phase 2.');
558
+ let parsedDictionary;
559
+ if (isHIC && barcodeData.length < 50) {
560
+ parsedDictionary = { data: { card_number: barcodeData, barcode_type: "1D" } };
561
+ } else {
562
+ const token = await authentification();
563
+ parsedDictionary = await kycService.formatBarcodeData({ textInput: barcodeData, token }, env);
564
+ }
565
+
566
+ const verifiedResult: ISilentCaptureResult & { region?: string } = {
567
+ path: result.path, templatePath: templatePath, bbox: bbox, success: true,
568
+ mrz: JSON.stringify(parsedDictionary), barcode: barcodeData, isAnalyzing: false,
569
+ country: countryData?.code, documentType: selectedDocumentType!.type,
570
+ region: selectedDocumentType!.region !== 'root' ? selectedDocumentType!.region : undefined,
571
+ };
572
+
573
+ setSilentCaptureResult(verifiedResult);
574
+ if (bbox) {
575
+ const safeBbox = bbox as IBbox;
576
+ setBboxBySide(prev => ({ ...prev, ['back']: safeBbox }));
577
+ }
578
+ await autoCapture(result.path!, verifiedResult);
579
+ return;
580
+ }
581
+
582
+ // If validation passed but we still need the barcode, trigger the UI phase!
583
+ logger.log('[CA Barcode] Image captured & template VERIFIED. Switching to Barcode phase.');
584
+ setPendingCAImage({ path: result.path!, templateResponse, templatePath, bbox });
585
+ setScanPhase('barcode');
586
+ setSilentCaptureResult(prev => ({ ...prev, isAnalyzing: false }));
587
+ return;
588
+ }
589
+
590
+ // 4. NORMAL COMPLETION (For standard MRZ / Passport / Non-barcode documents)
591
+ const verifiedResult: ISilentCaptureResult & { region?: string } = {
592
+ path: result.path, templatePath, bbox, success: true, mrz: verifiedMrz, isAnalyzing: false,
593
+ country: countryData?.code, documentType: selectedDocumentType.type, region: issuingSubdivision
341
594
  };
342
595
 
343
596
  setSilentCaptureResult(verifiedResult);
344
597
  if (bbox) setBboxBySide(prev => ({ ...prev, [currentSide]: bbox }));
345
-
346
598
  await autoCapture(result.path, verifiedResult);
347
-
599
+
348
600
  } catch (error: any) {
349
601
  console.log("Backend Verification Error:", error);
350
602
  const rawMessage = (error?.message || '').toLowerCase();
@@ -356,15 +608,26 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
356
608
  userFriendlyMessage = 'The document does not match your selected country.';
357
609
  } else if (rawMessage.includes('card_not_fully_in_frame')) {
358
610
  userFriendlyMessage = 'Move closer to the document and ensure all corners are visible.';
359
- } else if (rawMessage.includes('ne correspond pas au recto')) {
611
+ } else if (rawMessage.includes('ne correspond pas au recto')) {
360
612
  userFriendlyMessage = 'The back of the document does not match the front. You must scan the same ID.';
361
613
  }
362
614
 
615
+ // The error is thrown here, completely blocking the switch to 'barcode' phase
363
616
  setSilentCaptureResult(prev => ({ ...prev, isAnalyzing: false, success: false, error: userFriendlyMessage }));
364
617
  }
365
618
  }
366
619
  };
367
620
 
621
+ useEffect(() => {
622
+ if (scanPhase === 'barcode' && pendingCAImage && silentCaptureResult.barcode) {
623
+ const bData = silentCaptureResult.barcode;
624
+ if (typeof bData === 'string' && bData.length > 50) {
625
+ logger.log('[CA Barcode] Safety Net Triggered: Processing barcode.');
626
+ processCanadianBarcode(bData);
627
+ }
628
+ }
629
+ }, [scanPhase, pendingCAImage, silentCaptureResult.barcode]);
630
+
368
631
  const handleError = (event: { message: string }) => {
369
632
  showAlert('Erreur', event.message);
370
633
  setShowCamera(false);
@@ -375,6 +638,38 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
375
638
  actions.showCustomStepper(!showCamera);
376
639
  }, [showCamera]);
377
640
 
641
+ useEffect(() => {
642
+ if (Platform.OS === 'web' && typeof window !== 'undefined') {
643
+ try {
644
+ const url = new URL(window.location.origin);
645
+ if (url.hostname === 'localhost') {
646
+ url.hostname = '192.168.0.33';
647
+ }
648
+ const params = new URLSearchParams(window.location.search);
649
+ params.set('session_id', state.session.session_id);
650
+ params.set('resume', 'true');
651
+
652
+ if (state.currentComponentIndex !== undefined) {
653
+ params.set('component_index', String(state.currentComponentIndex));
654
+ }
655
+ if (countryData?.code) {
656
+ params.set('country', countryData.code);
657
+ }
658
+ if (selectedDocumentType?.type) {
659
+ params.set('document_type', selectedDocumentType.type);
660
+ }
661
+ if (selectedDocumentType?.region && selectedDocumentType.region !== 'root') {
662
+ params.set('region', selectedDocumentType.region);
663
+ }
664
+
665
+ url.hash = params.toString();
666
+ setCurrentUrl(url.toString());
667
+ } catch (e) {
668
+ console.error("Failed to construct QR handoff URL", e);
669
+ }
670
+ }
671
+ }, [state.session.session_id, state.currentComponentIndex, countryData, selectedDocumentType]);
672
+
378
673
  if (!countrySelectionData || !selectedDocumentType) {
379
674
  return (
380
675
  <View style={styles.root}>
@@ -390,6 +685,7 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
390
685
 
391
686
  if (showCamera) {
392
687
  const isBusy = isProcessingCapture;
688
+
393
689
  if (isRebootingCamera) {
394
690
  return (
395
691
  <View style={[styles.root, { justifyContent: 'center', alignItems: 'center', backgroundColor: '#000' }]}>
@@ -398,6 +694,7 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
398
694
  </View>
399
695
  );
400
696
  }
697
+
401
698
  return (
402
699
  <View style={styles.root}>
403
700
  <View style={[styles.cameraWrapper, { flex: 1 }]}>
@@ -411,7 +708,7 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
411
708
  </Text>
412
709
  </View>
413
710
  </View>
414
-
711
+
415
712
  <View style={[styles.cameraFeedContainer, { flex: 1, backgroundColor: '#000' }]}>
416
713
  {Platform.OS === 'web' && (
417
714
  <View style={styles.webTopControls}>
@@ -420,7 +717,7 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
420
717
  </TouchableOpacity>
421
718
  </View>
422
719
  )}
423
-
720
+
424
721
  <EnhancedCameraView
425
722
  key={`${currentSide}-${cameraKey}`}
426
723
  showCamera={true}
@@ -430,32 +727,83 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
430
727
  onError={handleError}
431
728
  onSilentCapture={handleSilentCapture}
432
729
  silentCaptureResult={silentCaptureResult}
730
+ isScanningBarcode={currentSide === 'back'}
731
+ barcodeTypes={targetBarcodeTypes}
732
+ onBarcodeScanned={(barcodeString) => {
733
+ const docTypeStr = selectedDocumentType?.type;
734
+ const isFullDataDoc = docTypeStr === 'drivers_licence' || docTypeStr === 'national_id' || docTypeStr === 'identity_card';
735
+ if (isFullDataDoc && barcodeString.length < 50) return;
736
+
737
+ if (scannedBarcodeRef.current !== barcodeString) {
738
+ scannedBarcodeRef.current = barcodeString;
739
+ if (scanPhase === 'barcode' && pendingCAImage) {
740
+ processCanadianBarcode(barcodeString);
741
+ } else {
742
+ setSilentCaptureResult(prev => ({ ...prev, barcode: barcodeString }));
743
+ }
744
+ }
745
+ }}
433
746
  overlayComponent={
434
747
  <>
435
748
  <IdCardOverlay
436
- xMin={cameraConfig.overlay.bbox.xMin} yMin={cameraConfig.overlay.bbox.yMin} xMax={cameraConfig.overlay.bbox.xMax} yMax={cameraConfig.overlay.bbox.yMax}
749
+ xMin={cameraConfig.overlay.bbox.xMin}
750
+ yMin={cameraConfig.overlay.bbox.yMin}
751
+ xMax={cameraConfig.overlay.bbox.xMax}
752
+ yMax={cameraConfig.overlay.bbox.yMax}
437
753
  instructions={cameraConfig.overlay.guideText}
438
754
  cornerOpacity={cameraConfig.overlay.bbox.cornerRadius || 0 as number}
439
- isSuccess={silentCaptureResult.success}
755
+ isSuccess={silentCaptureResult.success || scanPhase === 'barcode'}
440
756
  language={state.currentLanguage}
441
757
  stepperProps={{
442
758
  back: () => {
443
759
  if (currentSide === 'back') {
444
- setCurrentSide('front'); setShowCamera(false); setIsProcessingCapture(false); setProcessingImagePath(null);
760
+ scannedBarcodeRef.current = null;
761
+ setScanPhase('image');
762
+ setPendingCAImage(null);
763
+ setCurrentSide('front');
764
+ setShowCamera(false);
765
+ setIsProcessingCapture(false);
766
+ setProcessingImagePath(null);
445
767
  if (capturedImages['front']?.dir) {
446
768
  const frontImage = capturedImages['front'];
447
- setSilentCaptureResult((prev) => ({ ...prev, path: frontImage.dir, success: true, isAnalyzing: false, error: '', mrz: frontImage.mrz || '', templatePath: frontImage.templatePath || '', bbox: bboxBySide['front'] }));
448
- } else { setSilentCaptureResult((prev) => ({ ...prev, path: '', success: false, isAnalyzing: false, error: '', templatePath: '' })); }
449
- } else { actions.previousComponent(); }
769
+ setSilentCaptureResult((prev) => ({
770
+ ...prev, path: frontImage.dir, success: true, isAnalyzing: false, error: '', mrz: frontImage.mrz || '', templatePath: frontImage.templatePath || '', bbox: bboxBySide['front']
771
+ }));
772
+ } else {
773
+ setSilentCaptureResult((prev) => ({ ...prev, path: '', success: false, isAnalyzing: false, error: '', templatePath: '' }));
774
+ }
775
+ } else {
776
+ actions.previousComponent();
777
+ }
450
778
  },
451
779
  selectedDocumentType: selectedDocumentType ? (locale === 'en' ? getDocumentTypeInfo(selectedDocumentType.type).name.en : getDocumentTypeInfo(selectedDocumentType.type).name.fr) : '',
452
- step: state.currentComponentIndex + 1, totalSteps: state.template.components.length, side: currentSide,
780
+ step: state.currentComponentIndex + 1,
781
+ totalSteps: state.template.components.length,
782
+ side: currentSide,
453
783
  }}
454
784
  />
455
785
  </>
456
786
  }
457
787
  />
458
-
788
+
789
+ {scanPhase === 'barcode' && !isBusy && (
790
+ <View style={styles.barcodePromptContainer}>
791
+ <View style={styles.barcodePromptContent}>
792
+ <View style={styles.barcodeIconContainer}>
793
+ <Text style={styles.barcodeIcon}>✓</Text>
794
+ </View>
795
+ <View style={styles.barcodeTextContainer}>
796
+ <Text style={styles.barcodePromptTitle}>
797
+ {state.currentLanguage === 'en' ? 'Image Verified' : 'Image Vérifiée'}
798
+ </Text>
799
+ <Text style={styles.barcodePromptDesc}>
800
+ {state.currentLanguage === 'en' ? 'Move closer to fill the screen with the 2D barcode.' : 'Rapprochez-vous pour scanner le code-barres 2D.'}
801
+ </Text>
802
+ </View>
803
+ </View>
804
+ </View>
805
+ )}
806
+
459
807
  {!isBusy && silentCaptureResult.isAnalyzing && (
460
808
  <View style={styles.topAnalyzingPillContainer}>
461
809
  <View style={styles.topAnalyzingPill}>
@@ -466,15 +814,11 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
466
814
  </View>
467
815
  </View>
468
816
  )}
469
-
817
+
470
818
  {isBusy && (
471
819
  <View style={[StyleSheet.absoluteFillObject, { zIndex: 9999 }]}>
472
820
  {processingImagePath && (
473
- <Image
474
- source={{ uri: processingImagePath.startsWith('file://') ? processingImagePath : `file://${processingImagePath}` }}
475
- style={StyleSheet.absoluteFillObject}
476
- resizeMode="cover"
477
- />
821
+ <Image source={{ uri: processingImagePath.startsWith('file://') ? processingImagePath : `file://${processingImagePath}` }} style={StyleSheet.absoluteFillObject} resizeMode="cover" />
478
822
  )}
479
823
  <View style={styles.processingOverlay}>
480
824
  <ActivityIndicator size="large" color="#2DBD60" />
@@ -484,7 +828,7 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
484
828
  </View>
485
829
  </View>
486
830
  )}
487
-
831
+
488
832
  {!isBusy && Platform.OS === 'web' ? (
489
833
  <View style={styles.webBottomControlBar}>
490
834
  {silentCaptureResult.error && (
@@ -506,15 +850,12 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
506
850
  <TouchableOpacity style={styles.fallbackRefreshButton} onPress={refreshCamera}>
507
851
  <Text style={styles.fallbackRefreshText}>↻ Refresh Camera</Text>
508
852
  </TouchableOpacity>
509
- <TouchableOpacity
510
- style={[styles.fallbackRefreshButton, { marginTop: 15, backgroundColor: 'rgba(220, 38, 38, 0.8)', borderColor: '#DC2626' }]}
511
- onPress={() => setShowCamera(false)}
512
- >
853
+ <TouchableOpacity style={[styles.fallbackRefreshButton, { marginTop: 15, backgroundColor: 'rgba(220, 38, 38, 0.8)', borderColor: '#DC2626' }]} onPress={() => setShowCamera(false)}>
513
854
  <Text style={styles.fallbackRefreshText}>Cancel / Go Back</Text>
514
855
  </TouchableOpacity>
515
856
  </View>
516
857
  ) : null}
517
-
858
+
518
859
  {silentCaptureResult.error && !isBusy && Platform.OS !== 'web' ? (
519
860
  <View style={[styles.floatingErrorBanner, { zIndex: 10000 }]}>
520
861
  <Text style={styles.floatingErrorText}>⚠️ {silentCaptureResult.error}</Text>
@@ -537,6 +878,7 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
537
878
  <Text style={{ fontSize: 14, color: '#666', textAlign: 'center', marginBottom: 16, lineHeight: 22 }}>
538
879
  {getLocalizedText(component.instructions)}
539
880
  </Text>
881
+
540
882
  <View style={{ alignItems: 'center', justifyContent: 'center', flexDirection: "column", gap: 16 }}>
541
883
  <View style={styles.imagePreviewWrapper}>
542
884
  {capturedImages[currentSide]?.dir ? (
@@ -545,31 +887,80 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
545
887
  <Image source={{ uri: silentCaptureResult.path }} style={styles.previewImage} />
546
888
  ) : null}
547
889
  </View>
890
+
548
891
  {!capturedImages[currentSide]?.dir && (
549
892
  <View style={{ width: '100%', gap: 12 }}>
550
- <Button
551
- title={state.currentLanguage === "en" ? "Start Scanning" : "Commencer la numérisation"}
552
- onPress={() => { setShowCamera(true); refreshCamera(); actions.showCustomStepper(false); }}
553
- variant="primary" size="large" fullWidth
554
- />
555
- {Platform.OS === 'web' && (
556
- <Button
557
- title={state.currentLanguage === "en" ? "Continue on Phone" : "Continuer sur le téléphone"}
558
- onPress={() => setShowQRModal(true)}
559
- variant="outline" size="large" fullWidth
560
- />
893
+
894
+ {isWebScanningRestricted ? (
895
+ <>
896
+ <View style={styles.restrictionWarningBanner}>
897
+ <Text style={styles.restrictionWarningTitle}>
898
+ {state.currentLanguage === 'en' ? '⚠️ Web Scanning Unavailable' : '⚠️ Numérisation Web Indisponible'}
899
+ </Text>
900
+ <Text style={styles.restrictionWarningText}>
901
+ {state.currentLanguage === 'en'
902
+ ? `This document can only be scanned using our mobile app.`
903
+ : `Ce document ne peut être scanné qu'avec notre application mobile.`}
904
+ </Text>
905
+ </View>
906
+
907
+ <Button
908
+ title={state.currentLanguage === "en" ? "Continue on Phone" : "Continuer sur le téléphone"}
909
+ onPress={() => setShowQRModal(true)}
910
+ variant="primary"
911
+ size="large"
912
+ fullWidth
913
+ />
914
+ </>
915
+ ) : (
916
+ /* 🟢 NORMAL FLOW: Show "Start Scanning" and optional web handoff */
917
+ <>
918
+ <Button
919
+ title={state.currentLanguage === "en" ? "Start Scanning" : "Commencer la numérisation"}
920
+ onPress={() => {
921
+ setShowCamera(true);
922
+ refreshCamera();
923
+ actions.showCustomStepper(false);
924
+ }}
925
+ variant="primary"
926
+ size="large"
927
+ fullWidth
928
+ />
929
+
930
+ {Platform.OS === 'web' && (
931
+ <Button
932
+ title={state.currentLanguage === "en" ? "Continue on Phone" : "Continuer sur le téléphone"}
933
+ onPress={() => setShowQRModal(true)}
934
+ variant="outline"
935
+ size="large"
936
+ fullWidth
937
+ />
938
+ )}
939
+ </>
561
940
  )}
941
+
562
942
  </View>
563
943
  )}
944
+
564
945
  {capturedImages[currentSide]?.dir && (
565
946
  <>
566
947
  <Button title={t('kyc.idCardCapture.retakeButton')} onPress={() => retakePicture(currentSide)} variant="outline" size="medium" fullWidth />
567
948
  <Button title={t('common.next')} onPress={() => {
568
- if (!selectedDocumentType) { showAlert('Error', 'Document type not selected'); return; }
949
+ if (!selectedDocumentType) {
950
+ showAlert('Error', 'Document type not selected');
951
+ return;
952
+ }
569
953
  if (currentSide === 'back' || selectedDocumentType.type === 'passport') {
570
954
  actions.nextComponent();
571
955
  } else {
572
- setShowCamera(true); refreshCamera(); setCurrentSide('back'); setSilentCaptureResult({ success: false, isAnalyzing: false, path: '', error: '', templatePath: undefined }); setIsProcessingCapture(false); setProcessingImagePath(null);
956
+ setShowCamera(true);
957
+ refreshCamera();
958
+ setCurrentSide('back');
959
+ setSilentCaptureResult({ success: false, isAnalyzing: false, path: '', error: '', templatePath: undefined });
960
+ setIsProcessingCapture(false);
961
+ setProcessingImagePath(null);
962
+ setScanPhase('image');
963
+ setPendingCAImage(null);
573
964
  }
574
965
  }} variant="primary" size="large" fullWidth />
575
966
  </>
@@ -578,22 +969,91 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
578
969
  </View>
579
970
  </ScrollView>
580
971
  </View>
972
+
581
973
  {showQRModal && Platform.OS === 'web' && (
582
- <View style={styles.qrModalOverlay}>
583
- <View style={styles.qrModalContainer}>
584
- <Text style={styles.qrModalTitle}>
585
- {state.currentLanguage === 'en' ? 'Scan to continue' : 'Scannez pour continuer'}
586
- </Text>
587
- <Text style={styles.qrModalText}>
588
- {state.currentLanguage === 'en' ? "Point your phone's camera at this QR code to seamlessly continue the process on your mobile device." : "Pointez l'appareil photo de votre téléphone vers ce code QR pour continuer le processus en toute fluidité sur votre appareil mobile."}
589
- </Text>
590
- <View style={styles.qrCodeWrapper}>
591
- <QRCode value={currentUrl} size={220} backgroundColor="transparent" />
974
+ <View style={styles.qrModalOverlay}>
975
+ <View style={styles.qrModalContainer}>
976
+ <View style={styles.qrModalHeader}>
977
+ <Text style={styles.qrModalTitle}>
978
+ {state.currentLanguage === 'en' ? 'Continue on your phone' : 'Continuer sur votre téléphone'}
979
+ </Text>
980
+ <Text style={styles.qrModalText}>
981
+ {state.currentLanguage === 'en'
982
+ ? "Scan the QR code to download our mobile app and complete the scanning process securely."
983
+ : "Scannez le code QR pour télécharger notre application et terminer la numérisation en toute sécurité."}
984
+ </Text>
985
+ </View>
986
+
987
+ <View style={styles.qrToggleContainer}>
988
+ <TouchableOpacity
989
+ style={[styles.qrToggleButton, qrPlatform === 'android' && styles.qrToggleButtonActive]}
990
+ onPress={() => setQrPlatform('android')}
991
+ >
992
+ <Text style={[styles.qrToggleText, qrPlatform === 'android' && styles.qrToggleTextActive]}>
993
+ Android
994
+ </Text>
995
+ </TouchableOpacity>
996
+ <TouchableOpacity
997
+ style={[styles.qrToggleButton, qrPlatform === 'ios' && styles.qrToggleButtonActive]}
998
+ onPress={() => setQrPlatform('ios')}
999
+ >
1000
+ <Text style={[styles.qrToggleText, qrPlatform === 'ios' && styles.qrToggleTextActive]}>
1001
+ iOS (Apple)
1002
+ </Text>
1003
+ </TouchableOpacity>
1004
+ </View>
1005
+
1006
+ <View style={styles.qrCodeWrapper}>
1007
+ <QRCode
1008
+ value={
1009
+ qrPlatform === 'android'
1010
+ ? 'https://play.google.com/store/apps/details?id=transfergratis.com&hl=en_CA&gl=US'
1011
+ : 'https://apps.apple.com/ca/app/transfergratis-money-transfer/id1605411731'
1012
+ }
1013
+ size={200} // Slightly reduced to accommodate the toggle switch
1014
+ backgroundColor="#FFFFFF"
1015
+ color="#0F172A"
1016
+ />
1017
+ </View>
1018
+
1019
+ {isWebScanningRestricted && (
1020
+ <View style={styles.storeLinksContainer}>
1021
+ <Text style={styles.storeLinksTitle}>
1022
+ {state.currentLanguage === 'en' ? 'Or click to open directly:' : 'Ou cliquez pour ouvrir directement :'}
1023
+ </Text>
1024
+ <View style={styles.storeButtonsRow}>
1025
+ <TouchableOpacity
1026
+ style={styles.storeButton}
1027
+ onPress={() => window.open('https://play.google.com/store/apps/details?id=transfergratis.com&hl=en_CA&gl=US', '_blank')}
1028
+ >
1029
+ <Text style={styles.storeButtonText}>Google Play</Text>
1030
+ </TouchableOpacity>
1031
+ <TouchableOpacity
1032
+ style={styles.storeButton}
1033
+ onPress={() => window.open('https://apps.apple.com/ca/app/transfergratis-money-transfer/id1605411731', '_blank')}
1034
+ >
1035
+ <Text style={styles.storeButtonText}>App Store</Text>
1036
+ </TouchableOpacity>
1037
+ </View>
1038
+ </View>
1039
+ )}
1040
+
1041
+ <View style={styles.listeningContainer}>
1042
+ <ActivityIndicator size="small" color="#2DBD60" />
1043
+ <Text style={styles.listeningText}>
1044
+ {state.currentLanguage === 'en' ? "Waiting for you to complete on mobile..." : "En attente de votre complétion sur mobile..."}
1045
+ </Text>
1046
+ </View>
1047
+
1048
+ <Button
1049
+ title={state.currentLanguage === 'en' ? "Cancel & Stay on Laptop" : "Annuler et rester sur l'ordinateur"}
1050
+ onPress={() => setShowQRModal(false)}
1051
+ variant="outline"
1052
+ fullWidth
1053
+ />
592
1054
  </View>
593
- <Button title={state.currentLanguage === 'en' ? "Close" : "Fermer"} onPress={() => setShowQRModal(false)} variant="outline" fullWidth />
594
1055
  </View>
595
- </View>
596
- )}
1056
+ )}
597
1057
  </View>
598
1058
  );
599
1059
  };
@@ -634,9 +1094,82 @@ const styles = StyleSheet.create({
634
1094
  topAnalyzingPillContainer: { position: 'absolute', top: Platform.OS === 'android' ? 60 : 75, left: 0, right: 0, alignItems: 'center', zIndex: 100 },
635
1095
  topAnalyzingPill: { flexDirection: 'row', alignItems: 'center', backgroundColor: 'rgba(0,0,0,0.7)', paddingVertical: 10, paddingHorizontal: 20, borderRadius: 30, gap: 10, shadowColor: '#000', shadowOffset: {width: 0, height: 4}, shadowOpacity: 0.3, shadowRadius: 8 },
636
1096
  analyzingPillText: { color: 'white', fontSize: 14, fontWeight: 'bold' },
637
- qrModalOverlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.6)', justifyContent: 'center', alignItems: 'center', zIndex: 999999, ...Platform.select({ web: { backdropFilter: 'blur(5px)' } as any }) },
638
- qrModalContainer: { backgroundColor: '#FFFFFF', borderRadius: 24, padding: 32, width: '90%', maxWidth: 400, alignItems: 'center', shadowColor: '#000', shadowOffset: { width: 0, height: 10 }, shadowOpacity: 0.15, shadowRadius: 30, elevation: 24, },
639
- qrModalTitle: { fontSize: 22, fontWeight: 'bold', color: '#0F172A', marginBottom: 12, textAlign: 'center' },
640
- qrModalText: { fontSize: 15, color: '#64748B', textAlign: 'center', marginBottom: 24, lineHeight: 22, },
641
- qrCodeWrapper: { padding: 16, backgroundColor: '#FFFFFF', borderRadius: 16, borderWidth: 1, borderColor: '#E2E8F0', marginBottom: 24, }
1097
+ qrModalOverlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(15, 23, 42, 0.85)', justifyContent: 'center', alignItems: 'center', zIndex: 999999, ...Platform.select({ web: { backdropFilter: 'blur(8px)' } as any }) },
1098
+ qrModalContainer: { backgroundColor: '#FFFFFF', borderRadius: 24, padding: 32, width: '90%', maxWidth: 420, alignItems: 'center', shadowColor: '#000', shadowOffset: { width: 0, height: 20 }, shadowOpacity: 0.25, shadowRadius: 40, elevation: 24 },
1099
+ qrModalHeader: { alignItems: 'center', marginBottom: 24 },
1100
+ qrModalTitle: { fontSize: 24, fontWeight: '800', color: '#0F172A', marginBottom: 12, textAlign: 'center' },
1101
+ qrModalText: { fontSize: 15, color: '#475569', textAlign: 'center', lineHeight: 22 },
1102
+ qrCodeWrapper: { padding: 20, backgroundColor: '#FFFFFF', borderRadius: 20, borderWidth: 2, borderColor: '#E2E8F0', marginBottom: 16, shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.05, shadowRadius: 10 },
1103
+ listeningContainer: { flexDirection: 'row', alignItems: 'center', backgroundColor: '#F0FDF4', paddingVertical: 12, paddingHorizontal: 20, borderRadius: 12, marginBottom: 24, borderWidth: 1, borderColor: '#BBF7D0' },
1104
+ listeningText: { marginLeft: 12, color: '#166534', fontSize: 14, fontWeight: '600' },
1105
+ barcodePromptContainer: { position: 'absolute', top: Platform.OS === 'android' ? 60 : 75, left: 0, right: 0, alignItems: 'center', zIndex: 150, paddingHorizontal: 24, },
1106
+ barcodePromptContent: { flexDirection: 'row', alignItems: 'center', backgroundColor: 'rgba(15, 23, 42, 0.85)', paddingVertical: 14, paddingHorizontal: 20, borderRadius: 16, borderWidth: 1, borderColor: 'rgba(255, 255, 255, 0.15)', shadowColor: '#000', shadowOffset: { width: 0, height: 8 }, shadowOpacity: 0.3, shadowRadius: 12, elevation: 10, maxWidth: 400, ...Platform.select({ web: { backdropFilter: 'blur(8px)' } as any, ios: { backdropFilter: 'blur(8px)' } as any, }), },
1107
+ barcodeIconContainer: { width: 36, height: 36, borderRadius: 18, backgroundColor: 'rgba(45, 189, 96, 0.2)', justifyContent: 'center', alignItems: 'center', marginRight: 14, borderWidth: 1, borderColor: 'rgba(45, 189, 96, 0.5)', },
1108
+ barcodeIcon: { fontSize: 16, color: '#2DBD60', fontWeight: 'bold', },
1109
+ barcodeTextContainer: { flex: 1, },
1110
+ barcodePromptTitle: { color: '#2DBD60', fontSize: 16, fontWeight: '700', marginBottom: 4, },
1111
+ barcodePromptDesc: { color: '#F8FAFC', fontSize: 14, fontWeight: '500', lineHeight: 20, },
1112
+ storeLinksContainer: { width: '100%', alignItems: 'center', marginBottom: 24, paddingTop: 16, borderTopWidth: 1, borderTopColor: '#E2E8F0' },
1113
+ storeLinksTitle: { fontSize: 14, color: '#64748B', fontWeight: '600', marginBottom: 12 },
1114
+ storeButtonsRow: { flexDirection: 'row', gap: 12, width: '100%', justifyContent: 'center' },
1115
+ storeButton: { flex: 1, backgroundColor: '#0F172A', paddingVertical: 12, borderRadius: 10, alignItems: 'center' },
1116
+ storeButtonText: { color: '#FFFFFF', fontWeight: 'bold', fontSize: 14 },
1117
+ restrictionWarningBanner: {
1118
+ backgroundColor: '#FFFBEB', // Light amber background
1119
+ borderWidth: 1,
1120
+ borderColor: '#F59E0B', // Amber border
1121
+ borderRadius: 12,
1122
+ padding: 16,
1123
+ marginBottom: 4,
1124
+ width: '100%',
1125
+ shadowColor: '#F59E0B',
1126
+ shadowOffset: { width: 0, height: 2 },
1127
+ shadowOpacity: 0.1,
1128
+ shadowRadius: 4,
1129
+ },
1130
+ restrictionWarningTitle: {
1131
+ color: '#B45309', // Dark amber text
1132
+ fontSize: 15,
1133
+ fontWeight: '800',
1134
+ marginBottom: 6,
1135
+ textAlign: 'center'
1136
+ },
1137
+ restrictionWarningText: {
1138
+ color: '#D97706',
1139
+ fontSize: 13,
1140
+ fontWeight: '500',
1141
+ textAlign: 'center',
1142
+ lineHeight: 18
1143
+ },
1144
+ qrToggleContainer: {
1145
+ flexDirection: 'row',
1146
+ backgroundColor: '#F1F5F9', // Light gray background
1147
+ borderRadius: 12,
1148
+ padding: 4,
1149
+ marginBottom: 20,
1150
+ width: '100%',
1151
+ },
1152
+ qrToggleButton: {
1153
+ flex: 1,
1154
+ paddingVertical: 10,
1155
+ borderRadius: 8,
1156
+ alignItems: 'center',
1157
+ justifyContent: 'center',
1158
+ },
1159
+ qrToggleButtonActive: {
1160
+ backgroundColor: '#FFFFFF',
1161
+ shadowColor: '#0F172A',
1162
+ shadowOffset: { width: 0, height: 2 },
1163
+ shadowOpacity: 0.08,
1164
+ shadowRadius: 4,
1165
+ elevation: 2,
1166
+ },
1167
+ qrToggleText: {
1168
+ fontSize: 14,
1169
+ fontWeight: '600',
1170
+ color: '#64748B', // Inactive text color
1171
+ },
1172
+ qrToggleTextActive: {
1173
+ color: '#0F172A', // Active text color
1174
+ },
642
1175
  });