@sanctum-key/react-native-sdk 1.0.24 → 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 (73) 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 +5 -17
  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 +509 -95
  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.json +0 -14
  34. package/build/src/modules/api/CardAuthentification.d.ts +2 -0
  35. package/build/src/modules/api/CardAuthentification.d.ts.map +1 -1
  36. package/build/src/modules/api/CardAuthentification.js +22 -39
  37. package/build/src/modules/api/CardAuthentification.js.map +1 -1
  38. package/build/src/modules/api/KYCService.d.ts +7 -2
  39. package/build/src/modules/api/KYCService.d.ts.map +1 -1
  40. package/build/src/modules/api/KYCService.js +163 -57
  41. package/build/src/modules/api/KYCService.js.map +1 -1
  42. package/build/src/types/KYC.types.d.ts +1 -0
  43. package/build/src/types/KYC.types.d.ts.map +1 -1
  44. package/build/src/types/KYC.types.js.map +1 -1
  45. package/build/src/utils/aamvaParser.d.ts +62 -0
  46. package/build/src/utils/aamvaParser.d.ts.map +1 -0
  47. package/build/src/utils/aamvaParser.js +91 -0
  48. package/build/src/utils/aamvaParser.js.map +1 -0
  49. package/build/src/utils/cropByObb.d.ts.map +1 -1
  50. package/build/src/utils/cropByObb.js +4 -3
  51. package/build/src/utils/cropByObb.js.map +1 -1
  52. package/build/src/web/WebKYCEntry.d.ts +1 -1
  53. package/build/src/web/WebKYCEntry.d.ts.map +1 -1
  54. package/build/src/web/WebKYCEntry.js +105 -239
  55. package/build/src/web/WebKYCEntry.js.map +1 -1
  56. package/package.json +3 -1
  57. package/src/components/EnhancedCameraView.tsx +116 -42
  58. package/src/components/EnhancedCameraView.web.tsx +98 -338
  59. package/src/components/KYCElements/CameraCapture.tsx +1 -1
  60. package/src/components/KYCElements/CountrySelection.tsx +60 -240
  61. package/src/components/KYCElements/CountrySelectionTemplate.tsx +7 -20
  62. package/src/components/KYCElements/IDCardCapture.tsx +672 -151
  63. package/src/components/KYCElements/VerificationProgressTemplate.tsx +112 -65
  64. package/src/components/OverLay/type.ts +1 -1
  65. package/src/components/WebBarcodeScanner.tsx +273 -0
  66. package/src/config/KYCConfigs.ts +2 -2
  67. package/src/config/region_mapping.json +0 -14
  68. package/src/modules/api/CardAuthentification.ts +41 -56
  69. package/src/modules/api/KYCService.ts +236 -82
  70. package/src/types/KYC.types.ts +1 -0
  71. package/src/utils/aamvaParser.ts +102 -0
  72. package/src/utils/cropByObb.ts +5 -3
  73. 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,50 +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
+
145
272
  const jsonDocMap: Record<string, string> = {
146
- 'nationalId': 'identity_card',
147
- 'national_id': 'identity_card',
148
- 'passport': 'passport',
149
- 'driversLicense': 'drivers_licence',
150
- 'drivers_licence': 'drivers_licence',
151
- 'residencePermit': 'permanent_residence',
152
- 'healthInsuranceCard': 'health_insurance_card'
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'
153
276
  };
154
- const searchDocType = jsonDocMap[rawDocType] || rawDocType;
155
277
 
278
+ const searchDocType = jsonDocMap[rawDocType] || rawDocType;
156
279
  const rawCountryName = ISO_TO_COUNTRY_NAME[countryData?.code || ''] || countryData?.code || countryKey;
157
280
  const baseMapping = (REGION_MAPPING as any).regionMapping || REGION_MAPPING;
158
281
 
@@ -161,26 +284,24 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
161
284
  const foundKey = Object.keys(baseMapping).find(k => k.toLowerCase() === rawCountryName.toLowerCase() || k.toLowerCase() === countryKey.toLowerCase());
162
285
  if (foundKey) countryMapping = baseMapping[foundKey];
163
286
  }
