@sanctum-key/react-native-sdk 1.0.19 → 1.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/build/package.json +1 -1
  2. package/build/src/components/KYCElements/CountrySelectionTemplate.d.ts.map +1 -1
  3. package/build/src/components/KYCElements/CountrySelectionTemplate.js +40 -128
  4. package/build/src/components/KYCElements/CountrySelectionTemplate.js.map +1 -1
  5. package/build/src/components/KYCElements/IDCardCapture.d.ts.map +1 -1
  6. package/build/src/components/KYCElements/IDCardCapture.js +166 -129
  7. package/build/src/components/KYCElements/IDCardCapture.js.map +1 -1
  8. package/build/src/components/KYCElements/VerificationProgressTemplate.d.ts.map +1 -1
  9. package/build/src/components/KYCElements/VerificationProgressTemplate.js +62 -40
  10. package/build/src/components/KYCElements/VerificationProgressTemplate.js.map +1 -1
  11. package/build/src/config/region_mapping.d.ts.map +1 -1
  12. package/build/src/config/region_mapping.js.map +1 -1
  13. package/build/src/hooks/useTemplateKYCFlow.js +1 -1
  14. package/build/src/hooks/useTemplateKYCFlow.js.map +1 -1
  15. package/build/src/modules/api/CardAuthentification.d.ts +5 -0
  16. package/build/src/modules/api/CardAuthentification.d.ts.map +1 -1
  17. package/build/src/modules/api/CardAuthentification.js +82 -14
  18. package/build/src/modules/api/CardAuthentification.js.map +1 -1
  19. package/build/src/modules/api/KYCService.d.ts +11 -0
  20. package/build/src/modules/api/KYCService.d.ts.map +1 -1
  21. package/build/src/modules/api/KYCService.js +104 -29
  22. package/build/src/modules/api/KYCService.js.map +1 -1
  23. package/build/src/web/WebKYCEntry.d.ts.map +1 -1
  24. package/build/src/web/WebKYCEntry.js +34 -6
  25. package/build/src/web/WebKYCEntry.js.map +1 -1
  26. package/package.json +1 -1
  27. package/src/components/KYCElements/CountrySelectionTemplate.tsx +53 -133
  28. package/src/components/KYCElements/IDCardCapture.tsx +613 -556
  29. package/src/components/KYCElements/VerificationProgressTemplate.tsx +80 -71
  30. package/src/config/region_mapping.ts +2 -1
  31. package/src/hooks/useTemplateKYCFlow.tsx +14 -14
  32. package/src/modules/api/CardAuthentification.ts +147 -67
  33. package/src/modules/api/KYCService.ts +166 -43
  34. package/src/web/WebKYCEntry.tsx +42 -7
@@ -1,4 +1,4 @@
1
- import React, { useEffect, useMemo, useState } from 'react';
1
+ import React, { useEffect, useMemo, useState } from 'react';
2
2
  import { View, Text, StyleSheet, Image, ScrollView, Platform, ActivityIndicator, TouchableOpacity, useWindowDimensions } from 'react-native';
3
3
  import { showAlert } from '../../utils/platformAlert';
4
4
  import { EnhancedCameraView } from '../EnhancedCameraView';
@@ -13,565 +13,622 @@ import { getDocumentTypeInfo } from '../../utils/get-document-type-info';
13
13
  import pathToBase64 from '../../utils/pathToBase64';
14
14
  import { cropByObb, cropImageWithBBoxWithTolerance, getObbConfidence, OBB_CONFIDENCE_THRESHOLD } from '../../utils/cropByObb';
15
15
  import REGION_MAPPING from '../../config/region_mapping.json';
16
- import QRCode from 'react-native-qrcode-svg';
16
+ import QRCode from 'react-native-qrcode-svg';
17
17
 
18
- 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' };
18
+ 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' };
19
19
 
20
20
  interface IIDCardPayload { dir: string; file: string; mrz: string; templatePath?: string; }
