@sanctum-key/react-native-sdk 1.0.16 → 1.0.18

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.
@@ -20,587 +20,579 @@ interface IIDCardPayload { dir: string; file: string; mrz: string; templatePath?
20
20
  interface IDCardCaptureProps { component: TemplateComponent; value?: Record<string, IIDCardPayload>; onValueChange: (value: Record<string, IIDCardPayload | string>) => void; error?: string; language?: string; currentSide?: string; }
21
21
 
22
22
  export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value = {}, onValueChange, error, language = 'en' }) => {
23
- const { t, locale } = useI18n();
24
- const [showCamera, setShowCamera] = useState(false);
25
- const [capturedImages, setCapturedImages] = useState<Record<string, IIDCardPayload>>(value || {});
26
- const [currentSide, setCurrentSide] = useState<'front' | 'back'>('front');
27
- const [bboxBySide, setBboxBySide] = useState<Record<string, IBbox>>({});
28
- const [silentCaptureResult, setSilentCaptureResult] = useState<ISilentCaptureResult>({ success: false, isAnalyzing: false });
29
- const [isProcessingCapture, setIsProcessingCapture] = useState(false);
30
- const [processingImagePath, setProcessingImagePath] = useState<string | null>(null);
31
-
32
- const [cameraKey, setCameraKey] = useState(0);
33
- const [isRebootingCamera, setIsRebootingCamera] = useState(false);
34
- // Web specific state
35
- const [cameraType, setCameraType] = useState<'back' | 'front'>('back');
36
-
37
- const documentTypeMapping: Record<string, GovernmentDocumentType> = { 'nationalId': 'national_id', 'passport': 'passport', 'driversLicense': 'drivers_licence', 'residencePermit': 'permanent_residence', 'healthInsuranceCard': 'health_insurance_card', };
38
- const { actions, state, env } = useTemplateKYCFlowContext();
39
-
40
- const getLocalizedText = (text: LocalizedText | Record<string, LocalizedText>): string => {
41
- if (text && typeof text[currentSide] === 'object' && text[currentSide][locale]) return text[currentSide][locale] || '';
42
- return "";
43
- };
44
-
45
- const countrySelectionData = useMemo(() => {
46
- const countrySelectionComponent = state.template.components.find(c => c.type === 'country_selection');
47
- return countrySelectionComponent ? state.componentData[countrySelectionComponent.id] : null;
48
- }, [state.template.components, state.componentData]);
49
-
50
- const selectedDocumentType = useMemo<{ type: GovernmentDocumentType; region: string } | null>(() => {
51
- if (!countrySelectionData?.documentType) return null;
52
- const backendDocType = countrySelectionData.documentType;
53
- const mappedType = documentTypeMapping[backendDocType] || backendDocType as GovernmentDocumentType;
54
- return { type: mappedType, region: countrySelectionData.region || 'root' };
55
- }, [countrySelectionData, documentTypeMapping]);
56
-
57
- const countryData = useMemo(() => countrySelectionData, [countrySelectionData]);
58
-
59
- const refreshCamera = () => {
60
- setIsRebootingCamera(true);
61
- setTimeout(() => {
62
- setCameraKey(prev => prev + 1);
63
- setIsRebootingCamera(false);
64
- }, 500);
65
- };
66
-
67
- const toggleCameraLens = () => {
68
- setCameraType(prev => prev === 'back' ? 'front' : 'back');
69
- refreshCamera();
70
- };
71
-
72
- useEffect(() => {
73
- if (value && Object.keys(value).length > 0) {
74
- if (JSON.stringify(value) !== JSON.stringify(capturedImages)) {
75
- const updatedImages = value as Record<string, IIDCardPayload>;
76
- setCapturedImages(updatedImages);
77
- const currentImageData = updatedImages[currentSide];
78
- if (currentImageData?.dir) {
79
- setSilentCaptureResult(prev => ({
80
- ...prev, path: currentImageData.dir, success: true, isAnalyzing: false, mrz: currentImageData.mrz || '', templatePath: currentImageData.templatePath || '',
23
+ const { t, locale } = useI18n();
24
+ const [showCamera, setShowCamera] = useState(false);
25
+ const [capturedImages, setCapturedImages] = useState<Record<string, IIDCardPayload>>(value || {});
26
+ const [currentSide, setCurrentSide] = useState<'front' | 'back'>('front');
27
+ const [bboxBySide, setBboxBySide] = useState<Record<string, IBbox>>({});
28
+ const [silentCaptureResult, setSilentCaptureResult] = useState<ISilentCaptureResult>({ success: false, isAnalyzing: false });
29
+ const [isProcessingCapture, setIsProcessingCapture] = useState(false);
30
+ const [processingImagePath, setProcessingImagePath] = useState<string | null>(null);
31
+
32
+ const [cameraKey, setCameraKey] = useState(0);
33
+ const [isRebootingCamera, setIsRebootingCamera] = useState(false);
34
+ // Web specific state
35
+ const [cameraType, setCameraType] = useState<'back' | 'front'>('back');
36
+
37
+ const documentTypeMapping: Record<string, GovernmentDocumentType> = { 'nationalId': 'national_id', 'passport': 'passport', 'driversLicense': 'drivers_licence', 'residencePermit': 'permanent_residence', 'healthInsuranceCard': 'health_insurance_card', };
38
+ const { actions, state, env } = useTemplateKYCFlowContext();
39
+
40
+ const getLocalizedText = (text: LocalizedText | Record<string, LocalizedText>): string => {
41
+ if (text && typeof text[currentSide] === 'object' && text[currentSide][locale]) return text[currentSide][locale] || '';
42
+ return "";
43
+ };
44
+
45
+ const countrySelectionData = useMemo(() => {
46
+ const countrySelectionComponent = state.template.components.find(c => c.type === 'country_selection');
47
+ return countrySelectionComponent ? state.componentData[countrySelectionComponent.id] : null;
48
+ }, [state.template.components, state.componentData]);
49
+
50
+ const selectedDocumentType = useMemo<{ type: GovernmentDocumentType; region: string } | null>(() => {
51
+ if (!countrySelectionData?.documentType) return null;
52
+ const backendDocType = countrySelectionData.documentType;
53
+ const mappedType = documentTypeMapping[backendDocType] || backendDocType as GovernmentDocumentType;
54
+ return { type: mappedType, region: countrySelectionData.region || 'root' };
55
+ }, [countrySelectionData, documentTypeMapping]);
56
+
57
+ const countryData = useMemo(() => countrySelectionData, [countrySelectionData]);
58
+
59
+ const refreshCamera = () => {
60
+ setIsRebootingCamera(true);
61
+ setTimeout(() => {
62
+ setCameraKey(prev => prev + 1);
63
+ setIsRebootingCamera(false);
64
+ }, 500);
65
+ };
66
+
67
+ const toggleCameraLens = () => {
68
+ setCameraType(prev => prev === 'back' ? 'front' : 'back');
69
+ refreshCamera();
70
+ };
71
+
72
+ useEffect(() => {
73
+ if (value && Object.keys(value).length > 0) {
74
+ if (JSON.stringify(value) !== JSON.stringify(capturedImages)) {
75
+ const updatedImages = value as Record<string, IIDCardPayload>;
76
+ setCapturedImages(updatedImages);
77
+ const currentImageData = updatedImages[currentSide];
78
+ if (currentImageData?.dir) {
79
+ setSilentCaptureResult(prev => ({
80
+ ...prev, path: currentImageData.dir, success: true, isAnalyzing: false, mrz: currentImageData.mrz || '', templatePath: currentImageData.templatePath || '',
81
+ }));
82
+ }
83
+ }
84
+ }
85
+ }, [value, currentSide]);
86
+
87
+ const cameraConfig = useMemo(() => {
88
+ const instructions = selectedDocumentType ? (locale === 'en' ? getDocumentTypeInfo(selectedDocumentType.type).instructions.en : getDocumentTypeInfo(selectedDocumentType.type).instructions.fr) : getLocalizedText(component.instructions as Record<string, LocalizedText>);
89
+ return {
90
+ cameraType: Platform.OS === 'web' ? cameraType : 'back',
91
+ flashMode: 'auto' as const,
92
+ overlay: {
93
+ guideText: instructions,
94
+ bbox: { xMin: 5, yMin: 15, xMax: 95, yMax: 85, borderColor: '#2DBD60', borderWidth: 3, cornerRadius: 8 }
95
+ }
96
+ };
97
+ }, [selectedDocumentType, locale, component.instructions, cameraType]);
98
+
99
+ const retakePicture = (sideToRetake: 'front' | 'back') => {
100
+ setIsProcessingCapture(false);
101
+ setProcessingImagePath(null);
102
+ setSilentCaptureResult({ path: '', success: false, isAnalyzing: false, error: '', templatePath: '', mrz: '', bbox: undefined });
103
+ setShowCamera(true);
104
+ refreshCamera();
105
+ actions.showCustomStepper(false);
106
+ setCapturedImages((prev) => { const newState = { ...prev }; delete newState[sideToRetake]; return newState; });
107
+ if (value) { const newValue = { ...value }; delete newValue[sideToRetake]; onValueChange(newValue); }
108
+ };
109
+
110
+ const getCurrentSideVerification = (currentSide: string, countryKey: string) => {
111
+ const rawDocType = countrySelectionData?.documentType;
112
+ if (!rawDocType || !countryKey) { return { authMethod: [], mrzTypes: [], regionMapping: null, key: 'root' }; }
113
+ const rawCountryName = ISO_TO_COUNTRY_NAME[countryData?.code || ''] || countryData?.code || countryKey;
114
+ const baseMapping = (REGION_MAPPING as any).regionMapping || REGION_MAPPING;
115
+ let countryMapping = baseMapping[rawCountryName];
116
+ if (!countryMapping) {
117
+ const foundKey = Object.keys(baseMapping).find(k => k.toLowerCase() === rawCountryName.toLowerCase() || k.toLowerCase() === countryKey.toLowerCase());
118
+ if (foundKey) countryMapping = baseMapping[foundKey];
119
+ }
120
+ if (!countryMapping) { return { authMethod: [], mrzTypes: [], regionMapping: null, key: 'root' }; }
121
+ const regionMapping = countryMapping[rawDocType];
122
+ if (!regionMapping) { return { authMethod: [], mrzTypes: [], regionMapping: null, key: 'root' }; }
123
+ const authMethod: string[] = [];
124
+ const mrzTypes: string[] = [];
125
+ const key = countrySelectionData.region?.trim()?.length > 0 ? countrySelectionData.region.trim() : 'root';
126
+ if (regionMapping?.[key] && Array.isArray(regionMapping[key])) {
127
+ regionMapping[key].forEach((item: any) => {
128
+ if (item[currentSide]) {
129
+ authMethod.push(item[currentSide]);
130
+ if (item?.mrz_type) mrzTypes.push(item?.mrz_type);
131
+ }
132
+ });
133
+ }
134
+ return { authMethod: removeDuplicates(authMethod), mrzTypes: removeDuplicates(mrzTypes), regionMapping, key };
135
+ }
136
+
137
+ const getCorrespondingMrzType = (templatePath: string, mapping: any, selectedDocumentType: string = "root") => {
138
+ if (!mapping || !mapping[selectedDocumentType]) return null;
139
+ const fileName = templatePath.split("/").pop()?.replace(".jpg", "").replace(".png", "");
140
+ if (!fileName) return null;
141
+ const pyName = `${fileName}.py`;
142
+ const found = mapping[selectedDocumentType].find((item: any) => item.py_file === pyName);
143
+ return found?.mrz_type || null;
144
+ }
145
+
146
+ const getCorrespondingAuthMethod = (templatePath: string, mapping: any, selectedDocumentType: string = "root", side: 'front' | 'back') => {
147
+ if (!mapping || !mapping[selectedDocumentType]) return null;
148
+ const fileName = templatePath.split("/").pop()?.replace(".jpg", "").replace(".png", "");
149
+ if (!fileName) return null;
150
+ const pyName = `${fileName}.py`;
151
+ const found = mapping[selectedDocumentType].find((item: any) => item.py_file === pyName);
152
+ return found?.[side] || null;
153
+ }
154
+
155
+ const autoCapture = async (capturePath: string, verified: ISilentCaptureResult) => {
156
+ if (isProcessingCapture) return;
157
+ setIsProcessingCapture(true);
158
+ setProcessingImagePath(capturePath);
159
+
160
+ try {
161
+ let imagePathForUpload = capturePath;
162
+
163
+ const overlayBbox = cameraConfig.overlay.bbox;
164
+ const uiCropBbox = {
165
+ minX: overlayBbox.xMin / 100,
166
+ minY: overlayBbox.yMin / 100,
167
+ width: (overlayBbox.xMax - overlayBbox.xMin) / 100,
168
+ height: (overlayBbox.yMax - overlayBbox.yMin) / 100,
169
+ };
170
+
171
+ try {
172
+ // Apply the crop using the UI percentages and a tight 2% tolerance
173
+ // NOTE: If you integrated the Kotlin centered crop function earlier, replace this with cropToCenterScanArea
174
+ imagePathForUpload = await cropImageWithBBoxWithTolerance(capturePath, uiCropBbox, 0.02);
175
+ } catch (e) {
176
+ console.warn("Crop failed, falling back to original image", e);
177
+ imagePathForUpload = capturePath;
178
+ }
179
+
180
+ const base64 = await pathToBase64(imagePathForUpload);
181
+ const newImages = {
182
+ ...capturedImages,
183
+ [currentSide]: { dir: imagePathForUpload, file: base64, mrz: verified.mrz || "", templatePath: verified.templatePath },
184
+ };
185
+
186
+ setCapturedImages(newImages);
187
+
188
+ if (verified.country && verified.documentType) {
189
+ onValueChange({
190
+ ...newImages,
191
+ country: verified.country,
192
+ documentType: GovernmentDocumentTypeBackend[verified.documentType as keyof typeof GovernmentDocumentTypeBackend] || '',
193
+ });
194
+ }
195
+
196
+ setTimeout(() => {
197
+ setShowCamera(false);
198
+ actions.showCustomStepper(true);
199
+ setSilentCaptureResult(prev => ({ ...prev, isAnalyzing: false }));
200
+ setIsProcessingCapture(false);
201
+ setProcessingImagePath(null);
202
+ }, 600);
203
+
204
+ } catch (e: any) {
205
+ console.error("Backend Error:", e);
206
+ const friendlyError = state.currentLanguage === 'en' ? 'Unable to process document. Please try again.' : 'Impossible de traiter le document. Veuillez réessayer.';
207
+ showAlert('Error', friendlyError);
208
+ setSilentCaptureResult(prev => ({ ...prev, isAnalyzing: false, success: false }));
209
+ setIsProcessingCapture(false);
210
+ setProcessingImagePath(null);
211
+ }
212
+ };
213
+
214
+ const handleSilentCapture = async (result: { success: boolean; path?: string; error?: string }) => {
215
+ if (silentCaptureResult.isAnalyzing || isProcessingCapture) return;
216
+ if (result.success && result.path) {
217
+ setSilentCaptureResult((prev) => ({ ...prev, isAnalyzing: true, success: false, error: '' }));
218
+ let templatePath = capturedImages[currentSide]?.templatePath || '';
219
+ let templateBbox: IBbox | undefined;
220
+ let templateResponse: any;
221
+ if (!selectedDocumentType) {
222
+ setSilentCaptureResult((prev) => ({ ...prev, isAnalyzing: false, success: false, error: 'Document type not selected' }));
223
+ return;
224
+ }
225
+ try {
226
+ if (!templatePath) {
227
+ const templateType = await checkTemplateType({ path: result.path || '', docType: selectedDocumentType?.type as GovernmentDocumentType, docRegion: countryData?.code || "", postfix: currentSide }, env);
228
+ templateResponse = templateType;
229
+ if (templateType.template_path) templatePath = templateType.template_path;
230
+ if (templateType.card_obb) {
231
+ const obbConfidence = getObbConfidence((templateType as any).card_obb);
232
+ if (obbConfidence !== null && obbConfidence < OBB_CONFIDENCE_THRESHOLD) {
233
+ setSilentCaptureResult((prev) => ({ ...prev, isAnalyzing: false, success: false, error: t('kyc.idCardCapture.cardNotFullyInFrame') }));
234
+ return;
235
+ }
236
+ try {
237
+ const crop = await cropByObb(result.path, (templateType as any).card_obb);
238
+ templateBbox = crop.bbox;
239
+ } catch { }
240
+ }
241
+ }
242
+ const extractedCountryKey = templatePath ? templatePath.split('/')[0] : (ISO_TO_COUNTRY_NAME[countryData?.code || ''] || 'root');
243
+ const regionMappings = getCurrentSideVerification(currentSide, extractedCountryKey);
244
+ let verificationRes: any;
245
+ if (currentSide === 'front') {
246
+ const matchedAuthMethod = getCorrespondingAuthMethod(templatePath, regionMappings.regionMapping, regionMappings.key || '', 'front');
247
+ const mrzType = getCorrespondingMrzType(templatePath, regionMappings.regionMapping, regionMappings.key || '') || 'TD1';
248
+ 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);
249
+ } else {
250
+ let matchedBackAuthMethod = getCorrespondingAuthMethod(templatePath, regionMappings.regionMapping, regionMappings.key || '', 'back');
251
+ if (!matchedBackAuthMethod && currentSide === 'back') {
252
+ matchedBackAuthMethod = 'MRZ';
253
+ }
254
+ const backMrzType = getCorrespondingMrzType(templatePath, regionMappings.regionMapping, regionMappings.key || '') || 'TD1';
255
+ 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);
256
+ }
257
+ const bbox = verificationRes?.bbox || templateBbox;
258
+ const mrz = verificationRes?.mrz ? JSON.stringify(verificationRes.mrz) : "";
259
+ const verifiedResult: ISilentCaptureResult = { path: result.path, templatePath, bbox, success: true, mrz, isAnalyzing: false, country: countryData?.code, documentType: selectedDocumentType.type };
260
+ setSilentCaptureResult(verifiedResult);
261
+ if (bbox) setBboxBySide(prev => ({ ...prev, [currentSide]: bbox }));
262
+ await autoCapture(result.path, verifiedResult);
263
+ } catch (error: any) {
264
+ console.log("Backend Verification Error:", error);
265
+
266
+ // 2. Define a map of technical keywords to user-friendly messages
267
+ const rawMessage = (error?.message || '').toLowerCase();
268
+
269
+ let userFriendlyMessage ='Verification failed. Please try again.';
270
+
271
+ if (rawMessage.includes('impossible') || rawMessage.includes('unreadable')) {
272
+ userFriendlyMessage = 'Document unreadable. Please hold the camera steady in good light.';
273
+ } else if (rawMessage.includes('country mismatch') || rawMessage.includes('pays sélectionné')) {
274
+ userFriendlyMessage = 'The document does not match your selected country.';
275
+ } else if (rawMessage.includes('card_not_fully_in_frame')) {
276
+ // 🚨 REMOVED 'too far' CHECK FROM HERE
277
+ userFriendlyMessage = 'Move closer to the document and ensure all corners are visible.';
278
+ }
279
+
280
+ // 3. Set ONLY the friendly message to the UI state
281
+ setSilentCaptureResult(prev => ({
282
+ ...prev,
283
+ isAnalyzing: false,
284
+ success: false,
285
+ error: userFriendlyMessage
81
286
  }));
82
- }
83
- }
84
- }
85
- }, [value, currentSide]);
86
-
87
- const cameraConfig = useMemo(() => {
88
- const instructions = selectedDocumentType ? (locale === 'en' ? getDocumentTypeInfo(selectedDocumentType.type).instructions.en : getDocumentTypeInfo(selectedDocumentType.type).instructions.fr) : getLocalizedText(component.instructions as Record<string, LocalizedText>);
89
- return {
90
- cameraType: Platform.OS === 'web' ? cameraType : 'back',
91
- flashMode: 'auto' as const,
92
- overlay: {
93
- guideText: instructions,
94
- bbox: { xMin: 5, yMin: 15, xMax: 95, yMax: 85, borderColor: '#2DBD60', borderWidth: 3, cornerRadius: 8 }
95
- }
96
- };
97
- }, [selectedDocumentType, locale, component.instructions, cameraType]);
98
-
99
- const retakePicture = (sideToRetake: 'front' | 'back') => {
100
- setIsProcessingCapture(false);
101
- setProcessingImagePath(null);
102
- setSilentCaptureResult({ path: '', success: false, isAnalyzing: false, error: '', templatePath: '', mrz: '', bbox: undefined });
103
- setShowCamera(true);
104
- refreshCamera();
105
- actions.showCustomStepper(false);
106
- setCapturedImages((prev) => { const newState = { ...prev }; delete newState[sideToRetake]; return newState; });
107
- if (value) { const newValue = { ...value }; delete newValue[sideToRetake]; onValueChange(newValue); }
108
- };
109
-
110
- const getCurrentSideVerification = (currentSide: string, countryKey: string) => {
111
- const rawDocType = countrySelectionData?.documentType;
112
- if (!rawDocType || !countryKey) { return { authMethod: [], mrzTypes: [], regionMapping: null, key: 'root' }; }
113
- const rawCountryName = ISO_TO_COUNTRY_NAME[countryData?.code || ''] || countryData?.code || countryKey;
114
- const baseMapping = (REGION_MAPPING as any).regionMapping || REGION_MAPPING;
115
- let countryMapping = baseMapping[rawCountryName];
116
- if (!countryMapping) {
117
- const foundKey = Object.keys(baseMapping).find(k => k.toLowerCase() === rawCountryName.toLowerCase() || k.toLowerCase() === countryKey.toLowerCase());
118
- if (foundKey) countryMapping = baseMapping[foundKey];
119
- }
120
- if (!countryMapping) { return { authMethod: [], mrzTypes: [], regionMapping: null, key: 'root' }; }
121
- const regionMapping = countryMapping[rawDocType];
122
- if (!regionMapping) { return { authMethod: [], mrzTypes: [], regionMapping: null, key: 'root' }; }
123
- const authMethod: string[] = [];
124
- const mrzTypes: string[] = [];
125
- const key = countrySelectionData.region?.trim()?.length > 0 ? countrySelectionData.region.trim() : 'root';
126
- if (regionMapping?.[key] && Array.isArray(regionMapping[key])) {
127
- regionMapping[key].forEach((item: any) => {
128
- if (item[currentSide]) {
129
- authMethod.push(item[currentSide]);
130
- if (item?.mrz_type) mrzTypes.push(item?.mrz_type);
131
- }
132
- });
133
- }
134
- return { authMethod: removeDuplicates(authMethod), mrzTypes: removeDuplicates(mrzTypes), regionMapping, key };
135
- }
136
-
137
- const getCorrespondingMrzType = (templatePath: string, mapping: any, selectedDocumentType: string = "root") => {
138
- if (!mapping || !mapping[selectedDocumentType]) return null;
139
- const fileName = templatePath.split("/").pop()?.replace(".jpg", "").replace(".png", "");
140
- if (!fileName) return null;
141
- const pyName = `${fileName}.py`;
142
- const found = mapping[selectedDocumentType].find((item: any) => item.py_file === pyName);
143
- return found?.mrz_type || null;
144
- }
145
-
146
- const getCorrespondingAuthMethod = (templatePath: string, mapping: any, selectedDocumentType: string = "root", side: 'front' | 'back') => {
147
- if (!mapping || !mapping[selectedDocumentType]) return null;
148
- const fileName = templatePath.split("/").pop()?.replace(".jpg", "").replace(".png", "");
149
- if (!fileName) return null;
150
- const pyName = `${fileName}.py`;
151
- const found = mapping[selectedDocumentType].find((item: any) => item.py_file === pyName);
152
- return found?.[side] || null;
153
- }
154
-
155
- const autoCapture = async (capturePath: string, verified: ISilentCaptureResult) => {
156
- if (isProcessingCapture) return;
157
- setIsProcessingCapture(true);
158
- setProcessingImagePath(capturePath);
159
-
160
- try {
161
- let imagePathForUpload = capturePath;
162
-
163
- // 🚨 THE FIX: Ignore the backend's broken absolute pixels.
164
- // Calculate the crop using the Green UI Frame percentages (0.0 to 1.0)
165
- const overlayBbox = cameraConfig.overlay.bbox;
166
- const uiCropBbox = {
167
- minX: overlayBbox.xMin / 100,
168
- minY: overlayBbox.yMin / 100,
169
- width: (overlayBbox.xMax - overlayBbox.xMin) / 100,
170
- height: (overlayBbox.yMax - overlayBbox.yMin) / 100,
171
- };
172
-
173
- try {
174
- // Apply the crop using the UI percentages and a tight 2% tolerance
175
- imagePathForUpload = await cropImageWithBBoxWithTolerance(capturePath, uiCropBbox, 0.02);
176
- // imagePathForUpload = await cropToCenterScanArea(capturePath, 0.91, 1.59);
177
- } catch (e) {
178
- console.warn("Crop failed, falling back to original image", e);
179
- imagePathForUpload = capturePath; // Fallback to raw image if crop fails
180
- }
181
-
182
- const base64 = await pathToBase64(imagePathForUpload);
183
- const newImages = {
184
- ...capturedImages,
185
- [currentSide]: { dir: imagePathForUpload, file: base64, mrz: verified.mrz || "", templatePath: verified.templatePath },
186
- };
187
-
188
- setCapturedImages(newImages);
189
-
190
- if (verified.country && verified.documentType) {
191
- onValueChange({
192
- ...newImages,
193
- country: verified.country,
194
- documentType: GovernmentDocumentTypeBackend[verified.documentType as keyof typeof GovernmentDocumentTypeBackend] || '',
195
- });
196
- }
197
-
198
- setTimeout(() => {
199
- setShowCamera(false);
200
- actions.showCustomStepper(true);
201
- setSilentCaptureResult(prev => ({ ...prev, isAnalyzing: false }));
202
- setIsProcessingCapture(false);
203
- setProcessingImagePath(null);
204
- }, 600);
205
-
206
- } catch (e: any) {
207
- console.error("Backend Error:", e);
208
- const friendlyError = state.currentLanguage === 'en' ? 'Unable to process document. Please try again.' : 'Impossible de traiter le document. Veuillez réessayer.';
209
- showAlert('Error', friendlyError);
210
- setSilentCaptureResult(prev => ({ ...prev, isAnalyzing: false, success: false }));
211
- setIsProcessingCapture(false);
212
- setProcessingImagePath(null);
213
- }
214
- };
215
-
216
- const handleSilentCapture = async (result: { success: boolean; path?: string; error?: string }) => {
217
- if (silentCaptureResult.isAnalyzing || isProcessingCapture) return;
218
- if (result.success && result.path) {
219
- setSilentCaptureResult((prev) => ({ ...prev, isAnalyzing: true, success: false, error: '' }));
220
- let templatePath = capturedImages[currentSide]?.templatePath || '';
221
- let templateBbox: IBbox | undefined;
222
- let templateResponse: any;
223
- if (!selectedDocumentType) {
224
- setSilentCaptureResult((prev) => ({ ...prev, isAnalyzing: false, success: false, error: 'Document type not selected' }));
225
- return;
226
- }
227
- try {
228
- if (!templatePath) {
229
- const templateType = await checkTemplateType({ path: result.path || '', docType: selectedDocumentType?.type as GovernmentDocumentType, docRegion: countryData?.code || "", postfix: currentSide }, env);
230
- templateResponse = templateType;
231
- if (templateType.template_path) templatePath = templateType.template_path;
232
- if (templateType.card_obb) {
233
- const obbConfidence = getObbConfidence((templateType as any).card_obb);
234
- if (obbConfidence !== null && obbConfidence < OBB_CONFIDENCE_THRESHOLD) {
235
- setSilentCaptureResult((prev) => ({ ...prev, isAnalyzing: false, success: false, error: t('kyc.idCardCapture.cardNotFullyInFrame') }));
236
- return;
237
- }
238
- try {
239
- const crop = await cropByObb(result.path, (templateType as any).card_obb);
240
- templateBbox = crop.bbox;
241
- } catch { }
242
- }
243
- }
244
- const extractedCountryKey = templatePath ? templatePath.split('/')[0] : (ISO_TO_COUNTRY_NAME[countryData?.code || ''] || 'root');
245
- const regionMappings = getCurrentSideVerification(currentSide, extractedCountryKey);
246
- let verificationRes: any;
247
- if (currentSide === 'front') {
248
- const matchedAuthMethod = getCorrespondingAuthMethod(templatePath, regionMappings.regionMapping, regionMappings.key || '', 'front');
249
- const mrzType = getCorrespondingMrzType(templatePath, regionMappings.regionMapping, regionMappings.key || '') || 'TD1';
250
- 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);
251
- } else {
252
- let matchedBackAuthMethod = getCorrespondingAuthMethod(templatePath, regionMappings.regionMapping, regionMappings.key || '', 'back');
253
- if (!matchedBackAuthMethod && currentSide === 'back') {
254
- matchedBackAuthMethod = 'MRZ';
255
- }
256
- const backMrzType = getCorrespondingMrzType(templatePath, regionMappings.regionMapping, regionMappings.key || '') || 'TD1';
257
- 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);
258
- }
259
- const bbox = verificationRes?.bbox || templateBbox;
260
- const mrz = verificationRes?.mrz ? JSON.stringify(verificationRes.mrz) : "";
261
- const verifiedResult: ISilentCaptureResult = { path: result.path, templatePath, bbox, success: true, mrz, isAnalyzing: false, country: countryData?.code, documentType: selectedDocumentType.type };
262
- setSilentCaptureResult(verifiedResult);
263
- if (bbox) setBboxBySide(prev => ({ ...prev, [currentSide]: bbox }));
264
- await autoCapture(result.path, verifiedResult);
265
- } catch (error: any) {
266
- // 1. Keep the technical log for your debugging console
267
- console.error("Backend Verification Error:", error);
268
-
269
- // 2. Define a map of technical keywords to user-friendly messages
270
- const rawMessage = (error?.message || '').toLowerCase();
271
-
272
- let userFriendlyMessage ='Verification failed. Please try again.';
273
-
274
- if (rawMessage.includes('impossible') || rawMessage.includes('unreadable')) {
275
- userFriendlyMessage = 'Document unreadable. Please hold the camera steady in good light.';
276
- } else if (rawMessage.includes('country mismatch') || rawMessage.includes('pays sélectionné')) {
277
- userFriendlyMessage = 'The document does not match your selected country.';
278
- } else if (rawMessage.includes('too far') || rawMessage.includes('card_not_fully_in_frame')) {
279
- userFriendlyMessage = 'Move closer to the document and ensure all corners are visible.';
280
- }
281
-
282
- // 3. Set ONLY the friendly message to the UI state
283
- setSilentCaptureResult(prev => ({
284
- ...prev,
285
- isAnalyzing: false,
286
- success: false,
287
- error: userFriendlyMessage
288
- }));
289
- }
290
- }
291
- };
292
-
293
- const handleError = (event: { message: string }) => {
294
- showAlert('Erreur', event.message);
295
- setShowCamera(false);
296
- setIsProcessingCapture(false);
297
- };
298
-
299
- useEffect(() => {
300
- actions.showCustomStepper(!showCamera);
301
- }, [showCamera]);
302
-
303
- if (!countrySelectionData || !selectedDocumentType) {
304
- return (
305
- <View style={styles.root}>
306
- <View style={styles.previewContainer}>
307
- <Text style={styles.title}>{getLocalizedText(component.labels)}</Text>
308
- <Text style={styles.description}>
309
- {state.currentLanguage === "en" ? "Please complete the country and document selection first." : "Veuillez d'abord compléter la sélection du pays et du document."}
310
- </Text>
311
- </View>
312
- </View>
313
- );
314
- }
315
-
316
- // --- CAMERA RENDER ---
317
- if (showCamera) {
318
- const isBusy = isProcessingCapture;
319
-
320
- if (isRebootingCamera) {
321
- return (
322
- <View style={[styles.root, { justifyContent: 'center', alignItems: 'center', backgroundColor: '#000' }]}>
323
- <ActivityIndicator size="large" color="#2DBD60" />
324
- <Text style={{ color: 'white', marginTop: 20, fontSize: 16 }}>Initializing Camera...</Text>
325
- </View>
326
- );
327
- }
328
-
329
- return (
330
- <View style={styles.root}>
331
- <View style={[styles.cameraWrapper, { flex: 1 }]}>
332
-
333
- <View style={styles.headerContainer}>
334
- <Text style={styles.headerTitle}>
335
- {selectedDocumentType ? (locale === 'en' ? getDocumentTypeInfo(selectedDocumentType.type).name.en : getDocumentTypeInfo(selectedDocumentType.type).name.fr) : ''}
336
- </Text>
337
- <View style={styles.stepBadge}>
338
- <Text style={styles.stepText}>
339
- {t('kyc.idCardCapture.captureTitle', { side: currentSide === 'front' ? locale === 'en' ? 'Front' : 'Recto' : locale === 'en' ? 'Back' : 'Verso' })}
340
- </Text>
341
- </View>
342
- </View>
343
-
344
- <View style={[styles.cameraFeedContainer, { flex: 1, backgroundColor: '#000' }]}>
345
-
346
- {/* WEB ONLY: Flip Camera Top Button */}
347
- {Platform.OS === 'web' && (
348
- <View style={styles.webTopControls}>
349
- <TouchableOpacity style={styles.webFlipButton} onPress={toggleCameraLens}>
350
- <Text style={styles.webFlipText}>🔄 Flip Lens</Text>
351
- </TouchableOpacity>
352
- </View>
353
- )}
354
-
355
- <EnhancedCameraView
356
- key={`${currentSide}-${cameraKey}`}
357
- showCamera={true}
358
- isProcessing={isBusy}
359
- cameraType={cameraConfig.cameraType}
360
- style={StyleSheet.absoluteFillObject}
361
- onError={handleError}
362
- onSilentCapture={handleSilentCapture}
363
- silentCaptureResult={silentCaptureResult}
364
- overlayComponent={
365
- <>
366
- <IdCardOverlay
367
- xMin={cameraConfig.overlay.bbox.xMin} yMin={cameraConfig.overlay.bbox.yMin} xMax={cameraConfig.overlay.bbox.xMax} yMax={cameraConfig.overlay.bbox.yMax}
368
- instructions={cameraConfig.overlay.guideText}
369
- cornerOpacity={cameraConfig.overlay.bbox.cornerRadius || 0 as number}
370
- isSuccess={silentCaptureResult.success}
371
- language={state.currentLanguage}
372
- stepperProps={{
373
- back: () => {
374
- if (currentSide === 'back') {
375
- setCurrentSide('front');
376
- setShowCamera(false);
377
- setIsProcessingCapture(false);
378
- setProcessingImagePath(null);
379
- if (capturedImages['front']?.dir) {
380
- const frontImage = capturedImages['front'];
381
- setSilentCaptureResult((prev) => ({ ...prev, path: frontImage.dir, success: true, isAnalyzing: false, error: '', mrz: frontImage.mrz || '', templatePath: frontImage.templatePath || '', bbox: bboxBySide['front'] }));
382
- } else { setSilentCaptureResult((prev) => ({ ...prev, path: '', success: false, isAnalyzing: false, error: '', templatePath: '' })); }
383
- } else { actions.previousComponent(); }
384
- },
385
- selectedDocumentType: selectedDocumentType ? (locale === 'en' ? getDocumentTypeInfo(selectedDocumentType.type).name.en : getDocumentTypeInfo(selectedDocumentType.type).name.fr) : '',
386
- step: state.currentComponentIndex + 1, totalSteps: state.template.components.length, side: currentSide,
387
- }}
388
- />
389
- </>
390
- }
391
- />
392
-
393
- {!isBusy && silentCaptureResult.isAnalyzing && (
394
- <View style={styles.topAnalyzingPillContainer}>
395
- <View style={styles.topAnalyzingPill}>
396
- <ActivityIndicator size="small" color="white" />
397
- <Text style={styles.analyzingPillText}>
398
- {state.currentLanguage === 'en' ? 'Scanning...' : 'Analyse...'}
399
- </Text>
287
+ }
288
+ }
289
+ };
290
+
291
+ const handleError = (event: { message: string }) => {
292
+ showAlert('Erreur', event.message);
293
+ setShowCamera(false);
294
+ setIsProcessingCapture(false);
295
+ };
296
+
297
+ useEffect(() => {
298
+ actions.showCustomStepper(!showCamera);
299
+ }, [showCamera]);
300
+
301
+ if (!countrySelectionData || !selectedDocumentType) {
302
+ return (
303
+ <View style={styles.root}>
304
+ <View style={styles.previewContainer}>
305
+ <Text style={styles.title}>{getLocalizedText(component.labels)}</Text>
306
+ <Text style={styles.description}>
307
+ {state.currentLanguage === "en" ? "Please complete the country and document selection first." : "Veuillez d'abord compléter la sélection du pays et du document."}
308
+ </Text>
309
+ </View>
310
+ </View>
311
+ );
312
+ }
313
+
314
+ // --- CAMERA RENDER ---
315
+ if (showCamera) {
316
+ const isBusy = isProcessingCapture;
317
+
318
+ if (isRebootingCamera) {
319
+ return (
320
+ <View style={[styles.root, { justifyContent: 'center', alignItems: 'center', backgroundColor: '#000' }]}>
321
+ <ActivityIndicator size="large" color="#2DBD60" />
322
+ <Text style={{ color: 'white', marginTop: 20, fontSize: 16 }}>Initializing Camera...</Text>
323
+ </View>
324
+ );
325
+ }
326
+
327
+ return (
328
+ <View style={styles.root}>
329
+ <View style={[styles.cameraWrapper, { flex: 1 }]}>
330
+
331
+ <View style={styles.headerContainer}>
332
+ <Text style={styles.headerTitle}>
333
+ {selectedDocumentType ? (locale === 'en' ? getDocumentTypeInfo(selectedDocumentType.type).name.en : getDocumentTypeInfo(selectedDocumentType.type).name.fr) : ''}
334
+ </Text>
335
+ <View style={styles.stepBadge}>
336
+ <Text style={styles.stepText}>
337
+ {t('kyc.idCardCapture.captureTitle', { side: currentSide === 'front' ? locale === 'en' ? 'Front' : 'Recto' : locale === 'en' ? 'Back' : 'Verso' })}
338
+ </Text>
339
+ </View>
340
+ </View>
341
+
342
+ <View style={[styles.cameraFeedContainer, { flex: 1, backgroundColor: '#000' }]}>
343
+
344
+ {/* WEB ONLY: Flip Camera Top Button */}
345
+ {Platform.OS === 'web' && (
346
+ <View style={styles.webTopControls}>
347
+ <TouchableOpacity style={styles.webFlipButton} onPress={toggleCameraLens}>
348
+ <Text style={styles.webFlipText}>🔄 Flip Lens</Text>
349
+ </TouchableOpacity>
350
+ </View>
351
+ )}
352
+
353
+ <EnhancedCameraView
354
+ key={`${currentSide}-${cameraKey}`}
355
+ showCamera={true}
356
+ isProcessing={isBusy}
357
+ cameraType={cameraConfig.cameraType}
358
+ style={StyleSheet.absoluteFillObject}
359
+ onError={handleError}
360
+ onSilentCapture={handleSilentCapture}
361
+ silentCaptureResult={silentCaptureResult}
362
+ overlayComponent={
363
+ <>
364
+ <IdCardOverlay
365
+ xMin={cameraConfig.overlay.bbox.xMin} yMin={cameraConfig.overlay.bbox.yMin} xMax={cameraConfig.overlay.bbox.xMax} yMax={cameraConfig.overlay.bbox.yMax}
366
+ instructions={cameraConfig.overlay.guideText}
367
+ cornerOpacity={cameraConfig.overlay.bbox.cornerRadius || 0 as number}
368
+ isSuccess={silentCaptureResult.success}
369
+ language={state.currentLanguage}
370
+ stepperProps={{
371
+ back: () => {
372
+ if (currentSide === 'back') {
373
+ setCurrentSide('front');
374
+ setShowCamera(false);
375
+ setIsProcessingCapture(false);
376
+ setProcessingImagePath(null);
377
+ if (capturedImages['front']?.dir) {
378
+ const frontImage = capturedImages['front'];
379
+ setSilentCaptureResult((prev) => ({ ...prev, path: frontImage.dir, success: true, isAnalyzing: false, error: '', mrz: frontImage.mrz || '', templatePath: frontImage.templatePath || '', bbox: bboxBySide['front'] }));
380
+ } else { setSilentCaptureResult((prev) => ({ ...prev, path: '', success: false, isAnalyzing: false, error: '', templatePath: '' })); }
381
+ } else { actions.previousComponent(); }
382
+ },
383
+ selectedDocumentType: selectedDocumentType ? (locale === 'en' ? getDocumentTypeInfo(selectedDocumentType.type).name.en : getDocumentTypeInfo(selectedDocumentType.type).name.fr) : '',
384
+ step: state.currentComponentIndex + 1, totalSteps: state.template.components.length, side: currentSide,
385
+ }}
386
+ />
387
+ </>
388
+ }
389
+ />
390
+
391
+ {!isBusy && silentCaptureResult.isAnalyzing && (
392
+ <View style={styles.topAnalyzingPillContainer}>
393
+ <View style={styles.topAnalyzingPill}>
394
+ <ActivityIndicator size="small" color="white" />
395
+ <Text style={styles.analyzingPillText}>
396
+ {state.currentLanguage === 'en' ? 'Scanning...' : 'Analyse...'}
397
+ </Text>
398
+ </View>
399
+ </View>
400
+ )}
401
+
402
+ {isBusy && (
403
+ <View style={[StyleSheet.absoluteFillObject, { zIndex: 9999 }]}>
404
+ {processingImagePath && (
405
+ <Image
406
+ source={{ uri: processingImagePath.startsWith('file://') ? processingImagePath : `file://${processingImagePath}` }}
407
+ style={StyleSheet.absoluteFillObject}
408
+ resizeMode="cover"
409
+ />
410
+ )}
411
+ <View style={styles.processingOverlay}>
412
+ <ActivityIndicator size="large" color="#2DBD60" />
413
+ <Text style={styles.processingText}>
414
+ {state.currentLanguage === 'en' ? 'Perfect!\nProcessing Document...' : 'Parfait!\nTraitement du document...'}
415
+ </Text>
416
+ </View>
417
+ </View>
418
+ )}
419
+
420
+ {/* 🚨 THE ESCAPE HATCH: Branching UI for Web vs Mobile */}
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
+ ) : <View style={{ height: 10 }} />}
428
+
429
+ <View style={styles.webActionButtonsRow}>
430
+ <TouchableOpacity style={styles.webSecondaryButton} onPress={() => setShowCamera(false)}>
431
+ <Text style={styles.webSecondaryButtonText}>Cancel</Text>
432
+ </TouchableOpacity>
433
+ <TouchableOpacity style={styles.webPrimaryButton} onPress={refreshCamera}>
434
+ <Text style={styles.webPrimaryButtonText}>↻ Refresh Camera</Text>
435
+ </TouchableOpacity>
436
+ </View>
400
437
  </View>
401
- </View>
402
- )}
403
-
404
- {isBusy && (
405
- <View style={[StyleSheet.absoluteFillObject, { zIndex: 9999 }]}>
406
- {processingImagePath && (
407
- <Image
408
- source={{ uri: processingImagePath.startsWith('file://') ? processingImagePath : `file://${processingImagePath}` }}
409
- style={StyleSheet.absoluteFillObject}
410
- resizeMode="cover"
411
- />
412
- )}
413
- <View style={styles.processingOverlay}>
414
- <ActivityIndicator size="large" color="#2DBD60" />
415
- <Text style={styles.processingText}>
416
- {state.currentLanguage === 'en' ? 'Perfect!\nProcessing Document...' : 'Parfait!\nTraitement du document...'}
417
- </Text>
438
+ ) : !isBusy ? (
439
+ <View style={styles.escapeHatchContainer}>
440
+ <TouchableOpacity style={styles.fallbackRefreshButton} onPress={refreshCamera}>
441
+ <Text style={styles.fallbackRefreshText}>↻ Refresh Camera</Text>
442
+ </TouchableOpacity>
443
+ <TouchableOpacity
444
+ style={[styles.fallbackRefreshButton, { marginTop: 15, backgroundColor: 'rgba(220, 38, 38, 0.8)', borderColor: '#DC2626' }]}
445
+ onPress={() => setShowCamera(false)}
446
+ >
447
+ <Text style={styles.fallbackRefreshText}>Cancel / Go Back</Text>
448
+ </TouchableOpacity>
418
449
  </View>
419
- </View>
420
- )}
421
-
422
- {/* 🚨 THE ESCAPE HATCH: Branching UI for Web vs Mobile */}
423
- {!isBusy && Platform.OS === 'web' ? (
424
- <View style={styles.webBottomControlBar}>
425
- {silentCaptureResult.error ? (
426
- <View style={styles.floatingErrorBanner}>
427
- <Text style={styles.floatingErrorText}>⚠️ {silentCaptureResult.error}</Text>
428
- </View>
429
- ) : <View style={{ height: 10 }} />}
430
-
431
- <View style={styles.webActionButtonsRow}>
432
- <TouchableOpacity style={styles.webSecondaryButton} onPress={() => setShowCamera(false)}>
433
- <Text style={styles.webSecondaryButtonText}>Cancel</Text>
434
- </TouchableOpacity>
435
- <TouchableOpacity style={styles.webPrimaryButton} onPress={refreshCamera}>
436
- <Text style={styles.webPrimaryButtonText}>↻ Refresh Camera</Text>
437
- </TouchableOpacity>
438
- </View>
450
+ ) : null}
451
+
452
+ {silentCaptureResult.error && !isBusy && Platform.OS !== 'web' ? (
453
+ <View style={[styles.floatingErrorBanner, { zIndex: 10000 }]}>
454
+ <Text style={styles.floatingErrorText}>⚠️ {silentCaptureResult.error}</Text>
439
455
  </View>
440
- ) : !isBusy ? (
441
- <View style={styles.escapeHatchContainer}>
442
- <TouchableOpacity style={styles.fallbackRefreshButton} onPress={refreshCamera}>
443
- <Text style={styles.fallbackRefreshText}>↻ Refresh Camera</Text>
444
- </TouchableOpacity>
445
- <TouchableOpacity
446
- style={[styles.fallbackRefreshButton, { marginTop: 15, backgroundColor: 'rgba(220, 38, 38, 0.8)', borderColor: '#DC2626' }]}
447
- onPress={() => setShowCamera(false)}
448
- >
449
- <Text style={styles.fallbackRefreshText}>Cancel / Go Back</Text>
450
- </TouchableOpacity>
456
+ ) : null}
457
+
458
+ </View>
459
+ </View>
460
+ </View>
461
+ );
462
+ }
463
+
464
+ // --- PREVIEW RENDER ---
465
+ return (
466
+ <View style={styles.root}>
467
+ <View style={styles.previewContainer}>
468
+ <ScrollView style={styles.previewItemContainer} showsVerticalScrollIndicator={false}>
469
+ <View key={currentSide} style={styles.sideContainer}>
470
+ <Text style={styles.sideTitle}>
471
+ {t('kyc.idCardCapture.captureTitle', { side: currentSide === 'front' ? locale === 'en' ? 'Front' : 'Recto' : locale === 'en' ? 'Back' : 'Verso' })}
472
+ </Text>
473
+ <Text style={{ fontSize: 14, color: '#666', textAlign: 'center', marginBottom: 16, lineHeight: 22 }}>
474
+ {getLocalizedText(component.instructions)}
475
+ </Text>
476
+
477
+ <View style={{ alignItems: 'center', justifyContent: 'center', flexDirection: "column", gap: 16 }}>
478
+
479
+ {/* 🚨 REMOVED THE ORANGE WARNING BANNER ENTIRELY */}
480
+
481
+ {/* 🚨 PREVIEW CONTAINER WITH PLATFORM OVERRIDES */}
482
+ <View style={styles.imagePreviewWrapper}>
483
+ {capturedImages[currentSide]?.dir ? (
484
+ <Image source={{ uri: capturedImages[currentSide].dir }} style={styles.previewImage} />
485
+ ) : silentCaptureResult.path ? (
486
+ <Image source={{ uri: silentCaptureResult.path }} style={styles.previewImage} />
487
+ ) : null}
451
488
  </View>
452
- ) : null}
453
-
454
- {silentCaptureResult.error && !isBusy && Platform.OS !== 'web' ? (
455
- <View style={[styles.floatingErrorBanner, { zIndex: 10000 }]}>
456
- <Text style={styles.floatingErrorText}>⚠️ {silentCaptureResult.error}</Text>
457
- </View>
458
- ) : null}
459
-
460
- </View>
461
- </View>
462
- </View>
463
- );
464
- }
465
-
466
- // --- PREVIEW RENDER ---
467
- return (
468
- <View style={styles.root}>
469
- <View style={styles.previewContainer}>
470
- <ScrollView style={styles.previewItemContainer} showsVerticalScrollIndicator={false}>
471
- <View key={currentSide} style={styles.sideContainer}>
472
- <Text style={styles.sideTitle}>
473
- {t('kyc.idCardCapture.captureTitle', { side: currentSide === 'front' ? locale === 'en' ? 'Front' : 'Recto' : locale === 'en' ? 'Back' : 'Verso' })}
474
- </Text>
475
- <Text style={{ fontSize: 14, color: '#666', textAlign: 'center', marginBottom: 16, lineHeight: 22 }}>
476
- {getLocalizedText(component.instructions)}
477
- </Text>
478
-
479
- <View style={{ alignItems: 'center', justifyContent: 'center', flexDirection: "column", gap: 16 }}>
480
- {silentCaptureResult?.error === 'TOO_FAR_AWAY' && (
481
- <View style={styles.warningBanner}>
482
- <Text style={styles.warningText}>
483
- {state.currentLanguage === "en" ? "Move the document closer to the camera and place it on a flat surface." : "Veuillez rapprocher le document de la caméra et le poser à plat."}
484
- </Text>
485
- </View>
486
- )}
487
-
488
- {/* 🚨 PREVIEW CONTAINER WITH PLATFORM OVERRIDES */}
489
- <View style={styles.imagePreviewWrapper}>
490
- {capturedImages[currentSide]?.dir ? (
491
- <Image source={{ uri: capturedImages[currentSide].dir }} style={styles.previewImage} />
492
- ) : silentCaptureResult.path ? (
493
- <Image source={{ uri: silentCaptureResult.path }} style={styles.previewImage} />
494
- ) : null}
495
- </View>
496
-
497
- {!capturedImages[currentSide]?.dir && (
498
- <Button
499
- title={state.currentLanguage === "en" ? "Start Scanning" : "Commencer la numérisation"}
500
- onPress={() => { setShowCamera(true); refreshCamera(); actions.showCustomStepper(false); }}
501
- variant="primary" size="large" fullWidth
502
- />
503
- )}
504
- {capturedImages[currentSide]?.dir && (
505
- <>
506
- <Button title={t('kyc.idCardCapture.retakeButton')} onPress={() => retakePicture(currentSide)} variant="outline" size="medium" fullWidth />
507
- <Button title={t('common.next')} onPress={() => {
508
- if (!selectedDocumentType) { showAlert('Error', 'Document type not selected'); return; }
509
- if (currentSide === 'back' || selectedDocumentType.type === 'passport') {
510
- actions.nextComponent();
511
- } else {
512
- setShowCamera(true);
513
- refreshCamera();
514
- setCurrentSide('back');
515
- setSilentCaptureResult({ success: false, isAnalyzing: false, path: '', error: '', templatePath: undefined });
516
- setIsProcessingCapture(false);
517
- setProcessingImagePath(null);
518
- }
519
- }} variant="primary" size="large" fullWidth />
520
- </>
521
- )}
522
- </View>
523
- </View>
524
- </ScrollView>
525
- </View>
526
- </View>
527
- );
489
+
490
+ {!capturedImages[currentSide]?.dir && (
491
+ <Button
492
+ title={state.currentLanguage === "en" ? "Start Scanning" : "Commencer la numérisation"}
493
+ onPress={() => { setShowCamera(true); refreshCamera(); actions.showCustomStepper(false); }}
494
+ variant="primary" size="large" fullWidth
495
+ />
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);
506
+ refreshCamera();
507
+ setCurrentSide('back');
508
+ setSilentCaptureResult({ success: false, isAnalyzing: false, path: '', error: '', templatePath: undefined });
509
+ setIsProcessingCapture(false);
510
+ setProcessingImagePath(null);
511
+ }
512
+ }} variant="primary" size="large" fullWidth />
513
+ </>
514
+ )}
515
+ </View>
516
+ </View>
517
+ </ScrollView>
518
+ </View>
519
+ </View>
520
+ );
528
521
  };
