@sanctum-key/react-native-sdk 1.0.11 → 1.0.13

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, useState } from 'react';
2
- import { View, Text, StyleSheet, Image, ScrollView, Platform, ActivityIndicator, TouchableOpacity } from 'react-native';
2
+ import { View, Text, StyleSheet, Image, ScrollView, Platform, ActivityIndicator, TouchableOpacity } from 'react-native'; // 🚨 Added TouchableOpacity
3
3
  import { showAlert } from '../../utils/platformAlert';
4
4
  import { EnhancedCameraView } from '../EnhancedCameraView';
5
5
  import { TemplateComponent, LocalizedText, GovernmentDocumentType, ISilentCaptureResult, IBbox, GovernmentDocumentTypeShorted, GovernmentDocumentTypeBackend } from '../../types/KYC.types';
@@ -14,21 +14,10 @@ import pathToBase64 from '../../utils/pathToBase64';
14
14
  import { cropByObb, cropImageWithBBoxWithTolerance, getObbConfidence, OBB_CONFIDENCE_THRESHOLD } from '../../utils/cropByObb';
15
15
  import REGION_MAPPING from '../../config/region_mapping.json';
16
16
 
17
- const ISO_TO_COUNTRY_NAME: Record<string, string> = {
18
- 'KE': 'Kenya', 'CM': 'Cameroon', 'NG': 'Nigeria', 'CA': 'Canada',
19
- 'FR': 'France', 'GH': 'Ghana', 'ZA': 'South Africa', 'GB': 'Britain',
20
- 'CI': 'Ivory Coast', 'SN': 'Senegal', 'TG': 'Togo', 'ML': 'Mali'
21
- };
17
+ const ISO_TO_COUNTRY_NAME: Record<string, string> = { 'KE': 'Kenya', 'CM': 'Cameroon', 'NG': 'Nigeria', 'CA': 'Canada', 'FR': 'France', 'GH': 'Ghana', 'ZA': 'South Africa', 'GB': 'Britain', 'CI': 'Ivory Coast', 'SN': 'Senegal', 'TG': 'Togo', 'ML': 'Mali' };
22
18
 
23
19
  interface IIDCardPayload { dir: string; file: string; mrz: string; templatePath?: string; }
24
- interface IDCardCaptureProps {
25
- component: TemplateComponent;
26
- value?: Record<string, IIDCardPayload>;
27
- onValueChange: (value: Record<string, IIDCardPayload | string>) => void;
28
- error?: string;
29
- language?: string;
30
- currentSide?: string;
31
- }
20
+ interface IDCardCaptureProps { component: TemplateComponent; value?: Record<string, IIDCardPayload>; onValueChange: (value: Record<string, IIDCardPayload | string>) => void; error?: string; language?: string; currentSide?: string; }
32
21
 
