@sanctum-key/react-native-sdk 1.0.12 → 1.0.14

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,18 @@ 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,
90
+ cameraType: Platform.OS === 'web' ? cameraType : 'back', // Keep strictly 'back' on mobile
91
+ flashMode: 'auto' as const,
89
92
  overlay: { guideText: instructions, bbox: { xMin: 15, yMin: 20, xMax: 85, yMax: 70, borderColor: '#2DBD60', borderWidth: 3, cornerRadius: 8 } }
90
93
  };
91
- }, [selectedDocumentType, locale, component.instructions]);
94
+ }, [selectedDocumentType, locale, component.instructions, cameraType]);
92
95
 
93
96
  const retakePicture = (sideToRetake: 'front' | 'back') => {
94
97
  setIsProcessingCapture(false);
95
98
  setProcessingImagePath(null);
96
99
  setSilentCaptureResult({ path: '', success: false, isAnalyzing: false, error: '', templatePath: '', mrz: '', bbox: undefined });
97
100
  setShowCamera(true);
101
+ refreshCamera();
98
102
  actions.showCustomStepper(false);
99
103
  setCapturedImages((prev) => { const newState = { ...prev }; delete newState[sideToRetake]; return newState; });
100
104
  if (value) { const newValue = { ...value }; delete newValue[sideToRetake]; onValueChange(newValue); }
@@ -179,7 +183,9 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
179
183
  setProcessingImagePath(null);
180
184
  }, 600);
181
185
  } catch (e: any) {
182
- showAlert('Error', e?.message || 'Impossible de capturer la photo');
186
+ console.error("Backend Error:", e);
187
+ const friendlyError = state.currentLanguage === 'en' ? 'Unable to process document. Please try again.' : 'Impossible de traiter le document. Veuillez réessayer.';
188
+ showAlert('Error', friendlyError);
183
189
  setSilentCaptureResult(prev => ({ ...prev, isAnalyzing: false, success: false }));
184
190
  setIsProcessingCapture(false);
185
191
  setProcessingImagePath(null);
@@ -236,8 +242,14 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
236
242
  if (bbox) setBboxBySide(prev => ({ ...prev, [currentSide]: bbox }));
237
243
  await autoCapture(result.path, verifiedResult);
238
244
  } catch (error: any) {
245
+ console.error("Backend Error:", error);
246
+
239
247
  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');
248
+
249
+ const errorMessage = isCardNotFullyInFrame
250
+ ? t('kyc.idCardCapture.cardNotFullyInFrame')
251
+ : (t('errors.genericVerificationFailed') || 'Verification failed. Please ensure the document is clear and try again.');
252
+
241
253
  setSilentCaptureResult(prev => ({ ...prev, isAnalyzing: false, success: false, error: errorMessage }));
242
254
  }
243
255
  }
@@ -266,134 +278,157 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
266
278
  );
267
279
  }
268
280
 