529
522
 
530
523
  const styles = StyleSheet.create({
531
- root: {
532
- flex: 1, width: '100%', backgroundColor: 'transparent', alignSelf: 'center',
533
- ...Platform.select({
534
- web: { minHeight: '85vh', justifyContent: 'center', alignItems: 'center', backdropFilter: 'blur(8px)' } as any,
535
- })
536
- },
537
- cameraWrapper: {
538
- width: '100%', backgroundColor: '#000000', overflow: 'hidden',
539
- ...Platform.select({
540
- 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,
541
- default: { flex: 1 }
542
- })
543
- },
544
- headerContainer: {
545
- flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 24, paddingVertical: 18, backgroundColor: '#FFFFFF', borderBottomWidth: 1, borderBottomColor: '#F1F5F9', zIndex: 10,
546
- ...Platform.select({ web: { display: 'flex' }, default: { display: 'none' } })
547
- },
548
- headerTitle: { fontSize: 18, fontWeight: '700', color: '#0F172A', },
549
- stepBadge: { backgroundColor: '#F1F5F9', paddingHorizontal: 12, paddingVertical: 6, borderRadius: 20, },
550
- stepText: { fontSize: 13, fontWeight: '600', color: '#64748B', },
551
- cameraFeedContainer: { flex: 1, position: 'relative', backgroundColor: '#000', },
552
- camera: { flex: 1, },
553
-
554
- // MOBILE: Escape Hatch layout
555
- escapeHatchContainer: { position: 'absolute', bottom: 40, left: 0, right: 0, alignItems: 'center', justifyContent: 'center', zIndex: 99999 },
556
- 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 },
557
- fallbackRefreshText: { color: '#FFFFFF', fontWeight: 'bold', fontSize: 16 },
558
-
559
- // WEB: Control Bar layout
560
- webBottomControlBar: { position: 'absolute', bottom: 0, left: 0, right: 0, paddingHorizontal: 20, paddingBottom: 20, paddingTop: 20, backgroundColor: 'rgba(0,0,0,0.6)', zIndex: 99999 },
561
- webActionButtonsRow: { flexDirection: 'row', justifyContent: 'space-between', gap: 12, marginTop: 12 },
562
- webPrimaryButton: { flex: 1, backgroundColor: '#2DBD60', paddingVertical: 14, borderRadius: 12, alignItems: 'center' },
563
- webPrimaryButtonText: { color: 'white', fontWeight: 'bold', fontSize: 16 },
564
- 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' },
565
- webSecondaryButtonText: { color: 'white', fontWeight: 'bold', fontSize: 16 },
566
-
567
- // WEB: Flip Controls
568
- webTopControls: { position: 'absolute', top: 20, right: 20, zIndex: 9999 },
569
- webFlipButton: { backgroundColor: 'rgba(0,0,0,0.5)', paddingHorizontal: 16, paddingVertical: 10, borderRadius: 20, borderWidth: 1, borderColor: 'rgba(255,255,255,0.3)' },
570
- webFlipText: { color: 'white', fontWeight: 'bold', fontSize: 14 },
571
-
572
- previewContainer: {
573
- width: '100%', backgroundColor: 'white', borderRadius: 12, paddingVertical: 24, paddingHorizontal: 20, shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.1, shadowRadius: 12, elevation: 8,
574
- ...Platform.select({ web: { alignSelf: 'center', maxWidth: 600 }, default: { margin: 10, width: '95%' }})
575
- },
576
- previewItemContainer: { flexGrow: 1, },
577
- title: { fontSize: 24, fontWeight: 'bold', color: '#333', marginBottom: 8, textAlign: 'center' },
578
- description: { fontSize: 16, color: '#666', textAlign: 'center', marginBottom: 24, lineHeight: 22 },
579
- sideContainer: { marginBottom: 24 },
580
- sideTitle: { fontSize: 25, fontWeight: 'bold', color: '#000', marginBottom: 12, textAlign: 'center' },
524
+ root: {
525
+ flex: 1, width: '100%', backgroundColor: 'transparent', alignSelf: 'center',
526
+ ...Platform.select({
527
+ web: { minHeight: '85vh', justifyContent: 'center', alignItems: 'center', backdropFilter: 'blur(8px)' } as any,
528
+ })
529
+ },
530
+ cameraWrapper: {
531
+ width: '100%', backgroundColor: '#000000', overflow: 'hidden',
532
+ ...Platform.select({
533
+ 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,
534
+ default: { flex: 1 }
535
+ })
536
+ },
537
+ headerContainer: {
538
+ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 24, paddingVertical: 18, backgroundColor: '#FFFFFF', borderBottomWidth: 1, borderBottomColor: '#F1F5F9', zIndex: 10,
539
+ ...Platform.select({ web: { display: 'flex' }, default: { display: 'none' } })
540
+ },
541
+ headerTitle: { fontSize: 18, fontWeight: '700', color: '#0F172A', },
542
+ stepBadge: { backgroundColor: '#F1F5F9', paddingHorizontal: 12, paddingVertical: 6, borderRadius: 20, },
543
+ stepText: { fontSize: 13, fontWeight: '600', color: '#64748B', },
544
+ cameraFeedContainer: { flex: 1, position: 'relative', backgroundColor: '#000', },
545
+ camera: { flex: 1, },
546
+
547
+ // MOBILE: Escape Hatch layout
548
+ escapeHatchContainer: { position: 'absolute', bottom: 40, left: 0, right: 0, alignItems: 'center', justifyContent: 'center', zIndex: 99999 },
549
+ 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 },
550
+ fallbackRefreshText: { color: '#FFFFFF', fontWeight: 'bold', fontSize: 16 },
551
+
552
+ // WEB: Control Bar layout
553
+ webBottomControlBar: { position: 'absolute', bottom: 0, left: 0, right: 0, paddingHorizontal: 20, paddingBottom: 20, paddingTop: 20, backgroundColor: 'rgba(0,0,0,0.6)', zIndex: 99999 },
554
+ webActionButtonsRow: { flexDirection: 'row', justifyContent: 'space-between', gap: 12, marginTop: 12 },
555
+ webPrimaryButton: { flex: 1, backgroundColor: '#2DBD60', paddingVertical: 14, borderRadius: 12, alignItems: 'center' },
556
+ webPrimaryButtonText: { color: 'white', fontWeight: 'bold', fontSize: 16 },
557
+ 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' },
558
+ webSecondaryButtonText: { color: 'white', fontWeight: 'bold', fontSize: 16 },
559
+
560
+ // WEB: Flip Controls
561
+ webTopControls: { position: 'absolute', top: 20, right: 20, zIndex: 9999 },
562
+ webFlipButton: { backgroundColor: 'rgba(0,0,0,0.5)', paddingHorizontal: 16, paddingVertical: 10, borderRadius: 20, borderWidth: 1, borderColor: 'rgba(255,255,255,0.3)' },
563
+ webFlipText: { color: 'white', fontWeight: 'bold', fontSize: 14 },
564
+
565
+ previewContainer: {
566
+ width: '100%', backgroundColor: 'white', borderRadius: 12, paddingVertical: 24, paddingHorizontal: 20, shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.1, shadowRadius: 12, elevation: 8,
567
+ ...Platform.select({ web: { alignSelf: 'center', maxWidth: 600 }, default: { margin: 10, width: '95%' }})
568
+ },
569
+ previewItemContainer: { flexGrow: 1, },
570
+ title: { fontSize: 24, fontWeight: 'bold', color: '#333', marginBottom: 8, textAlign: 'center' },
571
+ description: { fontSize: 16, color: '#666', textAlign: 'center', marginBottom: 24, lineHeight: 22 },
572
+ sideContainer: { marginBottom: 24 },
573
+ sideTitle: { fontSize: 25, fontWeight: 'bold', color: '#000', marginBottom: 12, textAlign: 'center' },
574
+
575
+ imagePreviewWrapper: {
576
+ width: '100%', borderRadius: 12, padding: 1, overflow: 'hidden', shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.18, shadowRadius: 8, elevation: 8, backgroundColor: '#f0f0f0',
577
+ ...Platform.select({
578
+ web: { aspectRatio: 1.59, height: 'auto' } as any,
579
+ default: { height: 220 }
580
+ })
581
+ },
582
+ previewImage: { width: '100%', height: '100%', borderRadius: 12, resizeMode: 'cover' },
583
+
584
+ floatingErrorBanner: {
585
+ backgroundColor: '#FEF2F2', borderWidth: 1, borderColor: '#FCA5A5', paddingVertical: 12, paddingHorizontal: 16, borderRadius: 12, alignItems: 'center', justifyContent: 'center', width: '100%',
586
+ ...Platform.select({
587
+ default: { position: 'absolute', top: Platform.OS === 'ios' ? 90 : 70, left: 24, right: 24, zIndex: 10000 }
588
+ })
589
+ },
590
+ floatingErrorText: { color: '#991B1B', fontSize: 14, fontWeight: '700', textAlign: 'center' },
591
+ processingOverlay: { flex: 1, backgroundColor: 'rgba(0, 0, 0, 0.6)', justifyContent: 'center', alignItems: 'center', zIndex: 9999 },
592
+ processingText: { color: '#FFF', fontSize: 18, fontWeight: 'bold', marginTop: 16, textAlign: 'center' },
581
593
 
