@sanctum-key/react-native-sdk 1.0.9 → 1.0.10

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 (24) hide show
  1. package/build/package.json +1 -1
  2. package/build/src/components/KYCElements/CountrySelection.d.ts.map +1 -1
  3. package/build/src/components/KYCElements/CountrySelection.js +259 -63
  4. package/build/src/components/KYCElements/CountrySelection.js.map +1 -1
  5. package/build/src/components/KYCElements/IDCardCapture.d.ts.map +1 -1
  6. package/build/src/components/KYCElements/IDCardCapture.js +222 -69
  7. package/build/src/components/KYCElements/IDCardCapture.js.map +1 -1
  8. package/build/src/components/KYCElements/PhoneVerificationTemplate.d.ts.map +1 -1
  9. package/build/src/components/KYCElements/PhoneVerificationTemplate.js +160 -21
  10. package/build/src/components/KYCElements/PhoneVerificationTemplate.js.map +1 -1
  11. package/build/src/config/region_mapping.json +727 -0
  12. package/build/src/modules/api/CardAuthentification.d.ts.map +1 -1
  13. package/build/src/modules/api/CardAuthentification.js +3 -7
  14. package/build/src/modules/api/CardAuthentification.js.map +1 -1
  15. package/build/src/modules/api/KYCService.d.ts +1 -2
  16. package/build/src/modules/api/KYCService.d.ts.map +1 -1
  17. package/build/src/modules/api/KYCService.js +106 -59
  18. package/build/src/modules/api/KYCService.js.map +1 -1
  19. package/package.json +1 -1
  20. package/src/components/KYCElements/CountrySelection.tsx +300 -74
  21. package/src/components/KYCElements/IDCardCapture.tsx +310 -156
  22. package/src/components/KYCElements/PhoneVerificationTemplate.tsx +201 -29
  23. package/src/modules/api/CardAuthentification.ts +5 -8
  24. package/src/modules/api/KYCService.ts +167 -105
@@ -12,6 +12,14 @@ import { backVerification, checkTemplateType, frontVerification } from '../../mo
12
12
  import { getDocumentTypeInfo } from '../../utils/get-document-type-info';
13
13
  import pathToBase64 from '../../utils/pathToBase64';
14
14
  import { cropByObb, cropImageWithBBoxWithTolerance, getObbConfidence, OBB_CONFIDENCE_THRESHOLD } from '../../utils/cropByObb';
15
+ import REGION_MAPPING from '../../config/region_mapping.json';
16
+
17
+ // 🌍 Map ISO codes to exact JSON root keys to ensure perfect dictionary lookups
18
+ const ISO_TO_COUNTRY_NAME: Record<string, string> = {
19
+ 'KE': 'Kenya', 'CM': 'Cameroon', 'NG': 'Nigeria', 'CA': 'Canada',
20
+ 'FR': 'France', 'GH': 'Ghana', 'ZA': 'South Africa', 'GB': 'Britain',
21
+ 'CI': 'Ivory Coast', 'SN': 'Senegal', 'TG': 'Togo', 'ML': 'Mali'
22
+ };
15
23
 
16
24
  interface IIDCardPayload { dir: string; file: string; mrz: string; templatePath?: string; }
17
25
  interface IDCardCaptureProps {
@@ -30,7 +38,6 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
30
38
  const [currentSide, setCurrentSide] = useState<'front' | 'back'>('front');
31
39
  const [bboxBySide, setBboxBySide] = useState<Record<string, IBbox>>({});
32
40
  const [silentCaptureResult, setSilentCaptureResult] = useState<ISilentCaptureResult>({ success: false, isAnalyzing: false });
33
-
34
41
  const [isProcessingCapture, setIsProcessingCapture] = useState(false);
35
42
  const [processingImagePath, setProcessingImagePath] = useState<string | null>(null);
36
43
 
@@ -65,23 +72,26 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
65
72
  if (JSON.stringify(value) !== JSON.stringify(capturedImages)) {
66
73
  const updatedImages = value as Record<string, IIDCardPayload>;
67
74
  setCapturedImages(updatedImages);
68
- Object.keys(updatedImages).forEach((side) => {
69
- const imageData = updatedImages[side];
70
- if (imageData?.dir) {
71
- setSilentCaptureResult(prev => ({
72
- ...prev, path: imageData.dir, success: true, isAnalyzing: false, mrz: imageData.mrz || '', templatePath: imageData.templatePath || '',
73
- }));
74
- }
75
- });
75
+
76
+ const currentImageData = updatedImages[currentSide];
77
+ if (currentImageData?.dir) {
78
+ setSilentCaptureResult(prev => ({
79
+ ...prev,
80
+ path: currentImageData.dir,
81
+ success: true,
82
+ isAnalyzing: false,
83
+ mrz: currentImageData.mrz || '',
84
+ templatePath: currentImageData.templatePath || '',
85
+ }));
86
+ }
76
87
  }
77
88
  }