164
- if (!countryMapping) { return { authMethod: [], mrzTypes: [], regionMapping: null, key: 'root' }; }
165
-
166
- // Use the translated name to look up the document in the JSON
167
- const regionMapping = countryMapping[searchDocType];
168
- if (!regionMapping) { return { authMethod: [], mrzTypes: [], regionMapping: null, key: 'root' }; }
169
287
 
288
+ if (!countryMapping || !countryMapping[searchDocType]) return { authMethod: [], mrzTypes: [], regionMapping: null, key: 'root' };
289
+
170
290
  const authMethod: string[] = [];
171
291
  const mrzTypes: string[] = [];
172
- const key = countrySelectionData.region?.trim()?.length > 0 ? countrySelectionData.region.trim() : 'root';
173
-
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
+
174
296
  if (regionMapping?.[key] && Array.isArray(regionMapping[key])) {
175
297
  regionMapping[key].forEach((item: any) => {
176
- if (item[currentSide]) {
177
- authMethod.push(item[currentSide]);
178
- if (item?.mrz_type) mrzTypes.push(item?.mrz_type);
179
- }
298
+ if (item[currentSide]) authMethod.push(item[currentSide]);
299
+ if (item?.mrz_type) mrzTypes.push(item?.mrz_type);
180
300
  });
181
301
  }
302
+
182
303
  return { authMethod: removeDuplicates(authMethod), mrzTypes: removeDuplicates(mrzTypes), regionMapping, key };
183
- };
304
+ }
184
305
 
185
306
  const getCorrespondingMrzType = (templatePath: string, mapping: any, regionKey: string = "root") => {
186
307
  if (!mapping || !mapping[regionKey]) return null;
@@ -204,33 +325,38 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
204
325
  if (isProcessingCapture) return;
205
326
  setIsProcessingCapture(true);
206
327
  setProcessingImagePath(capturePath);
328
+
207
329
  try {
208
330
  let imagePathForUpload = capturePath;
209
331
  const overlayBbox = cameraConfig.overlay.bbox;
210
332
  const uiCropBbox = {
211
- minX: overlayBbox.xMin / 100,
212
- minY: overlayBbox.yMin / 100,
333
+ minX: overlayBbox.xMin / 100, minY: overlayBbox.yMin / 100,
213
334
  width: (overlayBbox.xMax - overlayBbox.xMin) / 100,
214
335
  height: (overlayBbox.yMax - overlayBbox.yMin) / 100,
215
336
  };
337
+
216
338
  try {
217
339
  imagePathForUpload = await cropImageWithBBoxWithTolerance(capturePath, uiCropBbox, 0.06);
218
340
  } catch (e) {
219
341
  console.warn("Crop failed, falling back to original image", e);
220
- imagePathForUpload = capturePath;
342
+ imagePathForUpload = capturePath;
221
343
  }
344
+
222
345
  const base64 = await pathToBase64(imagePathForUpload);
223
346
  const newImages = {
224
347
  ...capturedImages,
225
348
  [currentSide]: { dir: imagePathForUpload, file: base64, mrz: verified.mrz || "", templatePath: verified.templatePath },
226
349
  };
350
+
227
351
  setCapturedImages(newImages);
228
352
 
229
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;
230
356
  onValueChange({
231
357
  ...newImages,
232
358
  country: verified.country,
233
- documentType: GovernmentDocumentTypeBackend[verified.documentType as keyof typeof GovernmentDocumentTypeBackend] || '',
359
+ documentType: resolvedDocumentType,
234
360
  ...(verified.region ? { region: verified.region } : {})
235
361
  });
236
362
  }
@@ -241,6 +367,8 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
241
367
  setSilentCaptureResult(prev => ({ ...prev, isAnalyzing: false }));
242
368
  setIsProcessingCapture(false);
243
369
  setProcessingImagePath(null);
370
+ setScanPhase('image');
371
+ setPendingCAImage(null);
244
372
  }, 600);
245
373
  } catch (e: any) {
246
374
  console.error("Backend Error:", e);
@@ -252,11 +380,100 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
252
380
  }
253
381
  };
254
382
 