21
- interface IDCardCaptureProps { component: TemplateComponent; value?: Record<string, IIDCardPayload>; onValueChange: (value: Record<string, IIDCardPayload | string>) => void; error?: string; language?: string; currentSide?: string; }
22
-
23
- export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value = {}, onValueChange, error, language = 'en' }) => {
24
- const { t, locale } = useI18n();
25
- const [showCamera, setShowCamera] = useState(false);
26
- const [capturedImages, setCapturedImages] = useState<Record<string, IIDCardPayload>>(value || {});
27
- const [currentSide, setCurrentSide] = useState<'front' | 'back'>('front');
28
- const [bboxBySide, setBboxBySide] = useState<Record<string, IBbox>>({});
29
- const [silentCaptureResult, setSilentCaptureResult] = useState<ISilentCaptureResult>({ success: false, isAnalyzing: false });
30
- const [isProcessingCapture, setIsProcessingCapture] = useState(false);
31
- const [processingImagePath, setProcessingImagePath] = useState<string | null>(null);
32
- const [cameraKey, setCameraKey] = useState(0);
33
- const [isRebootingCamera, setIsRebootingCamera] = useState(false);
34
- const [cameraType, setCameraType] = useState<'back' | 'front'>('back');
35
- const [showQRModal, setShowQRModal] = useState(false);
21
+ interface IDCardCaptureProps {
22
+ component: TemplateComponent;
23
+ value?: Record<string, IIDCardPayload>;
24
+ onValueChange: (value: Record<string, IIDCardPayload | string>) => void;
25
+ error?: string;
26
+ language?: string;
27
+ currentSide?: string;
28
+ }
29
+
30
+ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value = {}, onValueChange, error, language = 'en' }) => {
31
+ const { t, locale } = useI18n();
32
+ const [showCamera, setShowCamera] = useState(false);
33
+ const [capturedImages, setCapturedImages] = useState<Record<string, IIDCardPayload>>(value || {});
34
+ const [currentSide, setCurrentSide] = useState<'front' | 'back'>('front');
35
+ const [bboxBySide, setBboxBySide] = useState<Record<string, IBbox>>({});
36
+ const [silentCaptureResult, setSilentCaptureResult] = useState<ISilentCaptureResult>({ success: false, isAnalyzing: false });
37
+ const [isProcessingCapture, setIsProcessingCapture] = useState(false);
38
+ const [processingImagePath, setProcessingImagePath] = useState<string | null>(null);
39
+ const [cameraKey, setCameraKey] = useState(0);
40
+ const [isRebootingCamera, setIsRebootingCamera] = useState(false);
41
+ const [cameraType, setCameraType] = useState<'back' | 'front'>('back');
42
+ const [showQRModal, setShowQRModal] = useState(false);
43
+ const [currentUrl, setCurrentUrl] = useState('');
44
+
45
+ useEffect(() => {
46
+ if (typeof window !== 'undefined' && window.location) {
47
+ setCurrentUrl(window.location.href);
48
+ }
49
+ }, []);
50
+
51
+ const documentTypeMapping: Record<string, GovernmentDocumentType> = { 'nationalId': 'national_id', 'passport': 'passport', 'driversLicense': 'drivers_licence', 'residencePermit': 'permanent_residence', 'healthInsuranceCard': 'health_insurance_card', };
52
+
53
+ const { actions, state, env } = useTemplateKYCFlowContext();
54
+ const { width: screenWidth, height: screenHeight } = useWindowDimensions();
55
+
56
+ const getLocalizedText = (text: LocalizedText | Record<string, LocalizedText>): string => {
57
+ if (text && typeof text[currentSide] === 'object' && text[currentSide][locale]) return text[currentSide][locale] || '';
58
+ return "";
59
+ };
60
+
61
+ const countrySelectionData = useMemo(() => {
62
+ const countrySelectionComponent = state.template.components.find(c => c.type === 'country_selection');
63
+ return countrySelectionComponent ? state.componentData[countrySelectionComponent.id] : null;
64
+ }, [state.template.components, state.componentData]);
65
+
66
+ const selectedDocumentType = useMemo<{ type: GovernmentDocumentType; region: string } | null>(() => {
67
+ if (!countrySelectionData?.documentType) return null;
68
+ const backendDocType = countrySelectionData.documentType;
69
+ const mappedType = documentTypeMapping[backendDocType] || backendDocType as GovernmentDocumentType;
70
+ return { type: mappedType, region: countrySelectionData.region || 'root' };
71
+ }, [countrySelectionData, documentTypeMapping]);
72
+
73
+ const countryData = useMemo(() => countrySelectionData, [countrySelectionData]);
74
+
75
+ const refreshCamera = () => {
76
+ setIsRebootingCamera(true);
77
+ setTimeout(() => {
78
+ setCameraKey(prev => prev + 1);
79
+ setIsRebootingCamera(false);
80
+ }, 500);
81
+ };
82
+
83
+ const toggleCameraLens = () => {
84
+ setCameraType(prev => prev === 'back' ? 'front' : 'back');
85
+ refreshCamera();
86
+ };
87
+
88
+ useEffect(() => {
89
+ if (value && Object.keys(value).length > 0) {
90
+ if (JSON.stringify(value) !== JSON.stringify(capturedImages)) {
91
+ const updatedImages = value as Record<string, IIDCardPayload>;
92
+ setCapturedImages(updatedImages);
93
+ const currentImageData = updatedImages[currentSide];
94
+ if (currentImageData?.dir) {
95
+ setSilentCaptureResult(prev => ({
96
+ ...prev, path: currentImageData.dir, success: true, isAnalyzing: false, mrz: currentImageData.mrz || '', templatePath: currentImageData.templatePath || '',
97
+ }));
98
+ }
99
+ }
100
+ }
101
+ }, [value, currentSide]);
102
+
103
+ const cameraConfig = useMemo(() => {
104
+ const instructions = selectedDocumentType ? (locale === 'en' ? getDocumentTypeInfo(selectedDocumentType.type).instructions.en : getDocumentTypeInfo(selectedDocumentType.type).instructions.fr) : getLocalizedText(component.instructions as Record<string, LocalizedText>);
105
+ const isLandscape = screenWidth > screenHeight;
106
+ let xMin, xMax, yMin, yMax;
107
+
108
+ if (Platform.OS === 'web') {
109
+ const boxWidthPercent = isLandscape ? 0.40 : 0.85;
110
+ xMin = isLandscape ? 30 : 7.5;
111
+ xMax = xMin + (boxWidthPercent * 100);
112
+ const containerWidth = Math.min(screenWidth * 0.95, 1000);
113
+ const containerHeight = isLandscape ? screenHeight * 0.80 : screenHeight * 0.90;
114
+ const boxPixelWidth = containerWidth * boxWidthPercent;
115
+ const boxPixelHeight = boxPixelWidth / 1.59;
116
+ const heightPercent = (boxPixelHeight / containerHeight) * 100;
117
+ yMin = (100 - heightPercent) / 2.5;
118
+ yMax = yMin + heightPercent;
119
+ } else {
120
+ xMin = 5; xMax = 95; yMin = 34; yMax = 66;
121
+ }
122
+ const platformBbox = { xMin, yMin, xMax, yMax, borderColor: '#2DBD60', borderWidth: Platform.OS === 'web' ? 2 : 3, cornerRadius: 8 };
123
+ return {
124
+ cameraType: Platform.OS === 'web' ? cameraType : 'back',
125
+ flashMode: 'auto' as const,
126
+ overlay: { guideText: instructions, bbox: platformBbox }
127
+ };
128
+ }, [selectedDocumentType, locale, component.instructions, cameraType, screenWidth, screenHeight]);
129
+
130
+ const retakePicture = (sideToRetake: 'front' | 'back') => {
131
+ setIsProcessingCapture(false);
132
+ setProcessingImagePath(null);
133
+ setSilentCaptureResult({ path: '', success: false, isAnalyzing: false, error: '', templatePath: '', mrz: '', bbox: undefined });
134
+ setShowCamera(true);
135
+ refreshCamera();
136
+ actions.showCustomStepper(false);
137
+ setCapturedImages((prev) => { const newState = { ...prev }; delete newState[sideToRetake]; return newState; });
138
+ if (value) { const newValue = { ...value }; delete newValue[sideToRetake]; onValueChange(newValue); }
139
+ };
140
+
141
+ const getCurrentSideVerification = (currentSide: string, countryKey: string) => {
142
+ const rawDocType = countrySelectionData?.documentType;
143
+ if (!rawDocType || !countryKey) { return { authMethod: [], mrzTypes: [], regionMapping: null, key: 'root' }; }
144
+ const rawCountryName = ISO_TO_COUNTRY_NAME[countryData?.code || ''] || countryData?.code || countryKey;
145
+ const baseMapping = (REGION_MAPPING as any).regionMapping || REGION_MAPPING;
146
+ let countryMapping = baseMapping[rawCountryName];
147
+ if (!countryMapping) {
148
+ const foundKey = Object.keys(baseMapping).find(k => k.toLowerCase() === rawCountryName.toLowerCase() || k.toLowerCase() === countryKey.toLowerCase());
149
+ if (foundKey) countryMapping = baseMapping[foundKey];
150
+ }
151
+ if (!countryMapping) { return { authMethod: [], mrzTypes: [], regionMapping: null, key: 'root' }; }
152
+ const regionMapping = countryMapping[rawDocType];
153
+ if (!regionMapping) { return { authMethod: [], mrzTypes: [], regionMapping: null, key: 'root' }; }
154
+ const authMethod: string[] = [];
155
+ const mrzTypes: string[] = [];
156
+ const key = countrySelectionData.region?.trim()?.length > 0 ? countrySelectionData.region.trim() : 'root';
157
+ if (regionMapping?.[key] && Array.isArray(regionMapping[key])) {
158
+ regionMapping[key].forEach((item: any) => {
159
+ if (item[currentSide]) {
160
+ authMethod.push(item[currentSide]);
161
+ if (item?.mrz_type) mrzTypes.push(item?.mrz_type);
162
+ }
163
+ });
164
+ }
165
+ return { authMethod: removeDuplicates(authMethod), mrzTypes: removeDuplicates(mrzTypes), regionMapping, key };
166
+ }
167
+
168
+ const getCorrespondingMrzType = (templatePath: string, mapping: any, selectedDocumentType: string = "root") => {
169
+ if (!mapping || !mapping[selectedDocumentType]) return null;
170
+ const fileName = templatePath.split("/").pop()?.replace(".jpg", "").replace(".png", "");
171
+ if (!fileName) return null;
172
+ const pyName = `${fileName}.py`;
173
+ const found = mapping[selectedDocumentType].find((item: any) => item.py_file === pyName);
174
+ return found?.mrz_type || null;
175
+ }
176
+
177
+ const getCorrespondingAuthMethod = (templatePath: string, mapping: any, selectedDocumentType: string = "root", side: 'front' | 'back') => {
178
+ if (!mapping || !mapping[selectedDocumentType]) return null;
179
+ const fileName = templatePath.split("/").pop()?.replace(".jpg", "").replace(".png", "");
180
+ if (!fileName) return null;
181
+ const pyName = `${fileName}.py`;
182
+ const found = mapping[selectedDocumentType].find((item: any) => item.py_file === pyName);
183
+ return found?.[side] || null;
184
+ }
185
+
186
+ const autoCapture = async (capturePath: string, verified: ISilentCaptureResult & { region?: string }) => {
187
+ if (isProcessingCapture) return;
188
+ setIsProcessingCapture(true);
189
+ setProcessingImagePath(capturePath);
190
+ try {
191
+ let imagePathForUpload = capturePath;
192
+ const overlayBbox = cameraConfig.overlay.bbox;
193
+ const uiCropBbox = {
194
+ minX: overlayBbox.xMin / 100,
195
+ minY: overlayBbox.yMin / 100,
196
+ width: (overlayBbox.xMax - overlayBbox.xMin) / 100,
197
+ height: (overlayBbox.yMax - overlayBbox.yMin) / 100,
198
+ };
199
+ try {
200
+ imagePathForUpload = await cropImageWithBBoxWithTolerance(capturePath, uiCropBbox, 0.06);
201
+ } catch (e) {
202
+ console.warn("Crop failed, falling back to original image", e);
203
+ imagePathForUpload = capturePath;
204
+ }
205
+ const base64 = await pathToBase64(imagePathForUpload);
206
+ const newImages = {
207
+ ...capturedImages,
208
+ [currentSide]: { dir: imagePathForUpload, file: base64, mrz: verified.mrz || "", templatePath: verified.templatePath },
209
+ };
210
+ setCapturedImages(newImages);
211
+
212
+ if (verified.country && verified.documentType) {
213
+ onValueChange({
214
+ ...newImages,
215
+ country: verified.country,
216
+ documentType: GovernmentDocumentTypeBackend[verified.documentType as keyof typeof GovernmentDocumentTypeBackend] || '',
217
+ ...(verified.region ? { region: verified.region } : {})
218
+ });
219
+ }
220
+
221
+ setTimeout(() => {
222
+ setShowCamera(false);
223
+ actions.showCustomStepper(true);
224
+ setSilentCaptureResult(prev => ({ ...prev, isAnalyzing: false }));
225
+ setIsProcessingCapture(false);
226
+ setProcessingImagePath(null);
227
+ }, 600);
228
+ } catch (e: any) {
229
+ console.error("Backend Error:", e);
230
+ const friendlyError = state.currentLanguage === 'en' ? 'Unable to process document. Please try again.' : 'Impossible de traiter le document. Veuillez réessayer.';
231
+ showAlert('Error', friendlyError);
232
+ setSilentCaptureResult(prev => ({ ...prev, isAnalyzing: false, success: false }));
233
+ setIsProcessingCapture(false);
234
+ setProcessingImagePath(null);
235
+ }
236
+ };
237
+
238
+ const handleSilentCapture = async (result: { success: boolean; path?: string; error?: string }) => {
239
+ if (silentCaptureResult.isAnalyzing || isProcessingCapture) return;
240
+
241
+ if (result.success && result.path) {
242
+ setSilentCaptureResult((prev) => ({ ...prev, isAnalyzing: true, success: false, error: '' }));
243
+ let templatePath = capturedImages[currentSide]?.templatePath || '';
244
+ let templateBbox: IBbox | undefined;
245
+ let templateResponse: any;
36
246
 
37
- const [currentUrl, setCurrentUrl] = useState('');
38
- useEffect(() => {
39
- if (typeof window !== 'undefined' && window.location) {
40
- setCurrentUrl(window.location.href);
41
- }
42
- }, []);
43
-
44
- const documentTypeMapping: Record<string, GovernmentDocumentType> = { 'nationalId': 'national_id', 'passport': 'passport', 'driversLicense': 'drivers_licence', 'residencePermit': 'permanent_residence', 'healthInsuranceCard': 'health_insurance_card', };
45
- const { actions, state, env } = useTemplateKYCFlowContext();
46
- const { width: screenWidth, height: screenHeight } = useWindowDimensions();
47
-
48
- const getLocalizedText = (text: LocalizedText | Record<string, LocalizedText>): string => {
49
- if (text && typeof text[currentSide] === 'object' && text[currentSide][locale]) return text[currentSide][locale] || '';
50
- return "";
51
- };
52
-
53
- const countrySelectionData = useMemo(() => {
54
- const countrySelectionComponent = state.template.components.find(c => c.type === 'country_selection');
55
- return countrySelectionComponent ? state.componentData[countrySelectionComponent.id] : null;
56
- }, [state.template.components, state.componentData]);
57
-
58
- const selectedDocumentType = useMemo<{ type: GovernmentDocumentType; region: string } | null>(() => {
59
- if (!countrySelectionData?.documentType) return null;
60
- const backendDocType = countrySelectionData.documentType;
61
- const mappedType = documentTypeMapping[backendDocType] || backendDocType as GovernmentDocumentType;
62
- return { type: mappedType, region: countrySelectionData.region || 'root' };
63
- }, [countrySelectionData, documentTypeMapping]);
64
-
65
- const countryData = useMemo(() => countrySelectionData, [countrySelectionData]);
66
-
67
- const refreshCamera = () => {
68
- setIsRebootingCamera(true);
69
- setTimeout(() => {
70
- setCameraKey(prev => prev + 1);
71
- setIsRebootingCamera(false);
72
- }, 500);
73
- };
74
-
75
- const toggleCameraLens = () => {
76
- setCameraType(prev => prev === 'back' ? 'front' : 'back');
77
- refreshCamera();
78
- };
79
-
80
- useEffect(() => {
81
- if (value && Object.keys(value).length > 0) {
82
- if (JSON.stringify(value) !== JSON.stringify(capturedImages)) {
83
- const updatedImages = value as Record<string, IIDCardPayload>;
84
- setCapturedImages(updatedImages);
85
- const currentImageData = updatedImages[currentSide];
86
- if (currentImageData?.dir) {
87
- setSilentCaptureResult(prev => ({
88
- ...prev, path: currentImageData.dir, success: true, isAnalyzing: false, mrz: currentImageData.mrz || '', templatePath: currentImageData.templatePath || '',
89
- }));
90
- }
91
- }
92
- }
93
- }, [value, currentSide]);
94
-
95
- const cameraConfig = useMemo(() => {
96
- const instructions = selectedDocumentType ? (locale === 'en' ? getDocumentTypeInfo(selectedDocumentType.type).instructions.en : getDocumentTypeInfo(selectedDocumentType.type).instructions.fr) : getLocalizedText(component.instructions as Record<string, LocalizedText>);
247
+ if (!selectedDocumentType) {
248
+ setSilentCaptureResult((prev) => ({ ...prev, isAnalyzing: false, success: false, error: 'Document type not selected' }));
249
+ return;
250
+ }
251
+
252
+ const issuingSubdivision = selectedDocumentType.region !== 'root' ? selectedDocumentType.region : undefined;
253
+
254
+ try {
255
+ if (!templatePath) {
256
+ const templateType = await checkTemplateType({
257
+ path: result.path || '',
258
+ docType: selectedDocumentType?.type as GovernmentDocumentType,
259
+ docRegion: countryData?.code || "",
260
+ postfix: currentSide,
261
+ issuingSubdivision: issuingSubdivision
262
+ }, env);
263
+
264
+ templateResponse = templateType;
265
+ if (templateType.template_path) templatePath = templateType.template_path;
97
266
 
98
- const isLandscape = screenWidth > screenHeight;
99
- let xMin, xMax, yMin, yMax;
267
+ if (templateType.card_obb) {
268
+ const obbConfidence = getObbConfidence((templateType as any).card_obb);
269
+ if (obbConfidence !== null && obbConfidence < OBB_CONFIDENCE_THRESHOLD) {
270
+ setSilentCaptureResult((prev) => ({ ...prev, isAnalyzing: false, success: false, error: t('kyc.idCardCapture.cardNotFullyInFrame') }));
271
+ return;
272
+ }
273
+ try {
274
+ const crop = await cropByObb(result.path, (templateType as any).card_obb);
275
+ templateBbox = crop.bbox;
276
+ } catch { }
277
+ }
278
+ }
100
279
 
101
- if (Platform.OS === 'web') {
102
- // 🚨 UI FIX: Differentiate Desktop Web (Landscape) vs Mobile Web (Portrait)
103
- const boxWidthPercent = isLandscape ? 0.40 : 0.85;
104
-
105
- // On wide desktop, shift slightly right. On mobile portrait, center perfectly.
106
- xMin = isLandscape ? 30 : 7.5;
107
- xMax = xMin + (boxWidthPercent * 100);
108
-
109
- // Safely calculate height to guarantee a 1.59 aspect ratio
110
- const containerWidth = Math.min(screenWidth * 0.95, 1000);
111
- const containerHeight = isLandscape ? screenHeight * 0.80 : screenHeight * 0.90;
112
-
113
- const boxPixelWidth = containerWidth * boxWidthPercent;
114
- const boxPixelHeight = boxPixelWidth / 1.59;
115
- const heightPercent = (boxPixelHeight / containerHeight) * 100;
116
-
117
- // Center vertically
118
- yMin = (100 - heightPercent) / 2.5;
119
- yMax = yMin + heightPercent;
120
- } else {
121
- xMin = 5; xMax = 95; yMin = 34; yMax = 66;
122
- }
123
-
124
- const platformBbox = { xMin, yMin, xMax, yMax, borderColor: '#2DBD60', borderWidth: Platform.OS === 'web' ? 2 : 3, cornerRadius: 8 };
125
-
126
- return {
127
- cameraType: Platform.OS === 'web' ? cameraType : 'back',
128
- flashMode: 'auto' as const,
129
- overlay: { guideText: instructions, bbox: platformBbox }
130
- };
131
- }, [selectedDocumentType, locale, component.instructions, cameraType, screenWidth, screenHeight]);
132
-
133
- const retakePicture = (sideToRetake: 'front' | 'back') => {
134
- setIsProcessingCapture(false);
135
- setProcessingImagePath(null);
136
- setSilentCaptureResult({ path: '', success: false, isAnalyzing: false, error: '', templatePath: '', mrz: '', bbox: undefined });
137
- setShowCamera(true);
138
- refreshCamera();
139
- actions.showCustomStepper(false);
140
- setCapturedImages((prev) => { const newState = { ...prev }; delete newState[sideToRetake]; return newState; });
141
- if (value) { const newValue = { ...value }; delete newValue[sideToRetake]; onValueChange(newValue); }
142
- };
143
-
144
- const getCurrentSideVerification = (currentSide: string, countryKey: string) => {
145
- const rawDocType = countrySelectionData?.documentType;
146
- if (!rawDocType || !countryKey) { return { authMethod: [], mrzTypes: [], regionMapping: null, key: 'root' }; }
147
- const rawCountryName = ISO_TO_COUNTRY_NAME[countryData?.code || ''] || countryData?.code || countryKey;
148
- const baseMapping = (REGION_MAPPING as any).regionMapping || REGION_MAPPING;
149
- let countryMapping = baseMapping[rawCountryName];
150
- if (!countryMapping) {
151
- const foundKey = Object.keys(baseMapping).find(k => k.toLowerCase() === rawCountryName.toLowerCase() || k.toLowerCase() === countryKey.toLowerCase());
152
- if (foundKey) countryMapping = baseMapping[foundKey];
153
- }
154
- if (!countryMapping) { return { authMethod: [], mrzTypes: [], regionMapping: null, key: 'root' }; }
155
- const regionMapping = countryMapping[rawDocType];
156
- if (!regionMapping) { return { authMethod: [], mrzTypes: [], regionMapping: null, key: 'root' }; }
157
- const authMethod: string[] = [];
158
- const mrzTypes: string[] = [];
159
- const key = countrySelectionData.region?.trim()?.length > 0 ? countrySelectionData.region.trim() : 'root';
160
- if (regionMapping?.[key] && Array.isArray(regionMapping[key])) {
161
- regionMapping[key].forEach((item: any) => {
162
- if (item[currentSide]) {
163
- authMethod.push(item[currentSide]);
164
- if (item?.mrz_type) mrzTypes.push(item?.mrz_type);
165
- }
166
- });
167
- }
168
- return { authMethod: removeDuplicates(authMethod), mrzTypes: removeDuplicates(mrzTypes), regionMapping, key };
169
- }
170
-
171
- const getCorrespondingMrzType = (templatePath: string, mapping: any, selectedDocumentType: string = "root") => {
172
- if (!mapping || !mapping[selectedDocumentType]) return null;
173
- const fileName = templatePath.split("/").pop()?.replace(".jpg", "").replace(".png", "");
174
- if (!fileName) return null;
175
- const pyName = `${fileName}.py`;
176
- const found = mapping[selectedDocumentType].find((item: any) => item.py_file === pyName);
177
- return found?.mrz_type || null;
178
- }
179
-
180
- const getCorrespondingAuthMethod = (templatePath: string, mapping: any, selectedDocumentType: string = "root", side: 'front' | 'back') => {
181
- if (!mapping || !mapping[selectedDocumentType]) return null;
182
- const fileName = templatePath.split("/").pop()?.replace(".jpg", "").replace(".png", "");
183
- if (!fileName) return null;
184
- const pyName = `${fileName}.py`;
185
- const found = mapping[selectedDocumentType].find((item: any) => item.py_file === pyName);
186
- return found?.[side] || null;
187
- }
188
-
189
- const autoCapture = async (capturePath: string, verified: ISilentCaptureResult) => {
190
- if (isProcessingCapture) return;
191
- setIsProcessingCapture(true);
192
- setProcessingImagePath(capturePath);
193
- try {
194
- let imagePathForUpload = capturePath;
195
- const overlayBbox = cameraConfig.overlay.bbox;
196
- const uiCropBbox = {
197
- minX: overlayBbox.xMin / 100,
198
- minY: overlayBbox.yMin / 100,
199
- width: (overlayBbox.xMax - overlayBbox.xMin) / 100,
200
- height: (overlayBbox.yMax - overlayBbox.yMin) / 100,
201
- };
202
- try {
203
- imagePathForUpload = await cropImageWithBBoxWithTolerance(capturePath, uiCropBbox, 0.06);
204
- } catch (e) {
205
- console.warn("Crop failed, falling back to original image", e);
206
- imagePathForUpload = capturePath;
207
- }
208
- const base64 = await pathToBase64(imagePathForUpload);
209
- const newImages = {
210
- ...capturedImages,
211
- [currentSide]: { dir: imagePathForUpload, file: base64, mrz: verified.mrz || "", templatePath: verified.templatePath },
212
- };
213
- setCapturedImages(newImages);
214
- if (verified.country && verified.documentType) {
215
- onValueChange({
216
- ...newImages,
217
- country: verified.country,
218
- documentType: GovernmentDocumentTypeBackend[verified.documentType as keyof typeof GovernmentDocumentTypeBackend] || '',
219
- });
220
- }
221
- setTimeout(() => {
222
- setShowCamera(false);
223
- actions.showCustomStepper(true);
224
- setSilentCaptureResult(prev => ({ ...prev, isAnalyzing: false }));
225
- setIsProcessingCapture(false);
226
- setProcessingImagePath(null);
227
- }, 600);
228
- } catch (e: any) {
229
- console.error("Backend Error:", e);
230
- const friendlyError = state.currentLanguage === 'en' ? 'Unable to process document. Please try again.' : 'Impossible de traiter le document. Veuillez réessayer.';
231
- showAlert('Error', friendlyError);
232
- setSilentCaptureResult(prev => ({ ...prev, isAnalyzing: false, success: false }));
233
- setIsProcessingCapture(false);
234
- setProcessingImagePath(null);
235
- }
236
- };
237
-
238
- const handleSilentCapture = async (result: { success: boolean; path?: string; error?: string }) => {
239
- if (silentCaptureResult.isAnalyzing || isProcessingCapture) return;
240
- if (result.success && result.path) {
241
- setSilentCaptureResult((prev) => ({ ...prev, isAnalyzing: true, success: false, error: '' }));
242
- let templatePath = capturedImages[currentSide]?.templatePath || '';
243
- let templateBbox: IBbox | undefined;
244
- let templateResponse: any;
245
- if (!selectedDocumentType) {
246
- setSilentCaptureResult((prev) => ({ ...prev, isAnalyzing: false, success: false, error: 'Document type not selected' }));
247
- return;
248
- }
249
- try {
250
- if (!templatePath) {
251
- const templateType = await checkTemplateType({ path: result.path || '', docType: selectedDocumentType?.type as GovernmentDocumentType, docRegion: countryData?.code || "", postfix: currentSide }, env);
252
- templateResponse = templateType;
253
- if (templateType.template_path) templatePath = templateType.template_path;
254
- if (templateType.card_obb) {
255
- const obbConfidence = getObbConfidence((templateType as any).card_obb);
256
- if (obbConfidence !== null && obbConfidence < OBB_CONFIDENCE_THRESHOLD) {
257
- setSilentCaptureResult((prev) => ({ ...prev, isAnalyzing: false, success: false, error: t('kyc.idCardCapture.cardNotFullyInFrame') }));
258
- return;
259
- }
260
- try {
261
- const crop = await cropByObb(result.path, (templateType as any).card_obb);
262
- templateBbox = crop.bbox;
263
- } catch { }
264
- }
265
- }
266
- const extractedCountryKey = templatePath ? templatePath.split('/')[0] : (ISO_TO_COUNTRY_NAME[countryData?.code || ''] || 'root');
267
- const regionMappings = getCurrentSideVerification(currentSide, extractedCountryKey);
268
- let verificationRes: any;
269
- if (currentSide === 'front') {
270
- const matchedAuthMethod = getCorrespondingAuthMethod(templatePath, regionMappings.regionMapping, regionMappings.key || '', 'front');
271
- const mrzType = getCorrespondingMrzType(templatePath, regionMappings.regionMapping, regionMappings.key || '') || 'TD1';
272
- 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);
273
- } else {
274
- let matchedBackAuthMethod = getCorrespondingAuthMethod(templatePath, regionMappings.regionMapping, regionMappings.key || '', 'back');
275
- if (!matchedBackAuthMethod && currentSide === 'back') { matchedBackAuthMethod = 'MRZ'; }
276
- const backMrzType = getCorrespondingMrzType(templatePath, regionMappings.regionMapping, regionMappings.key || '') || '';
277
- 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);
278
- }
279
- const bbox = verificationRes?.bbox || templateBbox;
280
- const mrz = verificationRes?.mrz ? JSON.stringify(verificationRes.mrz) : "";
281
- const verifiedResult: ISilentCaptureResult = { path: result.path, templatePath, bbox, success: true, mrz, isAnalyzing: false, country: countryData?.code, documentType: selectedDocumentType.type };
282
- setSilentCaptureResult(verifiedResult);
283
- if (bbox) setBboxBySide(prev => ({ ...prev, [currentSide]: bbox }));
284
- await autoCapture(result.path, verifiedResult);
285
- } catch (error: any) {
286
- console.log("Backend Verification Error:", error);
287
- const rawMessage = (error?.message || '').toLowerCase();
288
- let userFriendlyMessage ='Verification failed. Please try again.';
289
- if (rawMessage.includes('impossible') || rawMessage.includes('unreadable')) {
290
- userFriendlyMessage = 'Document unreadable. Please hold the camera steady in good light.';
291
- } else if (rawMessage.includes('country mismatch') || rawMessage.includes('pays sélectionné')) {
292
- userFriendlyMessage = 'The document does not match your selected country.';
293
- } else if (rawMessage.includes('card_not_fully_in_frame')) {
294
- userFriendlyMessage = 'Move closer to the document and ensure all corners are visible.';
295
- }
296
- setSilentCaptureResult(prev => ({ ...prev, isAnalyzing: false, success: false, error: userFriendlyMessage }));
297
- }
298
- }
299
- };
300
-
301
- const handleError = (event: { message: string }) => {
302
- showAlert('Erreur', event.message);
303
- setShowCamera(false);
304
- setIsProcessingCapture(false);
305
- };
306
-
307
- useEffect(() => {
308
- actions.showCustomStepper(!showCamera);
309
- }, [showCamera]);
310
-
311
- if (!countrySelectionData || !selectedDocumentType) {
312
- return (
313
- <View style={styles.root}>
314
- <View style={styles.previewContainer}>
315
- <Text style={styles.title}>{getLocalizedText(component.labels)}</Text>
316
- <Text style={styles.description}>
317
- {state.currentLanguage === "en" ? "Please complete the country and document selection first." : "Veuillez d'abord compléter la sélection du pays et du document."}
318
- </Text>
319
- </View>
320
- </View>
321
- );
322
- }
323
-
324
- if (showCamera) {
325
- const isBusy = isProcessingCapture;
326
- if (isRebootingCamera) {
327
- return (
328
- <View style={[styles.root, { justifyContent: 'center', alignItems: 'center', backgroundColor: '#000' }]}>
329
- <ActivityIndicator size="large" color="#2DBD60" />
330
- <Text style={{ color: 'white', marginTop: 20, fontSize: 16 }}>Initializing Camera...</Text>
331
- </View>
332
- );
333
- }
334
- return (
335
- <View style={styles.root}>
336
- <View style={[styles.cameraWrapper, { flex: 1 }]}>
337
- <View style={styles.headerContainer}>
338
- <Text style={styles.headerTitle}>
339
- {selectedDocumentType ? (locale === 'en' ? getDocumentTypeInfo(selectedDocumentType.type).name.en : getDocumentTypeInfo(selectedDocumentType.type).name.fr) : ''}
340
- </Text>
341
- <View style={styles.stepBadge}>
342
- <Text style={styles.stepText}>
343
- {t('kyc.idCardCapture.captureTitle', { side: currentSide === 'front' ? locale === 'en' ? 'Front' : 'Recto' : locale === 'en' ? 'Back' : 'Verso' })}
344
- </Text>
345
- </View>
346
- </View>
347
-
348
- <View style={[styles.cameraFeedContainer, { flex: 1, backgroundColor: '#000' }]}>
349
- {Platform.OS === 'web' && (
350
- <View style={styles.webTopControls}>
351
- <TouchableOpacity style={styles.webFlipButton} onPress={toggleCameraLens}>
352
- <Text style={styles.webFlipText}>🔄 Flip Lens</Text>
353
- </TouchableOpacity>
354
- </View>
355
- )}
356
-
357
- <EnhancedCameraView
358
- key={`${currentSide}-${cameraKey}`}
359
- showCamera={true}
360
- isProcessing={isBusy}
361
- cameraType={cameraConfig.cameraType}
362
- style={{ ...StyleSheet.absoluteFillObject, ...(Platform.OS === 'web' ? { width: '100%', height: '100%', left: 0, top: 0 } as any : {}) }}
363
- onError={handleError}
364
- onSilentCapture={handleSilentCapture}
365
- silentCaptureResult={silentCaptureResult}
366
- overlayComponent={
367
- <>
368
- <IdCardOverlay
369
- xMin={cameraConfig.overlay.bbox.xMin} yMin={cameraConfig.overlay.bbox.yMin} xMax={cameraConfig.overlay.bbox.xMax} yMax={cameraConfig.overlay.bbox.yMax}
370
- instructions={cameraConfig.overlay.guideText}
371
- cornerOpacity={cameraConfig.overlay.bbox.cornerRadius || 0 as number}
372
- isSuccess={silentCaptureResult.success}
373
- language={state.currentLanguage}
374
- stepperProps={{
375
- back: () => {
376
- if (currentSide === 'back') {
377
- setCurrentSide('front'); setShowCamera(false); setIsProcessingCapture(false); 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>
400
- </View>
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
- {!isBusy && Platform.OS === 'web' ? (
422
- <View style={styles.webBottomControlBar}>
423
- {silentCaptureResult.error && (
424
- <View style={styles.floatingErrorBanner}>
425
- <Text style={styles.floatingErrorText}>⚠️ {silentCaptureResult.error}</Text>
426
- </View>
427
- )}
428
- <View style={styles.webActionButtonsRow}>
429
- <TouchableOpacity style={styles.webSecondaryButton} onPress={() => setShowCamera(false)}>
430
- <Text style={styles.webSecondaryButtonText}>Cancel</Text>
431
- </TouchableOpacity>
432
- <TouchableOpacity style={styles.webPrimaryButton} onPress={refreshCamera}>
433
- <Text style={styles.webPrimaryButtonText}>↻ Refresh Camera</Text>
434
- </TouchableOpacity>
435
- </View>
436
- </View>
437
- ) : !isBusy ? (
438
- <View style={styles.escapeHatchContainer}>
439
- <TouchableOpacity style={styles.fallbackRefreshButton} onPress={refreshCamera}>
440
- <Text style={styles.fallbackRefreshText}>↻ Refresh Camera</Text>
441
- </TouchableOpacity>
442
- <TouchableOpacity
443
- style={[styles.fallbackRefreshButton, { marginTop: 15, backgroundColor: 'rgba(220, 38, 38, 0.8)', borderColor: '#DC2626' }]}
444
- onPress={() => setShowCamera(false)}
445
- >
446
- <Text style={styles.fallbackRefreshText}>Cancel / Go Back</Text>
447
- </TouchableOpacity>
448
- </View>
449
- ) : null}
450
-
451
- {silentCaptureResult.error && !isBusy && Platform.OS !== 'web' ? (
452
- <View style={[styles.floatingErrorBanner, { zIndex: 10000 }]}>
453
- <Text style={styles.floatingErrorText}>⚠️ {silentCaptureResult.error}</Text>
454
- </View>
455
- ) : null}
456
- </View>
457
- </View>
458
- </View>
459
- );
460
- }
461
-
462
- return (
463
- <View style={styles.root}>
464
- <View style={styles.previewContainer}>
465
- <ScrollView style={styles.previewItemContainer} showsVerticalScrollIndicator={false}>
466
- <View key={currentSide} style={styles.sideContainer}>
467
- <Text style={styles.sideTitle}>
468
- {t('kyc.idCardCapture.captureTitle', { side: currentSide === 'front' ? locale === 'en' ? 'Front' : 'Recto' : locale === 'en' ? 'Back' : 'Verso' })}
469
- </Text>
470
- <Text style={{ fontSize: 14, color: '#666', textAlign: 'center', marginBottom: 16, lineHeight: 22 }}>
471
- {getLocalizedText(component.instructions)}
472
- </Text>
473
- <View style={{ alignItems: 'center', justifyContent: 'center', flexDirection: "column", gap: 16 }}>
474
- <View style={styles.imagePreviewWrapper}>
475
- {capturedImages[currentSide]?.dir ? (
476
- <Image source={{ uri: capturedImages[currentSide].dir }} style={styles.previewImage} />
477
- ) : silentCaptureResult.path ? (
478
- <Image source={{ uri: silentCaptureResult.path }} style={styles.previewImage} />
479
- ) : null}
480
- </View>
481
- {!capturedImages[currentSide]?.dir && (
482
- <View style={{ width: '100%', gap: 12 }}>
483
- <Button
484
- title={state.currentLanguage === "en" ? "Start Scanning" : "Commencer la numérisation"}
485
- onPress={() => { setShowCamera(true); refreshCamera(); actions.showCustomStepper(false); }}
486
- variant="primary" size="large" fullWidth
487
- />
488
- {Platform.OS === 'web' && (
489
- <Button
490
- title={state.currentLanguage === "en" ? "Continue on Phone" : "Continuer sur le téléphone"}
491
- onPress={() => setShowQRModal(true)}
492
- variant="outline" size="large" fullWidth
493
- />
494
- )}
495
- </View>
496
- )}
497
- {capturedImages[currentSide]?.dir && (
498
- <>
499
- <Button title={t('kyc.idCardCapture.retakeButton')} onPress={() => retakePicture(currentSide)} variant="outline" size="medium" fullWidth />
500
- <Button title={t('common.next')} onPress={() => {
501
- if (!selectedDocumentType) { showAlert('Error', 'Document type not selected'); return; }
502
- if (currentSide === 'back' || selectedDocumentType.type === 'passport') {
503
- actions.nextComponent();
504
- } else {
505
- setShowCamera(true); refreshCamera(); setCurrentSide('back'); setSilentCaptureResult({ success: false, isAnalyzing: false, path: '', error: '', templatePath: undefined }); setIsProcessingCapture(false); setProcessingImagePath(null);
506
- }
507
- }} variant="primary" size="large" fullWidth />
508
- </>
509
- )}
510
- </View>
511
- </View>
512
- </ScrollView>
513
- </View>
514
- {showQRModal && Platform.OS === 'web' && (
515
- <View style={styles.qrModalOverlay}>
516
- <View style={styles.qrModalContainer}>
517
- <Text style={styles.qrModalTitle}>
518
- {state.currentLanguage === 'en' ? 'Scan to continue' : 'Scannez pour continuer'}
519
- </Text>
520
- <Text style={styles.qrModalText}>
521
- {state.currentLanguage === 'en' ? "Point your phone's camera at this QR code to seamlessly continue the process on your mobile device." : "Pointez l'appareil photo de votre téléphone vers ce code QR pour continuer le processus en toute fluidité sur votre appareil mobile."}
522
- </Text>
523
- <View style={styles.qrCodeWrapper}>
524
- <QRCode value={currentUrl} size={220} backgroundColor="transparent" />
525
- </View>
526
- <Button title={state.currentLanguage === 'en' ? "Close" : "Fermer"} onPress={() => setShowQRModal(false)} variant="outline" fullWidth />
527
- </View>
528
- </View>
529
- )}
530
- </View>
531
- );
532
- };
533
-
534
- const styles = StyleSheet.create({
535
- root: { flex: 1, width: '100%', backgroundColor: 'transparent', alignSelf: 'center', ...Platform.select({ web: { minHeight: '85vh', justifyContent: 'center', alignItems: 'center' } as any, }) },
536
- cameraWrapper: { width: '100%', backgroundColor: '#000000', overflow: 'hidden', ...Platform.select({ web: { maxWidth: 1000, width: '95%', height: '80vh', minHeight: 600, borderRadius: 24, shadowColor: '#000', shadowOffset: { width: 0, height: 10 }, shadowOpacity: 0.15, shadowRadius: 30, elevation: 24 } as any, default: { flex: 1 } }) },
537
- // 🚨 UI FIX: flexWrap handles overflowing text perfectly on narrow mobile screens
538
- headerContainer: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 10, paddingHorizontal: 20, paddingVertical: 18, backgroundColor: '#FFFFFF', borderBottomWidth: 1, borderBottomColor: '#F1F5F9', zIndex: 10, ...Platform.select({ web: { display: 'flex' }, default: { display: 'none' } }) },
539
- headerTitle: { fontSize: 18, fontWeight: '700', color: '#0F172A', flexShrink: 1 },
540
- stepBadge: { backgroundColor: '#F1F5F9', paddingHorizontal: 12, paddingVertical: 6, borderRadius: 20, },
541
- stepText: { fontSize: 13, fontWeight: '600', color: '#64748B', },
542
- cameraFeedContainer: { flex: 1, position: 'relative', backgroundColor: '#000', },
543
- escapeHatchContainer: { position: 'absolute', bottom: 40, left: 0, right: 0, alignItems: 'center', justifyContent: 'center', zIndex: 99999 },
544
- 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 },
545
- fallbackRefreshText: { color: '#FFFFFF', fontWeight: 'bold', fontSize: 16 },
546
- webBottomControlBar: { position: 'absolute', bottom: 0, left: 0, right: 0, paddingHorizontal: 24, paddingBottom: 24, paddingTop: 30, backgroundColor: 'rgba(0,0,0,0.6)', zIndex: 99999 },
547
- webActionButtonsRow: { flexDirection: 'row', justifyContent: 'center', gap: 16, marginTop: 12, maxWidth: 500, alignSelf: 'center', width: '100%' },
548
- webPrimaryButton: { flex: 1, backgroundColor: '#2DBD60', paddingVertical: 14, borderRadius: 12, alignItems: 'center' },
549
- webPrimaryButtonText: { color: 'white', fontWeight: 'bold', fontSize: 16 },
550
- webSecondaryButton: { flex: 1, backgroundColor: 'rgba(255,255,255,0.15)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.3)', paddingVertical: 14, borderRadius: 12, alignItems: 'center' },
551
- webSecondaryButtonText: { color: 'white', fontWeight: 'bold', fontSize: 16 },
552
- webTopControls: { position: 'absolute', top: Platform.OS === 'ios' ? 70 : 20, right: 20, zIndex: 9999 },
553
- webFlipButton: { backgroundColor: 'rgba(0,0,0,0.6)', paddingHorizontal: 16, paddingVertical: 10, borderRadius: 20, borderWidth: 1, borderColor: 'rgba(255,255,255,0.4)', ...Platform.select({ web: { backdropFilter: 'blur(4px)' } as any }) },
554
- webFlipText: { color: 'white', fontWeight: 'bold', fontSize: 14 },
555
- previewContainer: { width: '100%', backgroundColor: 'white', borderRadius: 12, paddingVertical: 30, paddingHorizontal: 24, shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.08, shadowRadius: 16, elevation: 8, ...Platform.select({ web: { alignSelf: 'center', maxWidth: 650 }, default: { margin: 10, width: '95%' }}) },
556
- previewItemContainer: { flexGrow: 1, },
557
- title: { fontSize: 24, fontWeight: 'bold', color: '#333', marginBottom: 8, textAlign: 'center' },
558
- description: { fontSize: 16, color: '#666', textAlign: 'center', marginBottom: 24, lineHeight: 22 },
559
- sideContainer: { marginBottom: 24 },
560
- sideTitle: { fontSize: 22, fontWeight: 'bold', color: '#000', marginBottom: 12, textAlign: 'center' },
561
- imagePreviewWrapper: { width: '100%', borderRadius: 12, padding: 1, overflow: 'hidden', shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.12, shadowRadius: 8, elevation: 8, backgroundColor: '#f0f0f0', ...Platform.select({ web: { aspectRatio: 1.59, height: 'auto', maxWidth: 450, alignSelf: 'center' } as any, default: { height: 220 } }) },
562
- previewImage: { width: '100%', height: '100%', borderRadius: 12, resizeMode: 'cover' },
563
- floatingErrorBanner: { backgroundColor: '#FEF2F2', borderWidth: 1, borderColor: '#FCA5A5', paddingVertical: 12, paddingHorizontal: 16, borderRadius: 12, alignItems: 'center', justifyContent: 'center', width: '100%', ...Platform.select({ web: { position: 'absolute', bottom: '100%', marginBottom: 16, maxWidth: 500, alignSelf: 'center', zIndex: 100000, boxShadow: '0 4px 12px rgba(0,0,0,0.15)' } as any, default: { position: 'absolute', top: Platform.OS === 'ios' ? 90 : 70, left: 24, right: 24, zIndex: 10000 } }) },
564
- floatingErrorText: { color: '#991B1B', fontSize: 14, fontWeight: '700', textAlign: 'center' },
565
- processingOverlay: { flex: 1, backgroundColor: 'rgba(0, 0, 0, 0.7)', justifyContent: 'center', alignItems: 'center', zIndex: 9999, ...Platform.select({ web: { backdropFilter: 'blur(4px)' } as any }) },
566
- processingText: { color: '#FFF', fontSize: 18, fontWeight: 'bold', marginTop: 16, textAlign: 'center' },
567
- errorText: { color: '#dc2626', fontSize: 14, marginTop: 8, textAlign: 'center' },
568
- // 🚨 UI FIX: Dropped the pill slightly lower so it never overlaps the flip lens button
569
- topAnalyzingPillContainer: { position: 'absolute', top: Platform.OS === 'android' ? 60 : 75, left: 0, right: 0, alignItems: 'center', zIndex: 100 },
570
- topAnalyzingPill: { flexDirection: 'row', alignItems: 'center', backgroundColor: 'rgba(0,0,0,0.7)', paddingVertical: 10, paddingHorizontal: 20, borderRadius: 30, gap: 10, shadowColor: '#000', shadowOffset: {width: 0, height: 4}, shadowOpacity: 0.3, shadowRadius: 8 },
571
- analyzingPillText: { color: 'white', fontSize: 14, fontWeight: 'bold' },
572
- qrModalOverlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.6)', justifyContent: 'center', alignItems: 'center', zIndex: 999999, ...Platform.select({ web: { backdropFilter: 'blur(5px)' } as any }) },
573
- qrModalContainer: { backgroundColor: '#FFFFFF', borderRadius: 24, padding: 32, width: '90%', maxWidth: 400, alignItems: 'center', shadowColor: '#000', shadowOffset: { width: 0, height: 10 }, shadowOpacity: 0.15, shadowRadius: 30, elevation: 24, },
574
- qrModalTitle: { fontSize: 22, fontWeight: 'bold', color: '#0F172A', marginBottom: 12, textAlign: 'center' },
575
- qrModalText: { fontSize: 15, color: '#64748B', textAlign: 'center', marginBottom: 24, lineHeight: 22, },
576
- qrCodeWrapper: { padding: 16, backgroundColor: '#FFFFFF', borderRadius: 16, borderWidth: 1, borderColor: '#E2E8F0', marginBottom: 24, }
280
+ const extractedCountryKey = templatePath ? templatePath.split('/')[0] : (ISO_TO_COUNTRY_NAME[countryData?.code || ''] || 'root');
281
+ const regionMappings = getCurrentSideVerification(currentSide, extractedCountryKey);
282
+ let verificationRes: any;
283
+
284
+ if (currentSide === 'front') {
285
+ const matchedAuthMethod = getCorrespondingAuthMethod(templatePath, regionMappings.regionMapping, regionMappings.key || '', 'front');
286
+ const mrzType = getCorrespondingMrzType(templatePath, regionMappings.regionMapping, regionMappings.key || '') || 'TD1';
287
+
288
+ verificationRes = await frontVerification({
289
+ path: result.path,
290
+ regionMapping: { authMethod: matchedAuthMethod ? [matchedAuthMethod] : regionMappings.authMethod, mrzTypes: regionMappings.mrzTypes },
291
+ selectedDocumentType: GovernmentDocumentTypeShorted[selectedDocumentType?.type as keyof typeof GovernmentDocumentTypeShorted] || '',
292
+ code: countryData?.code || '',
293
+ currentSide,
294
+ templatePath,
295
+ mrzType,
296
+ issuingSubdivision: issuingSubdivision
297
+ }, env);
298
+ } else {
299
+ let matchedBackAuthMethod = getCorrespondingAuthMethod(templatePath, regionMappings.regionMapping, regionMappings.key || '', 'back');
300
+ if (!matchedBackAuthMethod && currentSide === 'back') { matchedBackAuthMethod = 'MRZ'; }
301
+ const backMrzType = getCorrespondingMrzType(templatePath, regionMappings.regionMapping, regionMappings.key || '') || '';
302
+
303
+ // 🚨 EXTRACT FRONT TEMPLATE TO VALIDATE AGAINST BACK TEMPLATE
304
+ const frontTemplatePath = capturedImages['front']?.templatePath || '';
305
+
306
+ verificationRes = await backVerification({
307
+ path: result.path,
308
+ regionMapping: { authMethod: matchedBackAuthMethod ? [matchedBackAuthMethod] : regionMappings.authMethod, mrzTypes: regionMappings.mrzTypes },
309
+ selectedDocumentType: GovernmentDocumentTypeShorted[selectedDocumentType.type as keyof typeof GovernmentDocumentTypeShorted] || '',
310
+ code: countryData?.code || '',
311
+ currentSide,
312
+ templatePath,
313
+ mrzType: backMrzType,
314
+ templateResponse,
315
+ issuingSubdivision: issuingSubdivision,
316
+ frontTemplatePath: frontTemplatePath // 🚨 PASSED DOWN TO TRIGGER VALIDATION
317
+ }, env);
318
+ }
319
+
320
+ const bbox = verificationRes?.bbox || templateBbox;
321
+ const mrz = verificationRes?.mrz ? JSON.stringify(verificationRes.mrz) : "";
322
+
323
+ const verifiedResult: ISilentCaptureResult & { region?: string } = {
324
+ path: result.path,
325
+ templatePath,
326
+ bbox,
327
+ success: true,
328
+ mrz,
329
+ isAnalyzing: false,
330
+ country: countryData?.code,
331
+ documentType: selectedDocumentType.type,
332
+ region: issuingSubdivision
333
+ };
334
+
335
+ setSilentCaptureResult(verifiedResult);
336
+ if (bbox) setBboxBySide(prev => ({ ...prev, [currentSide]: bbox }));
337
+
338
+ await autoCapture(result.path, verifiedResult);
339
+
340
+ } catch (error: any) {
341
+ console.log("Backend Verification Error:", error);
342
+ const rawMessage = (error?.message || '').toLowerCase();
343
+ let userFriendlyMessage ='Verification failed. Please try again.';
344
+
345
+ if (rawMessage.includes('impossible') || rawMessage.includes('unreadable')) {
346
+ userFriendlyMessage = 'Document unreadable. Please hold the camera steady in good light.';
347
+ } else if (rawMessage.includes('country mismatch') || rawMessage.includes('pays sélectionné')) {
348
+ userFriendlyMessage = 'The document does not match your selected country.';
349
+ } else if (rawMessage.includes('card_not_fully_in_frame')) {
350
+ userFriendlyMessage = 'Move closer to the document and ensure all corners are visible.';
351
+ } else if (rawMessage.includes('ne correspond pas au recto')) { // 🚨 New Security Error Handler
352
+ userFriendlyMessage = 'The back of the document does not match the front. You must scan the same ID.';
353
+ }
354
+
355
+ setSilentCaptureResult(prev => ({ ...prev, isAnalyzing: false, success: false, error: userFriendlyMessage }));
356
+ }
357
+ }
358
+ };
359
+
360
+ const handleError = (event: { message: string }) => {
361
+ showAlert('Erreur', event.message);
362
+ setShowCamera(false);
363
+ setIsProcessingCapture(false);
364
+ };
365
+
366
+ useEffect(() => {
367
+ actions.showCustomStepper(!showCamera);
368
+ }, [showCamera]);
369
+
370
+ if (!countrySelectionData || !selectedDocumentType) {
371
+ return (
372
+ <View style={styles.root}>
373
+ <View style={styles.previewContainer}>
374
+ <Text style={styles.title}>{getLocalizedText(component.labels)}</Text>
375
+ <Text style={styles.description}>
376
+ {state.currentLanguage === "en" ? "Please complete the country and document selection first." : "Veuillez d'abord compléter la sélection du pays et du document."}
377
+ </Text>
378
+ </View>
379
+ </View>
380
+ );
381
+ }
382
+
383
+ if (showCamera) {
384
+ const isBusy = isProcessingCapture;
385
+ if (isRebootingCamera) {
386
+ return (
387
+ <View style={[styles.root, { justifyContent: 'center', alignItems: 'center', backgroundColor: '#000' }]}>
388
+ <ActivityIndicator size="large" color="#2DBD60" />
389
+ <Text style={{ color: 'white', marginTop: 20, fontSize: 16 }}>Initializing Camera...</Text>
390
+ </View>
391
+ );
392
+ }
393
+ return (
394
+ <View style={styles.root}>
395
+ <View style={[styles.cameraWrapper, { flex: 1 }]}>
396
+ <View style={styles.headerContainer}>
397
+ <Text style={styles.headerTitle}>
398
+ {selectedDocumentType ? (locale === 'en' ? getDocumentTypeInfo(selectedDocumentType.type).name.en : getDocumentTypeInfo(selectedDocumentType.type).name.fr) : ''}
399
+ </Text>
400
+ <View style={styles.stepBadge}>
401
+ <Text style={styles.stepText}>
402
+ {t('kyc.idCardCapture.captureTitle', { side: currentSide === 'front' ? locale === 'en' ? 'Front' : 'Recto' : locale === 'en' ? 'Back' : 'Verso' })}
403
+ </Text>
404
+ </View>
405
+ </View>
406
+
407
+ <View style={[styles.cameraFeedContainer, { flex: 1, backgroundColor: '#000' }]}>
408
+ {Platform.OS === 'web' && (
409
+ <View style={styles.webTopControls}>
410
+ <TouchableOpacity style={styles.webFlipButton} onPress={toggleCameraLens}>
411
+ <Text style={styles.webFlipText}>🔄 Flip Lens</Text>
412
+ </TouchableOpacity>
413
+ </View>
414
+ )}
415
+
416
+ <EnhancedCameraView
417
+ key={`${currentSide}-${cameraKey}`}
418
+ showCamera={true}
419
+ isProcessing={isBusy}
420
+ cameraType={cameraConfig.cameraType}
421
+ style={{ ...StyleSheet.absoluteFillObject, ...(Platform.OS === 'web' ? { width: '100%', height: '100%', left: 0, top: 0 } as any : {}) }}
422
+ onError={handleError}
423
+ onSilentCapture={handleSilentCapture}
424
+ silentCaptureResult={silentCaptureResult}
425
+ overlayComponent={
426
+ <>
427
+ <IdCardOverlay
428
+ xMin={cameraConfig.overlay.bbox.xMin} yMin={cameraConfig.overlay.bbox.yMin} xMax={cameraConfig.overlay.bbox.xMax} yMax={cameraConfig.overlay.bbox.yMax}
429
+ instructions={cameraConfig.overlay.guideText}
430
+ cornerOpacity={cameraConfig.overlay.bbox.cornerRadius || 0 as number}
431
+ isSuccess={silentCaptureResult.success}
432
+ language={state.currentLanguage}
433
+ stepperProps={{
434
+ back: () => {
435
+ if (currentSide === 'back') {
436
+ setCurrentSide('front'); setShowCamera(false); setIsProcessingCapture(false); setProcessingImagePath(null);
437
+ if (capturedImages['front']?.dir) {
438
+ const frontImage = capturedImages['front'];
439
+ setSilentCaptureResult((prev) => ({ ...prev, path: frontImage.dir, success: true, isAnalyzing: false, error: '', mrz: frontImage.mrz || '', templatePath: frontImage.templatePath || '', bbox: bboxBySide['front'] }));
440
+ } else { setSilentCaptureResult((prev) => ({ ...prev, path: '', success: false, isAnalyzing: false, error: '', templatePath: '' })); }
441
+ } else { actions.previousComponent(); }
442
+ },
443
+ selectedDocumentType: selectedDocumentType ? (locale === 'en' ? getDocumentTypeInfo(selectedDocumentType.type).name.en : getDocumentTypeInfo(selectedDocumentType.type).name.fr) : '',
444
+ step: state.currentComponentIndex + 1, totalSteps: state.template.components.length, side: currentSide,
445
+ }}
446
+ />
447
+ </>
448
+ }
449
+ />
450
+
451
+ {!isBusy && silentCaptureResult.isAnalyzing && (
452
+ <View style={styles.topAnalyzingPillContainer}>
453
+ <View style={styles.topAnalyzingPill}>
454
+ <ActivityIndicator size="small" color="white" />
455
+ <Text style={styles.analyzingPillText}>
456
+ {state.currentLanguage === 'en' ? 'Scanning...' : 'Analyse...'}
457
+ </Text>
458
+ </View>
459
+ </View>
460
+ )}
461
+
462
+ {isBusy && (
463
+ <View style={[StyleSheet.absoluteFillObject, { zIndex: 9999 }]}>
464
+ {processingImagePath && (
465
+ <Image
466
+ source={{ uri: processingImagePath.startsWith('file://') ? processingImagePath : `file://${processingImagePath}` }}
467
+ style={StyleSheet.absoluteFillObject}
468
+ resizeMode="cover"
469
+ />
470
+ )}
471
+ <View style={styles.processingOverlay}>
472
+ <ActivityIndicator size="large" color="#2DBD60" />
473
+ <Text style={styles.processingText}>
474
+ {state.currentLanguage === 'en' ? 'Perfect!\nProcessing Document...' : 'Parfait!\nTraitement du document...'}
475
+ </Text>
476
+ </View>
477
+ </View>
478
+ )}
479
+
480
+ {!isBusy && Platform.OS === 'web' ? (
481
+ <View style={styles.webBottomControlBar}>
482
+ {silentCaptureResult.error && (
483
+ <View style={styles.floatingErrorBanner}>
484
+ <Text style={styles.floatingErrorText}>⚠️ {silentCaptureResult.error}</Text>
485
+ </View>
486
+ )}
487
+ <View style={styles.webActionButtonsRow}>
488
+ <TouchableOpacity style={styles.webSecondaryButton} onPress={() => setShowCamera(false)}>
489
+ <Text style={styles.webSecondaryButtonText}>Cancel</Text>
490
+ </TouchableOpacity>
491
+ <TouchableOpacity style={styles.webPrimaryButton} onPress={refreshCamera}>
492
+ <Text style={styles.webPrimaryButtonText}>↻ Refresh Camera</Text>
493
+ </TouchableOpacity>
494
+ </View>
495
+ </View>
496
+ ) : !isBusy ? (
497
+ <View style={styles.escapeHatchContainer}>
498
+ <TouchableOpacity style={styles.fallbackRefreshButton} onPress={refreshCamera}>
499
+ <Text style={styles.fallbackRefreshText}>↻ Refresh Camera</Text>
500
+ </TouchableOpacity>
501
+ <TouchableOpacity
502
+ style={[styles.fallbackRefreshButton, { marginTop: 15, backgroundColor: 'rgba(220, 38, 38, 0.8)', borderColor: '#DC2626' }]}
503
+ onPress={() => setShowCamera(false)}
504
+ >
505
+ <Text style={styles.fallbackRefreshText}>Cancel / Go Back</Text>
506
+ </TouchableOpacity>
507
+ </View>
508
+ ) : null}
509
+
510
+ {silentCaptureResult.error && !isBusy && Platform.OS !== 'web' ? (
511
+ <View style={[styles.floatingErrorBanner, { zIndex: 10000 }]}>
512
+ <Text style={styles.floatingErrorText}>⚠️ {silentCaptureResult.error}</Text>
513
+ </View>
514
+ ) : null}
515
+ </View>
516
+ </View>
517
+ </View>
518
+ );
519
+ }
520
+
521
+ return (
522
+ <View style={styles.root}>
523
+ <View style={styles.previewContainer}>
524
+ <ScrollView style={styles.previewItemContainer} showsVerticalScrollIndicator={false}>
525
+ <View key={currentSide} style={styles.sideContainer}>
526
+ <Text style={styles.sideTitle}>
527
+ {t('kyc.idCardCapture.captureTitle', { side: currentSide === 'front' ? locale === 'en' ? 'Front' : 'Recto' : locale === 'en' ? 'Back' : 'Verso' })}
528
+ </Text>
529
+ <Text style={{ fontSize: 14, color: '#666', textAlign: 'center', marginBottom: 16, lineHeight: 22 }}>
530
+ {getLocalizedText(component.instructions)}
531
+ </Text>
532
+ <View style={{ alignItems: 'center', justifyContent: 'center', flexDirection: "column", gap: 16 }}>
533
+ <View style={styles.imagePreviewWrapper}>
534
+ {capturedImages[currentSide]?.dir ? (
535
+ <Image source={{ uri: capturedImages[currentSide].dir }} style={styles.previewImage} />
536
+ ) : silentCaptureResult.path ? (
537
+ <Image source={{ uri: silentCaptureResult.path }} style={styles.previewImage} />
538
+ ) : null}
539
+ </View>
540
+ {!capturedImages[currentSide]?.dir && (
541
+ <View style={{ width: '100%', gap: 12 }}>
542
+ <Button
543
+ title={state.currentLanguage === "en" ? "Start Scanning" : "Commencer la numérisation"}
544
+ onPress={() => { setShowCamera(true); refreshCamera(); actions.showCustomStepper(false); }}
545
+ variant="primary" size="large" fullWidth
546
+ />
547
+ {Platform.OS === 'web' && (
548
+ <Button
549
+ title={state.currentLanguage === "en" ? "Continue on Phone" : "Continuer sur le téléphone"}
550
+ onPress={() => setShowQRModal(true)}
551
+ variant="outline" size="large" fullWidth
552
+ />
553
+ )}
554
+ </View>
555
+ )}
556
+ {capturedImages[currentSide]?.dir && (
557
+ <>
558
+ <Button title={t('kyc.idCardCapture.retakeButton')} onPress={() => retakePicture(currentSide)} variant="outline" size="medium" fullWidth />
559
+ <Button title={t('common.next')} onPress={() => {
560
+ if (!selectedDocumentType) { showAlert('Error', 'Document type not selected'); return; }
561
+ if (currentSide === 'back' || selectedDocumentType.type === 'passport') {
562
+ actions.nextComponent();
563
+ } else {
564
+ setShowCamera(true); refreshCamera(); setCurrentSide('back'); setSilentCaptureResult({ success: false, isAnalyzing: false, path: '', error: '', templatePath: undefined }); setIsProcessingCapture(false); setProcessingImagePath(null);
565
+ }
566
+ }} variant="primary" size="large" fullWidth />
567
+ </>
568
+ )}
569
+ </View>
570
+ </View>
571
+ </ScrollView>
572
+ </View>
573
+ {showQRModal && Platform.OS === 'web' && (
574
+ <View style={styles.qrModalOverlay}>
575
+ <View style={styles.qrModalContainer}>
576
+ <Text style={styles.qrModalTitle}>
577
+ {state.currentLanguage === 'en' ? 'Scan to continue' : 'Scannez pour continuer'}
578
+ </Text>
579
+ <Text style={styles.qrModalText}>
580
+ {state.currentLanguage === 'en' ? "Point your phone's camera at this QR code to seamlessly continue the process on your mobile device." : "Pointez l'appareil photo de votre téléphone vers ce code QR pour continuer le processus en toute fluidité sur votre appareil mobile."}
581
+ </Text>
582
+ <View style={styles.qrCodeWrapper}>
583
+ <QRCode value={currentUrl} size={220} backgroundColor="transparent" />
584
+ </View>
585
+ <Button title={state.currentLanguage === 'en' ? "Close" : "Fermer"} onPress={() => setShowQRModal(false)} variant="outline" fullWidth />
586
+ </View>
587
+ </View>
588
+ )}
589
+ </View>
590
+ );
591
+ };
592
+
593
+ const styles = StyleSheet.create({
594
+ root: { flex: 1, width: '100%', backgroundColor: 'transparent', alignSelf: 'center', ...Platform.select({ web: { minHeight: '85vh', justifyContent: 'center', alignItems: 'center' } as any, }) },
595
+ cameraWrapper: { width: '100%', backgroundColor: '#000000', overflow: 'hidden', ...Platform.select({ web: { maxWidth: 1000, width: '95%', height: '80vh', minHeight: 600, borderRadius: 24, shadowColor: '#000', shadowOffset: { width: 0, height: 10 }, shadowOpacity: 0.15, shadowRadius: 30, elevation: 24 } as any, default: { flex: 1 } }) },
596
+ headerContainer: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 10, paddingHorizontal: 20, paddingVertical: 18, backgroundColor: '#FFFFFF', borderBottomWidth: 1, borderBottomColor: '#F1F5F9', zIndex: 10, ...Platform.select({ web: { display: 'flex' }, default: { display: 'none' } }) },
597
+ headerTitle: { fontSize: 18, fontWeight: '700', color: '#0F172A', flexShrink: 1 },
598
+ stepBadge: { backgroundColor: '#F1F5F9', paddingHorizontal: 12, paddingVertical: 6, borderRadius: 20, },
599
+ stepText: { fontSize: 13, fontWeight: '600', color: '#64748B', },
600
+ cameraFeedContainer: { flex: 1, position: 'relative', backgroundColor: '#000', },
601
+ escapeHatchContainer: { position: 'absolute', bottom: 40, left: 0, right: 0, alignItems: 'center', justifyContent: 'center', zIndex: 99999 },
602
+ 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 },
603
+ fallbackRefreshText: { color: '#FFFFFF', fontWeight: 'bold', fontSize: 16 },
604
+ webBottomControlBar: { position: 'absolute', bottom: 0, left: 0, right: 0, paddingHorizontal: 24, paddingBottom: 24, paddingTop: 30, backgroundColor: 'rgba(0,0,0,0.6)', zIndex: 99999 },
605
+ webActionButtonsRow: { flexDirection: 'row', justifyContent: 'center', gap: 16, marginTop: 12, maxWidth: 500, alignSelf: 'center', width: '100%' },
606
+ webPrimaryButton: { flex: 1, backgroundColor: '#2DBD60', paddingVertical: 14, borderRadius: 12, alignItems: 'center' },
607
+ webPrimaryButtonText: { color: 'white', fontWeight: 'bold', fontSize: 16 },
608
+ webSecondaryButton: { flex: 1, backgroundColor: 'rgba(255,255,255,0.15)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.3)', paddingVertical: 14, borderRadius: 12, alignItems: 'center' },
609
+ webSecondaryButtonText: { color: 'white', fontWeight: 'bold', fontSize: 16 },
610
+ webTopControls: { position: 'absolute', top: Platform.OS === 'ios' ? 70 : 20, right: 20, zIndex: 9999 },
611
+ webFlipButton: { backgroundColor: 'rgba(0,0,0,0.6)', paddingHorizontal: 16, paddingVertical: 10, borderRadius: 20, borderWidth: 1, borderColor: 'rgba(255,255,255,0.4)', ...Platform.select({ web: { backdropFilter: 'blur(4px)' } as any }) },
612
+ webFlipText: { color: 'white', fontWeight: 'bold', fontSize: 14 },
613
+ previewContainer: { width: '100%', backgroundColor: 'white', borderRadius: 12, paddingVertical: 30, paddingHorizontal: 24, shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.08, shadowRadius: 16, elevation: 8, ...Platform.select({ web: { alignSelf: 'center', maxWidth: 650 }, default: { margin: 10, width: '95%' }}) },
614
+ previewItemContainer: { flexGrow: 1, },
615
+ title: { fontSize: 24, fontWeight: 'bold', color: '#333', marginBottom: 8, textAlign: 'center' },
616
+ description: { fontSize: 16, color: '#666', textAlign: 'center', marginBottom: 24, lineHeight: 22 },
617
+ sideContainer: { marginBottom: 24 },
618
+ sideTitle: { fontSize: 22, fontWeight: 'bold', color: '#000', marginBottom: 12, textAlign: 'center' },
619
+ imagePreviewWrapper: { width: '100%', borderRadius: 12, padding: 1, overflow: 'hidden', shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.12, shadowRadius: 8, elevation: 8, backgroundColor: '#f0f0f0', ...Platform.select({ web: { aspectRatio: 1.59, height: 'auto', maxWidth: 450, alignSelf: 'center' } as any, default: { height: 220 } }) },
620
+ previewImage: { width: '100%', height: '100%', borderRadius: 12, resizeMode: 'cover' },
621
+ floatingErrorBanner: { backgroundColor: '#FEF2F2', borderWidth: 1, borderColor: '#FCA5A5', paddingVertical: 12, paddingHorizontal: 16, borderRadius: 12, alignItems: 'center', justifyContent: 'center', width: '100%', ...Platform.select({ web: { position: 'absolute', bottom: '100%', marginBottom: 16, maxWidth: 500, alignSelf: 'center', zIndex: 100000, boxShadow: '0 4px 12px rgba(0,0,0,0.15)' } as any, default: { position: 'absolute', top: Platform.OS === 'ios' ? 90 : 70, left: 24, right: 24, zIndex: 10000 } }) },
622
+ floatingErrorText: { color: '#991B1B', fontSize: 14, fontWeight: '700', textAlign: 'center' },
623
+ processingOverlay: { flex: 1, backgroundColor: 'rgba(0, 0, 0, 0.7)', justifyContent: 'center', alignItems: 'center', zIndex: 9999, ...Platform.select({ web: { backdropFilter: 'blur(4px)' } as any }) },
624
+ processingText: { color: '#FFF', fontSize: 18, fontWeight: 'bold', marginTop: 16, textAlign: 'center' },
625
+ errorText: { color: '#dc2626', fontSize: 14, marginTop: 8, textAlign: 'center' },
626
+ topAnalyzingPillContainer: { position: 'absolute', top: Platform.OS === 'android' ? 60 : 75, left: 0, right: 0, alignItems: 'center', zIndex: 100 },
627
+ topAnalyzingPill: { flexDirection: 'row', alignItems: 'center', backgroundColor: 'rgba(0,0,0,0.7)', paddingVertical: 10, paddingHorizontal: 20, borderRadius: 30, gap: 10, shadowColor: '#000', shadowOffset: {width: 0, height: 4}, shadowOpacity: 0.3, shadowRadius: 8 },
628
+ analyzingPillText: { color: 'white', fontSize: 14, fontWeight: 'bold' },
629
+ qrModalOverlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.6)', justifyContent: 'center', alignItems: 'center', zIndex: 999999, ...Platform.select({ web: { backdropFilter: 'blur(5px)' } as any }) },
630
+ qrModalContainer: { backgroundColor: '#FFFFFF', borderRadius: 24, padding: 32, width: '90%', maxWidth: 400, alignItems: 'center', shadowColor: '#000', shadowOffset: { width: 0, height: 10 }, shadowOpacity: 0.15, shadowRadius: 30, elevation: 24, },
631
+ qrModalTitle: { fontSize: 22, fontWeight: 'bold', color: '#0F172A', marginBottom: 12, textAlign: 'center' },
632
+ qrModalText: { fontSize: 15, color: '#64748B', textAlign: 'center', marginBottom: 24, lineHeight: 22, },
633
+ qrCodeWrapper: { padding: 16, backgroundColor: '#FFFFFF', borderRadius: 16, borderWidth: 1, borderColor: '#E2E8F0', marginBottom: 24, }
577
634
  });