33
22
  export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value = {}, onValueChange, error, language = 'en' }) => {
34
23
  const { t, locale } = useI18n();
@@ -39,14 +28,11 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
39
28
  const [silentCaptureResult, setSilentCaptureResult] = useState<ISilentCaptureResult>({ success: false, isAnalyzing: false });
40
29
  const [isProcessingCapture, setIsProcessingCapture] = useState(false);
41
30
  const [processingImagePath, setProcessingImagePath] = useState<string | null>(null);
31
+
32
+ // 🚨 ADDED: Key to force camera re-mount
42
33
  const [cameraKey, setCameraKey] = useState(0);
43
34
 
44
-
45
- const documentTypeMapping: Record<string, GovernmentDocumentType> = {
46
- 'nationalId': 'national_id', 'passport': 'passport', 'driversLicense': 'drivers_licence',
47
- 'residencePermit': 'permanent_residence', 'healthInsuranceCard': 'health_insurance_card',
48
- };
49
-
35
+ const documentTypeMapping: Record<string, GovernmentDocumentType> = { 'nationalId': 'national_id', 'passport': 'passport', 'driversLicense': 'drivers_licence', 'residencePermit': 'permanent_residence', 'healthInsuranceCard': 'health_insurance_card', };
50
36
  const { actions, state, env } = useTemplateKYCFlowContext();
51
37
 
52
38
  const getLocalizedText = (text: LocalizedText | Record<string, LocalizedText>): string => {
@@ -54,10 +40,6 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
54
40
  return "";
55
41
  };
56
42
 
57
- const refreshCamera = () => {
58
- setCameraKey(prev => prev + 1);
59
- };
60
-
61
43
  const countrySelectionData = useMemo(() => {
62
44
  const countrySelectionComponent = state.template.components.find(c => c.type === 'country_selection');
63
45
  return countrySelectionComponent ? state.componentData[countrySelectionComponent.id] : null;
@@ -72,21 +54,28 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
72
54
 
73
55
  const countryData = useMemo(() => countrySelectionData, [countrySelectionData]);
74
56
 
57
+ const [isRebootingCamera, setIsRebootingCamera] = useState(false);
58
+
59
+ const refreshCamera = () => {
60
+ setIsRebootingCamera(true);
61
+
62
+ setTimeout(() => {
63
+ setCameraKey(prev => prev + 1);
64
+ setIsRebootingCamera(false);
65
+ }, 500);
66
+ };
67
+
68
+
69
+
75
70
  useEffect(() => {
76
71
  if (value && Object.keys(value).length > 0) {
77
72
  if (JSON.stringify(value) !== JSON.stringify(capturedImages)) {
78
73
  const updatedImages = value as Record<string, IIDCardPayload>;
79
74
  setCapturedImages(updatedImages);
80
-
81
75
  const currentImageData = updatedImages[currentSide];
82
76
  if (currentImageData?.dir) {
83
77
  setSilentCaptureResult(prev => ({
84
- ...prev,
85
- path: currentImageData.dir,
86
- success: true,
87
- isAnalyzing: false,
88
- mrz: currentImageData.mrz || '',
89
- templatePath: currentImageData.templatePath || '',
78
+ ...prev, path: currentImageData.dir, success: true, isAnalyzing: false, mrz: currentImageData.mrz || '', templatePath: currentImageData.templatePath || '',
90
79
  }));
91
80
  }
92
81
  }
@@ -94,9 +83,7 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
94
83
  }, [value, currentSide]);
95
84
 
96
85
  const cameraConfig = useMemo(() => {
97
- const instructions = selectedDocumentType
98
- ? (locale === 'en' ? getDocumentTypeInfo(selectedDocumentType.type).instructions.en : getDocumentTypeInfo(selectedDocumentType.type).instructions.fr)
99
- : getLocalizedText(component.instructions as Record<string, LocalizedText>);
86
+ const instructions = selectedDocumentType ? (locale === 'en' ? getDocumentTypeInfo(selectedDocumentType.type).instructions.en : getDocumentTypeInfo(selectedDocumentType.type).instructions.fr) : getLocalizedText(component.instructions as Record<string, LocalizedText>);
100
87
  return {
101
88
  cameraType: 'back' as const, flashMode: 'auto' as const,
102
89
  overlay: { guideText: instructions, bbox: { xMin: 15, yMin: 20, xMax: 85, yMax: 70, borderColor: '#2DBD60', borderWidth: 3, cornerRadius: 8 } }
@@ -114,36 +101,21 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
114
101
  };
115
102
 
116
103
  const getCurrentSideVerification = (currentSide: string, countryKey: string) => {
117
- const rawDocType = countrySelectionData?.documentType;
118
-
119
- if (!rawDocType || !countryKey) {
120
- return { authMethod: [], mrzTypes: [], regionMapping: null, key: 'root' };
121
- }
122
-
104
+ const rawDocType = countrySelectionData?.documentType;
105
+ if (!rawDocType || !countryKey) { return { authMethod: [], mrzTypes: [], regionMapping: null, key: 'root' }; }
123
106
  const rawCountryName = ISO_TO_COUNTRY_NAME[countryData?.code || ''] || countryData?.code || countryKey;
124
107
  const baseMapping = (REGION_MAPPING as any).regionMapping || REGION_MAPPING;
125
-
126
108
  let countryMapping = baseMapping[rawCountryName];
127
-
128
- // Fallback search in case of case mismatches
129
- if (!countryMapping) {
130
- const foundKey = Object.keys(baseMapping).find(k => k.toLowerCase() === rawCountryName.toLowerCase() || k.toLowerCase() === countryKey.toLowerCase());
131
- if (foundKey) countryMapping = baseMapping[foundKey];
132
- }
133
-
134
109
  if (!countryMapping) {
135
- return { authMethod: [], mrzTypes: [], regionMapping: null, key: 'root' };
110
+ const foundKey = Object.keys(baseMapping).find(k => k.toLowerCase() === rawCountryName.toLowerCase() || k.toLowerCase() === countryKey.toLowerCase());
111
+ if (foundKey) countryMapping = baseMapping[foundKey];
136
112
  }
137
-
113
+ if (!countryMapping) { return { authMethod: [], mrzTypes: [], regionMapping: null, key: 'root' }; }
138
114
  const regionMapping = countryMapping[rawDocType];
139
- if (!regionMapping) {
140
- return { authMethod: [], mrzTypes: [], regionMapping: null, key: 'root' };
141
- }
142
-
143
- const authMethod: string[] = [];
115
+ if (!regionMapping) { return { authMethod: [], mrzTypes: [], regionMapping: null, key: 'root' }; }
116
+ const authMethod: string[] = [];
144
117
  const mrzTypes: string[] = [];
145
118
  const key = countrySelectionData.region?.trim()?.length > 0 ? countrySelectionData.region.trim() : 'root';
146
-
147
119
  if (regionMapping?.[key] && Array.isArray(regionMapping[key])) {
148
120
  regionMapping[key].forEach((item: any) => {
149
121
  if (item[currentSide]) {
@@ -152,7 +124,6 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
152
124
  }
153
125
  });
154
126
  }
155
-
156
127
  return { authMethod: removeDuplicates(authMethod), mrzTypes: removeDuplicates(mrzTypes), regionMapping, key };
157
128
  }
158
129
 
@@ -219,16 +190,13 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
219
190
  if (silentCaptureResult.isAnalyzing || isProcessingCapture) return;
220
191
  if (result.success && result.path) {
221
192
  setSilentCaptureResult((prev) => ({ ...prev, isAnalyzing: true, success: false, error: '' }));
222
-
223
- // 🚨 Force a template fetch if we haven't successfully saved the current side yet
224
193
  let templatePath = capturedImages[currentSide]?.templatePath || '';
225
194
  let templateBbox: IBbox | undefined;
226
195
  let templateResponse: any;
227
-
228
196
  if (!selectedDocumentType) {
229
- setSilentCaptureResult((prev) => ({ ...prev, isAnalyzing: false, success: false, error: 'Document type not selected' })); return;
197
+ setSilentCaptureResult((prev) => ({ ...prev, isAnalyzing: false, success: false, error: 'Document type not selected' }));
198
+ return;
230
199
  }
231
-
232
200
  try {
233
201
  if (!templatePath) {
234
202
  const templateType = await checkTemplateType({ path: result.path || '', docType: selectedDocumentType?.type as GovernmentDocumentType, docRegion: countryData?.code || "", postfix: currentSide }, env);
@@ -246,10 +214,8 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
246
214
  } catch { }
247
215
  }
248
216
  }
249
-
250
217
  const extractedCountryKey = templatePath ? templatePath.split('/')[0] : (ISO_TO_COUNTRY_NAME[countryData?.code || ''] || 'root');
251
218
  const regionMappings = getCurrentSideVerification(currentSide, extractedCountryKey);
252
-
253
219
  let verificationRes: any;
254
220
  if (currentSide === 'front') {
255
221
  const matchedAuthMethod = getCorrespondingAuthMethod(templatePath, regionMappings.regionMapping, regionMappings.key || '', 'front');
@@ -257,36 +223,18 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
257
223
  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);
258
224
  } else {
259
225
  let matchedBackAuthMethod = getCorrespondingAuthMethod(templatePath, regionMappings.regionMapping, regionMappings.key || '', 'back');
260
-
261
226
  if (!matchedBackAuthMethod && currentSide === 'back') {
262
- matchedBackAuthMethod = 'MRZ';
227
+ matchedBackAuthMethod = 'MRZ';
263
228
  }
264
-
265
229
  const backMrzType = getCorrespondingMrzType(templatePath, regionMappings.regionMapping, regionMappings.key || '') || 'TD1';
266
-
267
- verificationRes = await backVerification({
268
- path: result.path,
269
- regionMapping: {
270
- authMethod: matchedBackAuthMethod ? [matchedBackAuthMethod] : regionMappings.authMethod,
271
- mrzTypes: regionMappings.mrzTypes
272
- },
273
- selectedDocumentType: GovernmentDocumentTypeShorted[selectedDocumentType.type as keyof typeof GovernmentDocumentTypeShorted] || '',
274
- code: countryData?.code || '',
275
- currentSide,
276
- templatePath,
277
- mrzType: backMrzType,
278
- templateResponse
279
- }, env);
230
+ 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);
280
231
  }
281
-
282
232
  const bbox = verificationRes?.bbox || templateBbox;
283
233
  const mrz = verificationRes?.mrz ? JSON.stringify(verificationRes.mrz) : "";
284
234
  const verifiedResult: ISilentCaptureResult = { path: result.path, templatePath, bbox, success: true, mrz, isAnalyzing: false, country: countryData?.code, documentType: selectedDocumentType.type };
285
-
286
235
  setSilentCaptureResult(verifiedResult);
287
236
  if (bbox) setBboxBySide(prev => ({ ...prev, [currentSide]: bbox }));
288
237
  await autoCapture(result.path, verifiedResult);
289
-
290
238
  } catch (error: any) {
291
239
  const isCardNotFullyInFrame = error?.message === 'CARD_NOT_FULLY_IN_FRAME' || error?.message?.includes('entirement');
292
240
  const errorMessage = isCardNotFullyInFrame ? t('kyc.idCardCapture.cardNotFullyInFrame') : (error?.message || 'Erreur de détection');
@@ -296,10 +244,14 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
296
244
  };
297
245
 
298
246
  const handleError = (event: { message: string }) => {
299
- showAlert('Erreur', event.message); setShowCamera(false); setIsProcessingCapture(false);
247
+ showAlert('Erreur', event.message);
248
+ setShowCamera(false);
249
+ setIsProcessingCapture(false);
300
250
  };
301
251
 
302
- useEffect(() => { actions.showCustomStepper(!showCamera); }, [showCamera]);
252
+ useEffect(() => {
253
+ actions.showCustomStepper(!showCamera);
254
+ }, [showCamera]);
303
255
 
304
256
  if (!countrySelectionData || !selectedDocumentType) {
305
257
  return (
@@ -314,105 +266,133 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
314
266
  );
315
267
  }
316
268
 
317
- // --- CAMERA RENDER ---
318
- if (showCamera) {
319
- const isBusy = isProcessingCapture;
269
+
270
+ // --- CAMERA RENDER ---
271
+ if (showCamera) {
272
+ const isBusy = isProcessingCapture;
273
+
274
+ if (isRebootingCamera) {
320
275
  return (
321
- <View style={styles.root}>
322
- <View style={styles.cameraWrapper}>
323
-
324
- {/* Web/Desktop Clean Header */}
325
- <View style={styles.headerContainer}>
326
- <Text style={styles.headerTitle}>
327
- {selectedDocumentType ? (locale === 'en' ? getDocumentTypeInfo(selectedDocumentType.type).name.en : getDocumentTypeInfo(selectedDocumentType.type).name.fr) : ''}
276
+ <View style={[styles.root, { justifyContent: 'center', alignItems: 'center', backgroundColor: '#000' }]}>
277
+ <ActivityIndicator size="large" color="#2DBD60" />
278
+ <Text style={{ color: 'white', marginTop: 20, fontSize: 16 }}>Initializing Camera...</Text>
279
+ </View>
280
+ );
281
+ }
282
+
283
+ return (
284
+ <View style={styles.root}>
285
+ <View style={[styles.cameraWrapper, { flex: 1, minHeight: 400 }]}>
286
+
287
+ <View style={styles.headerContainer}>
288
+ <Text style={styles.headerTitle}>
289
+ {selectedDocumentType ? (locale === 'en' ? getDocumentTypeInfo(selectedDocumentType.type).name.en : getDocumentTypeInfo(selectedDocumentType.type).name.fr) : ''}
290
+ </Text>
291
+ <View style={styles.stepBadge}>
292
+ <Text style={styles.stepText}>
293
+ {t('kyc.idCardCapture.captureTitle', { side: currentSide === 'front' ? locale === 'en' ? 'Front' : 'Recto' : locale === 'en' ? 'Back' : 'Verso' })}
328
294
  </Text>
329
- <View style={styles.stepBadge}>
330
- <Text style={styles.stepText}>
331
- {t('kyc.idCardCapture.captureTitle', { side: currentSide === 'front' ? locale === 'en' ? 'Front' : 'Recto' : locale === 'en' ? 'Back' : 'Verso' })}
332
- </Text>
333
- </View>
334
295
  </View>
296
+ </View>
297
+
298
+ <View style={[styles.cameraFeedContainer, { flex: 1, backgroundColor: '#000' }]}>
299
+
300
+ <EnhancedCameraView
301
+ key={`${currentSide}-${cameraKey}`}
302
+ showCamera={true}
303
+ isProcessing={isBusy}
304
+ cameraType={cameraConfig.cameraType}
305
+ style={styles.absoluteFillObject}
306
+ onError={handleError}
307
+ onSilentCapture={handleSilentCapture}
308
+ silentCaptureResult={silentCaptureResult}
309
+ overlayComponent={
310
+ <>
311
+ {/* We ONLY put the ID frame here, because if the camera fails, we don't care if the frame fails too */}
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
+ setIsProcessingCapture(false);
324
+ setProcessingImagePath(null);
325
+ if (capturedImages['front']?.dir) {
326
+ const frontImage = capturedImages['front'];
327
+ setSilentCaptureResult((prev) => ({ ...prev, path: frontImage.dir, success: true, isAnalyzing: false, error: '', mrz: frontImage.mrz || '', templatePath: frontImage.templatePath || '', bbox: bboxBySide['front'] }));
328
+ } else { setSilentCaptureResult((prev) => ({ ...prev, path: '', success: false, isAnalyzing: false, error: '', templatePath: '' })); }
329
+ } else { actions.previousComponent(); }
330
+ },
331
+ selectedDocumentType: selectedDocumentType ? (locale === 'en' ? getDocumentTypeInfo(selectedDocumentType.type).name.en : getDocumentTypeInfo(selectedDocumentType.type).name.fr) : '',
332
+ step: state.currentComponentIndex + 1, totalSteps: state.template.components.length, side: currentSide,
333
+ }}
334
+ />
335
+ </>
336
+ }
337
+ />
335
338
 
336
- <View style={styles.cameraFeedContainer}>
337
- <EnhancedCameraView
338
- key={`${currentSide}-${cameraKey}`}
339
- showCamera={true}
340
- isProcessing={isBusy}
341
- cameraType={cameraConfig.cameraType}
342
- style={styles.camera}
343
- onError={handleError}
344
- onSilentCapture={handleSilentCapture}
345
- silentCaptureResult={silentCaptureResult}
346
- overlayComponent={
347
- <>
348
- {!isBusy && silentCaptureResult.isAnalyzing && (
349
- <View style={styles.topAnalyzingPillContainer}>
350
- <View style={styles.topAnalyzingPill}>
351
- <ActivityIndicator size="small" color="white" />
352
- <Text style={styles.analyzingPillText}>
353
- {state.currentLanguage === 'en' ? 'Scanning...' : 'Analyse...'}
354
- </Text>
355
- </View>
356
- </View>
357
- )}
358
- {isBusy && (
359
- <View style={StyleSheet.absoluteFillObject}>
360
- {processingImagePath && (
361
- <Image
362
- source={{ uri: processingImagePath.startsWith('file://') ? processingImagePath : `file://${processingImagePath}` }}
363
- style={StyleSheet.absoluteFillObject}
364
- resizeMode="cover"
365
- />
366
- )}
367
- <View style={styles.processingOverlay}>
368
- <ActivityIndicator size="large" color="#2DBD60" />
369
- <Text style={styles.processingText}>
370
- {state.currentLanguage === 'en' ? 'Perfect!\nProcessing Document...' : 'Parfait!\nTraitement du document...'}
371
- </Text>
372
- </View>
373
- </View>
374
- )}
375
- <IdCardOverlay
376
- xMin={cameraConfig.overlay.bbox.xMin} yMin={cameraConfig.overlay.bbox.yMin} xMax={cameraConfig.overlay.bbox.xMax} yMax={cameraConfig.overlay.bbox.yMax}
377
- instructions={cameraConfig.overlay.guideText}
378
- cornerOpacity={cameraConfig.overlay.bbox.cornerRadius || 0 as number}
379
- isSuccess={silentCaptureResult.success}
380
- language={state.currentLanguage}
381
- stepperProps={{
382
- back: () => {
383
- if (currentSide === 'back') {
384
- setCurrentSide('front');
385
- setShowCamera(false);
386
- setIsProcessingCapture(false);
387
- setProcessingImagePath(null);
388
- if (capturedImages['front']?.dir) {
389
- const frontImage = capturedImages['front'];
390
- setSilentCaptureResult((prev) => ({ ...prev, path: frontImage.dir, success: true, isAnalyzing: false, error: '', mrz: frontImage.mrz || '', templatePath: frontImage.templatePath || '', bbox: bboxBySide['front'] }));
391
- } else { setSilentCaptureResult((prev) => ({ ...prev, path: '', success: false, isAnalyzing: false, error: '', templatePath: '' })); }
392
- } else { actions.previousComponent(); }
393
- },
394
- selectedDocumentType: selectedDocumentType ? (locale === 'en' ? getDocumentTypeInfo(selectedDocumentType.type).name.en : getDocumentTypeInfo(selectedDocumentType.type).name.fr) : '',
395
- step: state.currentComponentIndex + 1, totalSteps: state.template.components.length, side: currentSide,
396
- }}
397
- />
398
-
399
- <TouchableOpacity style={styles.refreshButton} onPress={refreshCamera}>
400
- <Text style={styles.refreshButtonText}>Refresh Camera</Text>
401
- </TouchableOpacity>
402
- </>
403
- }
404
- />
405
- {/* Elegant Floating Error Banner below the cutout */}
406
- {silentCaptureResult.error && !isBusy ? (
407
- <View style={styles.floatingErrorBanner}>
408
- <Text style={styles.floatingErrorText}>⚠️ {silentCaptureResult.error}</Text>
339
+
340
+ {!isBusy && silentCaptureResult.isAnalyzing && (
341
+ <View style={styles.topAnalyzingPillContainer}>
342
+ <View style={styles.topAnalyzingPill}>
343
+ <ActivityIndicator size="small" color="white" />
344
+ <Text style={styles.analyzingPillText}>
345
+ {state.currentLanguage === 'en' ? 'Scanning...' : 'Analyse...'}
346
+ </Text>
409
347
  </View>
410
- ) : null}
411
- </View>
348
+ </View>
349
+ )}
350
+
351
+ {isBusy && (
352
+ <View style={[StyleSheet.absoluteFillObject, { zIndex: 9999 }]}>
353
+ {processingImagePath && (
354
+ <Image
355
+ source={{ uri: processingImagePath.startsWith('file://') ? processingImagePath : `file://${processingImagePath}` }}
356
+ style={StyleSheet.absoluteFillObject}
357
+ resizeMode="cover"
358
+ />
359
+ )}
360
+ <View style={styles.processingOverlay}>
361
+ <ActivityIndicator size="large" color="#2DBD60" />
362
+ <Text style={styles.processingText}>
363
+ {state.currentLanguage === 'en' ? 'Perfect!\nProcessing Document...' : 'Parfait!\nTraitement du document...'}
364
+ </Text>
365
+ </View>
366
+ </View>
367
+ )}
368
+
369
+ {!isBusy && (
370
+ <View style={styles.escapeHatchContainer}>
371
+ {/* Refresh Button */}
372
+ <TouchableOpacity style={styles.fallbackRefreshButton} onPress={refreshCamera}>
373
+ <Text style={styles.fallbackRefreshText}>↻ Refresh Camera</Text>
374
+ </TouchableOpacity>
375
+
376
+ <TouchableOpacity
377
+ style={[styles.fallbackRefreshButton, { marginTop: 15, backgroundColor: 'rgba(220, 38, 38, 0.8)', borderColor: '#DC2626' }]}
378
+ onPress={() => setShowCamera(false)}
379
+ >
380
+ <Text style={styles.fallbackRefreshText}>Cancel / Go Back</Text>
381
+ </TouchableOpacity>
382
+ </View>
383
+ )}
384
+
385
+ {silentCaptureResult.error && !isBusy ? (
386
+ <View style={[styles.floatingErrorBanner, { zIndex: 10000 }]}>
387
+ <Text style={styles.floatingErrorText}>⚠️ {silentCaptureResult.error}</Text>
388
+ </View>
389
+ ) : null}
390
+
412
391
  </View>
413
392
  </View>
414
- );
415
- }
393
+ </View>
394
+ );
395
+ }
416
396
 