255
- const handleSilentCapture = async (result: { success: boolean; path?: string; error?: string }) => {
256
- 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
+ };
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: '' }));
257
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
+
258
474
  if (result.success && result.path) {
259
475
  setSilentCaptureResult((prev) => ({ ...prev, isAnalyzing: true, success: false, error: '' }));
476
+
260
477
  let templatePath = capturedImages[currentSide]?.templatePath || '';
261
478
  let templateBbox: IBbox | undefined;
262
479
  let templateResponse: any;
@@ -265,19 +482,20 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
265
482
  setSilentCaptureResult((prev) => ({ ...prev, isAnalyzing: false, success: false, error: 'Document type not selected' }));
266
483
  return;
267
484
  }
268
-
485
+
269
486
  const issuingSubdivision = selectedDocumentType.region !== 'root' ? selectedDocumentType.region : undefined;
270
-
487
+
271
488
  try {
489
+ // 1. Check & Crop the Template visually first
272
490
  if (!templatePath) {
273
- const templateType = await checkTemplateType({
274
- path: result.path || '',
275
- docType: selectedDocumentType?.type as GovernmentDocumentType,
276
- docRegion: countryData?.code || "",
491
+ const templateType = await checkTemplateType({
492
+ path: result.path || '',
493
+ docType: selectedDocumentType?.type as GovernmentDocumentType,
494
+ docRegion: countryData?.code || "",
277
495
  postfix: currentSide,
278
- issuingSubdivision: issuingSubdivision
496
+ issuingSubdivision: issuingSubdivision
279
497
  }, env);
280
-
498
+
281
499
  templateResponse = templateType;
282
500
  if (templateType.template_path) templatePath = templateType.template_path;
283
501
 
@@ -290,73 +508,95 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
290
508
  try {
291
509
  const crop = await cropByObb(result.path, (templateType as any).card_obb);
292
510
  templateBbox = crop.bbox;
293
- } catch { }
511
+ } catch {}
294
512
  }
295
513
  }
296
-
514
+
297
515
  const extractedCountryKey = templatePath ? templatePath.split('/')[0] : (ISO_TO_COUNTRY_NAME[countryData?.code || ''] || 'root');
298
516
  const regionMappings = getCurrentSideVerification(currentSide, extractedCountryKey);
299
517
  let verificationRes: any;
300
-
518
+
301
519
  if (currentSide === 'front') {
302
520
  const matchedAuthMethod = getCorrespondingAuthMethod(templatePath, regionMappings.regionMapping, regionMappings.key || '', 'front');
303
521
  const mrzType = getCorrespondingMrzType(templatePath, regionMappings.regionMapping, regionMappings.key || '') || 'TD1';
304
522
 
305
- verificationRes = await frontVerification({
306
- path: result.path,
307
- regionMapping: {
308
- authMethod: matchedAuthMethod ? [matchedAuthMethod] : [],
309
- mrzTypes: mrzType ? [mrzType] : []
310
- },
311
- selectedDocumentType: GovernmentDocumentTypeShorted[selectedDocumentType?.type as keyof typeof GovernmentDocumentTypeShorted] || '',
312
- code: countryData?.code || '',
313
- currentSide,
314
- templatePath,
315
- mrzType,
316
- 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
317
529
  }, env);
318
530
  } else {
531
+ logger.log("Attempting backVerification with:", { path: result.path, templatePath, side: currentSide });
319
532
  const matchedBackAuthMethod = getCorrespondingAuthMethod(templatePath, regionMappings.regionMapping, regionMappings.key || '', 'back');
320
533
  const backMrzType = getCorrespondingMrzType(templatePath, regionMappings.regionMapping, regionMappings.key || '') || '';
321
534
  const frontTemplatePath = capturedImages['front']?.templatePath || '';
322
-
323
- verificationRes = await backVerification({
324
- path: result.path,
325
- regionMapping: {
326
- authMethod: matchedBackAuthMethod ? [matchedBackAuthMethod] : [],
327
- mrzTypes: backMrzType ? [backMrzType] : []
328
- },
329
- selectedDocumentType: GovernmentDocumentTypeShorted[selectedDocumentType.type as keyof typeof GovernmentDocumentTypeShorted] || '',
330
- code: countryData?.code || '',
331
- currentSide,
332
- templatePath,
333
- mrzType: backMrzType,
334
- templateResponse,
335
- issuingSubdivision: issuingSubdivision,
336
- 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
337
543
  }, env);
338
544
  }
339
-
545
+
340
546
  const bbox = verificationRes?.bbox || templateBbox;
341
- const mrz = verificationRes?.mrz ? JSON.stringify(verificationRes.mrz) : "";
342
-
343
- const verifiedResult: ISilentCaptureResult & { region?: string } = {
344
- path: result.path,
345
- templatePath,
346
- bbox,
347
- success: true,
348
- mrz,
349
- isAnalyzing: false,
350
- country: countryData?.code,
351
- documentType: selectedDocumentType.type,
352
- 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
353
594
  };
354
595
 
355
596
  setSilentCaptureResult(verifiedResult);
356
597
  if (bbox) setBboxBySide(prev => ({ ...prev, [currentSide]: bbox }));
357
-
358
598
  await autoCapture(result.path, verifiedResult);
359
-
599
+
360
600
  } catch (error: any) {
361
601
  console.log("Backend Verification Error:", error);
362
602
  const rawMessage = (error?.message || '').toLowerCase();
@@ -368,15 +608,26 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
368
608
  userFriendlyMessage = 'The document does not match your selected country.';
369
609
  } else if (rawMessage.includes('card_not_fully_in_frame')) {
370
610
  userFriendlyMessage = 'Move closer to the document and ensure all corners are visible.';
371
- } else if (rawMessage.includes('ne correspond pas au recto')) {
611
+ } else if (rawMessage.includes('ne correspond pas au recto')) {
372
612
  userFriendlyMessage = 'The back of the document does not match the front. You must scan the same ID.';
373
613
  }
