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

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'; // 🚨 Added TouchableOpacity
2
+ import { View, Text, StyleSheet, Image, ScrollView, Platform, ActivityIndicator, TouchableOpacity } from 'react-native';
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';
@@ -29,8 +29,10 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
29
29
  const [isProcessingCapture, setIsProcessingCapture] = useState(false);
30
30
  const [processingImagePath, setProcessingImagePath] = useState<string | null>(null);
31
31
 
32
- // 🚨 ADDED: Key to force camera re-mount
33
32
  const [cameraKey, setCameraKey] = useState(0);
33
+ const [isRebootingCamera, setIsRebootingCamera] = useState(false);
34
+ // Web specific state
35
+ const [cameraType, setCameraType] = useState<'back' | 'front'>('back');
34
36
 
35
37
  const documentTypeMapping: Record<string, GovernmentDocumentType> = { 'nationalId': 'national_id', 'passport': 'passport', 'driversLicense': 'drivers_licence', 'residencePermit': 'permanent_residence', 'healthInsuranceCard': 'health_insurance_card', };
36
38
  const { actions, state, env } = useTemplateKYCFlowContext();
@@ -54,18 +56,18 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
54
56
 
55
57
  const countryData = useMemo(() => countrySelectionData, [countrySelectionData]);
56
58
 
57
- const [isRebootingCamera, setIsRebootingCamera] = useState(false);
58
-
59
59
  const refreshCamera = () => {
60
60
  setIsRebootingCamera(true);
61
-
62
61
  setTimeout(() => {
63
62
  setCameraKey(prev => prev + 1);
64
63
  setIsRebootingCamera(false);
65
64
  }, 500);
66
65
  };
67
66
 
68
-
67
+ const toggleCameraLens = () => {
68
+ setCameraType(prev => prev === 'back' ? 'front' : 'back');
69
+ refreshCamera();
70
+ };
69
71
 