417
397
  return (
418
398
  <View style={styles.root}>
@@ -473,132 +453,82 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
473
453
  };
474
454
 
475
455
  const styles = StyleSheet.create({
476
- root: {
477
- flex: 1,
478
- width: '100%',
479
- backgroundColor: 'transparent',
480
- alignSelf: 'center',
481
- ...(Platform.OS === 'web'
482
- ? ({
483
- minHeight: '85vh',
484
- justifyContent: 'center',
485
- alignItems: 'center',
486
- // Note: backdropFilter is valid in React Native Web but TS might complain, cast safely
487
- backdropFilter: 'blur(8px)'
488
- } as any)
489
- : {})
456
+ root: {
457
+ flex: 1, width: '100%', backgroundColor: 'transparent', alignSelf: 'center',
458
+ ...(Platform.OS === 'web' ? ({ minHeight: '85vh', justifyContent: 'center', alignItems: 'center', backdropFilter: 'blur(8px)' } as any) : {})
490
459
  },
491
460
  cameraWrapper: {
492
- width: '100%',
493
- backgroundColor: '#FFFFFF',
494
- overflow: 'hidden',
495
- ...(Platform.OS === 'web'
496
- ? ({
497
- maxWidth: 500,
498
- height: 700,
499
- maxHeight: '90vh', // TypeScript will now ignore this thanks to the cast below
500
- borderRadius: 24,
501
- shadowColor: '#000',
502
- shadowOffset: { width: 0, height: 20 },
503
- shadowOpacity: 0.25,
504
- shadowRadius: 35,
505
- elevation: 24,
506
- } as any) // 🚨 CAST TO ANY
507
- : {
508
- flex: 1,
509
- })
461
+ width: '100%', backgroundColor: '#FFFFFF', overflow: 'hidden',
462
+ ...(Platform.OS === 'web' ? ({ maxWidth: 500, height: 700, maxHeight: '90vh', borderRadius: 24, shadowColor: '#000', shadowOffset: { width: 0, height: 20 }, shadowOpacity: 0.25, shadowRadius: 35, elevation: 24, } as any) : { flex: 1, })
510
463
  },
511
464
  headerContainer: {
512
- flexDirection: 'row',
513
- alignItems: 'center',
514
- justifyContent: 'space-between',
515
- paddingHorizontal: 24,
516
- paddingVertical: 18,
517
- backgroundColor: '#FFFFFF',
518
- borderBottomWidth: 1,
519
- borderBottomColor: '#F1F5F9',
520
- zIndex: 10,
521
- // Mobile hidden, Web visible to replace floating text
522
- ...(Platform.OS !== 'web' ? { display: 'none' } : {})
523
- },
524
- headerTitle: {
525
- fontSize: 18,
526
- fontWeight: '700',
527
- color: '#0F172A',
465
+ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 24, paddingVertical: 18, backgroundColor: '#FFFFFF', borderBottomWidth: 1, borderBottomColor: '#F1F5F9', zIndex: 10,
466
+ ...(Platform.OS !== 'web' ? { display: 'none' } : {})
528
467
  },
529
- stepBadge: {
530
- backgroundColor: '#F1F5F9',
531
- paddingHorizontal: 12,
532
- paddingVertical: 6,
533
- borderRadius: 20,
468
+ headerTitle: { fontSize: 18, fontWeight: '700', color: '#0F172A', },
469
+ stepBadge: { backgroundColor: '#F1F5F9', paddingHorizontal: 12, paddingVertical: 6, borderRadius: 20, },
470
+ stepText: { fontSize: 13, fontWeight: '600', color: '#64748B', },
471
+ cameraFeedContainer: { flex: 1, position: 'relative', backgroundColor: '#000', },
472
+ camera: { flex: 1, },
473
+ refreshButton: {
474
+ position: 'absolute', bottom: 100, alignSelf: 'center', backgroundColor: 'rgba(0,0,0,0.6)', paddingVertical: 12, paddingHorizontal: 24, borderRadius: 24, zIndex: 500
534
475
  },
535
- stepText: {
536
- fontSize: 13,
537
- fontWeight: '600',
538
- color: '#64748B',
476
+ refreshButtonText: { color: 'white', fontWeight: 'bold', fontSize: 16 },
477
+ previewContainer: {
478
+ width: '100%', backgroundColor: 'white', borderRadius: 12, paddingVertical: 24, paddingHorizontal: 20, shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.1, shadowRadius: 12, elevation: 8,
479
+ ...(Platform.OS === 'web' ? { alignSelf: 'center', maxWidth: 600 } : { margin: 10, width: '95%' })
539
480
  },
540
- cameraFeedContainer: {
541
- flex: 1,
542
- position: 'relative',
543
- backgroundColor: '#000',
481
+ previewItemContainer: { flexGrow: 1, },
482
+ title: { fontSize: 24, fontWeight: 'bold', color: '#333', marginBottom: 8, textAlign: 'center' },
483
+ description: { fontSize: 16, color: '#666', textAlign: 'center', marginBottom: 24, lineHeight: 22 },
484
+ sideContainer: { marginBottom: 24 },
485
+ sideTitle: { fontSize: 25, fontWeight: 'bold', color: '#000', marginBottom: 12, textAlign: 'center' },
486
+ imagePreviewWrapper: { width: '100%', height: 220, borderRadius: 12, padding: 1, overflow: 'hidden', shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.18, shadowRadius: 8, elevation: 8, backgroundColor: '#f0f0f0' },
487
+ previewImage: { width: '100%', height: '100%', borderRadius: 12, resizeMode: 'cover' },
488
+ floatingErrorBanner: {
489
+ position: 'absolute', bottom: 30, left: 24, right: 24, backgroundColor: '#FEF2F2', borderWidth: 1, borderColor: '#FCA5A5', paddingVertical: 12, paddingHorizontal: 16, borderRadius: 12, alignItems: 'center', justifyContent: 'center', shadowColor: '#DC2626', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.1, shadowRadius: 8, elevation: 8, zIndex: 100
544
490
  },
545
- camera: {
546
- flex: 1,
491
+ floatingErrorText: { color: '#991B1B', fontSize: 14, fontWeight: '700', textAlign: 'center' },
492
+ processingOverlay: { flex: 1, backgroundColor: 'rgba(0, 0, 0, 0.6)', justifyContent: 'center', alignItems: 'center', zIndex: 9999 },
493
+ processingText: { color: '#FFF', fontSize: 18, fontWeight: 'bold', marginTop: 16, textAlign: 'center' },
494
+ warningBanner: { backgroundColor: '#FF9500', padding: 12, borderRadius: 8, width: '100%', shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.2, shadowRadius: 4, elevation: 4 },
495
+ warningText: { color: 'white', fontWeight: 'bold', textAlign: 'center', fontSize: 16 },
496
+ errorText: { color: '#dc2626', fontSize: 14, marginTop: 8, textAlign: 'center' },
497
+ topAnalyzingPillContainer: { position: 'absolute', top: Platform.OS === 'android' ? 60 : 50, left: 0, right: 0, alignItems: 'center', zIndex: 100 },
498
+ topAnalyzingPill: { flexDirection: 'row', alignItems: 'center', backgroundColor: 'rgba(0,0,0,0.6)', paddingVertical: 8, paddingHorizontal: 16, borderRadius: 20, gap: 8 },
499
+ analyzingPillText: { color: 'white', fontSize: 14, fontWeight: 'bold' },
500
+ escapeHatchContainer: {
501
+ position: 'absolute',
502
+ bottom: 40,
503
+ left: 0,
504
+ right: 0,
505
+ alignItems: 'center',
506
+ justifyContent: 'center',
507
+ zIndex: 99999, // Guarantees it is the top-most element
547
508
  },
548
- previewContainer: {
549
- width: '100%',
550
- backgroundColor: 'white',
551
- borderRadius: 12,
552
- paddingVertical: 24,
553
- paddingHorizontal: 20,
554
- shadowColor: '#000',
555
- shadowOffset: { width: 0, height: 4 },
556
- shadowOpacity: 0.1,
557
- shadowRadius: 12,
558
- elevation: 8,
559
- ...(Platform.OS === 'web' ? { alignSelf: 'center', maxWidth: 600 } : { margin: 10, width: '95%' })
560
- },
561
- previewItemContainer: {
562
- flexGrow: 1,
563
- },
564
- title: { fontSize: 24, fontWeight: 'bold', color: '#333', marginBottom: 8, textAlign: 'center' },
565
- description: { fontSize: 16, color: '#666', textAlign: 'center', marginBottom: 24, lineHeight: 22 },
566
- sideContainer: { marginBottom: 24 },
567
- sideTitle: { fontSize: 25, fontWeight: 'bold', color: '#000', marginBottom: 12, textAlign: 'center' },
568
- imagePreviewWrapper: {
569
- width: '100%', height: 220, borderRadius: 12, padding: 1, overflow: 'hidden',
570
- shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.18, shadowRadius: 8, elevation: 8, backgroundColor: '#f0f0f0'
571
- },
572
- previewImage: { width: '100%', height: '100%', borderRadius: 12, resizeMode: 'cover' },
573
- floatingErrorBanner: {
574
- position: 'absolute',
575
- bottom: 30, // Pushed to the bottom for professional feel
576
- left: 24,
577
- right: 24,
578
- backgroundColor: '#FEF2F2',
509
+ fallbackRefreshButton: {
510
+ backgroundColor: 'rgba(0, 0, 0, 0.8)', // Darker so it's visible on white or black
579
511
  borderWidth: 1,
580
- borderColor: '#FCA5A5',
581
- paddingVertical: 12,
582
- paddingHorizontal: 16,
583
- borderRadius: 12,
584
- alignItems: 'center',
585
- justifyContent: 'center',
586
- shadowColor: '#DC2626',
587
- shadowOffset: { width: 0, height: 4 },
588
- shadowOpacity: 0.1,
589
- shadowRadius: 8,
590
- elevation: 8,
591
- zIndex: 100
592
- },
593
- floatingErrorText: { color: '#991B1B', fontSize: 14, fontWeight: '700', textAlign: 'center' },
594
- processingOverlay: { flex: 1, backgroundColor: 'rgba(0, 0, 0, 0.6)', justifyContent: 'center', alignItems: 'center', zIndex: 9999 },
595
- processingText: { color: '#FFF', fontSize: 18, fontWeight: 'bold', marginTop: 16, textAlign: 'center' },
596
- warningBanner: { backgroundColor: '#FF9500', padding: 12, borderRadius: 8, width: '100%', shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.2, shadowRadius: 4, elevation: 4 },
597
- warningText: { color: 'white', fontWeight: 'bold', textAlign: 'center', fontSize: 16 },
598
- errorText: { color: '#dc2626', fontSize: 14, marginTop: 8, textAlign: 'center' },
599
- topAnalyzingPillContainer: { position: 'absolute', top: Platform.OS === 'android' ? 60 : 50, left: 0, right: 0, alignItems: 'center', zIndex: 100 },
600
- topAnalyzingPill: { flexDirection: 'row', alignItems: 'center', backgroundColor: 'rgba(0,0,0,0.6)', paddingVertical: 8, paddingHorizontal: 16, borderRadius: 20, gap: 8 },
601
- analyzingPillText: { color: 'white', fontSize: 14, fontWeight: 'bold' },
602
- refreshButton: { position: 'absolute', bottom: 100, alignSelf: 'center', backgroundColor: 'rgba(0,0,0,0.5)', padding: 10, borderRadius: 20, zIndex: 500 },
603
- refreshButtonText: { color: 'white', fontWeight: 'bold' },
512
+ borderColor: 'rgba(255, 255, 255, 0.5)',
513
+ paddingVertical: 12,
514
+ paddingHorizontal: 24,
515
+ borderRadius: 24,
516
+ shadowColor: '#000',
517
+ shadowOffset: { width: 0, height: 4 },
518
+ shadowOpacity: 0.3,
519
+ shadowRadius: 5,
520
+ elevation: 8,
521
+ },
522
+ fallbackRefreshText: {
523
+ color: '#FFFFFF',
524
+ fontWeight: 'bold',
525
+ fontSize: 16,
526
+ },
527
+ absoluteFillObject: {
528
+ position: 'absolute',
529
+ top: 0,
530
+ left: 0,
531
+ right: 0,
532
+ bottom: 0,
533
+ },
604
534
  });