78
- }, [value]);
89
+ }, [value, currentSide]);
79
90
 
80
91
  const cameraConfig = useMemo(() => {
81
92
  const instructions = selectedDocumentType
82
93
  ? (locale === 'en' ? getDocumentTypeInfo(selectedDocumentType.type).instructions.en : getDocumentTypeInfo(selectedDocumentType.type).instructions.fr)
83
94
  : getLocalizedText(component.instructions as Record<string, LocalizedText>);
84
-
85
95
  return {
86
96
  cameraType: 'back' as const, flashMode: 'auto' as const,
87
97
  overlay: { guideText: instructions, bbox: { xMin: 15, yMin: 20, xMax: 85, yMax: 70, borderColor: '#2DBD60', borderWidth: 3, cornerRadius: 8 } }
@@ -89,31 +99,55 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
89
99
  }, [selectedDocumentType, locale, component.instructions]);
90
100
 
91
101
  const retakePicture = (sideToRetake: 'front' | 'back') => {
92
- // Completely wipe all processing states to prevent leakage
93
102
  setIsProcessingCapture(false);
94
103
  setProcessingImagePath(null);
95
104
  setSilentCaptureResult({ path: '', success: false, isAnalyzing: false, error: '', templatePath: '', mrz: '', bbox: undefined });
96
-
97
- setShowCamera(true);
105
+ setShowCamera(true);
98
106
  actions.showCustomStepper(false);
99
-
100
107
  setCapturedImages((prev) => { const newState = { ...prev }; delete newState[sideToRetake]; return newState; });
101
108
  if (value) { const newValue = { ...value }; delete newValue[sideToRetake]; onValueChange(newValue); }
102
109
  };
103
110
 
104
- const getCurrentSideVerification = (currentSide: string) => {
105
- if (!selectedDocumentType || !countryData?.regionMapping) return { authMethod: [], mrzTypes: [], regionMapping: null, key: 'root' };
106
- const regionMapping = countryData.regionMapping[selectedDocumentType.type as GovernmentDocumentType];
107
- const authMethod: string[] = []; const mrzTypes: string[] = [];
108
- const key = selectedDocumentType.region?.trim()?.length > 0 ? selectedDocumentType.region.trim() : 'root';
111
+ const getCurrentSideVerification = (currentSide: string, countryKey: string) => {
112
+ const rawDocType = countrySelectionData?.documentType;
113
+
114
+ if (!rawDocType || !countryKey) {
115
+ return { authMethod: [], mrzTypes: [], regionMapping: null, key: 'root' };
116
+ }
117
+
118
+ const rawCountryName = ISO_TO_COUNTRY_NAME[countryData?.code || ''] || countryData?.code || countryKey;
119
+ const baseMapping = (REGION_MAPPING as any).regionMapping || REGION_MAPPING;
120
+
121
+ let countryMapping = baseMapping[rawCountryName];
122
+
123
+ // Fallback search in case of case mismatches
124
+ if (!countryMapping) {
125
+ const foundKey = Object.keys(baseMapping).find(k => k.toLowerCase() === rawCountryName.toLowerCase() || k.toLowerCase() === countryKey.toLowerCase());
126
+ if (foundKey) countryMapping = baseMapping[foundKey];
127
+ }
128
+
129
+ if (!countryMapping) {
130
+ return { authMethod: [], mrzTypes: [], regionMapping: null, key: 'root' };
131
+ }
132
+
133
+ const regionMapping = countryMapping[rawDocType];
134
+ if (!regionMapping) {
135
+ return { authMethod: [], mrzTypes: [], regionMapping: null, key: 'root' };
136
+ }
137
+
138
+ const authMethod: string[] = [];
139
+ const mrzTypes: string[] = [];
140
+ const key = countrySelectionData.region?.trim()?.length > 0 ? countrySelectionData.region.trim() : 'root';
141
+
109
142
  if (regionMapping?.[key] && Array.isArray(regionMapping[key])) {
110
143
  regionMapping[key].forEach((item: any) => {
111
- if (item[currentSide as keyof typeof item]) {
112
- authMethod.push(item[currentSide as keyof typeof item]);
144
+ if (item[currentSide]) {
145
+ authMethod.push(item[currentSide]);
113
146
  if (item?.mrz_type) mrzTypes.push(item?.mrz_type);
114
147
  }
115
148
  });
116
149
  }
150
+
117
151
  return { authMethod: removeDuplicates(authMethod), mrzTypes: removeDuplicates(mrzTypes), regionMapping, key };
118
152
  }
119
153
 
@@ -121,7 +155,7 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
121
155
  if (!mapping || !mapping[selectedDocumentType]) return null;
122
156
  const fileName = templatePath.split("/").pop()?.replace(".jpg", "").replace(".png", "");
123
157
  if (!fileName) return null;
124
- const pyName = `${fileName}.py`;
158
+ const pyName = `${fileName}.py`;
125
159
  const found = mapping[selectedDocumentType].find((item: any) => item.py_file === pyName);
126
160
  return found?.mrz_type || null;
127
161
  }
@@ -137,10 +171,8 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
137
171
 
138
172
  const autoCapture = async (capturePath: string, verified: ISilentCaptureResult) => {
139
173
  if (isProcessingCapture) return;
140
-
141
- setIsProcessingCapture(true);
174
+ setIsProcessingCapture(true);
142
175
  setProcessingImagePath(capturePath);
143
-
144
176
  try {
145
177
  let imagePathForUpload = capturePath;
146
178
  if (verified.bbox) {
@@ -150,13 +182,11 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
150
182
  imagePathForUpload = capturePath;
151
183
  }
152
184
  }
153
-
154
185
  const base64 = await pathToBase64(imagePathForUpload);
155
186
  const newImages = {
156
187
  ...capturedImages,
157
188
  [currentSide]: { dir: imagePathForUpload, file: base64, mrz: verified.mrz || "", templatePath: verified.templatePath },
158
189
  };
159
-
160
190
  setCapturedImages(newImages);
161
191
  if (verified.country && verified.documentType) {
162
192
  onValueChange({
@@ -165,15 +195,13 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
165
195
  documentType: GovernmentDocumentTypeBackend[verified.documentType as keyof typeof GovernmentDocumentTypeBackend] || '',
166
196
  });
167
197
  }
168
-
169
198
  setTimeout(() => {
170
199
  setShowCamera(false);
171
200
  actions.showCustomStepper(true);
172
201
  setSilentCaptureResult(prev => ({ ...prev, isAnalyzing: false }));
173
202
  setIsProcessingCapture(false);
174
- setProcessingImagePath(null);
203
+ setProcessingImagePath(null);
175
204
  }, 600);
176
-
177
205
  } catch (e: any) {
178
206
  showAlert('Error', e?.message || 'Impossible de capturer la photo');
179
207
  setSilentCaptureResult(prev => ({ ...prev, isAnalyzing: false, success: false }));
@@ -184,25 +212,23 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
184
212
 
185
213
  const handleSilentCapture = async (result: { success: boolean; path?: string; error?: string }) => {
186
214
  if (silentCaptureResult.isAnalyzing || isProcessingCapture) return;
187
-
188
215
  if (result.success && result.path) {
189
216
  setSilentCaptureResult((prev) => ({ ...prev, isAnalyzing: true, success: false, error: '' }));
190
- let templatePath = silentCaptureResult.templatePath || '';
217
+
218
+ // 🚨 Force a template fetch if we haven't successfully saved the current side yet
219
+ let templatePath = capturedImages[currentSide]?.templatePath || '';
191
220
  let templateBbox: IBbox | undefined;
192
221
  let templateResponse: any;
193
-
222
+
194
223
  if (!selectedDocumentType) {
195
224
  setSilentCaptureResult((prev) => ({ ...prev, isAnalyzing: false, success: false, error: 'Document type not selected' })); return;
196
225
  }
197
226
 
198
- const regionMappings = getCurrentSideVerification(currentSide);
199
-
200
227
  try {
201
- if (templatePath.length === 0) {
228
+ if (!templatePath) {
202
229
  const templateType = await checkTemplateType({ path: result.path || '', docType: selectedDocumentType?.type as GovernmentDocumentType, docRegion: countryData?.code || "", postfix: currentSide }, env);
203
230
  templateResponse = templateType;
204
231
  if (templateType.template_path) templatePath = templateType.template_path;
205
-
206
232
  if (templateType.card_obb) {
207
233
  const obbConfidence = getObbConfidence((templateType as any).card_obb);
208
234
  if (obbConfidence !== null && obbConfidence < OBB_CONFIDENCE_THRESHOLD) {
@@ -216,26 +242,46 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
216
242
  }
217
243
  }
218
244
 
245
+ const extractedCountryKey = templatePath ? templatePath.split('/')[0] : (ISO_TO_COUNTRY_NAME[countryData?.code || ''] || 'root');
246
+ const regionMappings = getCurrentSideVerification(currentSide, extractedCountryKey);
247
+
219
248
  let verificationRes: any;
220
249
  if (currentSide === 'front') {
221
250
  const matchedAuthMethod = getCorrespondingAuthMethod(templatePath, regionMappings.regionMapping, regionMappings.key || '', 'front');
222
- const mrzType = getCorrespondingMrzType(templatePath, regionMappings.regionMapping, regionMappings.key || '') || '';
251
+ const mrzType = getCorrespondingMrzType(templatePath, regionMappings.regionMapping, regionMappings.key || '') || 'TD1';
223
252
  verificationRes = await frontVerification({ path: result.path, regionMapping: { authMethod: matchedAuthMethod ? [matchedAuthMethod] : regionMappings.authMethod, mrzTypes: regionMappings.mrzTypes }, selectedDocumentType: GovernmentDocumentTypeShorted[selectedDocumentType?.type as keyof typeof GovernmentDocumentTypeShorted] || '', code: countryData?.code || '', currentSide, templatePath, mrzType }, env);
224
253
  } else {
225
- const matchedBackAuthMethod = getCorrespondingAuthMethod(templatePath, regionMappings.regionMapping, regionMappings.key || '', 'back');
226
- const backMrzType = getCorrespondingMrzType(templatePath, regionMappings.regionMapping, regionMappings.key || '') || '';
227
- verificationRes = await backVerification({ path: result.path, regionMapping: { authMethod: matchedBackAuthMethod ? [matchedBackAuthMethod] : regionMappings.authMethod, mrzTypes: regionMappings.mrzTypes }, selectedDocumentType: GovernmentDocumentTypeShorted[selectedDocumentType.type as keyof typeof GovernmentDocumentTypeShorted] || '', code: countryData?.code || '', currentSide, templatePath, mrzType: backMrzType, templateResponse }, env);
228
- }
254
+ let matchedBackAuthMethod = getCorrespondingAuthMethod(templatePath, regionMappings.regionMapping, regionMappings.key || '', 'back');
255
+
256
+ if (!matchedBackAuthMethod && currentSide === 'back') {
257
+ matchedBackAuthMethod = 'MRZ';
258
+ }
229
259
 
260
+ const backMrzType = getCorrespondingMrzType(templatePath, regionMappings.regionMapping, regionMappings.key || '') || 'TD1';
261
+
262
+ verificationRes = await backVerification({
263
+ path: result.path,
264
+ regionMapping: {
265
+ authMethod: matchedBackAuthMethod ? [matchedBackAuthMethod] : regionMappings.authMethod,
266
+ mrzTypes: regionMappings.mrzTypes
267
+ },
268
+ selectedDocumentType: GovernmentDocumentTypeShorted[selectedDocumentType.type as keyof typeof GovernmentDocumentTypeShorted] || '',
269
+ code: countryData?.code || '',
270
+ currentSide,
271
+ templatePath,
272
+ mrzType: backMrzType,
273
+ templateResponse
274
+ }, env);
275
+ }
276
+
230
277
  const bbox = verificationRes?.bbox || templateBbox;
231
278
  const mrz = verificationRes?.mrz ? JSON.stringify(verificationRes.mrz) : "";
232
-
233
279
  const verifiedResult: ISilentCaptureResult = { path: result.path, templatePath, bbox, success: true, mrz, isAnalyzing: false, country: countryData?.code, documentType: selectedDocumentType.type };
280
+
234
281
  setSilentCaptureResult(verifiedResult);
235
282
  if (bbox) setBboxBySide(prev => ({ ...prev, [currentSide]: bbox }));
236
-
237
283
  await autoCapture(result.path, verifiedResult);
238
-
284
+
239
285
  } catch (error: any) {
240
286
  const isCardNotFullyInFrame = error?.message === 'CARD_NOT_FULLY_IN_FRAME' || error?.message?.includes('entirement');
241
287
  const errorMessage = isCardNotFullyInFrame ? t('kyc.idCardCapture.cardNotFullyInFrame') : (error?.message || 'Erreur de détection');
@@ -253,7 +299,7 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
253
299
  if (!countrySelectionData || !selectedDocumentType) {
254
300
  return (
255
301
  <View style={styles.root}>
256
- <View style={styles.container}>
302
+ <View style={styles.previewContainer}>
257
303
  <Text style={styles.title}>{getLocalizedText(component.labels)}</Text>
258
304
  <Text style={styles.description}>
259
305
  {state.currentLanguage === "en" ? "Please complete the country and document selection first." : "Veuillez d'abord compléter la sélection du pays et du document."}
@@ -265,84 +311,96 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
265
311
 
266
312
  // --- CAMERA RENDER ---
267
313
  if (showCamera) {
268
- const isBusy = isProcessingCapture;
269
-
314
+ const isBusy = isProcessingCapture;
270
315
  return (
271
- <View style={styles.cameraContainer}>
272
- <EnhancedCameraView
273
- key={currentSide} // 🚨 BUG FIX: Forces the camera instance to completely reset when switching sides
274
- showCamera={true}
275
- isProcessing={isBusy}
276
- cameraType={cameraConfig.cameraType}
277
- style={styles.camera}
278
- onError={handleError}
279
- onSilentCapture={handleSilentCapture}
280
- silentCaptureResult={silentCaptureResult}
281
- overlayComponent={
282
- <>
283
- {!isBusy && silentCaptureResult.isAnalyzing && (
284
- <View style={styles.topAnalyzingPillContainer}>
285
- <View style={styles.topAnalyzingPill}>
286
- <ActivityIndicator size="small" color="white" />
287
- <Text style={styles.analyzingPillText}>
288
- {state.currentLanguage === 'en' ? 'Scanning...' : 'Analyse...'}
289
- </Text>
290
- </View>
291
- </View>
292
- )}
316
+ <View style={styles.root}>
317
+ <View style={styles.cameraWrapper}>
318
+
319
+ {/* Web/Desktop Clean Header */}
320
+ <View style={styles.headerContainer}>
321
+ <Text style={styles.headerTitle}>
322
+ {selectedDocumentType ? (locale === 'en' ? getDocumentTypeInfo(selectedDocumentType.type).name.en : getDocumentTypeInfo(selectedDocumentType.type).name.fr) : ''}
323
+ </Text>
324
+ <View style={styles.stepBadge}>
325
+ <Text style={styles.stepText}>
326
+ {t('kyc.idCardCapture.captureTitle', { side: currentSide === 'front' ? locale === 'en' ? 'Front' : 'Recto' : locale === 'en' ? 'Back' : 'Verso' })}
327
+ </Text>
328
+ </View>
329
+ </View>
293
330
 
294
- {isBusy && (
295
- <View style={StyleSheet.absoluteFillObject}>
296
- {processingImagePath && (
297
- <Image
298
- source={{ uri: processingImagePath.startsWith('file://') ? processingImagePath : `file://${processingImagePath}` }}
299
- style={StyleSheet.absoluteFillObject}
300
- resizeMode="cover"
301
- />
331
+ <View style={styles.cameraFeedContainer}>
332
+ <EnhancedCameraView
333
+ key={currentSide}
334
+ showCamera={true}
335
+ isProcessing={isBusy}
336
+ cameraType={cameraConfig.cameraType}
337
+ style={styles.camera}
338
+ onError={handleError}
339
+ onSilentCapture={handleSilentCapture}
340
+ silentCaptureResult={silentCaptureResult}
341
+ overlayComponent={
342
+ <>
343
+ {!isBusy && silentCaptureResult.isAnalyzing && (
344
+ <View style={styles.topAnalyzingPillContainer}>
345
+ <View style={styles.topAnalyzingPill}>
346
+ <ActivityIndicator size="small" color="white" />
347
+ <Text style={styles.analyzingPillText}>
348
+ {state.currentLanguage === 'en' ? 'Scanning...' : 'Analyse...'}
349
+ </Text>
350
+ </View>
351
+ </View>
302
352
  )}
303
- <View style={styles.processingOverlay}>
304
- <ActivityIndicator size="large" color="#2DBD60" />
305
- <Text style={styles.processingText}>
306
- {state.currentLanguage === 'en' ? 'Perfect!\nProcessing Document...' : 'Parfait!\nTraitement du document...'}
307
- </Text>
308
- </View>
309
- </View>
310
- )}
311
-
312
- <IdCardOverlay
313
- xMin={cameraConfig.overlay.bbox.xMin} yMin={cameraConfig.overlay.bbox.yMin} xMax={cameraConfig.overlay.bbox.xMax} yMax={cameraConfig.overlay.bbox.yMax}
314
- instructions={cameraConfig.overlay.guideText}
315
- cornerOpacity={cameraConfig.overlay.bbox.cornerRadius || 0 as number}
316
- isSuccess={silentCaptureResult.success}
317
- language={state.currentLanguage}
318
- stepperProps={{
319
- back: () => {
320
- if (currentSide === 'back') {
321
- setCurrentSide('front');
322
- setShowCamera(false);
323
- // 🚨 Clean up any residual state when going backwards
324
- setIsProcessingCapture(false);
325
- setProcessingImagePath(null);
326
-
327
- if (capturedImages['front']?.dir) {
328
- const frontImage = capturedImages['front'];
329
- setSilentCaptureResult((prev) => ({ ...prev, path: frontImage.dir, success: true, isAnalyzing: false, error: '', mrz: frontImage.mrz || '', templatePath: frontImage.templatePath || '', bbox: bboxBySide['front'] }));
330
- } else { setSilentCaptureResult((prev) => ({ ...prev, path: '', success: false, isAnalyzing: false, error: '', templatePath: '' })); }
331
- } else { actions.previousComponent(); }
332
- },
333
- selectedDocumentType: selectedDocumentType ? (locale === 'en' ? getDocumentTypeInfo(selectedDocumentType.type).name.en : getDocumentTypeInfo(selectedDocumentType.type).name.fr) : '',
334
- step: state.currentComponentIndex + 1, totalSteps: state.template.components.length, side: currentSide,
335
- }}
336
- />
337
- </>
338
- }
339
- />
340
-
341
- {silentCaptureResult.error && !isBusy ? (
342
- <View style={styles.floatingErrorBanner}>
343
- <Text style={styles.floatingErrorText}>{silentCaptureResult.error}</Text>
353
+ {isBusy && (
354
+ <View style={StyleSheet.absoluteFillObject}>
355
+ {processingImagePath && (
356
+ <Image
357
+ source={{ uri: processingImagePath.startsWith('file://') ? processingImagePath : `file://${processingImagePath}` }}
358
+ style={StyleSheet.absoluteFillObject}
359
+ resizeMode="cover"
360
+ />
361
+ )}
362
+ <View style={styles.processingOverlay}>
363
+ <ActivityIndicator size="large" color="#2DBD60" />
364
+ <Text style={styles.processingText}>
365
+ {state.currentLanguage === 'en' ? 'Perfect!\nProcessing Document...' : 'Parfait!\nTraitement du document...'}
366
+ </Text>
367
+ </View>
368
+ </View>
369
+ )}
370
+ <IdCardOverlay
371
+ xMin={cameraConfig.overlay.bbox.xMin} yMin={cameraConfig.overlay.bbox.yMin} xMax={cameraConfig.overlay.bbox.xMax} yMax={cameraConfig.overlay.bbox.yMax}
372
+ instructions={cameraConfig.overlay.guideText}
373
+ cornerOpacity={cameraConfig.overlay.bbox.cornerRadius || 0 as number}
374
+ isSuccess={silentCaptureResult.success}
375
+ language={state.currentLanguage}
376
+ stepperProps={{
377
+ back: () => {
378
+ if (currentSide === 'back') {
379
+ setCurrentSide('front');
380
+ setShowCamera(false);
381
+ setIsProcessingCapture(false);
382
+ setProcessingImagePath(null);
383
+ if (capturedImages['front']?.dir) {
384
+ const frontImage = capturedImages['front'];
385
+ setSilentCaptureResult((prev) => ({ ...prev, path: frontImage.dir, success: true, isAnalyzing: false, error: '', mrz: frontImage.mrz || '', templatePath: frontImage.templatePath || '', bbox: bboxBySide['front'] }));
386
+ } else { setSilentCaptureResult((prev) => ({ ...prev, path: '', success: false, isAnalyzing: false, error: '', templatePath: '' })); }
387
+ } else { actions.previousComponent(); }
388
+ },
389
+ selectedDocumentType: selectedDocumentType ? (locale === 'en' ? getDocumentTypeInfo(selectedDocumentType.type).name.en : getDocumentTypeInfo(selectedDocumentType.type).name.fr) : '',
390
+ step: state.currentComponentIndex + 1, totalSteps: state.template.components.length, side: currentSide,
391
+ }}
392
+ />
393
+ </>
394
+ }
395
+ />
396
+ {/* Elegant Floating Error Banner below the cutout */}
397
+ {silentCaptureResult.error && !isBusy ? (
398
+ <View style={styles.floatingErrorBanner}>
399
+ <Text style={styles.floatingErrorText}>⚠️ {silentCaptureResult.error}</Text>
400
+ </View>
401
+ ) : null}
344
402
  </View>
345
- ) : null}
403
+ </View>
346
404
  </View>
347
405
  );
348
406
  }
@@ -359,7 +417,6 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
359
417
  {getLocalizedText(component.instructions)}
360
418
  </Text>
361
419
  <View style={{ alignItems: 'center', justifyContent: 'center', flexDirection: "column", gap: 16 }}>
362
-
363
420
  {silentCaptureResult?.error === 'TOO_FAR_AWAY' && (
364
421
  <View style={styles.warningBanner}>
365
422
  <Text style={styles.warningText}>
@@ -367,7 +424,6 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
367
424
  </Text>
368
425
  </View>
369
426
  )}
370
-
371
427
  <View style={styles.imagePreviewWrapper}>
372
428
  {capturedImages[currentSide]?.dir ? (
373
429
  <Image source={{ uri: capturedImages[currentSide].dir }} style={styles.previewImage} />
@@ -375,7 +431,6 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
375
431
  <Image source={{ uri: silentCaptureResult.path }} style={styles.previewImage} />
376
432
  ) : null}
377
433
  </View>
378
-
379
434
  {!capturedImages[currentSide]?.dir && (
380
435
  <Button
381
436
  title={state.currentLanguage === "en" ? "Start Scanning" : "Commencer la numérisation"}
@@ -383,22 +438,19 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
383
438
  variant="primary" size="large" fullWidth
384
439
  />
385
440
  )}
386
-
387
441
  {capturedImages[currentSide]?.dir && (
388
442
  <>
389
443
  <Button title={t('kyc.idCardCapture.retakeButton')} onPress={() => retakePicture(currentSide)} variant="outline" size="medium" fullWidth />
390
444
  <Button title={t('common.next')} onPress={() => {
391
445
  if (!selectedDocumentType) { showAlert('Error', 'Document type not selected'); return; }
392
-
393
- // 🚨 BUG FIX: Clear all state before moving forward
394
- if (currentSide === 'back' || selectedDocumentType.type === 'passport') {
395
- actions.nextComponent();
396
- } else {
397
- setShowCamera(true);
398
- setCurrentSide('back');
399
- setSilentCaptureResult({ success: false, isAnalyzing: false, path: '', error: '' });
400
- setIsProcessingCapture(false);
401
- setProcessingImagePath(null);
446
+ if (currentSide === 'back' || selectedDocumentType.type === 'passport') {
447
+ actions.nextComponent();
448
+ } else {
449
+ setShowCamera(true);
450
+ setCurrentSide('back');
451
+ setSilentCaptureResult({ success: false, isAnalyzing: false, path: '', error: '', templatePath: undefined });
452
+ setIsProcessingCapture(false);
453
+ setProcessingImagePath(null);
402
454
  }
403
455
  }} variant="primary" size="large" fullWidth />
404
456
  </>
@@ -412,28 +464,130 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
412
464
  };
413
465
 
414
466
  const styles = StyleSheet.create({
415
- root: { flex: 1, maxWidth: 760, width: '100%' },
416
- container: { backgroundColor: 'white', margin: 10, borderRadius: 10, paddingVertical: 16, paddingHorizontal: 16, shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.35, shadowRadius: 4.84, elevation: 10 },
417
- cameraContainer: { flex: 1, width: '100%', height: '100%' },
418
- previewContainer: { width: '95%', backgroundColor: 'white', margin: 10, borderRadius: 10, paddingVertical: 16, paddingHorizontal: 16 },
419
- previewItemContainer: {},
420
- title: { fontSize: 24, fontWeight: 'bold', color: '#333', marginBottom: 8, textAlign: 'center' },
421
- description: { fontSize: 16, color: '#666', textAlign: 'center', marginBottom: 24, lineHeight: 22 },
422
- sideContainer: { marginBottom: 24 },
423
- sideTitle: { fontSize: 25, fontWeight: 'bold', color: '#000', marginBottom: 12, textAlign: 'center' },
424
- imagePreviewWrapper: { width: '100%', height: 200, borderRadius: 12, padding: 1, overflow: 'hidden', shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.18, shadowRadius: 8, elevation: 8, backgroundColor: '#000' },
425
- previewImage: { width: '100%', height: '100%', borderRadius: 12, resizeMode: 'contain' },
426
- floatingErrorBanner: { position: 'absolute', top: 60, left: '10%', right: '10%', backgroundColor: 'rgba(220, 38, 38, 0.95)', paddingVertical: 12, paddingHorizontal: 16, borderRadius: 8, alignItems: 'center', justifyContent: 'center', shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.3, shadowRadius: 5, elevation: 8, zIndex: 100 },
427
- floatingErrorText: { color: 'white', fontSize: 14, fontWeight: '700', textAlign: 'center' },
428
- processingOverlay: { flex: 1, backgroundColor: 'rgba(0, 0, 0, 0.6)', justifyContent: 'center', alignItems: 'center', zIndex: 9999 },
429
- processingText: { color: '#FFF', fontSize: 18, fontWeight: 'bold', marginTop: 16, textAlign: 'center' },
430
- warningBanner: { backgroundColor: '#FF9500', padding: 12, borderRadius: 8, width: '100%', shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.2, shadowRadius: 4, elevation: 4 },
431
- warningText: { color: 'white', fontWeight: 'bold', textAlign: 'center', fontSize: 16 },
432
- errorText: { color: '#dc2626', fontSize: 14, marginTop: 8, textAlign: 'center' },
433
- topAnalyzingPillContainer: { position: 'absolute', top: Platform.OS === 'android' ? 60 : 50, left: 0, right: 0, alignItems: 'center', zIndex: 100 },
434
- topAnalyzingPill: { flexDirection: 'row', alignItems: 'center', backgroundColor: 'rgba(0,0,0,0.6)', paddingVertical: 8, paddingHorizontal: 16, borderRadius: 20, gap: 8 },
435
- analyzingPillText: { color: 'white', fontSize: 14, fontWeight: 'bold' },
467
+ root: {
468
+ flex: 1,
469
+ width: '100%',
470
+ backgroundColor: 'transparent',
471
+ alignSelf: 'center',
472
+ ...(Platform.OS === 'web'
473
+ ? ({
474
+ minHeight: '85vh',
475
+ justifyContent: 'center',
476
+ alignItems: 'center',
477
+ // Note: backdropFilter is valid in React Native Web but TS might complain, cast safely
478
+ backdropFilter: 'blur(8px)'
479
+ } as any)
480
+ : {})
481
+ },
482
+ cameraWrapper: {
483
+ width: '100%',
484
+ backgroundColor: '#FFFFFF',
485
+ overflow: 'hidden',
486
+ ...(Platform.OS === 'web'
487
+ ? ({
488
+ maxWidth: 500,
489
+ height: 700,
490
+ maxHeight: '90vh', // TypeScript will now ignore this thanks to the cast below
491
+ borderRadius: 24,
492
+ shadowColor: '#000',
493
+ shadowOffset: { width: 0, height: 20 },
494
+ shadowOpacity: 0.25,
495
+ shadowRadius: 35,
496
+ elevation: 24,
497
+ } as any) // 🚨 CAST TO ANY
498
+ : {
499
+ flex: 1,
500
+ })
501
+ },
502
+ headerContainer: {
503
+ flexDirection: 'row',
504
+ alignItems: 'center',
505
+ justifyContent: 'space-between',
506
+ paddingHorizontal: 24,
507
+ paddingVertical: 18,
508
+ backgroundColor: '#FFFFFF',
509
+ borderBottomWidth: 1,
510
+ borderBottomColor: '#F1F5F9',
511
+ zIndex: 10,
512
+ // Mobile hidden, Web visible to replace floating text
513
+ ...(Platform.OS !== 'web' ? { display: 'none' } : {})
514
+ },
515
+ headerTitle: {
516
+ fontSize: 18,
517
+ fontWeight: '700',
518
+ color: '#0F172A',
519
+ },
520
+ stepBadge: {
521
+ backgroundColor: '#F1F5F9',
522
+ paddingHorizontal: 12,
523
+ paddingVertical: 6,
524
+ borderRadius: 20,
525
+ },
526
+ stepText: {
527
+ fontSize: 13,
528
+ fontWeight: '600',
529
+ color: '#64748B',
530
+ },
531
+ cameraFeedContainer: {
532
+ flex: 1,
533
+ position: 'relative',
534
+ backgroundColor: '#000',
535
+ },
436
536
  camera: {
437
537
  flex: 1,
438
538
  },
539
+ previewContainer: {
540
+ width: '100%',
541
+ backgroundColor: 'white',
542
+ borderRadius: 12,
543
+ paddingVertical: 24,
544
+ paddingHorizontal: 20,
545
+ shadowColor: '#000',
546
+ shadowOffset: { width: 0, height: 4 },
547
+ shadowOpacity: 0.1,
548
+ shadowRadius: 12,
549
+ elevation: 8,
550
+ ...(Platform.OS === 'web' ? { alignSelf: 'center', maxWidth: 600 } : { margin: 10, width: '95%' })
551
+ },
552
+ previewItemContainer: {
553
+ flexGrow: 1,
554
+ },
555
+ title: { fontSize: 24, fontWeight: 'bold', color: '#333', marginBottom: 8, textAlign: 'center' },
556
+ description: { fontSize: 16, color: '#666', textAlign: 'center', marginBottom: 24, lineHeight: 22 },
557
+ sideContainer: { marginBottom: 24 },
558
+ sideTitle: { fontSize: 25, fontWeight: 'bold', color: '#000', marginBottom: 12, textAlign: 'center' },
559
+ imagePreviewWrapper: {
560
+ width: '100%', height: 220, borderRadius: 12, padding: 1, overflow: 'hidden',
561
+ shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.18, shadowRadius: 8, elevation: 8, backgroundColor: '#f0f0f0'
562
+ },
563
+ previewImage: { width: '100%', height: '100%', borderRadius: 12, resizeMode: 'cover' },
564
+ floatingErrorBanner: {
565
+ position: 'absolute',
566
+ bottom: 30, // Pushed to the bottom for professional feel
567
+ left: 24,
568
+ right: 24,
569
+ backgroundColor: '#FEF2F2',
570
+ borderWidth: 1,
571
+ borderColor: '#FCA5A5',
572
+ paddingVertical: 12,
573
+ paddingHorizontal: 16,
574
+ borderRadius: 12,
575
+ alignItems: 'center',
576
+ justifyContent: 'center',
577
+ shadowColor: '#DC2626',
578
+ shadowOffset: { width: 0, height: 4 },
579
+ shadowOpacity: 0.1,
580
+ shadowRadius: 8,
581
+ elevation: 8,
582
+ zIndex: 100
583
+ },
584
+ floatingErrorText: { color: '#991B1B', fontSize: 14, fontWeight: '700', textAlign: 'center' },
585
+ processingOverlay: { flex: 1, backgroundColor: 'rgba(0, 0, 0, 0.6)', justifyContent: 'center', alignItems: 'center', zIndex: 9999 },
586
+ processingText: { color: '#FFF', fontSize: 18, fontWeight: 'bold', marginTop: 16, textAlign: 'center' },
587
+ warningBanner: { backgroundColor: '#FF9500', padding: 12, borderRadius: 8, width: '100%', shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.2, shadowRadius: 4, elevation: 4 },
588
+ warningText: { color: 'white', fontWeight: 'bold', textAlign: 'center', fontSize: 16 },
589
+ errorText: { color: '#dc2626', fontSize: 14, marginTop: 8, textAlign: 'center' },
590
+ topAnalyzingPillContainer: { position: 'absolute', top: Platform.OS === 'android' ? 60 : 50, left: 0, right: 0, alignItems: 'center', zIndex: 100 },
591
+ topAnalyzingPill: { flexDirection: 'row', alignItems: 'center', backgroundColor: 'rgba(0,0,0,0.6)', paddingVertical: 8, paddingHorizontal: 16, borderRadius: 20, gap: 8 },
592
+ analyzingPillText: { color: 'white', fontSize: 14, fontWeight: 'bold' }
439
593
  });