70
72
  useEffect(() => {
71
73
  if (value && Object.keys(value).length > 0) {
@@ -85,16 +87,21 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
85
87
  const cameraConfig = useMemo(() => {
86
88
  const instructions = selectedDocumentType ? (locale === 'en' ? getDocumentTypeInfo(selectedDocumentType.type).instructions.en : getDocumentTypeInfo(selectedDocumentType.type).instructions.fr) : getLocalizedText(component.instructions as Record<string, LocalizedText>);
87
89
  return {
88
- cameraType: 'back' as const, flashMode: 'auto' as const,
89
- overlay: { guideText: instructions, bbox: { xMin: 15, yMin: 20, xMax: 85, yMax: 70, borderColor: '#2DBD60', borderWidth: 3, cornerRadius: 8 } }
90
+ cameraType: Platform.OS === 'web' ? cameraType : 'back',
91
+ flashMode: 'auto' as const,
92
+ overlay: {
93
+ guideText: instructions,
94
+ bbox: { xMin: 5, yMin: 15, xMax: 95, yMax: 85, borderColor: '#2DBD60', borderWidth: 3, cornerRadius: 8 }
95
+ }
90
96
  };
91
- }, [selectedDocumentType, locale, component.instructions]);
97
+ }, [selectedDocumentType, locale, component.instructions, cameraType]);
92
98
 
93
99
  const retakePicture = (sideToRetake: 'front' | 'back') => {
94
100
  setIsProcessingCapture(false);
95
101
  setProcessingImagePath(null);
96
102
  setSilentCaptureResult({ path: '', success: false, isAnalyzing: false, error: '', templatePath: '', mrz: '', bbox: undefined });
97
103
  setShowCamera(true);
104
+ refreshCamera();
98
105
  actions.showCustomStepper(false);
99
106
  setCapturedImages((prev) => { const newState = { ...prev }; delete newState[sideToRetake]; return newState; });
100
107
  if (value) { const newValue = { ...value }; delete newValue[sideToRetake]; onValueChange(newValue); }
@@ -149,21 +156,36 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
149
156
  if (isProcessingCapture) return;
150
157
  setIsProcessingCapture(true);
151
158
  setProcessingImagePath(capturePath);
159
+
152
160
  try {
153
161
  let imagePathForUpload = capturePath;
154
- if (verified.bbox) {
155
- try {
156
- imagePathForUpload = await cropImageWithBBoxWithTolerance(capturePath, verified.bbox, 0.15);
157
- } catch {
158
- imagePathForUpload = capturePath;
159
- }
162
+
163
+ // 🚨 THE FIX: Ignore the backend's broken absolute pixels.
164
+ // Calculate the crop using the Green UI Frame percentages (0.0 to 1.0)
165
+ const overlayBbox = cameraConfig.overlay.bbox;
166
+ const uiCropBbox = {
167
+ minX: overlayBbox.xMin / 100,
168
+ minY: overlayBbox.yMin / 100,
169
+ width: (overlayBbox.xMax - overlayBbox.xMin) / 100,
170
+ height: (overlayBbox.yMax - overlayBbox.yMin) / 100,
171
+ };
172
+
173
+ try {
174
+ // Apply the crop using the UI percentages and a tight 2% tolerance
175
+ imagePathForUpload = await cropImageWithBBoxWithTolerance(capturePath, uiCropBbox, 0.02);
176
+ } catch (e) {
177
+ console.warn("Crop failed, falling back to original image", e);
178
+ imagePathForUpload = capturePath; // Fallback to raw image if crop fails
160
179
  }
180
+
161
181
  const base64 = await pathToBase64(imagePathForUpload);
162
182
  const newImages = {
163
183
  ...capturedImages,
164
184
  [currentSide]: { dir: imagePathForUpload, file: base64, mrz: verified.mrz || "", templatePath: verified.templatePath },
165
185
  };
186
+
166
187
  setCapturedImages(newImages);
188
+
167
189
  if (verified.country && verified.documentType) {
168
190
  onValueChange({
169
191
  ...newImages,
@@ -171,6 +193,7 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
171
193
  documentType: GovernmentDocumentTypeBackend[verified.documentType as keyof typeof GovernmentDocumentTypeBackend] || '',
172
194
  });
173
195
  }
196
+
174
197
  setTimeout(() => {
175
198
  setShowCamera(false);
176
199
  actions.showCustomStepper(true);
@@ -178,8 +201,11 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
178
201
  setIsProcessingCapture(false);
179
202
  setProcessingImagePath(null);
180
203
  }, 600);
204
+
181
205
  } catch (e: any) {
182
- showAlert('Error', e?.message || 'Impossible de capturer la photo');
206
+ console.error("Backend Error:", e);
207
+ const friendlyError = state.currentLanguage === 'en' ? 'Unable to process document. Please try again.' : 'Impossible de traiter le document. Veuillez réessayer.';
208
+ showAlert('Error', friendlyError);
183
209
  setSilentCaptureResult(prev => ({ ...prev, isAnalyzing: false, success: false }));
184
210
  setIsProcessingCapture(false);
185
211
  setProcessingImagePath(null);
@@ -236,9 +262,29 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
236
262
  if (bbox) setBboxBySide(prev => ({ ...prev, [currentSide]: bbox }));
237
263
  await autoCapture(result.path, verifiedResult);
238
264
  } catch (error: any) {
239
- const isCardNotFullyInFrame = error?.message === 'CARD_NOT_FULLY_IN_FRAME' || error?.message?.includes('entirement');
240
- const errorMessage = isCardNotFullyInFrame ? t('kyc.idCardCapture.cardNotFullyInFrame') : (error?.message || 'Erreur de détection');
241
- setSilentCaptureResult(prev => ({ ...prev, isAnalyzing: false, success: false, error: errorMessage }));
265
+ // 1. Keep the technical log for your debugging console
266
+ console.error("Backend Verification Error:", error);
267
+
268
+ // 2. Define a map of technical keywords to user-friendly messages
269
+ const rawMessage = (error?.message || '').toLowerCase();
270
+
271
+ let userFriendlyMessage ='Verification failed. Please try again.';
272
+
273
+ if (rawMessage.includes('impossible') || rawMessage.includes('unreadable')) {
274
+ userFriendlyMessage = 'Document unreadable. Please hold the camera steady in good light.';
275
+ } else if (rawMessage.includes('country mismatch') || rawMessage.includes('pays sélectionné')) {
276
+ userFriendlyMessage = 'The document does not match your selected country.';
277
+ } else if (rawMessage.includes('too far') || rawMessage.includes('card_not_fully_in_frame')) {
278
+ userFriendlyMessage = 'Move closer to the document and ensure all corners are visible.';
279
+ }
280
+
281
+ // 3. Set ONLY the friendly message to the UI state
282
+ setSilentCaptureResult(prev => ({
283
+ ...prev,
284
+ isAnalyzing: false,
285
+ success: false,
286
+ error: userFriendlyMessage
287
+ }));
242
288
  }
243
289
  }
244
290
  };
@@ -266,134 +312,157 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
266
312
  );