281
+ // --- CAMERA RENDER ---
282
+ if (showCamera) {
283
+ const isBusy = isProcessingCapture;
269
284
 
270
- // --- CAMERA RENDER ---
271
- if (showCamera) {
272
- const isBusy = isProcessingCapture;
285
+ if (isRebootingCamera) {
286
+ return (
287
+ <View style={[styles.root, { justifyContent: 'center', alignItems: 'center', backgroundColor: '#000' }]}>
288
+ <ActivityIndicator size="large" color="#2DBD60" />
289
+ <Text style={{ color: 'white', marginTop: 20, fontSize: 16 }}>Initializing Camera...</Text>
290
+ </View>
291
+ );
292
+ }
273
293
 
274
- if (isRebootingCamera) {
275
294
  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' })}
295
+ <View style={styles.root}>
296
+ <View style={[styles.cameraWrapper, { flex: 1 }]}>
297
+
298
+ <View style={styles.headerContainer}>
299
+ <Text style={styles.headerTitle}>
300
+ {selectedDocumentType ? (locale === 'en' ? getDocumentTypeInfo(selectedDocumentType.type).name.en : getDocumentTypeInfo(selectedDocumentType.type).name.fr) : ''}
294
301
  </Text>
302
+ <View style={styles.stepBadge}>
303
+ <Text style={styles.stepText}>
304
+ {t('kyc.idCardCapture.captureTitle', { side: currentSide === 'front' ? locale === 'en' ? 'Front' : 'Recto' : locale === 'en' ? 'Back' : 'Verso' })}
305
+ </Text>
306
+ </View>
295
307
  </View>
296
- </View>
297
-
298
- <View style={[styles.cameraFeedContainer, { flex: 1, backgroundColor: '#000' }]}>
299
308
 
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>
309
+ <View style={[styles.cameraFeedContainer, { flex: 1, backgroundColor: '#000' }]}>
310
+
311
+ {/* WEB ONLY: Flip Camera Top Button */}
312
+ {Platform.OS === 'web' && (
313
+ <View style={styles.webTopControls}>
314
+ <TouchableOpacity style={styles.webFlipButton} onPress={toggleCameraLens}>
315
+ <Text style={styles.webFlipText}>🔄 Flip Lens</Text>
316
+ </TouchableOpacity>
347
317
  </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>
318
+ )}
319
+
320
+ <EnhancedCameraView
321
+ key={`${currentSide}-${cameraKey}`}
322
+ showCamera={true}
323
+ isProcessing={isBusy}
324
+ cameraType={cameraConfig.cameraType}
325
+ style={StyleSheet.absoluteFillObject}
326
+ onError={handleError}
327
+ onSilentCapture={handleSilentCapture}
328
+ silentCaptureResult={silentCaptureResult}
329
+ overlayComponent={
330
+ <>
331
+ <IdCardOverlay
332
+ xMin={cameraConfig.overlay.bbox.xMin} yMin={cameraConfig.overlay.bbox.yMin} xMax={cameraConfig.overlay.bbox.xMax} yMax={cameraConfig.overlay.bbox.yMax}
333
+ instructions={cameraConfig.overlay.guideText}
334
+ cornerOpacity={cameraConfig.overlay.bbox.cornerRadius || 0 as number}
335
+ isSuccess={silentCaptureResult.success}
336
+ language={state.currentLanguage}
337
+ stepperProps={{
338
+ back: () => {
339
+ if (currentSide === 'back') {
340
+ setCurrentSide('front');
341
+ setShowCamera(false);
342
+ setIsProcessingCapture(false);
343
+ setProcessingImagePath(null);
344
+ if (capturedImages['front']?.dir) {
345
+ const frontImage = capturedImages['front'];
346
+ setSilentCaptureResult((prev) => ({ ...prev, path: frontImage.dir, success: true, isAnalyzing: false, error: '', mrz: frontImage.mrz || '', templatePath: frontImage.templatePath || '', bbox: bboxBySide['front'] }));
347
+ } else { setSilentCaptureResult((prev) => ({ ...prev, path: '', success: false, isAnalyzing: false, error: '', templatePath: '' })); }
348
+ } else { actions.previousComponent(); }
349
+ },
350
+ selectedDocumentType: selectedDocumentType ? (locale === 'en' ? getDocumentTypeInfo(selectedDocumentType.type).name.en : getDocumentTypeInfo(selectedDocumentType.type).name.fr) : '',
351
+ step: state.currentComponentIndex + 1, totalSteps: state.template.components.length, side: currentSide,
352
+ }}
353
+ />
354
+ </>
355
+ }
356
+ />
357
+
358
+ {!isBusy && silentCaptureResult.isAnalyzing && (
359
+ <View style={styles.topAnalyzingPillContainer}>
360
+ <View style={styles.topAnalyzingPill}>
361
+ <ActivityIndicator size="small" color="white" />
362
+ <Text style={styles.analyzingPillText}>
363
+ {state.currentLanguage === 'en' ? 'Scanning...' : 'Analyse...'}
364
+ </Text>
365
+ </View>
365
366
  </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}
367
+ )}
368
+
369
+ {isBusy && (
370
+ <View style={[StyleSheet.absoluteFillObject, { zIndex: 9999 }]}>
371
+ {processingImagePath && (
372
+ <Image
373
+ source={{ uri: processingImagePath.startsWith('file://') ? processingImagePath : `file://${processingImagePath}` }}
374
+ style={StyleSheet.absoluteFillObject}
375
+ resizeMode="cover"
376
+ />
377
+ )}
378
+ <View style={styles.processingOverlay}>
379
+ <ActivityIndicator size="large" color="#2DBD60" />
380
+ <Text style={styles.processingText}>
381
+ {state.currentLanguage === 'en' ? 'Perfect!\nProcessing Document...' : 'Parfait!\nTraitement du document...'}
382
+ </Text>
383
+ </View>
384
+ </View>
385
+ )}
386
+
387
+ {/* 🚨 THE ESCAPE HATCH: Branching UI for Web vs Mobile */}
388
+ {!isBusy && Platform.OS === 'web' ? (
389
+ <View style={styles.webBottomControlBar}>
390
+ {silentCaptureResult.error ? (
391
+ <View style={styles.floatingErrorBanner}>
392
+ <Text style={styles.floatingErrorText}>⚠️ {silentCaptureResult.error}</Text>
393
+ </View>
394
+ ) : <View style={{ height: 10 }} />}
395
+
396
+ <View style={styles.webActionButtonsRow}>
397
+ <TouchableOpacity style={styles.webSecondaryButton} onPress={() => setShowCamera(false)}>
398
+ <Text style={styles.webSecondaryButtonText}>Cancel</Text>
399
+ </TouchableOpacity>
400
+ <TouchableOpacity style={styles.webPrimaryButton} onPress={refreshCamera}>
401
+ <Text style={styles.webPrimaryButtonText}>↻ Refresh Camera</Text>
402
+ </TouchableOpacity>
403
+ </View>
404
+ </View>
405
+ ) : !isBusy ? (
406
+ <View style={styles.escapeHatchContainer}>
407
+ <TouchableOpacity style={styles.fallbackRefreshButton} onPress={refreshCamera}>
408
+ <Text style={styles.fallbackRefreshText}>↻ Refresh Camera</Text>
409
+ </TouchableOpacity>
410
+ <TouchableOpacity
411
+ style={[styles.fallbackRefreshButton, { marginTop: 15, backgroundColor: 'rgba(220, 38, 38, 0.8)', borderColor: '#DC2626' }]}
412
+ onPress={() => setShowCamera(false)}
413
+ >
414
+ <Text style={styles.fallbackRefreshText}>Cancel / Go Back</Text>
415
+ </TouchableOpacity>
416
+ </View>
417
+ ) : null}
418
+
419
+ {silentCaptureResult.error && !isBusy && Platform.OS !== 'web' ? (
420
+ <View style={[styles.floatingErrorBanner, { zIndex: 10000 }]}>
421
+ <Text style={styles.floatingErrorText}>⚠️ {silentCaptureResult.error}</Text>
422
+ </View>
423
+ ) : null}
390
424
 
425
+ </View>
391
426
  </View>
392
427
  </View>
393
- </View>
394
- );
395
- }
428
+ );
429
+ }
396
430
 