374
614
 
615
+ // The error is thrown here, completely blocking the switch to 'barcode' phase
375
616
  setSilentCaptureResult(prev => ({ ...prev, isAnalyzing: false, success: false, error: userFriendlyMessage }));
376
617
  }
377
618
  }
378
619
  };
379
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
+
380
631
  const handleError = (event: { message: string }) => {
381
632
  showAlert('Erreur', event.message);
382
633
  setShowCamera(false);
@@ -387,6 +638,38 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
387
638
  actions.showCustomStepper(!showCamera);
388
639
  }, [showCamera]);
389
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
+
390
673
  if (!countrySelectionData || !selectedDocumentType) {
391
674
  return (
392
675
  <View style={styles.root}>
@@ -402,6 +685,7 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
402
685
 
403
686
  if (showCamera) {
404
687
  const isBusy = isProcessingCapture;
688
+
405
689
  if (isRebootingCamera) {
406
690
  return (
407
691
  <View style={[styles.root, { justifyContent: 'center', alignItems: 'center', backgroundColor: '#000' }]}>
@@ -410,6 +694,7 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
410
694
  </View>
411
695
  );
412
696
  }
697
+
413
698
  return (
414
699
  <View style={styles.root}>
415
700
  <View style={[styles.cameraWrapper, { flex: 1 }]}>
@@ -423,7 +708,7 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
423
708
  </Text>
424
709
  </View>
425
710
  </View>
426
-
711
+
427
712
  <View style={[styles.cameraFeedContainer, { flex: 1, backgroundColor: '#000' }]}>
428
713
  {Platform.OS === 'web' && (
429
714
  <View style={styles.webTopControls}>
@@ -432,7 +717,7 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
432
717
  </TouchableOpacity>
433
718
  </View>
434
719
  )}
435
-
720
+
436
721
  <EnhancedCameraView
437
722
  key={`${currentSide}-${cameraKey}`}
438
723
  showCamera={true}
@@ -442,32 +727,83 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
442
727
  onError={handleError}
443
728
  onSilentCapture={handleSilentCapture}
444
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
+ }}
445
746
  overlayComponent={
446
747
  <>
447
748
  <IdCardOverlay
448
- 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}
449
753
  instructions={cameraConfig.overlay.guideText}
450
754
  cornerOpacity={cameraConfig.overlay.bbox.cornerRadius || 0 as number}
451
- isSuccess={silentCaptureResult.success}
755
+ isSuccess={silentCaptureResult.success || scanPhase === 'barcode'}
452
756
  language={state.currentLanguage}
453
757
  stepperProps={{
454
758
  back: () => {
455
759
  if (currentSide === 'back') {
456
- 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);
457
767
  if (capturedImages['front']?.dir) {
458
768
  const frontImage = capturedImages['front'];
459
- setSilentCaptureResult((prev) => ({ ...prev, path: frontImage.dir, success: true, isAnalyzing: false, error: '', mrz: frontImage.mrz || '', templatePath: frontImage.templatePath || '', bbox: bboxBySide['front'] }));
460
- } else { setSilentCaptureResult((prev) => ({ ...prev, path: '', success: false, isAnalyzing: false, error: '', templatePath: '' })); }
461
- } 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
+ }
462
778
  },
463
779
  selectedDocumentType: selectedDocumentType ? (locale === 'en' ? getDocumentTypeInfo(selectedDocumentType.type).name.en : getDocumentTypeInfo(selectedDocumentType.type).name.fr) : '',
464
- 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,
465
783
  }}