267
313
  }
268
314
 
315
+ // --- CAMERA RENDER ---
316
+ if (showCamera) {
317
+ const isBusy = isProcessingCapture;
269
318
 
270
- // --- CAMERA RENDER ---
271
- if (showCamera) {
272
- const isBusy = isProcessingCapture;
319
+ if (isRebootingCamera) {
320
+ return (
321
+ <View style={[styles.root, { justifyContent: 'center', alignItems: 'center', backgroundColor: '#000' }]}>
322
+ <ActivityIndicator size="large" color="#2DBD60" />
323
+ <Text style={{ color: 'white', marginTop: 20, fontSize: 16 }}>Initializing Camera...</Text>
324
+ </View>
325
+ );
326
+ }
273
327
 
274
- if (isRebootingCamera) {
275
328
  return (
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' })}
329
+ <View style={styles.root}>
330
+ <View style={[styles.cameraWrapper, { flex: 1 }]}>
331
+
332
+ <View style={styles.headerContainer}>
333
+ <Text style={styles.headerTitle}>
334
+ {selectedDocumentType ? (locale === 'en' ? getDocumentTypeInfo(selectedDocumentType.type).name.en : getDocumentTypeInfo(selectedDocumentType.type).name.fr) : ''}
294
335
  </Text>
336
+ <View style={styles.stepBadge}>
337
+ <Text style={styles.stepText}>
338
+ {t('kyc.idCardCapture.captureTitle', { side: currentSide === 'front' ? locale === 'en' ? 'Front' : 'Recto' : locale === 'en' ? 'Back' : 'Verso' })}
339
+ </Text>
340
+ </View>
295
341
  </View>
296
- </View>
297
-
298
- <View style={[styles.cameraFeedContainer, { flex: 1, backgroundColor: '#000' }]}>
299
342
 
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
- />
338
-
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>
343
+ <View style={[styles.cameraFeedContainer, { flex: 1, backgroundColor: '#000' }]}>
344
+
345
+ {/* WEB ONLY: Flip Camera Top Button */}
346
+ {Platform.OS === 'web' && (
347
+ <View style={styles.webTopControls}>
348
+ <TouchableOpacity style={styles.webFlipButton} onPress={toggleCameraLens}>
349
+ <Text style={styles.webFlipText}>🔄 Flip Lens</Text>
350
+ </TouchableOpacity>
347
351
  </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>
352
+ )}
353
+
354
+ <EnhancedCameraView
355
+ key={`${currentSide}-${cameraKey}`}
356
+ showCamera={true}
357
+ isProcessing={isBusy}
358
+ cameraType={cameraConfig.cameraType}
359
+ style={StyleSheet.absoluteFillObject}
360
+ onError={handleError}
361
+ onSilentCapture={handleSilentCapture}
362
+ silentCaptureResult={silentCaptureResult}
363
+ overlayComponent={
364
+ <>
365
+ <IdCardOverlay
366
+ xMin={cameraConfig.overlay.bbox.xMin} yMin={cameraConfig.overlay.bbox.yMin} xMax={cameraConfig.overlay.bbox.xMax} yMax={cameraConfig.overlay.bbox.yMax}
367
+ instructions={cameraConfig.overlay.guideText}
368
+ cornerOpacity={cameraConfig.overlay.bbox.cornerRadius || 0 as number}
369
+ isSuccess={silentCaptureResult.success}
370
+ language={state.currentLanguage}
371
+ stepperProps={{
372
+ back: () => {
373
+ if (currentSide === 'back') {
374
+ setCurrentSide('front');
375
+ setShowCamera(false);
376
+ setIsProcessingCapture(false);
377
+ setProcessingImagePath(null);
378
+ if (capturedImages['front']?.dir) {
379
+ const frontImage = capturedImages['front'];
380
+ setSilentCaptureResult((prev) => ({ ...prev, path: frontImage.dir, success: true, isAnalyzing: false, error: '', mrz: frontImage.mrz || '', templatePath: frontImage.templatePath || '', bbox: bboxBySide['front'] }));
381
+ } else { setSilentCaptureResult((prev) => ({ ...prev, path: '', success: false, isAnalyzing: false, error: '', templatePath: '' })); }
382
+ } else { actions.previousComponent(); }
383
+ },
384
+ selectedDocumentType: selectedDocumentType ? (locale === 'en' ? getDocumentTypeInfo(selectedDocumentType.type).name.en : getDocumentTypeInfo(selectedDocumentType.type).name.fr) : '',
385
+ step: state.currentComponentIndex + 1, totalSteps: state.template.components.length, side: currentSide,
386
+ }}
387
+ />
388
+ </>
389
+ }
390
+ />
391
+
392
+ {!isBusy && silentCaptureResult.isAnalyzing && (
393
+ <View style={styles.topAnalyzingPillContainer}>
394
+ <View style={styles.topAnalyzingPill}>
395
+ <ActivityIndicator size="small" color="white" />
396
+ <Text style={styles.analyzingPillText}>
397
+ {state.currentLanguage === 'en' ? 'Scanning...' : 'Analyse...'}
398
+ </Text>
399
+ </View>
365
400
  </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}
401
+ )}
402
+
403
+ {isBusy && (
404
+ <View style={[StyleSheet.absoluteFillObject, { zIndex: 9999 }]}>
405
+ {processingImagePath && (
406
+ <Image
407
+ source={{ uri: processingImagePath.startsWith('file://') ? processingImagePath : `file://${processingImagePath}` }}
408
+ style={StyleSheet.absoluteFillObject}
409
+ resizeMode="cover"
410
+ />
411
+ )}
412
+ <View style={styles.processingOverlay}>
413
+ <ActivityIndicator size="large" color="#2DBD60" />
414
+ <Text style={styles.processingText}>
415
+ {state.currentLanguage === 'en' ? 'Perfect!\nProcessing Document...' : 'Parfait!\nTraitement du document...'}
416
+ </Text>
417
+ </View>
418
+ </View>
419
+ )}
420
+
421
+ {/* 🚨 THE ESCAPE HATCH: Branching UI for Web vs Mobile */}
422
+ {!isBusy && Platform.OS === 'web' ? (
423
+ <View style={styles.webBottomControlBar}>
424
+ {silentCaptureResult.error ? (
425
+ <View style={styles.floatingErrorBanner}>
426
+ <Text style={styles.floatingErrorText}>⚠️ {silentCaptureResult.error}</Text>
427
+ </View>
428
+ ) : <View style={{ height: 10 }} />}
429
+
430
+ <View style={styles.webActionButtonsRow}>
431
+ <TouchableOpacity style={styles.webSecondaryButton} onPress={() => setShowCamera(false)}>
432
+ <Text style={styles.webSecondaryButtonText}>Cancel</Text>
433
+ </TouchableOpacity>
434
+ <TouchableOpacity style={styles.webPrimaryButton} onPress={refreshCamera}>
435
+ <Text style={styles.webPrimaryButtonText}>↻ Refresh Camera</Text>
436
+ </TouchableOpacity>
437
+ </View>
438
+ </View>
439
+ ) : !isBusy ? (
440
+ <View style={styles.escapeHatchContainer}>
441
+ <TouchableOpacity style={styles.fallbackRefreshButton} onPress={refreshCamera}>
442
+ <Text style={styles.fallbackRefreshText}>↻ Refresh Camera</Text>
443
+ </TouchableOpacity>
444
+ <TouchableOpacity
445
+ style={[styles.fallbackRefreshButton, { marginTop: 15, backgroundColor: 'rgba(220, 38, 38, 0.8)', borderColor: '#DC2626' }]}
446
+ onPress={() => setShowCamera(false)}
447
+ >
448
+ <Text style={styles.fallbackRefreshText}>Cancel / Go Back</Text>
449
+ </TouchableOpacity>
450
+ </View>
451
+ ) : null}
452
+
453
+ {silentCaptureResult.error && !isBusy && Platform.OS !== 'web' ? (
454
+ <View style={[styles.floatingErrorBanner, { zIndex: 10000 }]}>
455
+ <Text style={styles.floatingErrorText}>⚠️ {silentCaptureResult.error}</Text>
456
+ </View>
457
+ ) : null}
390
458
 
459
+ </View>
391
460
  </View>
392
461
  </View>
393
- </View>
394
- );
395
- }
462
+ );
463
+ }
396
464
 