431
+ // --- PREVIEW RENDER ---
397
432
  return (
398
433
  <View style={styles.root}>
399
434
  <View style={styles.previewContainer}>
@@ -405,6 +440,7 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
405
440
  <Text style={{ fontSize: 14, color: '#666', textAlign: 'center', marginBottom: 16, lineHeight: 22 }}>
406
441
  {getLocalizedText(component.instructions)}
407
442
  </Text>
443
+
408
444
  <View style={{ alignItems: 'center', justifyContent: 'center', flexDirection: "column", gap: 16 }}>
409
445
  {silentCaptureResult?.error === 'TOO_FAR_AWAY' && (
410
446
  <View style={styles.warningBanner}>
@@ -413,6 +449,8 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
413
449
  </Text>
414
450
  </View>
415
451
  )}
452
+
453
+ {/* 🚨 PREVIEW CONTAINER WITH PLATFORM OVERRIDES */}
416
454
  <View style={styles.imagePreviewWrapper}>
417
455
  {capturedImages[currentSide]?.dir ? (
418
456
  <Image source={{ uri: capturedImages[currentSide].dir }} style={styles.previewImage} />
@@ -420,10 +458,11 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
420
458
  <Image source={{ uri: silentCaptureResult.path }} style={styles.previewImage} />
421
459
  ) : null}
422
460
  </View>
461
+
423
462
  {!capturedImages[currentSide]?.dir && (
424
463
  <Button
425
464
  title={state.currentLanguage === "en" ? "Start Scanning" : "Commencer la numérisation"}
426
- onPress={() => { setShowCamera(true); actions.showCustomStepper(false); }}
465
+ onPress={() => { setShowCamera(true); refreshCamera(); actions.showCustomStepper(false); }}
427
466
  variant="primary" size="large" fullWidth
428
467
  />
429
468
  )}
@@ -436,6 +475,7 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
436
475
  actions.nextComponent();
437
476
  } else {
438
477
  setShowCamera(true);
478
+ refreshCamera();
439
479
  setCurrentSide('back');
440
480
  setSilentCaptureResult({ success: false, isAnalyzing: false, path: '', error: '', templatePath: undefined });
441
481
  setIsProcessingCapture(false);
@@ -455,38 +495,69 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
455
495
  const styles = StyleSheet.create({
456
496
  root: {
457
497
  flex: 1, width: '100%', backgroundColor: 'transparent', alignSelf: 'center',
458
- ...(Platform.OS === 'web' ? ({ minHeight: '85vh', justifyContent: 'center', alignItems: 'center', backdropFilter: 'blur(8px)' } as any) : {})
498
+ ...Platform.select({
499
+ web: { minHeight: '85vh', justifyContent: 'center', alignItems: 'center', backdropFilter: 'blur(8px)' } as any,
500
+ })
459
501
  },
460
502
  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, })
503
+ width: '100%', backgroundColor: '#000000', overflow: 'hidden',
504
+ ...Platform.select({
505
+ 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,
506
+ default: { flex: 1 }
507
+ })
463
508
  },
464
509
  headerContainer: {
465
510
  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' } : {})
511
+ ...Platform.select({ web: { display: 'flex' }, default: { display: 'none' } })
467
512
  },