466
784
  />
467
785
  </>
468
786
  }
469
787
  />
470
-
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
+
471
807
  {!isBusy && silentCaptureResult.isAnalyzing && (
472
808
  <View style={styles.topAnalyzingPillContainer}>
473
809
  <View style={styles.topAnalyzingPill}>
@@ -478,15 +814,11 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
478
814
  </View>
479
815
  </View>
480
816
  )}
481
-
817
+
482
818
  {isBusy && (
483
819
  <View style={[StyleSheet.absoluteFillObject, { zIndex: 9999 }]}>
484
820
  {processingImagePath && (
485
- <Image
486
- source={{ uri: processingImagePath.startsWith('file://') ? processingImagePath : `file://${processingImagePath}` }}
487
- style={StyleSheet.absoluteFillObject}
488
- resizeMode="cover"
489
- />
821
+ <Image source={{ uri: processingImagePath.startsWith('file://') ? processingImagePath : `file://${processingImagePath}` }} style={StyleSheet.absoluteFillObject} resizeMode="cover" />
490
822
  )}
491
823
  <View style={styles.processingOverlay}>
492
824
  <ActivityIndicator size="large" color="#2DBD60" />
@@ -496,7 +828,7 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
496
828
  </View>
497
829
  </View>
498
830
  )}
