@sanctum-key/react-native-sdk 1.0.36 → 1.0.38

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.
@@ -1,5 +1,5 @@
1
1
  import React, { useEffect, useMemo, useRef, useState } from 'react';
2
- import { View, Text, StyleSheet, Image, ScrollView, Platform, ActivityIndicator, TouchableOpacity, useWindowDimensions, Alert } from 'react-native';
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';
5
5
  import { GovernmentDocumentTypeShorted, GovernmentDocumentTypeBackend } from '../../types/KYC.types';
@@ -14,7 +14,6 @@ import pathToBase64 from '../../utils/pathToBase64';
14
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
17
  import { cropImageWithBBoxWithTolerance } from '../../utils/cropByObb';
19
18
  import kycService, { authentification } from '../../modules/api/KYCService';
20
19
  const ISO_TO_COUNTRY_NAME = {
@@ -23,13 +22,11 @@ const ISO_TO_COUNTRY_NAME = {
23
22
  'CI': 'Ivory Coast', 'SN': 'Senegal', 'TG': 'Togo', 'ML': 'Mali'
24
23
  };
25
24
  const SHORT_TO_LONG_REGION = {
26
- "AB": "Alberta", "BC": "British Columbia", "MB": "Manitoba",
27
- "NB": "New Brunswick", "NL": "Newfoundland and Labrador",
28
- "NT": "Northwest Territories", "NS": "Nova Scotia", "NU": "Nunavut",
29
- "ON": "Ontario", "PE": "Prince Edward Island", "QC": "Quebec",
25
+ "AB": "Alberta", "BC": "British Columbia", "MB": "Manitoba", "NB": "New Brunswick",
26
+ "NL": "Newfoundland and Labrador", "NT": "Northwest Territories", "NS": "Nova Scotia",
27
+ "NU": "Nunavut", "ON": "Ontario", "PE": "Prince Edward Island", "QC": "Quebec",
30
28
  "SK": "Saskatchewan", "YT": "Yukon"
31
29
  };
32
- // 🚨 Isolates the 1-second re-renders away from the Camera!
33
30
  const ExpiringCodeTimer = ({ initialCode, language }) => {
34
31
  const [timeLeft, setTimeLeft] = useState(300);
35
32
  useEffect(() => {
@@ -70,12 +67,48 @@ export const IDCardCapture = ({ component, value = {}, onValueChange, error, lan
70
67
  const [scanPhase, setScanPhase] = useState('image');
71
68
  const [pendingCAImage, setPendingCAImage] = useState(null);
72
69
  const scannedBarcodeRef = useRef(null);
73
- // Ref mirrors — prevent stale closure reads in handleSilentCapture on iOS
70
+ const [generatedCode, setGeneratedCode] = useState(null);
71
+ const [isGeneratingCode, setIsGeneratingCode] = useState(false);
72
+ const { actions, state, env, apiKey } = useTemplateKYCFlowContext();
73
+ const { width: screenWidth, height: screenHeight } = useWindowDimensions();
74
+ console.log(currentUrl);
75
+ const countrySelectionData = useMemo(() => {
76
+ const countrySelectionComponent = state.template.components.find(c => c.type === 'country_selection');
77
+ return countrySelectionComponent ? state.componentData[countrySelectionComponent.id] : null;
78
+ }, [state.template.components, state.componentData]);
79
+ const documentTypeMapping = {
80
+ 'nationalId': 'national_id', 'national_id': 'national_id', 'identity_card': 'national_id',
81
+ 'passport': 'passport', 'driversLicense': 'drivers_licence', 'drivers_licence': 'drivers_licence',
82
+ 'residencePermit': 'permanent_residence', 'permanent_residence': 'permanent_residence',
83
+ 'healthInsuranceCard': 'health_insurance_card', 'health_insurance_card': 'health_insurance_card',
84
+ };
85
+ const selectedDocumentType = useMemo(() => {
86
+ if (!countrySelectionData?.documentType)
87
+ return null;
88
+ const backendDocType = countrySelectionData.documentType;
89
+ const mappedType = documentTypeMapping[backendDocType] || backendDocType;
90
+ return { type: mappedType, region: countrySelectionData.region || 'root' };
91
+ }, [countrySelectionData, documentTypeMapping]);
92
+ const countryData = useMemo(() => countrySelectionData, [countrySelectionData]);
93
+ // ==========================================
94
+ // 🚨 CRITICAL FIX: REF MIRRORS FOR IOS BRIDGE
95
+ // Bypass stale native closures by mirroring all state into refs
96
+ // ==========================================
74
97
  const isAnalyzingRef = useRef(false);
75
98
  const isProcessingCaptureRef = useRef(false);
76
99
  const scanPhaseRef = useRef('image');
77
- const [generatedCode, setGeneratedCode] = useState(null);
78
- const [isGeneratingCode, setIsGeneratingCode] = useState(false);
100
+ const currentSideRef = useRef(currentSide);
101
+ const capturedImagesRef = useRef(capturedImages);
102
+ const selectedDocTypeRef = useRef(selectedDocumentType);
103
+ const countryDataRef = useRef(countryData);
104
+ useEffect(() => { isAnalyzingRef.current = silentCaptureResult.isAnalyzing ?? false; }, [silentCaptureResult.isAnalyzing]);
105
+ useEffect(() => { isProcessingCaptureRef.current = isProcessingCapture; }, [isProcessingCapture]);
106
+ useEffect(() => { scanPhaseRef.current = scanPhase; }, [scanPhase]);
107
+ useEffect(() => { currentSideRef.current = currentSide; }, [currentSide]);
108
+ useEffect(() => { capturedImagesRef.current = capturedImages; }, [capturedImages]);
109
+ useEffect(() => { selectedDocTypeRef.current = selectedDocumentType; }, [selectedDocumentType]);
110
+ useEffect(() => { countryDataRef.current = countryData; }, [countryData]);
111
+ // ==========================================
79
112
  const handleGenerateCode = async () => {
80
113
  if (isGeneratingCode)
81
114
  return;
@@ -86,16 +119,12 @@ export const IDCardCapture = ({ component, value = {}, onValueChange, error, lan
86
119
  setIsGeneratingCode(false);
87
120
  return;
88
121
  }
89
- // Pass the dynamic apiKey to your service
90
122
  const res = await kycService.generateReferralCode({ session_id: state.session.session_id }, apiKey);
91
- // Extract the code from the response
92
123
  const newCode = res.code || res.referral_code || res?.data?.code || res?.data?.referral_code;
93
- if (newCode) {
124
+ if (newCode)
94
125
  setGeneratedCode(newCode);
95
- }
96
- else {
126
+ else
97
127
  showAlert('Error', 'Failed to parse code from server response.');
98
- }
99
128
  }
100
129
  catch (error) {
101
130
  showAlert('Error', error.message || 'Failed to generate code');
@@ -107,50 +136,19 @@ export const IDCardCapture = ({ component, value = {}, onValueChange, error, lan
107
136
  useEffect(() => {
108
137
  if (typeof window !== 'undefined' && window.location)
109
138
  setCurrentUrl(window.location.href);
110
- console.log(currentUrl);
111
139
  }, []);
112
- const documentTypeMapping = {
113
- 'nationalId': 'national_id', 'national_id': 'national_id', 'identity_card': 'national_id',
114
- 'passport': 'passport', 'driversLicense': 'drivers_licence', 'drivers_licence': 'drivers_licence',
115
- 'residencePermit': 'permanent_residence', 'permanent_residence': 'permanent_residence',
116
- 'healthInsuranceCard': 'health_insurance_card', 'health_insurance_card': 'health_insurance_card',
117
- };
118
- const { actions, state, env, apiKey } = useTemplateKYCFlowContext();
119
- const { width: screenWidth, height: screenHeight } = useWindowDimensions();
120
140
  const getLocalizedText = (text) => {
121
141
  if (text && typeof text[currentSide] === 'object' && text[currentSide][locale])
122
142
  return text[currentSide][locale] || '';
123
143
  return "";
124
144
  };
125
- useEffect(() => {
126
- if (Platform.OS === 'web' && typeof window !== 'undefined') {
127
- const baseUrl = window.location.origin + window.location.pathname;
128
- const handoffUrl = `${baseUrl}?session_id=${state.session.session_id}&resume=true`;
129
- setCurrentUrl(handoffUrl);
130
- }
131
- }, [state.session.session_id]);
132
- const countrySelectionData = useMemo(() => {
133
- const countrySelectionComponent = state.template.components.find(c => c.type === 'country_selection');
134
- return countrySelectionComponent ? state.componentData[countrySelectionComponent.id] : null;
135
- }, [state.template.components, state.componentData]);
136
- const selectedDocumentType = useMemo(() => {
137
- if (!countrySelectionData?.documentType)
138
- return null;
139
- const backendDocType = countrySelectionData.documentType;
140
- const mappedType = documentTypeMapping[backendDocType] || backendDocType;
141
- return { type: mappedType, region: countrySelectionData.region || 'root' };
142
- }, [countrySelectionData, documentTypeMapping]);
143
- const countryData = useMemo(() => countrySelectionData, [countrySelectionData]);
144
145
  const isWebScanningRestricted = useMemo(() => {
145
146
  if (Platform.OS !== 'web')
146
147
  return false;
147
148
  if (countryData?.code !== 'CA')
148
149
  return false;
149
150
  const docType = selectedDocumentType?.type;
150
- return docType === 'drivers_licence' ||
151
- docType === 'health_insurance_card' ||
152
- docType === 'national_id' ||
153
- docType === 'identity_card';
151
+ return docType === 'drivers_licence' || docType === 'health_insurance_card' || docType === 'national_id' || docType === 'identity_card';
154
152
  }, [countryData?.code, selectedDocumentType?.type]);
155
153
  const targetBarcodeTypes = useMemo(() => {
156
154
  const docType = selectedDocumentType?.type;
@@ -164,25 +162,13 @@ export const IDCardCapture = ({ component, value = {}, onValueChange, error, lan
164
162
  setScanPhase('image');
165
163
  setPendingCAImage(null);
166
164
  scannedBarcodeRef.current = null;
167
- setCapturedImages((prev) => {
168
- const newState = { ...prev };
169
- delete newState[currentSide];
170
- return newState;
171
- });
165
+ setCapturedImages((prev) => { const newState = { ...prev }; delete newState[currentSide]; return newState; });
172
166
  if (value) {
173
167
  const newValue = { ...value };
174
168
  delete newValue[currentSide];
175
169
  onValueChange(newValue);
176
170
  }
177
- setSilentCaptureResult({
178
- success: false,
179
- isAnalyzing: false,
180
- path: '',
181
- error: '',
182
- templatePath: '',
183
- mrz: '',
184
- bbox: undefined
185
- });
171
+ setSilentCaptureResult({ success: false, isAnalyzing: false, path: '', error: '', templatePath: '', mrz: '', bbox: undefined });
186
172
  }
187
173
  setTimeout(() => {
188
174
  setCameraKey(prev => prev + 1);
@@ -194,35 +180,19 @@ export const IDCardCapture = ({ component, value = {}, onValueChange, error, lan
194
180
  refreshCamera();
195
181
  };
196
182
  useEffect(() => {
197
- if (value && Object.keys(value).length > 0) {
198
- if (JSON.stringify(value) !== JSON.stringify(capturedImages)) {
199
- const updatedImages = value;
200
- setCapturedImages(updatedImages);
201
- const currentImageData = updatedImages[currentSide];
202
- if (currentImageData?.dir) {
203
- setSilentCaptureResult(prev => ({
204
- ...prev,
205
- path: currentImageData.dir,
206
- success: true,
207
- isAnalyzing: false,
208
- mrz: currentImageData.mrz || '',
209
- templatePath: currentImageData.templatePath || '',
210
- }));
211
- }
183
+ if (value && Object.keys(value).length > 0 && JSON.stringify(value) !== JSON.stringify(capturedImages)) {
184
+ const updatedImages = value;
185
+ setCapturedImages(updatedImages);
186
+ const currentImageData = updatedImages[currentSide];
187
+ if (currentImageData?.dir) {
188
+ setSilentCaptureResult(prev => ({ ...prev, path: currentImageData.dir, success: true, isAnalyzing: false, mrz: currentImageData.mrz || '', templatePath: currentImageData.templatePath || '' }));
212
189
  }
213
190
  }
214
191
  }, [value, currentSide]);
215
- // Keep refs in sync with state so handleSilentCapture always reads current values
216
- useEffect(() => { isAnalyzingRef.current = silentCaptureResult.isAnalyzing ?? false; }, [silentCaptureResult.isAnalyzing]);
217
- useEffect(() => { isProcessingCaptureRef.current = isProcessingCapture; }, [isProcessingCapture]);
218
- useEffect(() => { scanPhaseRef.current = scanPhase; }, [scanPhase]);
219
192
  const cameraConfig = useMemo(() => {
220
- let instructions = selectedDocumentType
221
- ? (locale === 'en' ? getDocumentTypeInfo(selectedDocumentType.type).instructions.en : getDocumentTypeInfo(selectedDocumentType.type).instructions.fr)
222
- : getLocalizedText(component.instructions);
223
- if (scanPhase === 'barcode') {
193
+ let instructions = selectedDocumentType ? (locale === 'en' ? getDocumentTypeInfo(selectedDocumentType.type).instructions.en : getDocumentTypeInfo(selectedDocumentType.type).instructions.fr) : getLocalizedText(component.instructions);
194
+ if (scanPhase === 'barcode')
224
195
  instructions = '';
225
- }
226
196
  const isLandscape = screenWidth > screenHeight;
227
197
  let xMin, xMax, yMin, yMax;
228
198
  if (Platform.OS === 'web') {
@@ -243,20 +213,8 @@ export const IDCardCapture = ({ component, value = {}, onValueChange, error, lan
243
213
  yMin = 34;
244
214
  yMax = 66;
245
215
  }
246
- const platformBbox = {
247
- xMin,
248
- yMin,
249
- xMax,
250
- yMax,
251
- borderColor: scanPhase === 'barcode' ? '#EAB308' : '#2DBD60',
252
- borderWidth: Platform.OS === 'web' ? 2 : 3,
253
- cornerRadius: 8
254
- };
255
- return {
256
- cameraType: Platform.OS === 'web' ? cameraType : 'back',
257
- flashMode: 'auto',
258
- overlay: { guideText: instructions, bbox: platformBbox }
259
- };
216
+ const platformBbox = { xMin, yMin, xMax, yMax, borderColor: scanPhase === 'barcode' ? '#EAB308' : '#2DBD60', borderWidth: Platform.OS === 'web' ? 2 : 3, cornerRadius: 8 };
217
+ return { cameraType: Platform.OS === 'web' ? cameraType : 'back', flashMode: 'auto', overlay: { guideText: instructions, bbox: platformBbox } };
260
218
  }, [selectedDocumentType, locale, component.instructions, cameraType, screenWidth, screenHeight, scanPhase]);
261
219
  const retakePicture = (sideToRetake) => {
262
220
  setIsProcessingCapture(false);
@@ -268,28 +226,20 @@ export const IDCardCapture = ({ component, value = {}, onValueChange, error, lan
268
226
  setShowCamera(true);
269
227
  refreshCamera();
270
228
  actions.showCustomStepper(false);
271
- setCapturedImages((prev) => {
272
- const newState = { ...prev };
273
- delete newState[sideToRetake];
274
- return newState;
275
- });
229
+ setCapturedImages((prev) => { const newState = { ...prev }; delete newState[sideToRetake]; return newState; });
276
230
  if (value) {
277
231
  const newValue = { ...value };
278
232
  delete newValue[sideToRetake];
279
233
  onValueChange(newValue);
280
234
  }
281
235
  };
282
- const getCurrentSideVerification = (currentSide, countryKey) => {
283
- const rawDocType = countrySelectionData?.documentType;
236
+ const getCurrentSideVerification = (side, countryKey, cData) => {
237
+ const rawDocType = cData?.documentType;
284
238
  if (!rawDocType || !countryKey)
285
239
  return { authMethod: [], mrzTypes: [], regionMapping: null, key: 'root' };
286
- const jsonDocMap = {
287
- 'nationalId': 'identity_card', 'national_id': 'identity_card', 'passport': 'passport',
288
- 'driversLicense': 'drivers_licence', 'drivers_licence': 'drivers_licence',
289
- 'residencePermit': 'permanent_residence', 'healthInsuranceCard': 'health_insurance_card'
290
- };
240
+ const jsonDocMap = { 'nationalId': 'identity_card', 'national_id': 'identity_card', 'passport': 'passport', 'driversLicense': 'drivers_licence', 'drivers_licence': 'drivers_licence', 'residencePermit': 'permanent_residence', 'healthInsuranceCard': 'health_insurance_card' };
291
241
  const searchDocType = jsonDocMap[rawDocType] || rawDocType;
292
- const rawCountryName = ISO_TO_COUNTRY_NAME[countryData?.code || ''] || countryData?.code || countryKey;
242
+ const rawCountryName = ISO_TO_COUNTRY_NAME[cData?.code || ''] || cData?.code || countryKey;
293
243
  const baseMapping = REGION_MAPPING.regionMapping || REGION_MAPPING;
294
244
  let countryMapping = baseMapping[rawCountryName];
295
245
  if (!countryMapping) {
@@ -301,13 +251,13 @@ export const IDCardCapture = ({ component, value = {}, onValueChange, error, lan
301
251
  return { authMethod: [], mrzTypes: [], regionMapping: null, key: 'root' };
302
252
  const authMethod = [];
303
253
  const mrzTypes = [];
304
- const rawRegionKey = countrySelectionData.region?.trim()?.length > 0 ? countrySelectionData.region.trim() : 'root';
254
+ const rawRegionKey = cData.region?.trim()?.length > 0 ? cData.region.trim() : 'root';
305
255
  const key = SHORT_TO_LONG_REGION[rawRegionKey] || rawRegionKey;
306
256
  const regionMapping = countryMapping[searchDocType];
307
257
  if (regionMapping?.[key] && Array.isArray(regionMapping[key])) {
308
258
  regionMapping[key].forEach((item) => {
309
- if (item[currentSide])
310
- authMethod.push(item[currentSide]);
259
+ if (item[side])
260
+ authMethod.push(item[side]);
311
261
  if (item?.mrz_type)
312
262
  mrzTypes.push(item?.mrz_type);
313
263
  });
@@ -338,18 +288,12 @@ export const IDCardCapture = ({ component, value = {}, onValueChange, error, lan
338
288
  if (isProcessingCapture)
339
289
  return;
340
290
  setIsProcessingCapture(true);
341
- const safeCapturePath = (Platform.OS === 'ios' && !capturePath.startsWith('file://'))
342
- ? `file://${capturePath}`
343
- : capturePath;
291
+ const safeCapturePath = (Platform.OS === 'ios' && !capturePath.startsWith('file://')) ? `file://${capturePath}` : capturePath;
344
292
  setProcessingImagePath(safeCapturePath);
345
293
  try {
346
294
  let imagePathForUpload = capturePath;
347
295
  const overlayBbox = cameraConfig.overlay.bbox;
348
- const uiCropBbox = {
349
- minX: overlayBbox.xMin / 100, minY: overlayBbox.yMin / 100,
350
- width: (overlayBbox.xMax - overlayBbox.xMin) / 100,
351
- height: (overlayBbox.yMax - overlayBbox.yMin) / 100,
352
- };
296
+ const uiCropBbox = { minX: overlayBbox.xMin / 100, minY: overlayBbox.yMin / 100, width: (overlayBbox.xMax - overlayBbox.xMin) / 100, height: (overlayBbox.yMax - overlayBbox.yMin) / 100 };
353
297
  try {
354
298
  imagePathForUpload = await cropImageWithBBoxWithTolerance(safeCapturePath, uiCropBbox, 0.06);
355
299
  }
@@ -357,26 +301,18 @@ export const IDCardCapture = ({ component, value = {}, onValueChange, error, lan
357
301
  console.warn("Crop failed, falling back to original image", e);
358
302
  imagePathForUpload = safeCapturePath;
359
303
  }
360
- const safeCroppedPath = (Platform.OS === 'ios' && !imagePathForUpload.startsWith('file://'))
361
- ? `file://${imagePathForUpload}`
362
- : imagePathForUpload;
304
+ const safeCroppedPath = (Platform.OS === 'ios' && !imagePathForUpload.startsWith('file://')) ? `file://${imagePathForUpload}` : imagePathForUpload;
363
305
  const base64 = await pathToBase64(safeCroppedPath);
306
+ const side = currentSideRef.current; // Grab live ref
364
307
  const newImages = {
365
- ...capturedImages,
366
- // Store safeCroppedPath (guaranteed file:// on iOS) as dir so
367
- // Image source={{ uri }} never receives a bare path on iOS.
368
- [currentSide]: { dir: safeCroppedPath, file: base64, mrz: verified.mrz || "", templatePath: verified.templatePath },
308
+ ...capturedImagesRef.current,
309
+ [side]: { dir: safeCroppedPath, file: base64, mrz: verified.mrz || "", templatePath: verified.templatePath },
369
310
  };
370
311
  setCapturedImages(newImages);
371
312
  if (verified.country && verified.documentType) {
372
313
  const docType = verified.documentType;
373
314
  const resolvedDocumentType = GovernmentDocumentTypeBackend[docType] || (Object.values(GovernmentDocumentTypeBackend).includes(docType) ? docType : null) || Object.entries(GovernmentDocumentTypeBackend).find(([, v]) => v === docType)?.[1] || docType;
374
- onValueChange({
375
- ...newImages,
376
- country: verified.country,
377
- documentType: resolvedDocumentType,
378
- ...(verified.region ? { region: verified.region } : {})
379
- });
315
+ onValueChange({ ...newImages, country: verified.country, documentType: resolvedDocumentType, ...(verified.region ? { region: verified.region } : {}) });
380
316
  }
381
317
  setTimeout(() => {
382
318
  setShowCamera(false);
@@ -389,7 +325,6 @@ export const IDCardCapture = ({ component, value = {}, onValueChange, error, lan
389
325
  }, 600);
390
326
  }
391
327
  catch (e) {
392
- console.error("Backend Error:", e);
393
328
  const friendlyError = state.currentLanguage === 'en' ? 'Unable to process document. Please try again.' : 'Impossible de traiter le document. Veuillez réessayer.';
394
329
  showAlert('Error', friendlyError);
395
330
  setSilentCaptureResult(prev => ({ ...prev, isAnalyzing: false, success: false }));
@@ -397,47 +332,42 @@ export const IDCardCapture = ({ component, value = {}, onValueChange, error, lan
397
332
  setProcessingImagePath(null);
398
333
  }
399
334
  };
400
- const isBarcodeRequired = () => {
401
- if (currentSide !== 'back')
335
+ const isBarcodeRequired = (side, cData, docType) => {
336
+ if (side !== 'back')
402
337
  return false;
403
- if (!countryData?.code || !selectedDocumentType?.type)
338
+ if (!cData?.code || !docType?.type)
404
339
  return false;
405
- const rawDocType = selectedDocumentType.type;
406
- const jsonDocMap = {
407
- 'nationalId': 'identity_card', 'national_id': 'identity_card', 'passport': 'passport',
408
- 'driversLicense': 'drivers_licence', 'drivers_licence': 'drivers_licence',
409
- 'residencePermit': 'permanent_residence', 'healthInsuranceCard': 'health_insurance_card'
410
- };
340
+ const rawDocType = docType.type;
341
+ const jsonDocMap = { 'nationalId': 'identity_card', 'national_id': 'identity_card', 'passport': 'passport', 'driversLicense': 'drivers_licence', 'drivers_licence': 'drivers_licence', 'residencePermit': 'permanent_residence', 'healthInsuranceCard': 'health_insurance_card' };
411
342
  const searchDocType = jsonDocMap[rawDocType] || rawDocType;
412
- const rawCountryName = ISO_TO_COUNTRY_NAME[countryData.code] || countryData.code;
343
+ const rawCountryName = ISO_TO_COUNTRY_NAME[cData.code] || cData.code;
413
344
  const baseMapping = REGION_MAPPING.regionMapping || REGION_MAPPING;
414
345
  let countryMapping = baseMapping[rawCountryName];
415
346
  if (!countryMapping) {
416
- const foundKey = Object.keys(baseMapping).find(k => k.toLowerCase() === rawCountryName.toLowerCase() || k.toLowerCase() === countryData.code.toLowerCase());
347
+ const foundKey = Object.keys(baseMapping).find(k => k.toLowerCase() === rawCountryName.toLowerCase() || k.toLowerCase() === cData.code.toLowerCase());
417
348
  if (foundKey)
418
349
  countryMapping = baseMapping[foundKey];
419
350
  }
420
351
  if (!countryMapping || !countryMapping[searchDocType])
421
352
  return false;
422
- const rawRegionKey = countryData.region?.trim()?.length > 0 ? countryData.region.trim() : 'root';
353
+ const rawRegionKey = cData.region?.trim()?.length > 0 ? cData.region.trim() : 'root';
423
354
  const regionKey = SHORT_TO_LONG_REGION[rawRegionKey] || rawRegionKey;
424
355
  const regionData = countryMapping[searchDocType][regionKey];
425
356
  if (!regionData || !Array.isArray(regionData))
426
357
  return false;
427
- const requiresBarcode = regionData.some((item) => {
358
+ return regionData.some((item) => {
428
359
  const method = String(item['back'] || '').toUpperCase();
429
360
  return method.includes('BARCODE') || method.includes('2D');
430
361
  });
431
- logger.log(`[Barcode Check] Dynamic check for ${countryData.code} - ${searchDocType} - ${regionKey}: requiresBarcode=${requiresBarcode}`);
432
- return requiresBarcode;
433
362
  };
434
363
  const processCanadianBarcode = async (barcodeString) => {
435
- if (isProcessingCapture || !pendingCAImage)
364
+ if (isProcessingCaptureRef.current || !pendingCAImage)
436
365
  return;
437
- logger.log('[CA Barcode] Valid barcode decoded. Sending to parser...');
438
366
  setSilentCaptureResult(prev => ({ ...prev, isAnalyzing: true, error: '' }));
439
367
  try {
440
- const isHIC = selectedDocumentType.type === 'health_insurance_card';
368
+ const docType = selectedDocTypeRef.current;
369
+ const cData = countryDataRef.current;
370
+ const isHIC = docType.type === 'health_insurance_card';
441
371
  let parsedDictionary;
442
372
  if (isHIC && barcodeString.length < 50) {
443
373
  parsedDictionary = { data: { card_number: barcodeString, barcode_type: "1D" } };
@@ -449,16 +379,9 @@ export const IDCardCapture = ({ component, value = {}, onValueChange, error, lan
449
379
  if (!parsedDictionary)
450
380
  throw new Error("Could not parse the barcode data.");
451
381
  const verifiedResult = {
452
- path: pendingCAImage.path,
453
- templatePath: pendingCAImage.templatePath,
454
- bbox: pendingCAImage.bbox,
455
- success: true,
456
- mrz: JSON.stringify(parsedDictionary),
457
- barcode: barcodeString,
458
- isAnalyzing: false,
459
- country: countryData?.code,
460
- documentType: selectedDocumentType.type,
461
- region: selectedDocumentType.region !== 'root' ? selectedDocumentType.region : undefined,
382
+ path: pendingCAImage.path, templatePath: pendingCAImage.templatePath, bbox: pendingCAImage.bbox, success: true,
383
+ mrz: JSON.stringify(parsedDictionary), barcode: barcodeString, isAnalyzing: false,
384
+ country: cData?.code, documentType: docType.type, region: docType.region !== 'root' ? docType.region : undefined,
462
385
  };
463
386
  setSilentCaptureResult(verifiedResult);
464
387
  if (pendingCAImage.bbox) {
@@ -468,7 +391,6 @@ export const IDCardCapture = ({ component, value = {}, onValueChange, error, lan
468
391
  await autoCapture(pendingCAImage.path, verifiedResult);
469
392
  }
470
393
  catch (error) {
471
- console.error("Barcode processing failed", error);
472
394
  setScanPhase('image');
473
395
  setPendingCAImage(null);
474
396
  scannedBarcodeRef.current = null;
@@ -482,42 +404,30 @@ export const IDCardCapture = ({ component, value = {}, onValueChange, error, lan
482
404
  return;
483
405
  if (isAnalyzingRef.current)
484
406
  return;
407
+ // USE REFS TO AVOID IOS NATIVE STALE CLOSURE
408
+ const side = currentSideRef.current;
409
+ const captured = capturedImagesRef.current;
410
+ const docType = selectedDocTypeRef.current;
411
+ const cData = countryDataRef.current;
485
412
  if (result.success && result.path) {
486
413
  setSilentCaptureResult((prev) => ({ ...prev, isAnalyzing: true, success: false, error: '' }));
487
- const safeCapturePath = Platform.OS === 'ios' && !result.path.startsWith('file://')
488
- ? `file://${result.path}`
489
- : result.path;
490
- let templatePath = capturedImages[currentSide]?.templatePath || '';
414
+ const safeCapturePath = Platform.OS === 'ios' && !result.path.startsWith('file://') ? `file://${result.path}` : result.path;
415
+ let templatePath = captured[side]?.templatePath || '';
491
416
  let templateBbox;
492
417
  let templateResponse;
493
- if (!selectedDocumentType) {
418
+ if (!docType) {
494
419
  setSilentCaptureResult((prev) => ({ ...prev, isAnalyzing: false, success: false, error: 'Document type not selected' }));
495
420
  return;
496
421
  }
497
- const issuingSubdivision = selectedDocumentType.region !== 'root' ? selectedDocumentType.region : undefined;
498
- // 🚨 WATCHDOG TIMER: If the iOS network drops and doesn't throw, this guarantees the UI unlocks after 20 seconds.
422
+ const issuingSubdivision = docType.region !== 'root' ? docType.region : undefined;
499
423
  const watchdog = setTimeout(() => {
500
424
  if (isAnalyzingRef.current) {
501
- setSilentCaptureResult((prev) => ({
502
- ...prev,
503
- isAnalyzing: false,
504
- success: false,
505
- error: state.currentLanguage === 'en' ? 'Connection timeout. Please try again.' : 'Délai de connexion dépassé. Veuillez réessayer.'
506
- }));
507
- Alert.alert(state.currentLanguage === 'en' ? "Timeout" : "Délai dépassé", state.currentLanguage === 'en'
508
- ? "The request took too long. Please verify your network and try again."
509
- : "La requête a pris trop de temps. Veuillez vérifier votre connexion et réessayer.");
425
+ setSilentCaptureResult((prev) => ({ ...prev, isAnalyzing: false, success: false, error: state.currentLanguage === 'en' ? 'Taking longer than expected. Hold steady.' : 'Traitement long. Veuillez rester immobile.' }));
510
426
  }
511
427
  }, 20000);
512
428
  try {
513
429
  if (!templatePath) {
514
- const templateType = await checkTemplateType({
515
- path: safeCapturePath,
516
- docType: selectedDocumentType?.type,
517
- docRegion: countryData?.code || "",
518
- postfix: currentSide,
519
- issuingSubdivision: issuingSubdivision
520
- }, env);
430
+ const templateType = await checkTemplateType({ path: safeCapturePath, docType: docType.type, docRegion: cData?.code || "", postfix: side, issuingSubdivision: issuingSubdivision }, env);
521
431
  templateResponse = templateType;
522
432
  if (templateType.template_path)
523
433
  templatePath = templateType.template_path;
@@ -535,57 +445,27 @@ export const IDCardCapture = ({ component, value = {}, onValueChange, error, lan
535
445
  catch { }
536
446
  }
537
447
  }
538
- const extractedCountryKey = templatePath ? templatePath.split('/')[0] : (ISO_TO_COUNTRY_NAME[countryData?.code || ''] || 'root');
539
- const regionMappings = getCurrentSideVerification(currentSide, extractedCountryKey);
448
+ const extractedCountryKey = templatePath ? templatePath.split('/')[0] : (ISO_TO_COUNTRY_NAME[cData?.code || ''] || 'root');
449
+ const regionMappings = getCurrentSideVerification(side, extractedCountryKey, cData);
540
450
  let verificationRes;
541
- if (currentSide === 'front') {
451
+ if (side === 'front') {
542
452
  const matchedAuthMethod = getCorrespondingAuthMethod(templatePath, regionMappings.regionMapping, regionMappings.key || '', 'front');
543
453
  const mrzType = getCorrespondingMrzType(templatePath, regionMappings.regionMapping, regionMappings.key || '') || 'TD1';
544
- verificationRes = await frontVerification({
545
- path: safeCapturePath,
546
- regionMapping: {
547
- authMethod: matchedAuthMethod ? [matchedAuthMethod] : [],
548
- mrzTypes: mrzType ? [mrzType] : []
549
- },
550
- selectedDocumentType: GovernmentDocumentTypeShorted[selectedDocumentType?.type] || '',
551
- code: countryData?.code || '',
552
- currentSide,
553
- templatePath,
554
- mrzType,
555
- issuingSubdivision: issuingSubdivision,
556
- clientSideBarcodeData: silentCaptureResult.barcode
557
- }, env);
454
+ verificationRes = await frontVerification({ path: safeCapturePath, regionMapping: { authMethod: matchedAuthMethod ? [matchedAuthMethod] : [], mrzTypes: mrzType ? [mrzType] : [] }, selectedDocumentType: GovernmentDocumentTypeShorted[docType.type] || '', code: cData?.code || '', currentSide: side, templatePath, mrzType, issuingSubdivision: issuingSubdivision, clientSideBarcodeData: silentCaptureResult.barcode }, env);
558
455
  }
559
456
  else {
560
- logger.log("Attempting backVerification with:", { path: safeCapturePath, templatePath, side: currentSide });
561
457
  const matchedBackAuthMethod = getCorrespondingAuthMethod(templatePath, regionMappings.regionMapping, regionMappings.key || '', 'back');
562
458
  const backMrzType = getCorrespondingMrzType(templatePath, regionMappings.regionMapping, regionMappings.key || '') || '';
563
- const frontTemplatePath = capturedImages['front']?.templatePath || '';
564
- verificationRes = await backVerification({
565
- path: safeCapturePath,
566
- regionMapping: {
567
- authMethod: matchedBackAuthMethod ? [matchedBackAuthMethod] : [],
568
- mrzTypes: backMrzType ? [backMrzType] : []
569
- },
570
- selectedDocumentType: GovernmentDocumentTypeShorted[selectedDocumentType.type] || '',
571
- code: countryData?.code || '',
572
- currentSide,
573
- templatePath,
574
- mrzType: backMrzType,
575
- templateResponse,
576
- issuingSubdivision: issuingSubdivision,
577
- frontTemplatePath: frontTemplatePath
578
- }, env);
459
+ const frontTemplatePath = captured['front']?.templatePath || '';
460
+ verificationRes = await backVerification({ path: safeCapturePath, regionMapping: { authMethod: matchedBackAuthMethod ? [matchedBackAuthMethod] : [], mrzTypes: backMrzType ? [backMrzType] : [] }, selectedDocumentType: GovernmentDocumentTypeShorted[docType.type] || '', code: cData?.code || '', currentSide: side, templatePath, mrzType: backMrzType, templateResponse, issuingSubdivision: issuingSubdivision, frontTemplatePath: frontTemplatePath }, env);
579
461
  }
580
462
  const bbox = verificationRes?.bbox || templateBbox;
581
463
  const verifiedMrz = verificationRes?.mrz ? JSON.stringify(verificationRes.mrz) : "";
582
- if (isBarcodeRequired()) {
464
+ if (isBarcodeRequired(side, cData, docType)) {
583
465
  const barcodeData = scannedBarcodeRef.current;
584
- const docTypeStr = selectedDocumentType.type;
585
- const isHIC = docTypeStr === 'health_insurance_card';
466
+ const isHIC = docType.type === 'health_insurance_card';
586
467
  const isValidScan = typeof barcodeData === 'string' && ((isHIC && barcodeData.length > 0) || barcodeData.length > 50);
587
468
  if (isValidScan) {
588
- logger.log('[CA Barcode] Image captured, verified & barcode ALREADY scanned! Skipping Phase 2.');
589
469
  let parsedDictionary;
590
470
  if (isHIC && barcodeData.length < 50) {
591
471
  parsedDictionary = { data: { card_number: barcodeData, barcode_type: "1D" } };
@@ -594,69 +474,50 @@ export const IDCardCapture = ({ component, value = {}, onValueChange, error, lan
594
474
  const token = await authentification();
595
475
  parsedDictionary = await kycService.formatBarcodeData({ textInput: barcodeData, token }, env);
596
476
  }
597
- const verifiedResult = {
598
- path: safeCapturePath, templatePath: templatePath, bbox: bbox, success: true,
599
- mrz: JSON.stringify(parsedDictionary), barcode: barcodeData, isAnalyzing: false,
600
- country: countryData?.code, documentType: selectedDocumentType.type,
601
- region: selectedDocumentType.region !== 'root' ? selectedDocumentType.region : undefined,
602
- };
477
+ const verifiedResult = { path: safeCapturePath, templatePath: templatePath, bbox: bbox, success: true, mrz: JSON.stringify(parsedDictionary), barcode: barcodeData, isAnalyzing: false, country: cData?.code, documentType: docType.type, region: docType.region !== 'root' ? docType.region : undefined };
603
478
  clearTimeout(watchdog);
604
479
  setSilentCaptureResult(verifiedResult);
605
- if (bbox) {
606
- const safeBbox = bbox;
607
- setBboxBySide(prev => ({ ...prev, ['back']: safeBbox }));
608
- }
480
+ if (bbox)
481
+ setBboxBySide(prev => ({ ...prev, ['back']: bbox }));
609
482
  await autoCapture(safeCapturePath, verifiedResult);
610
483
  return;
611
484
  }
612
- logger.log('[CA Barcode] Image captured & template VERIFIED. Switching to Barcode phase.');
613
485
  clearTimeout(watchdog);
614
486
  setPendingCAImage({ path: safeCapturePath, templateResponse, templatePath, bbox });
615
487
  setScanPhase('barcode');
616
488
  setSilentCaptureResult(prev => ({ ...prev, isAnalyzing: false }));
617
489
  return;
618
490
  }
619
- const verifiedResult = {
620
- path: safeCapturePath, templatePath, bbox, success: true, mrz: verifiedMrz,
621
- isAnalyzing: false, country: countryData?.code, documentType: selectedDocumentType.type,
622
- region: issuingSubdivision
623
- };
491
+ const verifiedResult = { path: safeCapturePath, templatePath, bbox, success: true, mrz: verifiedMrz, isAnalyzing: false, country: cData?.code, documentType: docType.type, region: issuingSubdivision };
624
492
  clearTimeout(watchdog);
625
493
  setSilentCaptureResult(verifiedResult);
626
494
  if (bbox)
627
- setBboxBySide(prev => ({ ...prev, [currentSide]: bbox }));
495
+ setBboxBySide(prev => ({ ...prev, [side]: bbox }));
628
496
  await autoCapture(safeCapturePath, verifiedResult);
629
497
  }
630
498
  catch (error) {
631
499
  clearTimeout(watchdog);
632
- console.log("Backend Verification Error:", error);
633
- const isNetworkError = !error.response ||
634
- error.message === 'Network Error' ||
635
- error.code === 'ECONNABORTED' ||
636
- error.message?.includes('Network');
637
- let userFriendlyMessage = 'Verification failed. Please try again.';
500
+ const isNetworkError = !error.response || error.message === 'Network Error' || error.code === 'ECONNABORTED' || error.message?.includes('Network');
501
+ let hintMessage = '';
638
502
  if (isNetworkError) {
639
- userFriendlyMessage = state.currentLanguage === 'en'
640
- ? 'Cannot connect to verification server. Please check your internet connection.'
641
- : 'Impossible de se connecter au serveur de vérification. Veuillez vérifier votre connexion internet.';
642
- Alert.alert(state.currentLanguage === 'en' ? "Connection Failed" : "Échec de connexion", userFriendlyMessage);
503
+ hintMessage = state.currentLanguage === 'en' ? 'Poor connection. Retrying...' : 'Connexion faible. Nouvelle tentative...';
643
504
  }
644
505
  else {
645
506
  const rawMessage = (error?.message || '').toLowerCase();
646
507
  if (rawMessage.includes('impossible') || rawMessage.includes('unreadable')) {
647
- userFriendlyMessage = 'Document unreadable. Please hold the camera steady in good light.';
508
+ hintMessage = state.currentLanguage === 'en' ? 'Move to better lighting.' : 'Améliorez l\'éclairage.';
648
509
  }
649
510
  else if (rawMessage.includes('country mismatch') || rawMessage.includes('pays sélectionné')) {
650
- userFriendlyMessage = 'The document does not match your selected country.';
511
+ hintMessage = state.currentLanguage === 'en' ? 'Wrong document type.' : 'Mauvais type de document.';
651
512
  }
652
513
  else if (rawMessage.includes('card_not_fully_in_frame')) {
653
- userFriendlyMessage = 'Move closer to the document and ensure all corners are visible.';
514
+ hintMessage = state.currentLanguage === 'en' ? 'Move closer.' : 'Rapprochez-vous.';
654
515
  }
655
516
  else if (rawMessage.includes('ne correspond pas au recto')) {
656
- userFriendlyMessage = 'The back of the document does not match the front. You must scan the same ID.';
517
+ hintMessage = state.currentLanguage === 'en' ? 'Scan the same ID.' : 'Scannez la même pièce.';
657
518
  }
658
519
  }
659
- setSilentCaptureResult(prev => ({ ...prev, isAnalyzing: false, success: false, error: userFriendlyMessage }));
520
+ setSilentCaptureResult(prev => ({ ...prev, isAnalyzing: false, success: false, error: hintMessage }));
660
521
  }
661
522
  }
662
523
  };
@@ -664,84 +525,33 @@ export const IDCardCapture = ({ component, value = {}, onValueChange, error, lan
664
525
  if (scanPhase === 'barcode' && pendingCAImage && silentCaptureResult.barcode) {
665
526
  const bData = silentCaptureResult.barcode;
666
527
  if (typeof bData === 'string' && bData.length > 50) {
667
- logger.log('[CA Barcode] Safety Net Triggered: Processing barcode.');
668
528
  processCanadianBarcode(bData);
669
529
  }
670
530
  }
671
531
  }, [scanPhase, pendingCAImage, silentCaptureResult.barcode]);
672
532
  const handleError = (event) => {
673
- logger.error("Camera Error:", event);
674
- // Normalize the error to lowercase for easy checking
675
533
  const errString = `${event.message} ${event.name} ${event.code}`.toLowerCase();
676
- let friendlyMessage = state.currentLanguage === 'en'
677
- ? 'An unknown camera error occurred.'
678
- : 'Une erreur de caméra inconnue est survenue.';
679
- // 1. Detect Hardware Lock (Camera in use by Zoom, Teams, or another app)
680
- if (errString.includes('notreadableerror') ||
681
- errString.includes('trackstarterror') ||
682
- errString.includes('in use') ||
683
- errString.includes('concurrent') ||
684
- errString.includes('camera unavailable')) {
685
- friendlyMessage = state.currentLanguage === 'en'
686
- ? 'Your camera is currently being used by another application (like Zoom or Teams). Please close the other app and try again.'
687
- : 'Votre caméra est actuellement utilisée par une autre application. Veuillez fermer l\'autre application et réessayer.';
534
+ let friendlyMessage = state.currentLanguage === 'en' ? 'Camera error.' : 'Erreur de caméra.';
535
+ if (errString.includes('notreadableerror') || errString.includes('trackstarterror') || errString.includes('in use') || errString.includes('concurrent') || errString.includes('camera unavailable')) {
536
+ friendlyMessage = state.currentLanguage === 'en' ? 'Camera is in use by another app.' : 'Caméra utilisée par une autre application.';
688
537
  }
689
- // 2. Detect Permission Denied
690
538
  else if (errString.includes('notallowederror') || errString.includes('permission')) {
691
- friendlyMessage = state.currentLanguage === 'en'
692
- ? 'Camera permission denied. Please allow camera access in your settings to proceed.'
693
- : 'Autorisation de la caméra refusée. Veuillez autoriser l\'accès dans vos paramètres.';
539
+ friendlyMessage = state.currentLanguage === 'en' ? 'Allow camera access in settings.' : 'Autorisez l\'accès à la caméra.';
694
540
  }
695
- // 3. Detect No Camera Found
696
541
  else if (errString.includes('notfounderror') || errString.includes('no camera')) {
697
- friendlyMessage = state.currentLanguage === 'en'
698
- ? 'No camera detected on this device.'
699
- : 'Aucune caméra détectée sur cet appareil.';
542
+ friendlyMessage = state.currentLanguage === 'en' ? 'No camera detected.' : 'Aucune caméra détectée.';
700
543
  }
701
- showAlert(state.currentLanguage === 'en' ? 'Camera Error' : 'Erreur de Caméra', friendlyMessage);
544
+ showAlert(state.currentLanguage === 'en' ? 'Camera Error' : 'Erreur', friendlyMessage);
702
545
  setShowCamera(false);
703
546
  setIsProcessingCapture(false);
704
547
  };
705
- useEffect(() => {
706
- actions.showCustomStepper(!showCamera);
707
- }, [showCamera]);
708
- useEffect(() => {
709
- if (Platform.OS === 'web' && typeof window !== 'undefined') {
710
- try {
711
- const url = new URL(window.location.origin);
712
- if (url.hostname === 'localhost') {
713
- url.hostname = '192.168.0.33';
714
- }
715
- const params = new URLSearchParams(window.location.search);
716
- params.set('session_id', state.session.session_id);
717
- params.set('resume', 'true');
718
- if (state.currentComponentIndex !== undefined) {
719
- params.set('component_index', String(state.currentComponentIndex));
720
- }
721
- if (countryData?.code) {
722
- params.set('country', countryData.code);
723
- }
724
- if (selectedDocumentType?.type) {
725
- params.set('document_type', selectedDocumentType.type);
726
- }
727
- if (selectedDocumentType?.region && selectedDocumentType.region !== 'root') {
728
- params.set('region', selectedDocumentType.region);
729
- }
730
- url.hash = params.toString();
731
- setCurrentUrl(url.toString());
732
- }
733
- catch (e) {
734
- console.error("Failed to construct QR handoff URL", e);
735
- }
736
- }
737
- }, [state.session.session_id, state.currentComponentIndex, countryData, selectedDocumentType]);
548
+ useEffect(() => { actions.showCustomStepper(!showCamera); }, [showCamera]);
549
+ // Render logic remains identical below...
738
550
  if (!countrySelectionData || !selectedDocumentType) {
739
551
  return (<View style={styles.root}>
740
552
  <View style={styles.previewContainer}>
741
553
  <Text style={styles.title}>{getLocalizedText(component.labels)}</Text>
742
- <Text style={styles.description}>
743
- {state.currentLanguage === "en" ? "Please complete the country and document selection first." : "Veuillez d'abord compléter la sélection du pays et du document."}
744
- </Text>
554
+ <Text style={styles.description}>{state.currentLanguage === "en" ? "Please complete the country and document selection first." : "Veuillez d'abord compléter la sélection du pays et du document."}</Text>
745
555
  </View>
746
556
  </View>);
747
557
  }
@@ -756,23 +566,15 @@ export const IDCardCapture = ({ component, value = {}, onValueChange, error, lan
756
566
  return (<View style={styles.root}>
757
567
  <View style={[styles.cameraWrapper, { flex: 1 }]}>
758
568
  <View style={styles.headerContainer}>
759
- <Text style={styles.headerTitle}>
760
- {selectedDocumentType ? (locale === 'en' ? getDocumentTypeInfo(selectedDocumentType.type).name.en : getDocumentTypeInfo(selectedDocumentType.type).name.fr) : ''}
761
- </Text>
569
+ <Text style={styles.headerTitle}>{selectedDocumentType ? (locale === 'en' ? getDocumentTypeInfo(selectedDocumentType.type).name.en : getDocumentTypeInfo(selectedDocumentType.type).name.fr) : ''}</Text>
762
570
  <View style={styles.stepBadge}>
763
- <Text style={styles.stepText}>
764
- {t('kyc.idCardCapture.captureTitle', { side: currentSide === 'front' ? locale === 'en' ? 'Front' : 'Recto' : locale === 'en' ? 'Back' : 'Verso' })}
765
- </Text>
571
+ <Text style={styles.stepText}>{t('kyc.idCardCapture.captureTitle', { side: currentSide === 'front' ? locale === 'en' ? 'Front' : 'Recto' : locale === 'en' ? 'Back' : 'Verso' })}</Text>
766
572
  </View>
767
573
  </View>
768
-
769
574
  <View style={[styles.cameraFeedContainer, { flex: 1, backgroundColor: '#000' }]}>
770
575
  {Platform.OS === 'web' && (<View style={styles.webTopControls}>
771
- <TouchableOpacity style={styles.webFlipButton} onPress={toggleCameraLens}>
772
- <Text style={styles.webFlipText}>🔄 Flip Lens</Text>
773
- </TouchableOpacity>
576
+ <TouchableOpacity style={styles.webFlipButton} onPress={toggleCameraLens}><Text style={styles.webFlipText}>🔄 Flip Lens</Text></TouchableOpacity>
774
577
  </View>)}
775
-
776
578
  <EnhancedCameraView key={`${currentSide}-${cameraKey}`} showCamera={true} isProcessing={isBusy} cameraType={cameraConfig.cameraType} style={{ ...StyleSheet.absoluteFillObject, ...(Platform.OS === 'web' ? { width: '100%', height: '100%', left: 0, top: 0 } : {}) }} onError={handleError} onSilentCapture={handleSilentCapture} silentCaptureResult={silentCaptureResult} isScanningBarcode={currentSide === 'back'} barcodeTypes={targetBarcodeTypes} onBarcodeScanned={(barcodeString) => {
777
579
  const docTypeStr = selectedDocumentType?.type;
778
580
  const isFullDataDoc = docTypeStr === 'drivers_licence' || docTypeStr === 'national_id' || docTypeStr === 'identity_card';
@@ -787,8 +589,7 @@ export const IDCardCapture = ({ component, value = {}, onValueChange, error, lan
787
589
  setSilentCaptureResult(prev => ({ ...prev, barcode: barcodeString }));
788
590
  }
789
591
  }
790
- }} overlayComponent={<>
791
- <IdCardOverlay xMin={cameraConfig.overlay.bbox.xMin} yMin={cameraConfig.overlay.bbox.yMin} xMax={cameraConfig.overlay.bbox.xMax} yMax={cameraConfig.overlay.bbox.yMax} instructions={cameraConfig.overlay.guideText} cornerOpacity={cameraConfig.overlay.bbox.cornerRadius || 0} isSuccess={silentCaptureResult.success || scanPhase === 'barcode'} language={state.currentLanguage} stepperProps={{
592
+ }} overlayComponent={<IdCardOverlay xMin={cameraConfig.overlay.bbox.xMin} yMin={cameraConfig.overlay.bbox.yMin} xMax={cameraConfig.overlay.bbox.xMax} yMax={cameraConfig.overlay.bbox.yMax} instructions={cameraConfig.overlay.guideText} cornerOpacity={cameraConfig.overlay.bbox.cornerRadius || 0} isSuccess={silentCaptureResult.success || scanPhase === 'barcode'} language={state.currentLanguage} stepperProps={{
792
593
  back: () => {
793
594
  if (currentSide === 'back') {
794
595
  scannedBarcodeRef.current = null;
@@ -800,9 +601,7 @@ export const IDCardCapture = ({ component, value = {}, onValueChange, error, lan
800
601
  setProcessingImagePath(null);
801
602
  if (capturedImages['front']?.dir) {
802
603
  const frontImage = capturedImages['front'];
803
- setSilentCaptureResult((prev) => ({
804
- ...prev, path: frontImage.dir, success: true, isAnalyzing: false, error: '', mrz: frontImage.mrz || '', templatePath: frontImage.templatePath || '', bbox: bboxBySide['front']
805
- }));
604
+ setSilentCaptureResult((prev) => ({ ...prev, path: frontImage.dir, success: true, isAnalyzing: false, error: '', mrz: frontImage.mrz || '', templatePath: frontImage.templatePath || '', bbox: bboxBySide['front'] }));
806
605
  }
807
606
  else {
808
607
  setSilentCaptureResult((prev) => ({ ...prev, path: '', success: false, isAnalyzing: false, error: '', templatePath: '' }));
@@ -812,72 +611,41 @@ export const IDCardCapture = ({ component, value = {}, onValueChange, error, lan
812
611
  actions.previousComponent();
813
612
  }
814
613
  },
815
- selectedDocumentType: selectedDocumentType ? (locale === 'en' ? getDocumentTypeInfo(selectedDocumentType.type).name.en : getDocumentTypeInfo(selectedDocumentType.type).name.fr) : '',
816
- step: state.currentComponentIndex + 1,
817
- totalSteps: state.template.components.length,
818
- side: currentSide,
819
- }}/>
820
- </>}/>
821
-
614
+ selectedDocumentType: selectedDocumentType ? (locale === 'en' ? getDocumentTypeInfo(selectedDocumentType.type).name.en : getDocumentTypeInfo(selectedDocumentType.type).name.fr) : '', step: state.currentComponentIndex + 1, totalSteps: state.template.components.length, side: currentSide,
615
+ }}/>}/>
822
616
  {scanPhase === 'barcode' && !isBusy && (<View style={styles.barcodePromptContainer}>
823
617
  <View style={styles.barcodePromptContent}>
824
- <View style={styles.barcodeIconContainer}>
825
- <Text style={styles.barcodeIcon}>✓</Text>
826
- </View>
618
+ <View style={styles.barcodeIconContainer}><Text style={styles.barcodeIcon}>✓</Text></View>
827
619
  <View style={styles.barcodeTextContainer}>
828
- <Text style={styles.barcodePromptTitle}>
829
- {state.currentLanguage === 'en' ? 'Image Verified' : 'Image Vérifiée'}
830
- </Text>
831
- <Text style={styles.barcodePromptDesc}>
832
- {state.currentLanguage === 'en' ? 'Move closer to fill the screen with the 2D barcode.' : 'Rapprochez-vous pour scanner le code-barres 2D.'}
833
- </Text>
620
+ <Text style={styles.barcodePromptTitle}>{state.currentLanguage === 'en' ? 'Image Verified' : 'Image Vérifiée'}</Text>
621
+ <Text style={styles.barcodePromptDesc}>{state.currentLanguage === 'en' ? 'Move closer to fill the screen with the 2D barcode.' : 'Rapprochez-vous pour scanner le code-barres 2D.'}</Text>
834
622
  </View>
835
623
  </View>
836
624
  </View>)}
837
-
838
625
  {!isBusy && silentCaptureResult.isAnalyzing && (<View style={styles.topAnalyzingPillContainer}>
839
626
  <View style={styles.topAnalyzingPill}>
840
627
  <ActivityIndicator size="small" color="white"/>
841
- <Text style={styles.analyzingPillText}>
842
- {state.currentLanguage === 'en' ? 'Scanning...' : 'Analyse...'}
843
- </Text>
628
+ <Text style={styles.analyzingPillText}>{state.currentLanguage === 'en' ? 'Scanning...' : 'Analyse...'}</Text>
844
629
  </View>
845
630
  </View>)}
846
-
847
631
  {isBusy && (<View style={[StyleSheet.absoluteFillObject, { zIndex: 9999 }]}>
848
- {processingImagePath && (<Image source={{ uri: processingImagePath.startsWith('file://') ? processingImagePath : `file://${processingImagePath}` }} style={StyleSheet.absoluteFillObject} resizeMode="cover"/>)}
632
+ {processingImagePath && <Image source={{ uri: processingImagePath.startsWith('file://') ? processingImagePath : `file://${processingImagePath}` }} style={StyleSheet.absoluteFillObject} resizeMode="cover"/>}
849
633
  <View style={styles.processingOverlay}>
850
634
  <ActivityIndicator size="large" color="#2DBD60"/>
851
- <Text style={styles.processingText}>
852
- {state.currentLanguage === 'en' ? 'Perfect!\nProcessing Document...' : 'Parfait!\nTraitement du document...'}
853
- </Text>
635
+ <Text style={styles.processingText}>{state.currentLanguage === 'en' ? 'Perfect!\nProcessing Document...' : 'Parfait!\nTraitement du document...'}</Text>
854
636
  </View>
855
637
  </View>)}
856
-
857
638
  {!isBusy && Platform.OS === 'web' ? (<View style={styles.webBottomControlBar}>
858
- {silentCaptureResult.error && (<View style={styles.floatingErrorBanner}>
859
- <Text style={styles.floatingErrorText}>⚠️ {silentCaptureResult.error}</Text>
860
- </View>)}
639
+ {silentCaptureResult.error && <View style={styles.floatingErrorBanner}><Text style={styles.floatingErrorText}>⚠️ {silentCaptureResult.error}</Text></View>}
861
640
  <View style={styles.webActionButtonsRow}>
862
- <TouchableOpacity style={styles.webSecondaryButton} onPress={() => setShowCamera(false)}>
863
- <Text style={styles.webSecondaryButtonText}>Cancel</Text>
864
- </TouchableOpacity>
865
- <TouchableOpacity style={styles.webPrimaryButton} onPress={refreshCamera}>
866
- <Text style={styles.webPrimaryButtonText}>↻ Refresh Camera</Text>
867
- </TouchableOpacity>
641
+ <TouchableOpacity style={styles.webSecondaryButton} onPress={() => setShowCamera(false)}><Text style={styles.webSecondaryButtonText}>Cancel</Text></TouchableOpacity>
642
+ <TouchableOpacity style={styles.webPrimaryButton} onPress={refreshCamera}><Text style={styles.webPrimaryButtonText}>↻ Refresh Camera</Text></TouchableOpacity>
868
643
  </View>
869
644
  </View>) : !isBusy ? (<View style={styles.escapeHatchContainer}>
870
- <TouchableOpacity style={styles.fallbackRefreshButton} onPress={refreshCamera}>
871
- <Text style={styles.fallbackRefreshText}>↻ Refresh Camera</Text>
872
- </TouchableOpacity>
873
- <TouchableOpacity style={[styles.fallbackRefreshButton, { marginTop: 15, backgroundColor: 'rgba(220, 38, 38, 0.8)', borderColor: '#DC2626' }]} onPress={() => setShowCamera(false)}>
874
- <Text style={styles.fallbackRefreshText}>Cancel / Go Back</Text>
875
- </TouchableOpacity>
876
- </View>) : null}
877
-
878
- {silentCaptureResult.error && !isBusy && Platform.OS !== 'web' ? (<View style={[styles.floatingErrorBanner, { zIndex: 10000 }]}>
879
- <Text style={styles.floatingErrorText}>⚠️ {silentCaptureResult.error}</Text>
645
+ <TouchableOpacity style={styles.fallbackRefreshButton} onPress={refreshCamera}><Text style={styles.fallbackRefreshText}>↻ Refresh Camera</Text></TouchableOpacity>
646
+ <TouchableOpacity style={[styles.fallbackRefreshButton, { marginTop: 15, backgroundColor: 'rgba(220, 38, 38, 0.8)', borderColor: '#DC2626' }]} onPress={() => setShowCamera(false)}><Text style={styles.fallbackRefreshText}>Cancel / Go Back</Text></TouchableOpacity>
880
647
  </View>) : null}
648
+ {silentCaptureResult.error && !isBusy && Platform.OS !== 'web' ? (<View style={[styles.floatingErrorBanner, { zIndex: 10000 }]}><Text style={styles.floatingErrorText}>⚠️ {silentCaptureResult.error}</Text></View>) : null}
881
649
  </View>
882
650
  </View>
883
651
  </View>);
@@ -886,47 +654,24 @@ export const IDCardCapture = ({ component, value = {}, onValueChange, error, lan
886
654
  <View style={styles.previewContainer}>
887
655
  <ScrollView style={styles.previewItemContainer} showsVerticalScrollIndicator={false}>
888
656
  <View key={currentSide} style={styles.sideContainer}>
889
- <Text style={styles.sideTitle}>
890
- {t('kyc.idCardCapture.captureTitle', { side: currentSide === 'front' ? locale === 'en' ? 'Front' : 'Recto' : locale === 'en' ? 'Back' : 'Verso' })}
891
- </Text>
892
- <Text style={{ fontSize: 14, color: '#666', textAlign: 'center', marginBottom: 16, lineHeight: 22 }}>
893
- {getLocalizedText(component.instructions)}
894
- </Text>
895
-
657
+ <Text style={styles.sideTitle}>{t('kyc.idCardCapture.captureTitle', { side: currentSide === 'front' ? locale === 'en' ? 'Front' : 'Recto' : locale === 'en' ? 'Back' : 'Verso' })}</Text>
658
+ <Text style={{ fontSize: 14, color: '#666', textAlign: 'center', marginBottom: 16, lineHeight: 22 }}>{getLocalizedText(component.instructions)}</Text>
896
659
  <View style={{ alignItems: 'center', justifyContent: 'center', flexDirection: "column", gap: 16 }}>
897
660
  <View style={styles.imagePreviewWrapper}>
898
661
  {capturedImages[currentSide]?.dir ? (<Image source={{ uri: capturedImages[currentSide].dir }} style={styles.previewImage}/>) : silentCaptureResult.path ? (<Image source={{ uri: silentCaptureResult.path }} style={styles.previewImage}/>) : null}
899
662
  </View>
900
-
901
663
  {!capturedImages[currentSide]?.dir && (<View style={{ width: '100%', gap: 12 }}>
902
-
903
664
  {isWebScanningRestricted ? (<>
904
665
  <View style={styles.restrictionWarningBanner}>
905
- <Text style={styles.restrictionWarningTitle}>
906
- {state.currentLanguage === 'en' ? '⚠️ Web Scanning Unavailable' : '⚠️ Numérisation Web Indisponible'}
907
- </Text>
908
- <Text style={styles.restrictionWarningText}>
909
- {state.currentLanguage === 'en'
910
- ? `This document can only be scanned using our mobile app.`
911
- : `Ce document ne peut être scanné qu'avec notre application mobile.`}
912
- </Text>
666
+ <Text style={styles.restrictionWarningTitle}>{state.currentLanguage === 'en' ? '⚠️ Web Scanning Unavailable' : '⚠️ Numérisation Web Indisponible'}</Text>
667
+ <Text style={styles.restrictionWarningText}>{state.currentLanguage === 'en' ? `This document can only be scanned using our mobile app.` : `Ce document ne peut être scanné qu'avec notre application mobile.`}</Text>
913
668
  </View>
914
-
915
669
  <Button title={state.currentLanguage === "en" ? "Continue on Phone" : "Continuer sur le téléphone"} onPress={() => setShowQRModal(true)} variant="primary" size="large" fullWidth/>
916
- </>) : (
917
- /* 🟢 NORMAL FLOW: Show "Start Scanning" and optional web handoff */
918
- <>
919
- <Button title={state.currentLanguage === "en" ? "Start Scanning" : "Commencer la numérisation"} onPress={() => {
920
- setShowCamera(true);
921
- refreshCamera();
922
- actions.showCustomStepper(false);
923
- }} variant="primary" size="large" fullWidth/>
924
-
670
+ </>) : (<>
671
+ <Button title={state.currentLanguage === "en" ? "Start Scanning" : "Commencer la numérisation"} onPress={() => { setShowCamera(true); refreshCamera(); actions.showCustomStepper(false); }} variant="primary" size="large" fullWidth/>
925
672
  {Platform.OS === 'web' && (<Button title={state.currentLanguage === "en" ? "Continue on Phone" : "Continuer sur le téléphone"} onPress={() => setShowQRModal(true)} variant="outline" size="large" fullWidth/>)}
926
673
  </>)}
927
-
928
674
  </View>)}
929
-
930
675
  {capturedImages[currentSide]?.dir && (<>
931
676
  <Button title={t('kyc.idCardCapture.retakeButton')} onPress={() => retakePicture(currentSide)} variant="outline" size="medium" fullWidth/>
932
677
  <Button title={t('common.next')} onPress={() => {
@@ -953,68 +698,68 @@ export const IDCardCapture = ({ component, value = {}, onValueChange, error, lan
953
698
  </View>
954
699
  </ScrollView>
955
700
  </View>
956
-
701
+
957
702
  {showQRModal && Platform.OS === 'web' && (<View style={styles.qrModalOverlay}>
958
- <View style={styles.qrModalContainer}>
959
- <View style={styles.qrModalHeader}>
960
- <Text style={styles.qrModalTitle}>
961
- {state.currentLanguage === 'en' ? 'Continue on your phone' : 'Continuer sur votre téléphone'}
962
- </Text>
963
- <Text style={styles.qrModalText}>
964
- {state.currentLanguage === 'en'
703
+ <View style={styles.qrModalContainer}>
704
+ <View style={styles.qrModalHeader}>
705
+ <Text style={styles.qrModalTitle}>
706
+ {state.currentLanguage === 'en' ? 'Continue on your phone' : 'Continuer sur votre téléphone'}
707
+ </Text>
708
+ <Text style={styles.qrModalText}>
709
+ {state.currentLanguage === 'en'
965
710
  ? "Scan the QR code to download our mobile app and complete the scanning process securely."
966
711
  : "Scannez le code QR pour télécharger notre application et terminer la numérisation en toute sécurité."}
967
- </Text>
968
- </View>
712
+ </Text>
713
+ </View>
969
714
 
970
- <View style={styles.qrToggleContainer}>
971
- <TouchableOpacity style={[styles.qrToggleButton, qrPlatform === 'android' && styles.qrToggleButtonActive]} onPress={() => setQrPlatform('android')}>
972
- <Text style={[styles.qrToggleText, qrPlatform === 'android' && styles.qrToggleTextActive]}>
973
- Android
974
- </Text>
975
- </TouchableOpacity>
976
- <TouchableOpacity style={[styles.qrToggleButton, qrPlatform === 'ios' && styles.qrToggleButtonActive]} onPress={() => setQrPlatform('ios')}>
977
- <Text style={[styles.qrToggleText, qrPlatform === 'ios' && styles.qrToggleTextActive]}>
978
- iOS (Apple)
979
- </Text>
980
- </TouchableOpacity>
981
- </View>
715
+ <View style={styles.qrToggleContainer}>
716
+ <TouchableOpacity style={[styles.qrToggleButton, qrPlatform === 'android' && styles.qrToggleButtonActive]} onPress={() => setQrPlatform('android')}>
717
+ <Text style={[styles.qrToggleText, qrPlatform === 'android' && styles.qrToggleTextActive]}>
718
+ Android
719
+ </Text>
720
+ </TouchableOpacity>
721
+ <TouchableOpacity style={[styles.qrToggleButton, qrPlatform === 'ios' && styles.qrToggleButtonActive]} onPress={() => setQrPlatform('ios')}>
722
+ <Text style={[styles.qrToggleText, qrPlatform === 'ios' && styles.qrToggleTextActive]}>
723
+ iOS (Apple)
724
+ </Text>
725
+ </TouchableOpacity>
726
+ </View>
982
727
 
983
- <View style={styles.qrCodeWrapper}>
984
- <QRCode value={qrPlatform === 'android'
728
+ <View style={styles.qrCodeWrapper}>
729
+ <QRCode value={qrPlatform === 'android'
985
730
  ? 'https://play.google.com/store/apps/details?id=transfergratis.com&hl=en_CA&gl=US'
986
731
  : 'https://apps.apple.com/ca/app/transfergratis-money-transfer/id1605411731'} size={180} backgroundColor="#FFFFFF" color="#0F172A"/>
987
- </View>
732
+ </View>
988
733
 
989
- <View style={styles.linkCodeSection}>
990
- <Text style={styles.linkCodeHeader}>
991
- {state.currentLanguage === 'en' ? 'Use code below to proceed on the Sanctum Key App.' : 'Vous avez déjà l\'application ? Liez-la :'}
992
- </Text>
993
-
994
- {generatedCode ? (<ExpiringCodeTimer initialCode={generatedCode} language={state.currentLanguage}/>) : (<TouchableOpacity style={styles.generateCodeButton} onPress={handleGenerateCode} disabled={isGeneratingCode}>
995
- {isGeneratingCode ? (<ActivityIndicator size="small" color="#0F172A"/>) : (<Text style={styles.generateCodeButtonText}>
996
- {state.currentLanguage === 'en' ? 'Generate 5-Digit Link Code' : 'Générer un code à 5 chiffres'}
997
- </Text>)}
998
- </TouchableOpacity>)}
999
- </View>
734
+ <View style={styles.linkCodeSection}>
735
+ <Text style={styles.linkCodeHeader}>
736
+ {state.currentLanguage === 'en' ? 'Use code below to proceed on the Sanctum Key App.' : 'Vous avez déjà l\'application ? Liez-la :'}
737
+ </Text>
738
+
739
+ {generatedCode ? (<ExpiringCodeTimer initialCode={generatedCode} language={state.currentLanguage}/>) : (<TouchableOpacity style={styles.generateCodeButton} onPress={handleGenerateCode} disabled={isGeneratingCode}>
740
+ {isGeneratingCode ? (<ActivityIndicator size="small" color="#0F172A"/>) : (<Text style={styles.generateCodeButtonText}>
741
+ {state.currentLanguage === 'en' ? 'Generate 5-Digit Link Code' : 'Générer un code à 5 chiffres'}
742
+ </Text>)}
743
+ </TouchableOpacity>)}
744
+ </View>
1000
745
 
1001
- {isWebScanningRestricted && (<View style={styles.storeLinksContainer}>
1002
- <Text style={styles.storeLinksTitle}>
1003
- {state.currentLanguage === 'en' ? 'Or click to open directly:' : 'Ou cliquez pour ouvrir directement :'}
1004
- </Text>
1005
- <View style={styles.storeButtonsRow}>
1006
- <TouchableOpacity style={styles.storeButton} onPress={() => window.open('https://play.google.com/store/apps/details?id=transfergratis.com&hl=en_CA&gl=US', '_blank')}>
1007
- <Text style={styles.storeButtonText}>Google Play</Text>
1008
- </TouchableOpacity>
1009
- <TouchableOpacity style={styles.storeButton} onPress={() => window.open('https://apps.apple.com/ca/app/transfergratis-money-transfer/id1605411731', '_blank')}>
1010
- <Text style={styles.storeButtonText}>App Store</Text>
1011
- </TouchableOpacity>
1012
- </View>
1013
- </View>)}
746
+ {isWebScanningRestricted && (<View style={styles.storeLinksContainer}>
747
+ <Text style={styles.storeLinksTitle}>
748
+ {state.currentLanguage === 'en' ? 'Or click to open directly:' : 'Ou cliquez pour ouvrir directement :'}
749
+ </Text>
750
+ <View style={styles.storeButtonsRow}>
751
+ <TouchableOpacity style={styles.storeButton} onPress={() => window.open('https://play.google.com/store/apps/details?id=transfergratis.com&hl=en_CA&gl=US', '_blank')}>
752
+ <Text style={styles.storeButtonText}>Google Play</Text>
753
+ </TouchableOpacity>
754
+ <TouchableOpacity style={styles.storeButton} onPress={() => window.open('https://apps.apple.com/ca/app/transfergratis-money-transfer/id1605411731', '_blank')}>
755
+ <Text style={styles.storeButtonText}>App Store</Text>
756
+ </TouchableOpacity>
757
+ </View>
758
+ </View>)}
1014
759
 
1015
- <Button title={state.currentLanguage === 'en' ? "Cancel & Stay on Laptop" : "Annuler et rester sur l'ordinateur"} onPress={() => setShowQRModal(false)} variant="outline" fullWidth/>
1016
- </View>
1017
- </View>)}
760
+ <Button title={state.currentLanguage === 'en' ? "Cancel & Stay on Laptop" : "Annuler et rester sur l'ordinateur"} onPress={() => setShowQRModal(false)} variant="outline" fullWidth/>
761
+ </View>
762
+ </View>)}
1018
763
  </View>);
1019
764
  };
1020
765
  const styles = StyleSheet.create({