465
+ // --- PREVIEW RENDER ---
397
466
  return (
398
467
  <View style={styles.root}>
399
468
  <View style={styles.previewContainer}>
@@ -405,6 +474,7 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
405
474
  <Text style={{ fontSize: 14, color: '#666', textAlign: 'center', marginBottom: 16, lineHeight: 22 }}>
406
475
  {getLocalizedText(component.instructions)}
407
476
  </Text>
477
+
408
478
  <View style={{ alignItems: 'center', justifyContent: 'center', flexDirection: "column", gap: 16 }}>
409
479
  {silentCaptureResult?.error === 'TOO_FAR_AWAY' && (
410
480
  <View style={styles.warningBanner}>
@@ -413,6 +483,8 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
413
483
  </Text>
414
484
  </View>
415
485
  )}
486
+
487
+ {/* 🚨 PREVIEW CONTAINER WITH PLATFORM OVERRIDES */}
416
488
  <View style={styles.imagePreviewWrapper}>
417
489
  {capturedImages[currentSide]?.dir ? (
418
490
  <Image source={{ uri: capturedImages[currentSide].dir }} style={styles.previewImage} />
@@ -420,10 +492,11 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
420
492
  <Image source={{ uri: silentCaptureResult.path }} style={styles.previewImage} />
421
493
  ) : null}
422
494
  </View>
495
+
423
496
  {!capturedImages[currentSide]?.dir && (
424
497
  <Button
425
498
  title={state.currentLanguage === "en" ? "Start Scanning" : "Commencer la numérisation"}
426
- onPress={() => { setShowCamera(true); actions.showCustomStepper(false); }}
499
+ onPress={() => { setShowCamera(true); refreshCamera(); actions.showCustomStepper(false); }}
427
500
  variant="primary" size="large" fullWidth
428
501
  />
429
502
  )}
@@ -436,6 +509,7 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
436
509
  actions.nextComponent();
437
510
  } else {
438
511
  setShowCamera(true);
512
+ refreshCamera();
439
513
  setCurrentSide('back');
440
514
  setSilentCaptureResult({ success: false, isAnalyzing: false, path: '', error: '', templatePath: undefined });
441
515
  setIsProcessingCapture(false);
@@ -455,38 +529,69 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
455
529
  const styles = StyleSheet.create({
456
530
  root: {
457
531
  flex: 1, width: '100%', backgroundColor: 'transparent', alignSelf: 'center',
458
- ...(Platform.OS === 'web' ? ({ minHeight: '85vh', justifyContent: 'center', alignItems: 'center', backdropFilter: 'blur(8px)' } as any) : {})
532
+ ...Platform.select({
533
+ web: { minHeight: '85vh', justifyContent: 'center', alignItems: 'center', backdropFilter: 'blur(8px)' } as any,
534
+ })
459
535
  },
460
536
  cameraWrapper: {
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, })
537
+ width: '100%', backgroundColor: '#000000', overflow: 'hidden',
538
+ ...Platform.select({
539
+ web: { maxWidth: 550, height: '80vh', minHeight: 600, borderRadius: 24, shadowColor: '#000', shadowOffset: { width: 0, height: 20 }, shadowOpacity: 0.25, shadowRadius: 35, elevation: 24 } as any,
540
+ default: { flex: 1 }
541
+ })
463
542
  },
464
543
  headerContainer: {
465
544
  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' } : {})
545
+ ...Platform.select({ web: { display: 'flex' }, default: { display: 'none' } })
467
546
  },
468
547
  headerTitle: { fontSize: 18, fontWeight: '700', color: '#0F172A', },
469
548
  stepBadge: { backgroundColor: '#F1F5F9', paddingHorizontal: 12, paddingVertical: 6, borderRadius: 20, },
470
549
  stepText: { fontSize: 13, fontWeight: '600', color: '#64748B', },
471
550
  cameraFeedContainer: { flex: 1, position: 'relative', backgroundColor: '#000', },
472
551
  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
475
- },
476
- refreshButtonText: { color: 'white', fontWeight: 'bold', fontSize: 16 },
552
+
553
+ // MOBILE: Escape Hatch layout
554
+ escapeHatchContainer: { position: 'absolute', bottom: 40, left: 0, right: 0, alignItems: 'center', justifyContent: 'center', zIndex: 99999 },
555
+ fallbackRefreshButton: { backgroundColor: 'rgba(0, 0, 0, 0.8)', borderWidth: 1, borderColor: 'rgba(255, 255, 255, 0.5)', paddingVertical: 12, paddingHorizontal: 24, borderRadius: 24, shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.3, shadowRadius: 5, elevation: 8 },
556
+ fallbackRefreshText: { color: '#FFFFFF', fontWeight: 'bold', fontSize: 16 },
557
+
558
+ // WEB: Control Bar layout
559
+ webBottomControlBar: { position: 'absolute', bottom: 0, left: 0, right: 0, paddingHorizontal: 20, paddingBottom: 20, paddingTop: 20, backgroundColor: 'rgba(0,0,0,0.6)', zIndex: 99999 },
560
+ webActionButtonsRow: { flexDirection: 'row', justifyContent: 'space-between', gap: 12, marginTop: 12 },
561
+ webPrimaryButton: { flex: 1, backgroundColor: '#2DBD60', paddingVertical: 14, borderRadius: 12, alignItems: 'center' },
562
+ webPrimaryButtonText: { color: 'white', fontWeight: 'bold', fontSize: 16 },
563
+ webSecondaryButton: { flex: 1, backgroundColor: 'rgba(255,255,255,0.2)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.4)', paddingVertical: 14, borderRadius: 12, alignItems: 'center' },
564
+ webSecondaryButtonText: { color: 'white', fontWeight: 'bold', fontSize: 16 },
565
+
566
+ // WEB: Flip Controls
567
+ webTopControls: { position: 'absolute', top: 20, right: 20, zIndex: 9999 },
568
+ webFlipButton: { backgroundColor: 'rgba(0,0,0,0.5)', paddingHorizontal: 16, paddingVertical: 10, borderRadius: 20, borderWidth: 1, borderColor: 'rgba(255,255,255,0.3)' },
569
+ webFlipText: { color: 'white', fontWeight: 'bold', fontSize: 14 },
570
+
477
571
  previewContainer: {
478
572
  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%' })
573
+ ...Platform.select({ web: { alignSelf: 'center', maxWidth: 600 }, default: { margin: 10, width: '95%' }})
480
574
  },