499
-
831
+
500
832
  {!isBusy && Platform.OS === 'web' ? (
501
833
  <View style={styles.webBottomControlBar}>
502
834
  {silentCaptureResult.error && (
@@ -518,15 +850,12 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
518
850
  <TouchableOpacity style={styles.fallbackRefreshButton} onPress={refreshCamera}>
519
851
  <Text style={styles.fallbackRefreshText}>↻ Refresh Camera</Text>
520
852
  </TouchableOpacity>
521
- <TouchableOpacity
522
- style={[styles.fallbackRefreshButton, { marginTop: 15, backgroundColor: 'rgba(220, 38, 38, 0.8)', borderColor: '#DC2626' }]}
523
- onPress={() => setShowCamera(false)}
524
- >
853
+ <TouchableOpacity style={[styles.fallbackRefreshButton, { marginTop: 15, backgroundColor: 'rgba(220, 38, 38, 0.8)', borderColor: '#DC2626' }]} onPress={() => setShowCamera(false)}>
525
854
  <Text style={styles.fallbackRefreshText}>Cancel / Go Back</Text>
526
855
  </TouchableOpacity>
527
856
  </View>
528
857
  ) : null}
529
-
858
+
530
859
  {silentCaptureResult.error && !isBusy && Platform.OS !== 'web' ? (
531
860
  <View style={[styles.floatingErrorBanner, { zIndex: 10000 }]}>
532
861
  <Text style={styles.floatingErrorText}>⚠️ {silentCaptureResult.error}</Text>
@@ -549,6 +878,7 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
549
878
  <Text style={{ fontSize: 14, color: '#666', textAlign: 'center', marginBottom: 16, lineHeight: 22 }}>
550
879
  {getLocalizedText(component.instructions)}
551
880
  </Text>
881
+
552
882
  <View style={{ alignItems: 'center', justifyContent: 'center', flexDirection: "column", gap: 16 }}>
553
883
  <View style={styles.imagePreviewWrapper}>
554
884
  {capturedImages[currentSide]?.dir ? (
@@ -557,31 +887,80 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
557
887
  <Image source={{ uri: silentCaptureResult.path }} style={styles.previewImage} />
558
888
  ) : null}
559
889
  </View>
890
+
560
891
  {!capturedImages[currentSide]?.dir && (
561
892
  <View style={{ width: '100%', gap: 12 }}>
562
- <Button
563
- title={state.currentLanguage === "en" ? "Start Scanning" : "Commencer la numérisation"}
564
- onPress={() => { setShowCamera(true); refreshCamera(); actions.showCustomStepper(false); }}
565
- variant="primary" size="large" fullWidth
566
- />
567
- {Platform.OS === 'web' && (
568
- <Button
569
- title={state.currentLanguage === "en" ? "Continue on Phone" : "Continuer sur le téléphone"}
570
- onPress={() => setShowQRModal(true)}
571
- variant="outline" size="large" fullWidth
572
- />
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
+ </>
573
940
  )}
941
+
574
942
  </View>
575
943
  )}
944
+
576
945
  {capturedImages[currentSide]?.dir && (
577
946
  <>
578
947
  <Button title={t('kyc.idCardCapture.retakeButton')} onPress={() => retakePicture(currentSide)} variant="outline" size="medium" fullWidth />
579
948
  <Button title={t('common.next')} onPress={() => {
580
- if (!selectedDocumentType) { showAlert('Error', 'Document type not selected'); return; }
949
+ if (!selectedDocumentType) {
950
+ showAlert('Error', 'Document type not selected');
951
+ return;
952
+ }
581
953
  if (currentSide === 'back' || selectedDocumentType.type === 'passport') {
582
954
  actions.nextComponent();
583
955
  } else {
584
- 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);
585
964
  }
586
965
  }} variant="primary" size="large" fullWidth />
587
966
  </>
@@ -590,22 +969,91 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
590
969
  </View>
591
970
  </ScrollView>
592
971
  </View>
972
+
593
973
  {showQRModal && Platform.OS === 'web' && (
594
- <View style={styles.qrModalOverlay}>
595
- <View style={styles.qrModalContainer}>
596
- <Text style={styles.qrModalTitle}>
597
- {state.currentLanguage === 'en' ? 'Scan to continue' : 'Scannez pour continuer'}
598
- </Text>
599
- <Text style={styles.qrModalText}>
600
- {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."}
601
- </Text>
602
- <View style={styles.qrCodeWrapper}>
603
- <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
+ />
604
1054
  </View>
605
- <Button title={state.currentLanguage === 'en' ? "Close" : "Fermer"} onPress={() => setShowQRModal(false)} variant="outline" fullWidth />
606
1055
  </View>
607
- </View>
608
- )}
1056
+ )}
609
1057
  </View>
610
1058
  );
611
1059
  };
@@ -646,9 +1094,82 @@ const styles = StyleSheet.create({
646
1094
  topAnalyzingPillContainer: { position: 'absolute', top: Platform.OS === 'android' ? 60 : 75, left: 0, right: 0, alignItems: 'center', zIndex: 100 },
647
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 },
648
1096
  analyzingPillText: { color: 'white', fontSize: 14, fontWeight: 'bold' },
649
- qrModalOverlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.6)', justifyContent: 'center', alignItems: 'center', zIndex: 999999, ...Platform.select({ web: { backdropFilter: 'blur(5px)' } as any }) },
650
- 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, },
651
- qrModalTitle: { fontSize: 22, fontWeight: 'bold', color: '#0F172A', marginBottom: 12, textAlign: 'center' },
652
- qrModalText: { fontSize: 15, color: '#64748B', textAlign: 'center', marginBottom: 24, lineHeight: 22, },
653
- 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
+ },
654
1175
  });