468
513
  headerTitle: { fontSize: 18, fontWeight: '700', color: '#0F172A', },
469
514
  stepBadge: { backgroundColor: '#F1F5F9', paddingHorizontal: 12, paddingVertical: 6, borderRadius: 20, },
470
515
  stepText: { fontSize: 13, fontWeight: '600', color: '#64748B', },
471
516
  cameraFeedContainer: { flex: 1, position: 'relative', backgroundColor: '#000', },
472
517
  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 },
518
+
519
+ // MOBILE: Escape Hatch layout
520
+ escapeHatchContainer: { position: 'absolute', bottom: 40, left: 0, right: 0, alignItems: 'center', justifyContent: 'center', zIndex: 99999 },
521
+ 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 },
522
+ fallbackRefreshText: { color: '#FFFFFF', fontWeight: 'bold', fontSize: 16 },
523
+
524
+ // WEB: Control Bar layout
525
+ webBottomControlBar: { position: 'absolute', bottom: 0, left: 0, right: 0, paddingHorizontal: 20, paddingBottom: 20, paddingTop: 20, backgroundColor: 'rgba(0,0,0,0.6)', zIndex: 99999 },
526
+ webActionButtonsRow: { flexDirection: 'row', justifyContent: 'space-between', gap: 12, marginTop: 12 },
527
+ webPrimaryButton: { flex: 1, backgroundColor: '#2DBD60', paddingVertical: 14, borderRadius: 12, alignItems: 'center' },
528
+ webPrimaryButtonText: { color: 'white', fontWeight: 'bold', fontSize: 16 },
529
+ 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' },
530
+ webSecondaryButtonText: { color: 'white', fontWeight: 'bold', fontSize: 16 },
531
+
532
+ // WEB: Flip Controls
533
+ webTopControls: { position: 'absolute', top: 20, right: 20, zIndex: 9999 },
534
+ webFlipButton: { backgroundColor: 'rgba(0,0,0,0.5)', paddingHorizontal: 16, paddingVertical: 10, borderRadius: 20, borderWidth: 1, borderColor: 'rgba(255,255,255,0.3)' },
535
+ webFlipText: { color: 'white', fontWeight: 'bold', fontSize: 14 },
536
+
477
537
  previewContainer: {
478
538
  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
+ ...Platform.select({ web: { alignSelf: 'center', maxWidth: 600 }, default: { margin: 10, width: '95%' }})
480
540
  },
481
541
  previewItemContainer: { flexGrow: 1, },
482
542
  title: { fontSize: 24, fontWeight: 'bold', color: '#333', marginBottom: 8, textAlign: 'center' },
483
543
  description: { fontSize: 16, color: '#666', textAlign: 'center', marginBottom: 24, lineHeight: 22 },
484
544
  sideContainer: { marginBottom: 24 },
485
545
  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' },
546
+
547
+ imagePreviewWrapper: {
548
+ width: '100%', borderRadius: 12, padding: 1, overflow: 'hidden', shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.18, shadowRadius: 8, elevation: 8, backgroundColor: '#f0f0f0',
549
+ ...Platform.select({
550
+ web: { aspectRatio: 1.59, height: 'auto' } as any, // 🚨 Perfect ID ratio on web
551
+ default: { height: 220 } // 🚨 Strict original height on mobile
552
+ })
553
+ },
487
554
  previewImage: { width: '100%', height: '100%', borderRadius: 12, resizeMode: 'cover' },
555
+
488
556
  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
557
+ backgroundColor: '#FEF2F2', borderWidth: 1, borderColor: '#FCA5A5', paddingVertical: 12, paddingHorizontal: 16, borderRadius: 12, alignItems: 'center', justifyContent: 'center', width: '100%',
558
+ ...Platform.select({
559
+ default: { position: 'absolute', top: Platform.OS === 'ios' ? 90 : 70, left: 24, right: 24, zIndex: 10000 }
560
+ })
490
561
  },
491
562
  floatingErrorText: { color: '#991B1B', fontSize: 14, fontWeight: '700', textAlign: 'center' },
492
563
  processingOverlay: { flex: 1, backgroundColor: 'rgba(0, 0, 0, 0.6)', justifyContent: 'center', alignItems: 'center', zIndex: 9999 },
@@ -496,39 +567,5 @@ const styles = StyleSheet.create({
496
567
  errorText: { color: '#dc2626', fontSize: 14, marginTop: 8, textAlign: 'center' },
497
568
  topAnalyzingPillContainer: { position: 'absolute', top: Platform.OS === 'android' ? 60 : 50, left: 0, right: 0, alignItems: 'center', zIndex: 100 },
498
569
  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
- },
570
+ analyzingPillText: { color: 'white', fontSize: 14, fontWeight: 'bold' }
534
571
  });