481
575
  previewItemContainer: { flexGrow: 1, },
482
576
  title: { fontSize: 24, fontWeight: 'bold', color: '#333', marginBottom: 8, textAlign: 'center' },
483
577
  description: { fontSize: 16, color: '#666', textAlign: 'center', marginBottom: 24, lineHeight: 22 },
484
578
  sideContainer: { marginBottom: 24 },
485
579
  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' },
580
+
581
+ imagePreviewWrapper: {
582
+ width: '100%', borderRadius: 12, padding: 1, overflow: 'hidden', shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.18, shadowRadius: 8, elevation: 8, backgroundColor: '#f0f0f0',
583
+ ...Platform.select({
584
+ web: { aspectRatio: 1.59, height: 'auto' } as any, // 🚨 Perfect ID ratio on web
585
+ default: { height: 220 } // 🚨 Strict original height on mobile
586
+ })
587
+ },
487
588
  previewImage: { width: '100%', height: '100%', borderRadius: 12, resizeMode: 'cover' },
589
+
488
590
  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
591
+ backgroundColor: '#FEF2F2', borderWidth: 1, borderColor: '#FCA5A5', paddingVertical: 12, paddingHorizontal: 16, borderRadius: 12, alignItems: 'center', justifyContent: 'center', width: '100%',
592
+ ...Platform.select({
593
+ default: { position: 'absolute', top: Platform.OS === 'ios' ? 90 : 70, left: 24, right: 24, zIndex: 10000 }
594
+ })
490
595
  },
491
596
  floatingErrorText: { color: '#991B1B', fontSize: 14, fontWeight: '700', textAlign: 'center' },
492
597
  processingOverlay: { flex: 1, backgroundColor: 'rgba(0, 0, 0, 0.6)', justifyContent: 'center', alignItems: 'center', zIndex: 9999 },
@@ -496,39 +601,5 @@ const styles = StyleSheet.create({
496
601
  errorText: { color: '#dc2626', fontSize: 14, marginTop: 8, textAlign: 'center' },
497
602
  topAnalyzingPillContainer: { position: 'absolute', top: Platform.OS === 'android' ? 60 : 50, left: 0, right: 0, alignItems: 'center', zIndex: 100 },
498
603
  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
508
- },
509
- fallbackRefreshButton: {
510
- backgroundColor: 'rgba(0, 0, 0, 0.8)', // Darker so it's visible on white or black
511
- borderWidth: 1,
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
+ analyzingPillText: { color: 'white', fontSize: 14, fontWeight: 'bold' }
534
605
  });