582
- imagePreviewWrapper: {
583
- width: '100%', borderRadius: 12, padding: 1, overflow: 'hidden', shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.18, shadowRadius: 8, elevation: 8, backgroundColor: '#f0f0f0',
584
- ...Platform.select({
585
- web: { aspectRatio: 1.59, height: 'auto' } as any, // 🚨 Perfect ID ratio on web
586
- default: { height: 220 } // 🚨 Strict original height on mobile
587
- })
588
- },
589
- previewImage: { width: '100%', height: '100%', borderRadius: 12, resizeMode: 'cover' },
590
-
591
- floatingErrorBanner: {
592
- backgroundColor: '#FEF2F2', borderWidth: 1, borderColor: '#FCA5A5', paddingVertical: 12, paddingHorizontal: 16, borderRadius: 12, alignItems: 'center', justifyContent: 'center', width: '100%',
593
- ...Platform.select({
594
- default: { position: 'absolute', top: Platform.OS === 'ios' ? 90 : 70, left: 24, right: 24, zIndex: 10000 }
595
- })
596
- },
597
- floatingErrorText: { color: '#991B1B', fontSize: 14, fontWeight: '700', textAlign: 'center' },
598
- processingOverlay: { flex: 1, backgroundColor: 'rgba(0, 0, 0, 0.6)', justifyContent: 'center', alignItems: 'center', zIndex: 9999 },
599
- processingText: { color: '#FFF', fontSize: 18, fontWeight: 'bold', marginTop: 16, textAlign: 'center' },
600
- warningBanner: { backgroundColor: '#FF9500', padding: 12, borderRadius: 8, width: '100%', shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.2, shadowRadius: 4, elevation: 4 },
601
- warningText: { color: 'white', fontWeight: 'bold', textAlign: 'center', fontSize: 16 },
602
- errorText: { color: '#dc2626', fontSize: 14, marginTop: 8, textAlign: 'center' },
603
- topAnalyzingPillContainer: { position: 'absolute', top: Platform.OS === 'android' ? 60 : 50, left: 0, right: 0, alignItems: 'center', zIndex: 100 },
604
- topAnalyzingPill: { flexDirection: 'row', alignItems: 'center', backgroundColor: 'rgba(0,0,0,0.6)', paddingVertical: 8, paddingHorizontal: 16, borderRadius: 20, gap: 8 },
605
- analyzingPillText: { color: 'white', fontSize: 14, fontWeight: 'bold' }
594
+ errorText: { color: '#dc2626', fontSize: 14, marginTop: 8, textAlign: 'center' },
595
+ topAnalyzingPillContainer: { position: 'absolute', top: Platform.OS === 'android' ? 60 : 50, left: 0, right: 0, alignItems: 'center', zIndex: 100 },
596
+ topAnalyzingPill: { flexDirection: 'row', alignItems: 'center', backgroundColor: 'rgba(0,0,0,0.6)', paddingVertical: 8, paddingHorizontal: 16, borderRadius: 20, gap: 8 },
597
+ analyzingPillText: { color: 'white', fontSize: 14, fontWeight: 'bold' }
606
598
  });