@sanctum-key/react-native-sdk 1.0.37 → 1.0.39
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.
- package/build/package.json +1 -1
- package/build/src/components/KYCElements/IDCardCapture.d.ts.map +1 -1
- package/build/src/components/KYCElements/IDCardCapture.js +190 -412
- package/build/src/components/KYCElements/IDCardCapture.js.map +1 -1
- package/package.json +1 -1
- package/src/components/KYCElements/IDCardCapture.tsx +306 -688
|
@@ -14,13 +14,12 @@ import pathToBase64 from '../../utils/pathToBase64';
|
|
|
14
14
|
import { cropByObb, getObbConfidence, OBB_CONFIDENCE_THRESHOLD } from '../../utils/cropByObb';
|
|
15
15
|
import REGION_MAPPING from '../../config/region_mapping.json';
|
|
16
16
|
import QRCode from 'react-native-qrcode-svg';
|
|
17
|
-
import {
|
|
18
|
-
import { cropImageWithBBoxWithTolerance } from '../../utils/cropByObb';
|
|
17
|
+
import { cropImageWithBBoxWithTolerance } from '../../utils/cropByObb';
|
|
19
18
|
import kycService, { authentification } from '../../modules/api/KYCService';
|
|
20
19
|
|
|
21
20
|
const ISO_TO_COUNTRY_NAME: Record<string, string> = {
|
|
22
|
-
'KE': 'Kenya', 'CM': 'Cameroon', 'NG': 'Nigeria', 'CA': 'Canada',
|
|
23
|
-
'FR': 'France', 'GH': 'Ghana', 'ZA': 'South Africa', 'GB': 'Britain',
|
|
21
|
+
'KE': 'Kenya', 'CM': 'Cameroon', 'NG': 'Nigeria', 'CA': 'Canada',
|
|
22
|
+
'FR': 'France', 'GH': 'Ghana', 'ZA': 'South Africa', 'GB': 'Britain',
|
|
24
23
|
'CI': 'Ivory Coast', 'SN': 'Senegal', 'TG': 'Togo', 'ML': 'Mali'
|
|
25
24
|
};
|
|
26
25
|
|
|
@@ -41,17 +40,14 @@ interface IDCardCaptureProps {
|
|
|
41
40
|
}
|
|
42
41
|
|
|
43
42
|
const SHORT_TO_LONG_REGION: Record<string, string> = {
|
|
44
|
-
"AB": "Alberta", "BC": "British Columbia", "MB": "Manitoba",
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
"ON": "Ontario", "PE": "Prince Edward Island", "QC": "Quebec",
|
|
43
|
+
"AB": "Alberta", "BC": "British Columbia", "MB": "Manitoba", "NB": "New Brunswick",
|
|
44
|
+
"NL": "Newfoundland and Labrador", "NT": "Northwest Territories", "NS": "Nova Scotia",
|
|
45
|
+
"NU": "Nunavut", "ON": "Ontario", "PE": "Prince Edward Island", "QC": "Quebec",
|
|
48
46
|
"SK": "Saskatchewan", "YT": "Yukon"
|
|
49
47
|
};
|
|
50
48
|
|
|
51
|
-
// 🚨 Isolates the 1-second re-renders away from the Camera!
|
|
52
49
|
const ExpiringCodeTimer = ({ initialCode, language }: { initialCode: string, language: string }) => {
|
|
53
50
|
const [timeLeft, setTimeLeft] = useState(300);
|
|
54
|
-
|
|
55
51
|
useEffect(() => {
|
|
56
52
|
if (timeLeft <= 0) return;
|
|
57
53
|
const timer = setInterval(() => setTimeLeft((prev) => prev - 1), 1000);
|
|
@@ -65,7 +61,6 @@ const ExpiringCodeTimer = ({ initialCode, language }: { initialCode: string, lan
|
|
|
65
61
|
};
|
|
66
62
|
|
|
67
63
|
if (timeLeft <= 0) return null;
|
|
68
|
-
|
|
69
64
|
return (
|
|
70
65
|
<View style={styles.activeCodeContainer}>
|
|
71
66
|
<Text style={styles.activeCodeText}>{initialCode}</Text>
|
|
@@ -83,6 +78,7 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
|
|
|
83
78
|
const [currentSide, setCurrentSide] = useState<'front' | 'back'>('front');
|
|
84
79
|
const [bboxBySide, setBboxBySide] = useState<Record<string, IBbox>>({});
|
|
85
80
|
const [silentCaptureResult, setSilentCaptureResult] = useState<ISilentCaptureResult>({ success: false, isAnalyzing: false });
|
|
81
|
+
|
|
86
82
|
const [isProcessingCapture, setIsProcessingCapture] = useState(false);
|
|
87
83
|
const [processingImagePath, setProcessingImagePath] = useState<string | null>(null);
|
|
88
84
|
const [cameraKey, setCameraKey] = useState(0);
|
|
@@ -90,46 +86,71 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
|
|
|
90
86
|
const [cameraType, setCameraType] = useState<'back' | 'front'>('back');
|
|
91
87
|
const [showQRModal, setShowQRModal] = useState(false);
|
|
92
88
|
const [currentUrl, setCurrentUrl] = useState('');
|
|
89
|
+
console.log(currentUrl)
|
|
93
90
|
const [qrPlatform, setQrPlatform] = useState<'android' | 'ios'>('android');
|
|
94
|
-
|
|
95
91
|
const [scanPhase, setScanPhase] = useState<'image' | 'barcode'>('image');
|
|
96
92
|
const [pendingCAImage, setPendingCAImage] = useState<{ path: string, templateResponse: any, templatePath: string, bbox?: IBbox } | null>(null);
|
|
97
|
-
|
|
98
93
|
const scannedBarcodeRef = useRef<string | null>(null);
|
|
99
|
-
|
|
94
|
+
const [generatedCode, setGeneratedCode] = useState<string | null>(null);
|
|
95
|
+
const [isGeneratingCode, setIsGeneratingCode] = useState(false);
|
|
96
|
+
|
|
97
|
+
const { actions, state, env, apiKey } = useTemplateKYCFlowContext();
|
|
98
|
+
const { width: screenWidth, height: screenHeight } = useWindowDimensions();
|
|
99
|
+
|
|
100
|
+
const countrySelectionData = useMemo(() => {
|
|
101
|
+
const countrySelectionComponent = state.template.components.find(c => c.type === 'country_selection');
|
|
102
|
+
return countrySelectionComponent ? state.componentData[countrySelectionComponent.id] : null;
|
|
103
|
+
}, [state.template.components, state.componentData]);
|
|
104
|
+
|
|
105
|
+
const documentTypeMapping: Record<string, GovernmentDocumentType> = {
|
|
106
|
+
'nationalId': 'national_id', 'national_id': 'national_id', 'identity_card': 'national_id',
|
|
107
|
+
'passport': 'passport', 'driversLicense': 'drivers_licence', 'drivers_licence': 'drivers_licence',
|
|
108
|
+
'residencePermit': 'permanent_residence', 'permanent_residence': 'permanent_residence',
|
|
109
|
+
'healthInsuranceCard': 'health_insurance_card', 'health_insurance_card': 'health_insurance_card',
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const selectedDocumentType = useMemo<{ type: GovernmentDocumentType; region: string } | null>(() => {
|
|
113
|
+
if (!countrySelectionData?.documentType) return null;
|
|
114
|
+
const backendDocType = countrySelectionData.documentType;
|
|
115
|
+
const mappedType = documentTypeMapping[backendDocType] || backendDocType as GovernmentDocumentType;
|
|
116
|
+
return { type: mappedType, region: countrySelectionData.region || 'root' };
|
|
117
|
+
}, [countrySelectionData, documentTypeMapping]);
|
|
118
|
+
|
|
119
|
+
const countryData = useMemo(() => countrySelectionData, [countrySelectionData]);
|
|
120
|
+
|
|
121
|
+
// ==========================================
|
|
122
|
+
// 🚨 CRITICAL FIX: REF MIRRORS FOR IOS BRIDGE
|
|
123
|
+
// Bypass stale native closures by mirroring all state into refs
|
|
124
|
+
// ==========================================
|
|
100
125
|
const isAnalyzingRef = useRef<boolean>(false);
|
|
101
126
|
const isProcessingCaptureRef = useRef<boolean>(false);
|
|
102
127
|
const scanPhaseRef = useRef<'image' | 'barcode'>('image');
|
|
128
|
+
const currentSideRef = useRef(currentSide);
|
|
129
|
+
const capturedImagesRef = useRef(capturedImages);
|
|
130
|
+
const selectedDocTypeRef = useRef(selectedDocumentType);
|
|
131
|
+
const countryDataRef = useRef(countryData);
|
|
103
132
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
133
|
+
useEffect(() => { isAnalyzingRef.current = silentCaptureResult.isAnalyzing ?? false; }, [silentCaptureResult.isAnalyzing]);
|
|
134
|
+
useEffect(() => { isProcessingCaptureRef.current = isProcessingCapture; }, [isProcessingCapture]);
|
|
135
|
+
useEffect(() => { scanPhaseRef.current = scanPhase; }, [scanPhase]);
|
|
136
|
+
useEffect(() => { currentSideRef.current = currentSide; }, [currentSide]);
|
|
137
|
+
useEffect(() => { capturedImagesRef.current = capturedImages; }, [capturedImages]);
|
|
138
|
+
useEffect(() => { selectedDocTypeRef.current = selectedDocumentType; }, [selectedDocumentType]);
|
|
139
|
+
useEffect(() => { countryDataRef.current = countryData; }, [countryData]);
|
|
140
|
+
// ==========================================
|
|
107
141
|
|
|
108
142
|
const handleGenerateCode = async () => {
|
|
109
143
|
if (isGeneratingCode) return;
|
|
110
144
|
setIsGeneratingCode(true);
|
|
111
|
-
|
|
112
145
|
try {
|
|
113
146
|
if (!apiKey) {
|
|
114
147
|
showAlert('Error', 'API Key is missing.');
|
|
115
|
-
setIsGeneratingCode(false);
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// Pass the dynamic apiKey to your service
|
|
120
|
-
const res = await kycService.generateReferralCode(
|
|
121
|
-
{ session_id: state.session.session_id },
|
|
122
|
-
apiKey
|
|
123
|
-
);
|
|
124
|
-
|
|
125
|
-
// Extract the code from the response
|
|
126
|
-
const newCode = res.code || res.referral_code || res?.data?.code || res?.data?.referral_code;
|
|
127
|
-
|
|
128
|
-
if (newCode) {
|
|
129
|
-
setGeneratedCode(newCode);
|
|
130
|
-
} else {
|
|
131
|
-
showAlert('Error', 'Failed to parse code from server response.');
|
|
148
|
+
setIsGeneratingCode(false); return;
|
|
132
149
|
}
|
|
150
|
+
const res = await kycService.generateReferralCode({ session_id: state.session.session_id }, apiKey);
|
|
151
|
+
const newCode = res.code || res.referral_code || res?.data?.code || res?.data?.referral_code;
|
|
152
|
+
if (newCode) setGeneratedCode(newCode);
|
|
153
|
+
else showAlert('Error', 'Failed to parse code from server response.');
|
|
133
154
|
} catch (error: any) {
|
|
134
155
|
showAlert('Error', error.message || 'Failed to generate code');
|
|
135
156
|
} finally {
|
|
@@ -139,56 +160,18 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
|
|
|
139
160
|
|
|
140
161
|
useEffect(() => {
|
|
141
162
|
if (typeof window !== 'undefined' && window.location) setCurrentUrl(window.location.href);
|
|
142
|
-
console.log(currentUrl)
|
|
143
163
|
}, []);
|
|
144
164
|
|
|
145
|
-
const documentTypeMapping: Record<string, GovernmentDocumentType> = {
|
|
146
|
-
'nationalId': 'national_id', 'national_id': 'national_id', 'identity_card': 'national_id',
|
|
147
|
-
'passport': 'passport', 'driversLicense': 'drivers_licence', 'drivers_licence': 'drivers_licence',
|
|
148
|
-
'residencePermit': 'permanent_residence', 'permanent_residence': 'permanent_residence',
|
|
149
|
-
'healthInsuranceCard': 'health_insurance_card', 'health_insurance_card': 'health_insurance_card',
|
|
150
|
-
};
|
|
151
|
-
|
|
152
|
-
const { actions, state, env, apiKey } = useTemplateKYCFlowContext();
|
|
153
|
-
const { width: screenWidth, height: screenHeight } = useWindowDimensions();
|
|
154
|
-
|
|
155
165
|
const getLocalizedText = (text: LocalizedText | Record<string, LocalizedText>): string => {
|
|
156
|
-
if (text && typeof text[currentSide] === 'object' && text[currentSide][locale])
|
|
157
|
-
return text[currentSide][locale] || '';
|
|
166
|
+
if (text && typeof text[currentSide] === 'object' && text[currentSide][locale]) return text[currentSide][locale] || '';
|
|
158
167
|
return "";
|
|
159
168
|
};
|
|
160
169
|
|
|
161
|
-
useEffect(() => {
|
|
162
|
-
if (Platform.OS === 'web' && typeof window !== 'undefined') {
|
|
163
|
-
const baseUrl = window.location.origin + window.location.pathname;
|
|
164
|
-
const handoffUrl = `${baseUrl}?session_id=${state.session.session_id}&resume=true`;
|
|
165
|
-
setCurrentUrl(handoffUrl);
|
|
166
|
-
}
|
|
167
|
-
}, [state.session.session_id]);
|
|
168
|
-
|
|
169
|
-
const countrySelectionData = useMemo(() => {
|
|
170
|
-
const countrySelectionComponent = state.template.components.find(c => c.type === 'country_selection');
|
|
171
|
-
return countrySelectionComponent ? state.componentData[countrySelectionComponent.id] : null;
|
|
172
|
-
}, [state.template.components, state.componentData]);
|
|
173
|
-
|
|
174
|
-
const selectedDocumentType = useMemo<{ type: GovernmentDocumentType; region: string } | null>(() => {
|
|
175
|
-
if (!countrySelectionData?.documentType) return null;
|
|
176
|
-
const backendDocType = countrySelectionData.documentType;
|
|
177
|
-
const mappedType = documentTypeMapping[backendDocType] || backendDocType as GovernmentDocumentType;
|
|
178
|
-
return { type: mappedType, region: countrySelectionData.region || 'root' };
|
|
179
|
-
}, [countrySelectionData, documentTypeMapping]);
|
|
180
|
-
|
|
181
|
-
const countryData = useMemo(() => countrySelectionData, [countrySelectionData]);
|
|
182
|
-
|
|
183
170
|
const isWebScanningRestricted = useMemo(() => {
|
|
184
171
|
if (Platform.OS !== 'web') return false;
|
|
185
172
|
if (countryData?.code !== 'CA') return false;
|
|
186
|
-
|
|
187
173
|
const docType = selectedDocumentType?.type;
|
|
188
|
-
return docType === 'drivers_licence' ||
|
|
189
|
-
docType === 'health_insurance_card' ||
|
|
190
|
-
docType === 'national_id' ||
|
|
191
|
-
docType === 'identity_card';
|
|
174
|
+
return docType === 'drivers_licence' || docType === 'health_insurance_card' || docType === 'national_id' || docType === 'identity_card';
|
|
192
175
|
}, [countryData?.code, selectedDocumentType?.type]);
|
|
193
176
|
|
|
194
177
|
const targetBarcodeTypes = useMemo<any[]>(() => {
|
|
@@ -199,35 +182,14 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
|
|
|
199
182
|
|
|
200
183
|
const refreshCamera = () => {
|
|
201
184
|
setIsRebootingCamera(true);
|
|
202
|
-
|
|
203
185
|
if (scanPhase === 'barcode') {
|
|
204
186
|
setScanPhase('image');
|
|
205
187
|
setPendingCAImage(null);
|
|
206
188
|
scannedBarcodeRef.current = null;
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
delete newState[currentSide];
|
|
211
|
-
return newState;
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
if (value) {
|
|
215
|
-
const newValue = { ...value };
|
|
216
|
-
delete newValue[currentSide];
|
|
217
|
-
onValueChange(newValue);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
setSilentCaptureResult({
|
|
221
|
-
success: false,
|
|
222
|
-
isAnalyzing: false,
|
|
223
|
-
path: '',
|
|
224
|
-
error: '',
|
|
225
|
-
templatePath: '',
|
|
226
|
-
mrz: '',
|
|
227
|
-
bbox: undefined
|
|
228
|
-
});
|
|
189
|
+
setCapturedImages((prev) => { const newState = { ...prev }; delete newState[currentSide]; return newState; });
|
|
190
|
+
if (value) { const newValue = { ...value }; delete newValue[currentSide]; onValueChange(newValue); }
|
|
191
|
+
setSilentCaptureResult({ success: false, isAnalyzing: false, path: '', error: '', templatePath: '', mrz: '', bbox: undefined });
|
|
229
192
|
}
|
|
230
|
-
|
|
231
193
|
setTimeout(() => {
|
|
232
194
|
setCameraKey(prev => prev + 1);
|
|
233
195
|
setIsRebootingCamera(false);
|
|
@@ -240,116 +202,57 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
|
|
|
240
202
|
};
|
|
241
203
|
|
|
242
204
|
useEffect(() => {
|
|
243
|
-
if (value && Object.keys(value).length > 0) {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
if (currentImageData?.dir) {
|
|
250
|
-
setSilentCaptureResult(prev => ({
|
|
251
|
-
...prev,
|
|
252
|
-
path: currentImageData.dir,
|
|
253
|
-
success: true,
|
|
254
|
-
isAnalyzing: false,
|
|
255
|
-
mrz: currentImageData.mrz || '',
|
|
256
|
-
templatePath: currentImageData.templatePath || '',
|
|
257
|
-
}));
|
|
258
|
-
}
|
|
205
|
+
if (value && Object.keys(value).length > 0 && JSON.stringify(value) !== JSON.stringify(capturedImages)) {
|
|
206
|
+
const updatedImages = value as Record<string, IIDCardPayload>;
|
|
207
|
+
setCapturedImages(updatedImages);
|
|
208
|
+
const currentImageData = updatedImages[currentSide];
|
|
209
|
+
if (currentImageData?.dir) {
|
|
210
|
+
setSilentCaptureResult(prev => ({ ...prev, path: currentImageData.dir, success: true, isAnalyzing: false, mrz: currentImageData.mrz || '', templatePath: currentImageData.templatePath || '' }));
|
|
259
211
|
}
|
|
260
212
|
}
|
|
261
213
|
}, [value, currentSide]);
|
|
262
214
|
|
|
263
|
-
// Keep refs in sync with state so handleSilentCapture always reads current values
|
|
264
|
-
useEffect(() => { isAnalyzingRef.current = silentCaptureResult.isAnalyzing ?? false; }, [silentCaptureResult.isAnalyzing]);
|
|
265
|
-
useEffect(() => { isProcessingCaptureRef.current = isProcessingCapture; }, [isProcessingCapture]);
|
|
266
|
-
useEffect(() => { scanPhaseRef.current = scanPhase; }, [scanPhase]);
|
|
267
|
-
|
|
268
215
|
const cameraConfig = useMemo(() => {
|
|
269
|
-
let instructions = selectedDocumentType
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
if (scanPhase === 'barcode') {
|
|
274
|
-
instructions = '';
|
|
275
|
-
}
|
|
276
|
-
|
|
216
|
+
let instructions = selectedDocumentType ? (locale === 'en' ? getDocumentTypeInfo(selectedDocumentType.type).instructions.en : getDocumentTypeInfo(selectedDocumentType.type).instructions.fr) : getLocalizedText(component.instructions as Record<string, LocalizedText>);
|
|
217
|
+
if (scanPhase === 'barcode') instructions = '';
|
|
218
|
+
|
|
277
219
|
const isLandscape = screenWidth > screenHeight;
|
|
278
220
|
let xMin, xMax, yMin, yMax;
|
|
279
|
-
|
|
221
|
+
|
|
280
222
|
if (Platform.OS === 'web') {
|
|
281
223
|
const boxWidthPercent = isLandscape ? 0.40 : 0.85;
|
|
282
224
|
xMin = isLandscape ? 30 : 7.5;
|
|
283
225
|
xMax = xMin + (boxWidthPercent * 100);
|
|
284
|
-
|
|
285
226
|
const containerWidth = Math.min(screenWidth * 0.95, 1000);
|
|
286
227
|
const containerHeight = isLandscape ? screenHeight * 0.80 : screenHeight * 0.90;
|
|
287
|
-
|
|
288
228
|
const boxPixelWidth = containerWidth * boxWidthPercent;
|
|
289
229
|
const boxPixelHeight = boxPixelWidth / 1.59;
|
|
290
|
-
|
|
291
230
|
const heightPercent = (boxPixelHeight / containerHeight) * 100;
|
|
292
231
|
yMin = (100 - heightPercent) / 2.5;
|
|
293
232
|
yMax = yMin + heightPercent;
|
|
294
233
|
} else {
|
|
295
|
-
xMin = 5;
|
|
296
|
-
xMax = 95;
|
|
297
|
-
yMin = 34;
|
|
298
|
-
yMax = 66;
|
|
234
|
+
xMin = 5; xMax = 95; yMin = 34; yMax = 66;
|
|
299
235
|
}
|
|
300
|
-
|
|
301
|
-
const platformBbox
|
|
302
|
-
xMin,
|
|
303
|
-
yMin,
|
|
304
|
-
xMax,
|
|
305
|
-
yMax,
|
|
306
|
-
borderColor: scanPhase === 'barcode' ? '#EAB308' : '#2DBD60',
|
|
307
|
-
borderWidth: Platform.OS === 'web' ? 2 : 3,
|
|
308
|
-
cornerRadius: 8
|
|
309
|
-
};
|
|
310
|
-
|
|
311
|
-
return {
|
|
312
|
-
cameraType: Platform.OS === 'web' ? cameraType : 'back',
|
|
313
|
-
flashMode: 'auto' as const,
|
|
314
|
-
overlay: { guideText: instructions, bbox: platformBbox }
|
|
315
|
-
};
|
|
236
|
+
const platformBbox = { xMin, yMin, xMax, yMax, borderColor: scanPhase === 'barcode' ? '#EAB308' : '#2DBD60', borderWidth: Platform.OS === 'web' ? 2 : 3, cornerRadius: 8 };
|
|
237
|
+
return { cameraType: Platform.OS === 'web' ? cameraType : 'back', flashMode: 'auto' as const, overlay: { guideText: instructions, bbox: platformBbox } };
|
|
316
238
|
}, [selectedDocumentType, locale, component.instructions, cameraType, screenWidth, screenHeight, scanPhase]);
|
|
317
239
|
|
|
318
240
|
const retakePicture = (sideToRetake: 'front' | 'back') => {
|
|
319
|
-
setIsProcessingCapture(false);
|
|
320
|
-
|
|
321
|
-
scannedBarcodeRef.current = null;
|
|
322
|
-
setScanPhase('image');
|
|
323
|
-
setPendingCAImage(null);
|
|
241
|
+
setIsProcessingCapture(false); setProcessingImagePath(null); scannedBarcodeRef.current = null;
|
|
242
|
+
setScanPhase('image'); setPendingCAImage(null);
|
|
324
243
|
setSilentCaptureResult({ path: '', success: false, isAnalyzing: false, error: '', templatePath: '', mrz: '', bbox: undefined });
|
|
325
|
-
setShowCamera(true);
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
setCapturedImages((prev) => {
|
|
329
|
-
const newState = { ...prev };
|
|
330
|
-
delete newState[sideToRetake];
|
|
331
|
-
return newState;
|
|
332
|
-
});
|
|
333
|
-
|
|
334
|
-
if (value) {
|
|
335
|
-
const newValue = { ...value };
|
|
336
|
-
delete newValue[sideToRetake];
|
|
337
|
-
onValueChange(newValue);
|
|
338
|
-
}
|
|
244
|
+
setShowCamera(true); refreshCamera(); actions.showCustomStepper(false);
|
|
245
|
+
setCapturedImages((prev) => { const newState = { ...prev }; delete newState[sideToRetake]; return newState; });
|
|
246
|
+
if (value) { const newValue = { ...value }; delete newValue[sideToRetake]; onValueChange(newValue); }
|
|
339
247
|
};
|
|
340
248
|
|
|
341
|
-
const getCurrentSideVerification = (
|
|
342
|
-
const rawDocType =
|
|
249
|
+
const getCurrentSideVerification = (side: string, countryKey: string, cData: any) => {
|
|
250
|
+
const rawDocType = cData?.documentType;
|
|
343
251
|
if (!rawDocType || !countryKey) return { authMethod: [], mrzTypes: [], regionMapping: null, key: 'root' };
|
|
344
|
-
|
|
345
|
-
const jsonDocMap: Record<string, string> = {
|
|
346
|
-
'nationalId': 'identity_card', 'national_id': 'identity_card', 'passport': 'passport',
|
|
347
|
-
'driversLicense': 'drivers_licence', 'drivers_licence': 'drivers_licence',
|
|
348
|
-
'residencePermit': 'permanent_residence', 'healthInsuranceCard': 'health_insurance_card'
|
|
349
|
-
};
|
|
350
|
-
|
|
252
|
+
|
|
253
|
+
const jsonDocMap: Record<string, string> = { 'nationalId': 'identity_card', 'national_id': 'identity_card', 'passport': 'passport', 'driversLicense': 'drivers_licence', 'drivers_licence': 'drivers_licence', 'residencePermit': 'permanent_residence', 'healthInsuranceCard': 'health_insurance_card' };
|
|
351
254
|
const searchDocType = jsonDocMap[rawDocType] || rawDocType;
|
|
352
|
-
const rawCountryName = ISO_TO_COUNTRY_NAME[
|
|
255
|
+
const rawCountryName = ISO_TO_COUNTRY_NAME[cData?.code || ''] || cData?.code || countryKey;
|
|
353
256
|
const baseMapping = (REGION_MAPPING as any).regionMapping || REGION_MAPPING;
|
|
354
257
|
|
|
355
258
|
let countryMapping = baseMapping[rawCountryName];
|
|
@@ -357,22 +260,19 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
|
|
|
357
260
|
const foundKey = Object.keys(baseMapping).find(k => k.toLowerCase() === rawCountryName.toLowerCase() || k.toLowerCase() === countryKey.toLowerCase());
|
|
358
261
|
if (foundKey) countryMapping = baseMapping[foundKey];
|
|
359
262
|
}
|
|
360
|
-
|
|
361
263
|
if (!countryMapping || !countryMapping[searchDocType]) return { authMethod: [], mrzTypes: [], regionMapping: null, key: 'root' };
|
|
362
|
-
|
|
363
|
-
const authMethod: string[] = [];
|
|
364
|
-
const
|
|
365
|
-
const rawRegionKey = countrySelectionData.region?.trim()?.length > 0 ? countrySelectionData.region.trim() : 'root';
|
|
264
|
+
|
|
265
|
+
const authMethod: string[] = []; const mrzTypes: string[] = [];
|
|
266
|
+
const rawRegionKey = cData.region?.trim()?.length > 0 ? cData.region.trim() : 'root';
|
|
366
267
|
const key = SHORT_TO_LONG_REGION[rawRegionKey] || rawRegionKey;
|
|
367
268
|
const regionMapping = countryMapping[searchDocType];
|
|
368
|
-
|
|
269
|
+
|
|
369
270
|
if (regionMapping?.[key] && Array.isArray(regionMapping[key])) {
|
|
370
271
|
regionMapping[key].forEach((item: any) => {
|
|
371
|
-
if (item[
|
|
272
|
+
if (item[side]) authMethod.push(item[side]);
|
|
372
273
|
if (item?.mrz_type) mrzTypes.push(item?.mrz_type);
|
|
373
274
|
});
|
|
374
275
|
}
|
|
375
|
-
|
|
376
276
|
return { authMethod: removeDuplicates(authMethod), mrzTypes: removeDuplicates(mrzTypes), regionMapping, key };
|
|
377
277
|
}
|
|
378
278
|
|
|
@@ -397,119 +297,88 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
|
|
|
397
297
|
const autoCapture = async (capturePath: string, verified: ISilentCaptureResult & { region?: string }) => {
|
|
398
298
|
if (isProcessingCapture) return;
|
|
399
299
|
setIsProcessingCapture(true);
|
|
400
|
-
|
|
401
|
-
const safeCapturePath = (Platform.OS === 'ios' && !capturePath.startsWith('file://'))
|
|
402
|
-
? `file://${capturePath}`
|
|
403
|
-
: capturePath;
|
|
404
|
-
|
|
300
|
+
const safeCapturePath = (Platform.OS === 'ios' && !capturePath.startsWith('file://')) ? `file://${capturePath}` : capturePath;
|
|
405
301
|
setProcessingImagePath(safeCapturePath);
|
|
406
|
-
|
|
302
|
+
|
|
407
303
|
try {
|
|
408
304
|
let imagePathForUpload = capturePath;
|
|
409
305
|
const overlayBbox = cameraConfig.overlay.bbox;
|
|
410
|
-
const uiCropBbox = {
|
|
411
|
-
|
|
412
|
-
width: (overlayBbox.xMax - overlayBbox.xMin) / 100,
|
|
413
|
-
height: (overlayBbox.yMax - overlayBbox.yMin) / 100,
|
|
414
|
-
};
|
|
415
|
-
|
|
306
|
+
const uiCropBbox = { minX: overlayBbox.xMin / 100, minY: overlayBbox.yMin / 100, width: (overlayBbox.xMax - overlayBbox.xMin) / 100, height: (overlayBbox.yMax - overlayBbox.yMin) / 100 };
|
|
307
|
+
|
|
416
308
|
try {
|
|
417
309
|
imagePathForUpload = await cropImageWithBBoxWithTolerance(safeCapturePath, uiCropBbox, 0.06);
|
|
418
310
|
} catch (e) {
|
|
419
311
|
console.warn("Crop failed, falling back to original image", e);
|
|
420
|
-
imagePathForUpload = safeCapturePath;
|
|
312
|
+
imagePathForUpload = safeCapturePath;
|
|
421
313
|
}
|
|
422
|
-
|
|
423
|
-
const safeCroppedPath = (Platform.OS === 'ios' && !imagePathForUpload.startsWith('file://'))
|
|
424
|
-
? `file://${imagePathForUpload}`
|
|
425
|
-
: imagePathForUpload;
|
|
426
|
-
|
|
314
|
+
|
|
315
|
+
const safeCroppedPath = (Platform.OS === 'ios' && !imagePathForUpload.startsWith('file://')) ? `file://${imagePathForUpload}` : imagePathForUpload;
|
|
427
316
|
const base64 = await pathToBase64(safeCroppedPath);
|
|
317
|
+
const side = currentSideRef.current; // Grab live ref
|
|
318
|
+
|
|
428
319
|
const newImages = {
|
|
429
|
-
...
|
|
430
|
-
|
|
431
|
-
// Image source={{ uri }} never receives a bare path on iOS.
|
|
432
|
-
[currentSide]: { dir: safeCroppedPath, file: base64, mrz: verified.mrz || "", templatePath: verified.templatePath },
|
|
320
|
+
...capturedImagesRef.current,
|
|
321
|
+
[side]: { dir: safeCroppedPath, file: base64, mrz: verified.mrz || "", templatePath: verified.templatePath },
|
|
433
322
|
};
|
|
434
|
-
|
|
323
|
+
|
|
435
324
|
setCapturedImages(newImages);
|
|
436
|
-
|
|
437
325
|
if (verified.country && verified.documentType) {
|
|
438
326
|
const docType = verified.documentType as string;
|
|
439
327
|
const resolvedDocumentType = GovernmentDocumentTypeBackend[docType] || (Object.values(GovernmentDocumentTypeBackend).includes(docType as GovernmentDocumentType) ? docType : null) || Object.entries(GovernmentDocumentTypeBackend).find(([, v]) => v === docType)?.[1] || docType;
|
|
440
|
-
onValueChange({
|
|
441
|
-
...newImages,
|
|
442
|
-
country: verified.country,
|
|
443
|
-
documentType: resolvedDocumentType,
|
|
444
|
-
...(verified.region ? { region: verified.region } : {})
|
|
445
|
-
});
|
|
328
|
+
onValueChange({ ...newImages, country: verified.country, documentType: resolvedDocumentType, ...(verified.region ? { region: verified.region } : {}) });
|
|
446
329
|
}
|
|
447
|
-
|
|
330
|
+
|
|
448
331
|
setTimeout(() => {
|
|
449
|
-
setShowCamera(false);
|
|
450
|
-
actions.showCustomStepper(true);
|
|
332
|
+
setShowCamera(false); actions.showCustomStepper(true);
|
|
451
333
|
setSilentCaptureResult(prev => ({ ...prev, isAnalyzing: false }));
|
|
452
|
-
setIsProcessingCapture(false);
|
|
453
|
-
|
|
454
|
-
setScanPhase('image');
|
|
455
|
-
setPendingCAImage(null);
|
|
334
|
+
setIsProcessingCapture(false); setProcessingImagePath(null);
|
|
335
|
+
setScanPhase('image'); setPendingCAImage(null);
|
|
456
336
|
}, 600);
|
|
337
|
+
|
|
457
338
|
} catch (e: any) {
|
|
458
|
-
console.error("Backend Error:", e);
|
|
459
339
|
const friendlyError = state.currentLanguage === 'en' ? 'Unable to process document. Please try again.' : 'Impossible de traiter le document. Veuillez réessayer.';
|
|
460
340
|
showAlert('Error', friendlyError);
|
|
461
341
|
setSilentCaptureResult(prev => ({ ...prev, isAnalyzing: false, success: false }));
|
|
462
|
-
setIsProcessingCapture(false);
|
|
463
|
-
setProcessingImagePath(null);
|
|
342
|
+
setIsProcessingCapture(false); setProcessingImagePath(null);
|
|
464
343
|
}
|
|
465
344
|
};
|
|
466
345
|
|
|
467
|
-
const isBarcodeRequired = (): boolean => {
|
|
468
|
-
if (
|
|
469
|
-
if (!
|
|
470
|
-
|
|
471
|
-
const rawDocType = selectedDocumentType.type;
|
|
472
|
-
const jsonDocMap: Record<string, string> = {
|
|
473
|
-
'nationalId': 'identity_card', 'national_id': 'identity_card', 'passport': 'passport',
|
|
474
|
-
'driversLicense': 'drivers_licence', 'drivers_licence': 'drivers_licence',
|
|
475
|
-
'residencePermit': 'permanent_residence', 'healthInsuranceCard': 'health_insurance_card'
|
|
476
|
-
};
|
|
346
|
+
const isBarcodeRequired = (side: string, cData: any, docType: any): boolean => {
|
|
347
|
+
if (side !== 'back') return false;
|
|
348
|
+
if (!cData?.code || !docType?.type) return false;
|
|
477
349
|
|
|
350
|
+
const rawDocType = docType.type;
|
|
351
|
+
const jsonDocMap: Record<string, string> = { 'nationalId': 'identity_card', 'national_id': 'identity_card', 'passport': 'passport', 'driversLicense': 'drivers_licence', 'drivers_licence': 'drivers_licence', 'residencePermit': 'permanent_residence', 'healthInsuranceCard': 'health_insurance_card' };
|
|
478
352
|
const searchDocType = jsonDocMap[rawDocType] || rawDocType;
|
|
479
|
-
const rawCountryName = ISO_TO_COUNTRY_NAME[
|
|
353
|
+
const rawCountryName = ISO_TO_COUNTRY_NAME[cData.code] || cData.code;
|
|
480
354
|
const baseMapping = (REGION_MAPPING as any).regionMapping || REGION_MAPPING;
|
|
481
355
|
|
|
482
356
|
let countryMapping = baseMapping[rawCountryName];
|
|
483
357
|
if (!countryMapping) {
|
|
484
|
-
const foundKey = Object.keys(baseMapping).find(k => k.toLowerCase() === rawCountryName.toLowerCase() || k.toLowerCase() ===
|
|
358
|
+
const foundKey = Object.keys(baseMapping).find(k => k.toLowerCase() === rawCountryName.toLowerCase() || k.toLowerCase() === cData.code.toLowerCase());
|
|
485
359
|
if (foundKey) countryMapping = baseMapping[foundKey];
|
|
486
360
|
}
|
|
487
|
-
|
|
488
361
|
if (!countryMapping || !countryMapping[searchDocType]) return false;
|
|
489
|
-
|
|
490
|
-
const rawRegionKey =
|
|
362
|
+
|
|
363
|
+
const rawRegionKey = cData.region?.trim()?.length > 0 ? cData.region.trim() : 'root';
|
|
491
364
|
const regionKey = SHORT_TO_LONG_REGION[rawRegionKey] || rawRegionKey;
|
|
492
365
|
const regionData = countryMapping[searchDocType][regionKey];
|
|
493
366
|
|
|
494
367
|
if (!regionData || !Array.isArray(regionData)) return false;
|
|
495
|
-
|
|
496
|
-
const requiresBarcode = regionData.some((item: any) => {
|
|
368
|
+
return regionData.some((item: any) => {
|
|
497
369
|
const method = String(item['back'] || '').toUpperCase();
|
|
498
370
|
return method.includes('BARCODE') || method.includes('2D');
|
|
499
371
|
});
|
|
500
|
-
|
|
501
|
-
logger.log(`[Barcode Check] Dynamic check for ${countryData.code} - ${searchDocType} - ${regionKey}: requiresBarcode=${requiresBarcode}`);
|
|
502
|
-
return requiresBarcode;
|
|
503
372
|
};
|
|
504
373
|
|
|
505
374
|
const processCanadianBarcode = async (barcodeString: string) => {
|
|
506
|
-
if (
|
|
507
|
-
logger.log('[CA Barcode] Valid barcode decoded. Sending to parser...');
|
|
508
|
-
|
|
375
|
+
if (isProcessingCaptureRef.current || !pendingCAImage) return;
|
|
509
376
|
setSilentCaptureResult(prev => ({ ...prev, isAnalyzing: true, error: '' }));
|
|
510
377
|
|
|
511
378
|
try {
|
|
512
|
-
const
|
|
379
|
+
const docType = selectedDocTypeRef.current;
|
|
380
|
+
const cData = countryDataRef.current;
|
|
381
|
+
const isHIC = docType!.type === 'health_insurance_card';
|
|
513
382
|
let parsedDictionary;
|
|
514
383
|
|
|
515
384
|
if (isHIC && barcodeString.length < 50) {
|
|
@@ -518,34 +387,22 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
|
|
|
518
387
|
const token = await authentification();
|
|
519
388
|
parsedDictionary = await kycService.formatBarcodeData({ textInput: barcodeString, token }, env);
|
|
520
389
|
}
|
|
521
|
-
|
|
522
390
|
if (!parsedDictionary) throw new Error("Could not parse the barcode data.");
|
|
523
|
-
|
|
391
|
+
|
|
524
392
|
const verifiedResult: ISilentCaptureResult & { region?: string } = {
|
|
525
|
-
path: pendingCAImage.path,
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
success: true,
|
|
529
|
-
mrz: JSON.stringify(parsedDictionary),
|
|
530
|
-
barcode: barcodeString,
|
|
531
|
-
isAnalyzing: false,
|
|
532
|
-
country: countryData?.code,
|
|
533
|
-
documentType: selectedDocumentType!.type,
|
|
534
|
-
region: selectedDocumentType!.region !== 'root' ? selectedDocumentType!.region : undefined,
|
|
393
|
+
path: pendingCAImage.path, templatePath: pendingCAImage.templatePath, bbox: pendingCAImage.bbox, success: true,
|
|
394
|
+
mrz: JSON.stringify(parsedDictionary), barcode: barcodeString, isAnalyzing: false,
|
|
395
|
+
country: cData?.code, documentType: docType!.type, region: docType!.region !== 'root' ? docType!.region : undefined,
|
|
535
396
|
};
|
|
536
|
-
|
|
397
|
+
|
|
537
398
|
setSilentCaptureResult(verifiedResult);
|
|
538
399
|
if (pendingCAImage.bbox) {
|
|
539
400
|
const safeBbox = pendingCAImage.bbox as IBbox;
|
|
540
401
|
setBboxBySide(prev => ({ ...prev, ['back']: safeBbox }));
|
|
541
402
|
}
|
|
542
|
-
|
|
543
403
|
await autoCapture(pendingCAImage.path, verifiedResult);
|
|
544
404
|
} catch (error: any) {
|
|
545
|
-
|
|
546
|
-
setScanPhase('image');
|
|
547
|
-
setPendingCAImage(null);
|
|
548
|
-
scannedBarcodeRef.current = null;
|
|
405
|
+
setScanPhase('image'); setPendingCAImage(null); scannedBarcodeRef.current = null;
|
|
549
406
|
setSilentCaptureResult(prev => ({ ...prev, isAnalyzing: false, success: false, error: 'Failed to process barcode. Please retake the photo.' }));
|
|
550
407
|
}
|
|
551
408
|
};
|
|
@@ -554,47 +411,39 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
|
|
|
554
411
|
if (isProcessingCaptureRef.current) return;
|
|
555
412
|
if (scanPhaseRef.current === 'barcode') return;
|
|
556
413
|
if (isAnalyzingRef.current) return;
|
|
557
|
-
|
|
414
|
+
|
|
415
|
+
// USE REFS TO AVOID IOS NATIVE STALE CLOSURE
|
|
416
|
+
const side = currentSideRef.current;
|
|
417
|
+
const captured = capturedImagesRef.current;
|
|
418
|
+
const docType = selectedDocTypeRef.current;
|
|
419
|
+
const cData = countryDataRef.current;
|
|
420
|
+
|
|
558
421
|
if (result.success && result.path) {
|
|
559
422
|
setSilentCaptureResult((prev) => ({ ...prev, isAnalyzing: true, success: false, error: '' }));
|
|
560
|
-
|
|
423
|
+
|
|
561
424
|
const safeCapturePath = Platform.OS === 'ios' && !result.path.startsWith('file://') ? `file://${result.path}` : result.path;
|
|
562
|
-
let templatePath =
|
|
425
|
+
let templatePath = captured[side]?.templatePath || '';
|
|
563
426
|
let templateBbox: IBbox | undefined;
|
|
564
427
|
let templateResponse: any;
|
|
565
|
-
|
|
566
|
-
if (!
|
|
428
|
+
|
|
429
|
+
if (!docType) {
|
|
567
430
|
setSilentCaptureResult((prev) => ({ ...prev, isAnalyzing: false, success: false, error: 'Document type not selected' }));
|
|
568
431
|
return;
|
|
569
432
|
}
|
|
570
|
-
|
|
571
|
-
const issuingSubdivision =
|
|
572
|
-
|
|
573
|
-
// Subtle timeout handling without blocking pop-ups
|
|
433
|
+
|
|
434
|
+
const issuingSubdivision = docType.region !== 'root' ? docType.region : undefined;
|
|
435
|
+
|
|
574
436
|
const watchdog = setTimeout(() => {
|
|
575
437
|
if (isAnalyzingRef.current) {
|
|
576
|
-
setSilentCaptureResult((prev) => ({
|
|
577
|
-
...prev,
|
|
578
|
-
isAnalyzing: false,
|
|
579
|
-
success: false,
|
|
580
|
-
error: state.currentLanguage === 'en' ? 'Taking longer than expected. Hold steady.' : 'Traitement long. Veuillez rester immobile.'
|
|
581
|
-
}));
|
|
438
|
+
setSilentCaptureResult((prev) => ({ ...prev, isAnalyzing: false, success: false, error: state.currentLanguage === 'en' ? 'Taking longer than expected. Hold steady.' : 'Traitement long. Veuillez rester immobile.' }));
|
|
582
439
|
}
|
|
583
440
|
}, 20000);
|
|
584
|
-
|
|
441
|
+
|
|
585
442
|
try {
|
|
586
443
|
if (!templatePath) {
|
|
587
|
-
const templateType = await checkTemplateType({
|
|
588
|
-
path: safeCapturePath,
|
|
589
|
-
docType: selectedDocumentType?.type as GovernmentDocumentType,
|
|
590
|
-
docRegion: countryData?.code || "",
|
|
591
|
-
postfix: currentSide,
|
|
592
|
-
issuingSubdivision: issuingSubdivision
|
|
593
|
-
}, env);
|
|
594
|
-
|
|
444
|
+
const templateType = await checkTemplateType({ path: safeCapturePath, docType: docType.type as GovernmentDocumentType, docRegion: cData?.code || "", postfix: side, issuingSubdivision: issuingSubdivision }, env);
|
|
595
445
|
templateResponse = templateType;
|
|
596
446
|
if (templateType.template_path) templatePath = templateType.template_path;
|
|
597
|
-
|
|
598
447
|
if (templateType.card_obb) {
|
|
599
448
|
const obbConfidence = getObbConfidence((templateType as any).card_obb);
|
|
600
449
|
if (obbConfidence !== null && obbConfidence < OBB_CONFIDENCE_THRESHOLD) {
|
|
@@ -602,210 +451,108 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
|
|
|
602
451
|
setSilentCaptureResult((prev) => ({ ...prev, isAnalyzing: false, success: false, error: t('kyc.idCardCapture.cardNotFullyInFrame') }));
|
|
603
452
|
return;
|
|
604
453
|
}
|
|
605
|
-
try {
|
|
606
|
-
const crop = await cropByObb(safeCapturePath, (templateType as any).card_obb);
|
|
607
|
-
templateBbox = crop.bbox;
|
|
608
|
-
} catch {}
|
|
454
|
+
try { const crop = await cropByObb(safeCapturePath, (templateType as any).card_obb); templateBbox = crop.bbox; } catch {}
|
|
609
455
|
}
|
|
610
456
|
}
|
|
611
|
-
|
|
612
|
-
const extractedCountryKey = templatePath ? templatePath.split('/')[0] : (ISO_TO_COUNTRY_NAME[
|
|
613
|
-
const regionMappings = getCurrentSideVerification(
|
|
457
|
+
|
|
458
|
+
const extractedCountryKey = templatePath ? templatePath.split('/')[0] : (ISO_TO_COUNTRY_NAME[cData?.code || ''] || 'root');
|
|
459
|
+
const regionMappings = getCurrentSideVerification(side, extractedCountryKey, cData);
|
|
614
460
|
let verificationRes: any;
|
|
615
|
-
|
|
616
|
-
if (
|
|
461
|
+
|
|
462
|
+
if (side === 'front') {
|
|
617
463
|
const matchedAuthMethod = getCorrespondingAuthMethod(templatePath, regionMappings.regionMapping, regionMappings.key || '', 'front');
|
|
618
464
|
const mrzType = getCorrespondingMrzType(templatePath, regionMappings.regionMapping, regionMappings.key || '') || 'TD1';
|
|
619
|
-
|
|
620
|
-
verificationRes = await frontVerification({
|
|
621
|
-
path: safeCapturePath,
|
|
622
|
-
regionMapping: { authMethod: matchedAuthMethod ? [matchedAuthMethod] : [], mrzTypes: mrzType ? [mrzType] : [] },
|
|
623
|
-
selectedDocumentType: GovernmentDocumentTypeShorted[selectedDocumentType?.type as keyof typeof GovernmentDocumentTypeShorted] || '',
|
|
624
|
-
code: countryData?.code || '',
|
|
625
|
-
currentSide,
|
|
626
|
-
templatePath,
|
|
627
|
-
mrzType,
|
|
628
|
-
issuingSubdivision: issuingSubdivision,
|
|
629
|
-
clientSideBarcodeData: silentCaptureResult.barcode
|
|
630
|
-
}, env);
|
|
465
|
+
verificationRes = await frontVerification({ path: safeCapturePath, regionMapping: { authMethod: matchedAuthMethod ? [matchedAuthMethod] : [], mrzTypes: mrzType ? [mrzType] : [] }, selectedDocumentType: GovernmentDocumentTypeShorted[docType.type as keyof typeof GovernmentDocumentTypeShorted] || '', code: cData?.code || '', currentSide: side, templatePath, mrzType, issuingSubdivision: issuingSubdivision, clientSideBarcodeData: silentCaptureResult.barcode }, env);
|
|
631
466
|
} else {
|
|
632
|
-
logger.log("Attempting backVerification with:", { path: safeCapturePath, templatePath, side: currentSide });
|
|
633
467
|
const matchedBackAuthMethod = getCorrespondingAuthMethod(templatePath, regionMappings.regionMapping, regionMappings.key || '', 'back');
|
|
634
468
|
const backMrzType = getCorrespondingMrzType(templatePath, regionMappings.regionMapping, regionMappings.key || '') || '';
|
|
635
|
-
const frontTemplatePath =
|
|
636
|
-
|
|
637
|
-
verificationRes = await backVerification({
|
|
638
|
-
path: safeCapturePath,
|
|
639
|
-
regionMapping: { authMethod: matchedBackAuthMethod ? [matchedBackAuthMethod] : [], mrzTypes: backMrzType ? [backMrzType] : [] },
|
|
640
|
-
selectedDocumentType: GovernmentDocumentTypeShorted[selectedDocumentType.type as keyof typeof GovernmentDocumentTypeShorted] || '',
|
|
641
|
-
code: countryData?.code || '',
|
|
642
|
-
currentSide,
|
|
643
|
-
templatePath,
|
|
644
|
-
mrzType: backMrzType,
|
|
645
|
-
templateResponse,
|
|
646
|
-
issuingSubdivision: issuingSubdivision,
|
|
647
|
-
frontTemplatePath: frontTemplatePath
|
|
648
|
-
}, env);
|
|
469
|
+
const frontTemplatePath = captured['front']?.templatePath || '';
|
|
470
|
+
verificationRes = await backVerification({ path: safeCapturePath, regionMapping: { authMethod: matchedBackAuthMethod ? [matchedBackAuthMethod] : [], mrzTypes: backMrzType ? [backMrzType] : [] }, selectedDocumentType: GovernmentDocumentTypeShorted[docType.type as keyof typeof GovernmentDocumentTypeShorted] || '', code: cData?.code || '', currentSide: side, templatePath, mrzType: backMrzType, templateResponse, issuingSubdivision: issuingSubdivision, frontTemplatePath: frontTemplatePath }, env);
|
|
649
471
|
}
|
|
650
|
-
|
|
472
|
+
|
|
651
473
|
const bbox = verificationRes?.bbox || templateBbox;
|
|
652
474
|
const verifiedMrz = verificationRes?.mrz ? JSON.stringify(verificationRes.mrz) : "";
|
|
653
|
-
|
|
654
|
-
if (isBarcodeRequired()) {
|
|
475
|
+
|
|
476
|
+
if (isBarcodeRequired(side, cData, docType)) {
|
|
655
477
|
const barcodeData = scannedBarcodeRef.current;
|
|
656
|
-
const
|
|
657
|
-
const isHIC = docTypeStr === 'health_insurance_card';
|
|
478
|
+
const isHIC = docType.type === 'health_insurance_card';
|
|
658
479
|
const isValidScan = typeof barcodeData === 'string' && ((isHIC && barcodeData.length > 0) || barcodeData.length > 50);
|
|
659
|
-
|
|
480
|
+
|
|
660
481
|
if (isValidScan) {
|
|
661
|
-
logger.log('[CA Barcode] Image captured, verified & barcode ALREADY scanned! Skipping Phase 2.');
|
|
662
482
|
let parsedDictionary;
|
|
663
|
-
|
|
664
483
|
if (isHIC && barcodeData.length < 50) {
|
|
665
484
|
parsedDictionary = { data: { card_number: barcodeData, barcode_type: "1D" } };
|
|
666
485
|
} else {
|
|
667
486
|
const token = await authentification();
|
|
668
487
|
parsedDictionary = await kycService.formatBarcodeData({ textInput: barcodeData, token }, env);
|
|
669
488
|
}
|
|
670
|
-
|
|
671
|
-
const verifiedResult: ISilentCaptureResult & { region?: string } = {
|
|
672
|
-
path: safeCapturePath, templatePath: templatePath, bbox: bbox, success: true,
|
|
673
|
-
mrz: JSON.stringify(parsedDictionary), barcode: barcodeData, isAnalyzing: false,
|
|
674
|
-
country: countryData?.code, documentType: selectedDocumentType!.type,
|
|
675
|
-
region: selectedDocumentType!.region !== 'root' ? selectedDocumentType!.region : undefined,
|
|
676
|
-
};
|
|
489
|
+
const verifiedResult: ISilentCaptureResult & { region?: string } = { path: safeCapturePath, templatePath: templatePath, bbox: bbox, success: true, mrz: JSON.stringify(parsedDictionary), barcode: barcodeData, isAnalyzing: false, country: cData?.code, documentType: docType.type, region: docType.region !== 'root' ? docType.region : undefined };
|
|
677
490
|
|
|
678
|
-
clearTimeout(watchdog);
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
if (bbox) {
|
|
682
|
-
const safeBbox = bbox as IBbox;
|
|
683
|
-
setBboxBySide(prev => ({ ...prev, ['back']: safeBbox }));
|
|
684
|
-
}
|
|
491
|
+
clearTimeout(watchdog); setSilentCaptureResult(verifiedResult);
|
|
492
|
+
if (bbox) setBboxBySide(prev => ({ ...prev, ['back']: bbox as IBbox }));
|
|
685
493
|
await autoCapture(safeCapturePath!, verifiedResult);
|
|
686
494
|
return;
|
|
687
495
|
}
|
|
688
|
-
|
|
689
|
-
logger.log('[CA Barcode] Image captured & template VERIFIED. Switching to Barcode phase.');
|
|
496
|
+
|
|
690
497
|
clearTimeout(watchdog);
|
|
691
498
|
setPendingCAImage({ path: safeCapturePath!, templateResponse, templatePath, bbox });
|
|
692
499
|
setScanPhase('barcode');
|
|
693
500
|
setSilentCaptureResult(prev => ({ ...prev, isAnalyzing: false }));
|
|
694
501
|
return;
|
|
695
502
|
}
|
|
696
|
-
|
|
697
|
-
const verifiedResult: ISilentCaptureResult & { region?: string } = {
|
|
698
|
-
path: safeCapturePath, templatePath, bbox, success: true, mrz: verifiedMrz,
|
|
699
|
-
isAnalyzing: false, country: countryData?.code, documentType: selectedDocumentType.type,
|
|
700
|
-
region: issuingSubdivision
|
|
701
|
-
};
|
|
702
|
-
|
|
503
|
+
|
|
504
|
+
const verifiedResult: ISilentCaptureResult & { region?: string } = { path: safeCapturePath, templatePath, bbox, success: true, mrz: verifiedMrz, isAnalyzing: false, country: cData?.code, documentType: docType.type, region: issuingSubdivision };
|
|
703
505
|
clearTimeout(watchdog);
|
|
704
506
|
setSilentCaptureResult(verifiedResult);
|
|
705
|
-
if (bbox) setBboxBySide(prev => ({ ...prev, [
|
|
507
|
+
if (bbox) setBboxBySide(prev => ({ ...prev, [side]: bbox }));
|
|
706
508
|
await autoCapture(safeCapturePath, verifiedResult);
|
|
707
|
-
|
|
509
|
+
|
|
708
510
|
} catch (error: any) {
|
|
709
511
|
clearTimeout(watchdog);
|
|
710
512
|
const isNetworkError = !error.response || error.message === 'Network Error' || error.code === 'ECONNABORTED' || error.message?.includes('Network');
|
|
711
|
-
|
|
712
|
-
// Keep UI errors extremely brief and actionable
|
|
713
513
|
let hintMessage = '';
|
|
714
|
-
|
|
715
514
|
if (isNetworkError) {
|
|
716
515
|
hintMessage = state.currentLanguage === 'en' ? 'Poor connection. Retrying...' : 'Connexion faible. Nouvelle tentative...';
|
|
717
516
|
} else {
|
|
718
517
|
const rawMessage = (error?.message || '').toLowerCase();
|
|
719
|
-
if (rawMessage.includes('impossible') || rawMessage.includes('unreadable')) {
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
} else if (rawMessage.includes('card_not_fully_in_frame')) {
|
|
724
|
-
hintMessage = state.currentLanguage === 'en' ? 'Move closer.' : 'Rapprochez-vous.';
|
|
725
|
-
} else if (rawMessage.includes('ne correspond pas au recto')) {
|
|
726
|
-
hintMessage = state.currentLanguage === 'en' ? 'Scan the same ID.' : 'Scannez la même pièce.';
|
|
727
|
-
}
|
|
518
|
+
if (rawMessage.includes('impossible') || rawMessage.includes('unreadable')) { hintMessage = state.currentLanguage === 'en' ? 'Move to better lighting.' : 'Améliorez l\'éclairage.'; }
|
|
519
|
+
else if (rawMessage.includes('country mismatch') || rawMessage.includes('pays sélectionné')) { hintMessage = state.currentLanguage === 'en' ? 'Wrong document type.' : 'Mauvais type de document.'; }
|
|
520
|
+
else if (rawMessage.includes('card_not_fully_in_frame')) { hintMessage = state.currentLanguage === 'en' ? 'Move closer.' : 'Rapprochez-vous.'; }
|
|
521
|
+
else if (rawMessage.includes('ne correspond pas au recto')) { hintMessage = state.currentLanguage === 'en' ? 'Scan the same ID.' : 'Scannez la même pièce.'; }
|
|
728
522
|
}
|
|
729
|
-
|
|
730
523
|
setSilentCaptureResult(prev => ({ ...prev, isAnalyzing: false, success: false, error: hintMessage }));
|
|
731
524
|
}
|
|
732
525
|
}
|
|
733
526
|
};
|
|
734
|
-
|
|
527
|
+
|
|
735
528
|
useEffect(() => {
|
|
736
529
|
if (scanPhase === 'barcode' && pendingCAImage && silentCaptureResult.barcode) {
|
|
737
530
|
const bData = silentCaptureResult.barcode;
|
|
738
531
|
if (typeof bData === 'string' && bData.length > 50) {
|
|
739
|
-
logger.log('[CA Barcode] Safety Net Triggered: Processing barcode.');
|
|
740
532
|
processCanadianBarcode(bData);
|
|
741
533
|
}
|
|
742
534
|
}
|
|
743
535
|
}, [scanPhase, pendingCAImage, silentCaptureResult.barcode]);
|
|
744
|
-
|
|
745
536
|
|
|
746
537
|
const handleError = (event: { message: string, name?: string, code?: string }) => {
|
|
747
|
-
logger.error("Camera Error:", event);
|
|
748
538
|
const errString = `${event.message} ${event.name} ${event.code}`.toLowerCase();
|
|
749
|
-
|
|
750
539
|
let friendlyMessage = state.currentLanguage === 'en' ? 'Camera error.' : 'Erreur de caméra.';
|
|
751
|
-
|
|
752
|
-
if (errString.includes('
|
|
753
|
-
|
|
754
|
-
} else if (errString.includes('notallowederror') || errString.includes('permission')) {
|
|
755
|
-
friendlyMessage = state.currentLanguage === 'en' ? 'Allow camera access in settings.' : 'Autorisez l\'accès à la caméra.';
|
|
756
|
-
} else if (errString.includes('notfounderror') || errString.includes('no camera')) {
|
|
757
|
-
friendlyMessage = state.currentLanguage === 'en' ? 'No camera detected.' : 'Aucune caméra détectée.';
|
|
758
|
-
}
|
|
759
|
-
|
|
540
|
+
if (errString.includes('notreadableerror') || errString.includes('trackstarterror') || errString.includes('in use') || errString.includes('concurrent') || errString.includes('camera unavailable')) { friendlyMessage = state.currentLanguage === 'en' ? 'Camera is in use by another app.' : 'Caméra utilisée par une autre application.'; }
|
|
541
|
+
else if (errString.includes('notallowederror') || errString.includes('permission')) { friendlyMessage = state.currentLanguage === 'en' ? 'Allow camera access in settings.' : 'Autorisez l\'accès à la caméra.'; }
|
|
542
|
+
else if (errString.includes('notfounderror') || errString.includes('no camera')) { friendlyMessage = state.currentLanguage === 'en' ? 'No camera detected.' : 'Aucune caméra détectée.'; }
|
|
760
543
|
showAlert(state.currentLanguage === 'en' ? 'Camera Error' : 'Erreur', friendlyMessage);
|
|
761
|
-
setShowCamera(false);
|
|
762
|
-
setIsProcessingCapture(false);
|
|
544
|
+
setShowCamera(false); setIsProcessingCapture(false);
|
|
763
545
|
};
|
|
764
546
|
|
|
765
|
-
useEffect(() => {
|
|
766
|
-
actions.showCustomStepper(!showCamera);
|
|
767
|
-
}, [showCamera]);
|
|
768
|
-
|
|
769
|
-
useEffect(() => {
|
|
770
|
-
if (Platform.OS === 'web' && typeof window !== 'undefined') {
|
|
771
|
-
try {
|
|
772
|
-
const url = new URL(window.location.origin);
|
|
773
|
-
if (url.hostname === 'localhost') {
|
|
774
|
-
url.hostname = '192.168.0.33';
|
|
775
|
-
}
|
|
776
|
-
const params = new URLSearchParams(window.location.search);
|
|
777
|
-
params.set('session_id', state.session.session_id);
|
|
778
|
-
params.set('resume', 'true');
|
|
779
|
-
|
|
780
|
-
if (state.currentComponentIndex !== undefined) {
|
|
781
|
-
params.set('component_index', String(state.currentComponentIndex));
|
|
782
|
-
}
|
|
783
|
-
if (countryData?.code) {
|
|
784
|
-
params.set('country', countryData.code);
|
|
785
|
-
}
|
|
786
|
-
if (selectedDocumentType?.type) {
|
|
787
|
-
params.set('document_type', selectedDocumentType.type);
|
|
788
|
-
}
|
|
789
|
-
if (selectedDocumentType?.region && selectedDocumentType.region !== 'root') {
|
|
790
|
-
params.set('region', selectedDocumentType.region);
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
url.hash = params.toString();
|
|
794
|
-
setCurrentUrl(url.toString());
|
|
795
|
-
} catch (e) {
|
|
796
|
-
console.error("Failed to construct QR handoff URL", e);
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
}, [state.session.session_id, state.currentComponentIndex, countryData, selectedDocumentType]);
|
|
547
|
+
useEffect(() => { actions.showCustomStepper(!showCamera); }, [showCamera]);
|
|
800
548
|
|
|
549
|
+
// Render logic remains identical below...
|
|
801
550
|
if (!countrySelectionData || !selectedDocumentType) {
|
|
802
551
|
return (
|
|
803
552
|
<View style={styles.root}>
|
|
804
553
|
<View style={styles.previewContainer}>
|
|
805
554
|
<Text style={styles.title}>{getLocalizedText(component.labels)}</Text>
|
|
806
|
-
<Text style={styles.description}>
|
|
807
|
-
{state.currentLanguage === "en" ? "Please complete the country and document selection first." : "Veuillez d'abord compléter la sélection du pays et du document."}
|
|
808
|
-
</Text>
|
|
555
|
+
<Text style={styles.description}>{state.currentLanguage === "en" ? "Please complete the country and document selection first." : "Veuillez d'abord compléter la sélection du pays et du document."}</Text>
|
|
809
556
|
</View>
|
|
810
557
|
</View>
|
|
811
558
|
);
|
|
@@ -813,7 +560,6 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
|
|
|
813
560
|
|
|
814
561
|
if (showCamera) {
|
|
815
562
|
const isBusy = isProcessingCapture;
|
|
816
|
-
|
|
817
563
|
if (isRebootingCamera) {
|
|
818
564
|
return (
|
|
819
565
|
<View style={[styles.root, { justifyContent: 'center', alignItems: 'center', backgroundColor: '#000' }]}>
|
|
@@ -822,46 +568,30 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
|
|
|
822
568
|
</View>
|
|
823
569
|
);
|
|
824
570
|
}
|
|
825
|
-
|
|
826
571
|
return (
|
|
827
572
|
<View style={styles.root}>
|
|
828
573
|
<View style={[styles.cameraWrapper, { flex: 1 }]}>
|
|
829
574
|
<View style={styles.headerContainer}>
|
|
830
|
-
<Text style={styles.headerTitle}>
|
|
831
|
-
{selectedDocumentType ? (locale === 'en' ? getDocumentTypeInfo(selectedDocumentType.type).name.en : getDocumentTypeInfo(selectedDocumentType.type).name.fr) : ''}
|
|
832
|
-
</Text>
|
|
575
|
+
<Text style={styles.headerTitle}>{selectedDocumentType ? (locale === 'en' ? getDocumentTypeInfo(selectedDocumentType.type).name.en : getDocumentTypeInfo(selectedDocumentType.type).name.fr) : ''}</Text>
|
|
833
576
|
<View style={styles.stepBadge}>
|
|
834
|
-
<Text style={styles.stepText}>
|
|
835
|
-
{t('kyc.idCardCapture.captureTitle', { side: currentSide === 'front' ? locale === 'en' ? 'Front' : 'Recto' : locale === 'en' ? 'Back' : 'Verso' })}
|
|
836
|
-
</Text>
|
|
577
|
+
<Text style={styles.stepText}>{t('kyc.idCardCapture.captureTitle', { side: currentSide === 'front' ? locale === 'en' ? 'Front' : 'Recto' : locale === 'en' ? 'Back' : 'Verso' })}</Text>
|
|
837
578
|
</View>
|
|
838
579
|
</View>
|
|
839
|
-
|
|
840
580
|
<View style={[styles.cameraFeedContainer, { flex: 1, backgroundColor: '#000' }]}>
|
|
841
581
|
{Platform.OS === 'web' && (
|
|
842
582
|
<View style={styles.webTopControls}>
|
|
843
|
-
<TouchableOpacity style={styles.webFlipButton} onPress={toggleCameraLens}>
|
|
844
|
-
<Text style={styles.webFlipText}>🔄 Flip Lens</Text>
|
|
845
|
-
</TouchableOpacity>
|
|
583
|
+
<TouchableOpacity style={styles.webFlipButton} onPress={toggleCameraLens}><Text style={styles.webFlipText}>🔄 Flip Lens</Text></TouchableOpacity>
|
|
846
584
|
</View>
|
|
847
585
|
)}
|
|
848
|
-
|
|
849
586
|
<EnhancedCameraView
|
|
850
|
-
key={`${currentSide}-${cameraKey}`}
|
|
851
|
-
showCamera={true}
|
|
852
|
-
isProcessing={isBusy}
|
|
853
|
-
cameraType={cameraConfig.cameraType}
|
|
587
|
+
key={`${currentSide}-${cameraKey}`} showCamera={true} isProcessing={isBusy} cameraType={cameraConfig.cameraType}
|
|
854
588
|
style={{ ...StyleSheet.absoluteFillObject, ...(Platform.OS === 'web' ? { width: '100%', height: '100%', left: 0, top: 0 } as any : {}) }}
|
|
855
|
-
onError={handleError}
|
|
856
|
-
|
|
857
|
-
silentCaptureResult={silentCaptureResult}
|
|
858
|
-
isScanningBarcode={currentSide === 'back'}
|
|
859
|
-
barcodeTypes={targetBarcodeTypes}
|
|
589
|
+
onError={handleError} onSilentCapture={handleSilentCapture} silentCaptureResult={silentCaptureResult}
|
|
590
|
+
isScanningBarcode={currentSide === 'back'} barcodeTypes={targetBarcodeTypes}
|
|
860
591
|
onBarcodeScanned={(barcodeString) => {
|
|
861
592
|
const docTypeStr = selectedDocumentType?.type;
|
|
862
593
|
const isFullDataDoc = docTypeStr === 'drivers_licence' || docTypeStr === 'national_id' || docTypeStr === 'identity_card';
|
|
863
594
|
if (isFullDataDoc && barcodeString.length < 50) return;
|
|
864
|
-
|
|
865
595
|
if (scannedBarcodeRef.current !== barcodeString) {
|
|
866
596
|
scannedBarcodeRef.current = barcodeString;
|
|
867
597
|
if (scanPhase === 'barcode' && pendingCAImage) {
|
|
@@ -872,122 +602,70 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
|
|
|
872
602
|
}
|
|
873
603
|
}}
|
|
874
604
|
overlayComponent={
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
stepperProps={{
|
|
886
|
-
back: () => {
|
|
887
|
-
if (currentSide === 'back') {
|
|
888
|
-
scannedBarcodeRef.current = null;
|
|
889
|
-
setScanPhase('image');
|
|
890
|
-
setPendingCAImage(null);
|
|
891
|
-
setCurrentSide('front');
|
|
892
|
-
setShowCamera(false);
|
|
893
|
-
setIsProcessingCapture(false);
|
|
894
|
-
setProcessingImagePath(null);
|
|
895
|
-
if (capturedImages['front']?.dir) {
|
|
896
|
-
const frontImage = capturedImages['front'];
|
|
897
|
-
setSilentCaptureResult((prev) => ({
|
|
898
|
-
...prev, path: frontImage.dir, success: true, isAnalyzing: false, error: '', mrz: frontImage.mrz || '', templatePath: frontImage.templatePath || '', bbox: bboxBySide['front']
|
|
899
|
-
}));
|
|
900
|
-
} else {
|
|
901
|
-
setSilentCaptureResult((prev) => ({ ...prev, path: '', success: false, isAnalyzing: false, error: '', templatePath: '' }));
|
|
902
|
-
}
|
|
605
|
+
<IdCardOverlay
|
|
606
|
+
xMin={cameraConfig.overlay.bbox.xMin} yMin={cameraConfig.overlay.bbox.yMin} xMax={cameraConfig.overlay.bbox.xMax} yMax={cameraConfig.overlay.bbox.yMax}
|
|
607
|
+
instructions={cameraConfig.overlay.guideText} cornerOpacity={cameraConfig.overlay.bbox.cornerRadius || 0 as number} isSuccess={silentCaptureResult.success || scanPhase === 'barcode'} language={state.currentLanguage}
|
|
608
|
+
stepperProps={{
|
|
609
|
+
back: () => {
|
|
610
|
+
if (currentSide === 'back') {
|
|
611
|
+
scannedBarcodeRef.current = null; setScanPhase('image'); setPendingCAImage(null); setCurrentSide('front'); setShowCamera(false); setIsProcessingCapture(false); setProcessingImagePath(null);
|
|
612
|
+
if (capturedImages['front']?.dir) {
|
|
613
|
+
const frontImage = capturedImages['front'];
|
|
614
|
+
setSilentCaptureResult((prev) => ({ ...prev, path: frontImage.dir, success: true, isAnalyzing: false, error: '', mrz: frontImage.mrz || '', templatePath: frontImage.templatePath || '', bbox: bboxBySide['front'] }));
|
|
903
615
|
} else {
|
|
904
|
-
|
|
616
|
+
setSilentCaptureResult((prev) => ({ ...prev, path: '', success: false, isAnalyzing: false, error: '', templatePath: '' }));
|
|
905
617
|
}
|
|
906
|
-
}
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
}}
|
|
912
|
-
/>
|
|
913
|
-
</>
|
|
618
|
+
} else { actions.previousComponent(); }
|
|
619
|
+
},
|
|
620
|
+
selectedDocumentType: selectedDocumentType ? (locale === 'en' ? getDocumentTypeInfo(selectedDocumentType.type).name.en : getDocumentTypeInfo(selectedDocumentType.type).name.fr) : '', step: state.currentComponentIndex + 1, totalSteps: state.template.components.length, side: currentSide,
|
|
621
|
+
}}
|
|
622
|
+
/>
|
|
914
623
|
}
|
|
915
624
|
/>
|
|
916
|
-
|
|
917
625
|
{scanPhase === 'barcode' && !isBusy && (
|
|
918
626
|
<View style={styles.barcodePromptContainer}>
|
|
919
627
|
<View style={styles.barcodePromptContent}>
|
|
920
|
-
<View style={styles.barcodeIconContainer}>
|
|
921
|
-
<Text style={styles.barcodeIcon}>✓</Text>
|
|
922
|
-
</View>
|
|
628
|
+
<View style={styles.barcodeIconContainer}><Text style={styles.barcodeIcon}>✓</Text></View>
|
|
923
629
|
<View style={styles.barcodeTextContainer}>
|
|
924
|
-
<Text style={styles.barcodePromptTitle}>
|
|
925
|
-
|
|
926
|
-
</Text>
|
|
927
|
-
<Text style={styles.barcodePromptDesc}>
|
|
928
|
-
{state.currentLanguage === 'en' ? 'Move closer to fill the screen with the 2D barcode.' : 'Rapprochez-vous pour scanner le code-barres 2D.'}
|
|
929
|
-
</Text>
|
|
630
|
+
<Text style={styles.barcodePromptTitle}>{state.currentLanguage === 'en' ? 'Image Verified' : 'Image Vérifiée'}</Text>
|
|
631
|
+
<Text style={styles.barcodePromptDesc}>{state.currentLanguage === 'en' ? 'Move closer to fill the screen with the 2D barcode.' : 'Rapprochez-vous pour scanner le code-barres 2D.'}</Text>
|
|
930
632
|
</View>
|
|
931
633
|
</View>
|
|
932
634
|
</View>
|
|
933
635
|
)}
|
|
934
|
-
|
|
935
636
|
{!isBusy && silentCaptureResult.isAnalyzing && (
|
|
936
637
|
<View style={styles.topAnalyzingPillContainer}>
|
|
937
638
|
<View style={styles.topAnalyzingPill}>
|
|
938
639
|
<ActivityIndicator size="small" color="white" />
|
|
939
|
-
<Text style={styles.analyzingPillText}>
|
|
940
|
-
{state.currentLanguage === 'en' ? 'Scanning...' : 'Analyse...'}
|
|
941
|
-
</Text>
|
|
640
|
+
<Text style={styles.analyzingPillText}>{state.currentLanguage === 'en' ? 'Scanning...' : 'Analyse...'}</Text>
|
|
942
641
|
</View>
|
|
943
642
|
</View>
|
|
944
643
|
)}
|
|
945
|
-
|
|
946
644
|
{isBusy && (
|
|
947
645
|
<View style={[StyleSheet.absoluteFillObject, { zIndex: 9999 }]}>
|
|
948
|
-
{processingImagePath && (
|
|
949
|
-
<Image source={{ uri: processingImagePath.startsWith('file://') ? processingImagePath : `file://${processingImagePath}` }} style={StyleSheet.absoluteFillObject} resizeMode="cover" />
|
|
950
|
-
)}
|
|
646
|
+
{processingImagePath && <Image source={{ uri: processingImagePath.startsWith('file://') ? processingImagePath : `file://${processingImagePath}` }} style={StyleSheet.absoluteFillObject} resizeMode="cover" />}
|
|
951
647
|
<View style={styles.processingOverlay}>
|
|
952
648
|
<ActivityIndicator size="large" color="#2DBD60" />
|
|
953
|
-
<Text style={styles.processingText}>
|
|
954
|
-
{state.currentLanguage === 'en' ? 'Perfect!\nProcessing Document...' : 'Parfait!\nTraitement du document...'}
|
|
955
|
-
</Text>
|
|
649
|
+
<Text style={styles.processingText}>{state.currentLanguage === 'en' ? 'Perfect!\nProcessing Document...' : 'Parfait!\nTraitement du document...'}</Text>
|
|
956
650
|
</View>
|
|
957
651
|
</View>
|
|
958
652
|
)}
|
|
959
|
-
|
|
960
653
|
{!isBusy && Platform.OS === 'web' ? (
|
|
961
654
|
<View style={styles.webBottomControlBar}>
|
|
962
|
-
{silentCaptureResult.error &&
|
|
963
|
-
<View style={styles.floatingErrorBanner}>
|
|
964
|
-
<Text style={styles.floatingErrorText}>⚠️ {silentCaptureResult.error}</Text>
|
|
965
|
-
</View>
|
|
966
|
-
)}
|
|
655
|
+
{silentCaptureResult.error && <View style={styles.floatingErrorBanner}><Text style={styles.floatingErrorText}>⚠️ {silentCaptureResult.error}</Text></View>}
|
|
967
656
|
<View style={styles.webActionButtonsRow}>
|
|
968
|
-
<TouchableOpacity style={styles.webSecondaryButton} onPress={() => setShowCamera(false)}>
|
|
969
|
-
|
|
970
|
-
</TouchableOpacity>
|
|
971
|
-
<TouchableOpacity style={styles.webPrimaryButton} onPress={refreshCamera}>
|
|
972
|
-
<Text style={styles.webPrimaryButtonText}>↻ Refresh Camera</Text>
|
|
973
|
-
</TouchableOpacity>
|
|
657
|
+
<TouchableOpacity style={styles.webSecondaryButton} onPress={() => setShowCamera(false)}><Text style={styles.webSecondaryButtonText}>Cancel</Text></TouchableOpacity>
|
|
658
|
+
<TouchableOpacity style={styles.webPrimaryButton} onPress={refreshCamera}><Text style={styles.webPrimaryButtonText}>↻ Refresh Camera</Text></TouchableOpacity>
|
|
974
659
|
</View>
|
|
975
660
|
</View>
|
|
976
661
|
) : !isBusy ? (
|
|
977
662
|
<View style={styles.escapeHatchContainer}>
|
|
978
|
-
<TouchableOpacity style={styles.fallbackRefreshButton} onPress={refreshCamera}>
|
|
979
|
-
|
|
980
|
-
</TouchableOpacity>
|
|
981
|
-
<TouchableOpacity style={[styles.fallbackRefreshButton, { marginTop: 15, backgroundColor: 'rgba(220, 38, 38, 0.8)', borderColor: '#DC2626' }]} onPress={() => setShowCamera(false)}>
|
|
982
|
-
<Text style={styles.fallbackRefreshText}>Cancel / Go Back</Text>
|
|
983
|
-
</TouchableOpacity>
|
|
663
|
+
<TouchableOpacity style={styles.fallbackRefreshButton} onPress={refreshCamera}><Text style={styles.fallbackRefreshText}>↻ Refresh Camera</Text></TouchableOpacity>
|
|
664
|
+
<TouchableOpacity style={[styles.fallbackRefreshButton, { marginTop: 15, backgroundColor: 'rgba(220, 38, 38, 0.8)', borderColor: '#DC2626' }]} onPress={() => setShowCamera(false)}><Text style={styles.fallbackRefreshText}>Cancel / Go Back</Text></TouchableOpacity>
|
|
984
665
|
</View>
|
|
985
666
|
) : null}
|
|
986
|
-
|
|
987
667
|
{silentCaptureResult.error && !isBusy && Platform.OS !== 'web' ? (
|
|
988
|
-
<View style={[styles.floatingErrorBanner, { zIndex: 10000 }]}>
|
|
989
|
-
<Text style={styles.floatingErrorText}>⚠️ {silentCaptureResult.error}</Text>
|
|
990
|
-
</View>
|
|
668
|
+
<View style={[styles.floatingErrorBanner, { zIndex: 10000 }]}><Text style={styles.floatingErrorText}>⚠️ {silentCaptureResult.error}</Text></View>
|
|
991
669
|
) : null}
|
|
992
670
|
</View>
|
|
993
671
|
</View>
|
|
@@ -1000,96 +678,36 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
|
|
|
1000
678
|
<View style={styles.previewContainer}>
|
|
1001
679
|
<ScrollView style={styles.previewItemContainer} showsVerticalScrollIndicator={false}>
|
|
1002
680
|
<View key={currentSide} style={styles.sideContainer}>
|
|
1003
|
-
<Text style={styles.sideTitle}>
|
|
1004
|
-
|
|
1005
|
-
</Text>
|
|
1006
|
-
<Text style={{ fontSize: 14, color: '#666', textAlign: 'center', marginBottom: 16, lineHeight: 22 }}>
|
|
1007
|
-
{getLocalizedText(component.instructions)}
|
|
1008
|
-
</Text>
|
|
1009
|
-
|
|
681
|
+
<Text style={styles.sideTitle}>{t('kyc.idCardCapture.captureTitle', { side: currentSide === 'front' ? locale === 'en' ? 'Front' : 'Recto' : locale === 'en' ? 'Back' : 'Verso' })}</Text>
|
|
682
|
+
<Text style={{ fontSize: 14, color: '#666', textAlign: 'center', marginBottom: 16, lineHeight: 22 }}>{getLocalizedText(component.instructions)}</Text>
|
|
1010
683
|
<View style={{ alignItems: 'center', justifyContent: 'center', flexDirection: "column", gap: 16 }}>
|
|
1011
684
|
<View style={styles.imagePreviewWrapper}>
|
|
1012
|
-
{capturedImages[currentSide]?.dir ? (
|
|
1013
|
-
<Image source={{ uri: capturedImages[currentSide].dir }} style={styles.previewImage} />
|
|
1014
|
-
) : silentCaptureResult.path ? (
|
|
1015
|
-
<Image source={{ uri: silentCaptureResult.path }} style={styles.previewImage} />
|
|
1016
|
-
) : null}
|
|
685
|
+
{capturedImages[currentSide]?.dir ? ( <Image source={{ uri: capturedImages[currentSide].dir }} style={styles.previewImage} /> ) : silentCaptureResult.path ? ( <Image source={{ uri: silentCaptureResult.path }} style={styles.previewImage} /> ) : null}
|
|
1017
686
|
</View>
|
|
1018
|
-
|
|
1019
687
|
{!capturedImages[currentSide]?.dir && (
|
|
1020
688
|
<View style={{ width: '100%', gap: 12 }}>
|
|
1021
|
-
|
|
1022
689
|
{isWebScanningRestricted ? (
|
|
1023
690
|
<>
|
|
1024
691
|
<View style={styles.restrictionWarningBanner}>
|
|
1025
|
-
<Text style={styles.restrictionWarningTitle}>
|
|
1026
|
-
|
|
1027
|
-
</Text>
|
|
1028
|
-
<Text style={styles.restrictionWarningText}>
|
|
1029
|
-
{state.currentLanguage === 'en'
|
|
1030
|
-
? `This document can only be scanned using our mobile app.`
|
|
1031
|
-
: `Ce document ne peut être scanné qu'avec notre application mobile.`}
|
|
1032
|
-
</Text>
|
|
692
|
+
<Text style={styles.restrictionWarningTitle}>{state.currentLanguage === 'en' ? '⚠️ Web Scanning Unavailable' : '⚠️ Numérisation Web Indisponible'}</Text>
|
|
693
|
+
<Text style={styles.restrictionWarningText}>{state.currentLanguage === 'en' ? `This document can only be scanned using our mobile app.` : `Ce document ne peut être scanné qu'avec notre application mobile.`}</Text>
|
|
1033
694
|
</View>
|
|
1034
|
-
|
|
1035
|
-
<Button
|
|
1036
|
-
title={state.currentLanguage === "en" ? "Continue on Phone" : "Continuer sur le téléphone"}
|
|
1037
|
-
onPress={() => setShowQRModal(true)}
|
|
1038
|
-
variant="primary"
|
|
1039
|
-
size="large"
|
|
1040
|
-
fullWidth
|
|
1041
|
-
/>
|
|
695
|
+
<Button title={state.currentLanguage === "en" ? "Continue on Phone" : "Continuer sur le téléphone"} onPress={() => setShowQRModal(true)} variant="primary" size="large" fullWidth />
|
|
1042
696
|
</>
|
|
1043
697
|
) : (
|
|
1044
|
-
/* 🟢 NORMAL FLOW: Show "Start Scanning" and optional web handoff */
|
|
1045
698
|
<>
|
|
1046
|
-
<Button
|
|
1047
|
-
|
|
1048
|
-
onPress={() => {
|
|
1049
|
-
setShowCamera(true);
|
|
1050
|
-
refreshCamera();
|
|
1051
|
-
actions.showCustomStepper(false);
|
|
1052
|
-
}}
|
|
1053
|
-
variant="primary"
|
|
1054
|
-
size="large"
|
|
1055
|
-
fullWidth
|
|
1056
|
-
/>
|
|
1057
|
-
|
|
1058
|
-
{Platform.OS === 'web' && (
|
|
1059
|
-
<Button
|
|
1060
|
-
title={state.currentLanguage === "en" ? "Continue on Phone" : "Continuer sur le téléphone"}
|
|
1061
|
-
onPress={() => setShowQRModal(true)}
|
|
1062
|
-
variant="outline"
|
|
1063
|
-
size="large"
|
|
1064
|
-
fullWidth
|
|
1065
|
-
/>
|
|
1066
|
-
)}
|
|
699
|
+
<Button title={state.currentLanguage === "en" ? "Start Scanning" : "Commencer la numérisation"} onPress={() => { setShowCamera(true); refreshCamera(); actions.showCustomStepper(false); }} variant="primary" size="large" fullWidth />
|
|
700
|
+
{Platform.OS === 'web' && ( <Button title={state.currentLanguage === "en" ? "Continue on Phone" : "Continuer sur le téléphone"} onPress={() => setShowQRModal(true)} variant="outline" size="large" fullWidth /> )}
|
|
1067
701
|
</>
|
|
1068
702
|
)}
|
|
1069
|
-
|
|
1070
703
|
</View>
|
|
1071
704
|
)}
|
|
1072
|
-
|
|
1073
705
|
{capturedImages[currentSide]?.dir && (
|
|
1074
706
|
<>
|
|
1075
707
|
<Button title={t('kyc.idCardCapture.retakeButton')} onPress={() => retakePicture(currentSide)} variant="outline" size="medium" fullWidth />
|
|
1076
708
|
<Button title={t('common.next')} onPress={() => {
|
|
1077
|
-
if (!selectedDocumentType) {
|
|
1078
|
-
|
|
1079
|
-
return;
|
|
1080
|
-
}
|
|
1081
|
-
if (currentSide === 'back' || selectedDocumentType.type === 'passport') {
|
|
1082
|
-
actions.nextComponent();
|
|
1083
|
-
} else {
|
|
1084
|
-
setShowCamera(true);
|
|
1085
|
-
refreshCamera();
|
|
1086
|
-
setCurrentSide('back');
|
|
1087
|
-
setSilentCaptureResult({ success: false, isAnalyzing: false, path: '', error: '', templatePath: undefined });
|
|
1088
|
-
setIsProcessingCapture(false);
|
|
1089
|
-
setProcessingImagePath(null);
|
|
1090
|
-
setScanPhase('image');
|
|
1091
|
-
setPendingCAImage(null);
|
|
1092
|
-
}
|
|
709
|
+
if (!selectedDocumentType) { showAlert('Error', 'Document type not selected'); return; }
|
|
710
|
+
if (currentSide === 'back' || selectedDocumentType.type === 'passport') { actions.nextComponent(); } else { setShowCamera(true); refreshCamera(); setCurrentSide('back'); setSilentCaptureResult({ success: false, isAnalyzing: false, path: '', error: '', templatePath: undefined }); setIsProcessingCapture(false); setProcessingImagePath(null); setScanPhase('image'); setPendingCAImage(null); }
|
|
1093
711
|
}} variant="primary" size="large" fullWidth />
|
|
1094
712
|
</>
|
|
1095
713
|
)}
|
|
@@ -1099,96 +717,96 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
|
|
|
1099
717
|
</View>
|
|
1100
718
|
|
|
1101
719
|
{showQRModal && Platform.OS === 'web' && (
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
720
|
+
<View style={styles.qrModalOverlay}>
|
|
721
|
+
<View style={styles.qrModalContainer}>
|
|
722
|
+
<View style={styles.qrModalHeader}>
|
|
723
|
+
<Text style={styles.qrModalTitle}>
|
|
724
|
+
{state.currentLanguage === 'en' ? 'Continue on your phone' : 'Continuer sur votre téléphone'}
|
|
725
|
+
</Text>
|
|
726
|
+
<Text style={styles.qrModalText}>
|
|
727
|
+
{state.currentLanguage === 'en'
|
|
728
|
+
? "Scan the QR code to download our mobile app and complete the scanning process securely."
|
|
729
|
+
: "Scannez le code QR pour télécharger notre application et terminer la numérisation en toute sécurité."}
|
|
730
|
+
</Text>
|
|
731
|
+
</View>
|
|
1114
732
|
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
733
|
+
<View style={styles.qrToggleContainer}>
|
|
734
|
+
<TouchableOpacity
|
|
735
|
+
style={[styles.qrToggleButton, qrPlatform === 'android' && styles.qrToggleButtonActive]}
|
|
736
|
+
onPress={() => setQrPlatform('android')}
|
|
737
|
+
>
|
|
738
|
+
<Text style={[styles.qrToggleText, qrPlatform === 'android' && styles.qrToggleTextActive]}>
|
|
739
|
+
Android
|
|
740
|
+
</Text>
|
|
741
|
+
</TouchableOpacity>
|
|
742
|
+
<TouchableOpacity
|
|
743
|
+
style={[styles.qrToggleButton, qrPlatform === 'ios' && styles.qrToggleButtonActive]}
|
|
744
|
+
onPress={() => setQrPlatform('ios')}
|
|
745
|
+
>
|
|
746
|
+
<Text style={[styles.qrToggleText, qrPlatform === 'ios' && styles.qrToggleTextActive]}>
|
|
747
|
+
iOS (Apple)
|
|
748
|
+
</Text>
|
|
749
|
+
</TouchableOpacity>
|
|
750
|
+
</View>
|
|
1133
751
|
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
752
|
+
<View style={styles.qrCodeWrapper}>
|
|
753
|
+
<QRCode
|
|
754
|
+
value={
|
|
755
|
+
qrPlatform === 'android'
|
|
756
|
+
? 'https://play.google.com/store/apps/details?id=transfergratis.com&hl=en_CA&gl=US'
|
|
757
|
+
: 'https://apps.apple.com/ca/app/transfergratis-money-transfer/id1605411731'
|
|
758
|
+
}
|
|
759
|
+
size={180}
|
|
760
|
+
backgroundColor="#FFFFFF"
|
|
761
|
+
color="#0F172A"
|
|
762
|
+
/>
|
|
763
|
+
</View>
|
|
1146
764
|
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
</Text>
|
|
1151
|
-
|
|
1152
|
-
{generatedCode ? (
|
|
1153
|
-
<ExpiringCodeTimer initialCode={generatedCode} language={state.currentLanguage} />
|
|
1154
|
-
) : (
|
|
1155
|
-
<TouchableOpacity style={styles.generateCodeButton} onPress={handleGenerateCode} disabled={isGeneratingCode}>
|
|
1156
|
-
{isGeneratingCode ? (
|
|
1157
|
-
<ActivityIndicator size="small" color="#0F172A" />
|
|
1158
|
-
) : (
|
|
1159
|
-
<Text style={styles.generateCodeButtonText}>
|
|
1160
|
-
{state.currentLanguage === 'en' ? 'Generate 5-Digit Link Code' : 'Générer un code à 5 chiffres'}
|
|
765
|
+
<View style={styles.linkCodeSection}>
|
|
766
|
+
<Text style={styles.linkCodeHeader}>
|
|
767
|
+
{state.currentLanguage === 'en' ? 'Use code below to proceed on the Sanctum Key App.' : 'Vous avez déjà l\'application ? Liez-la :'}
|
|
1161
768
|
</Text>
|
|
769
|
+
|
|
770
|
+
{generatedCode ? (
|
|
771
|
+
<ExpiringCodeTimer initialCode={generatedCode} language={state.currentLanguage} />
|
|
772
|
+
) : (
|
|
773
|
+
<TouchableOpacity style={styles.generateCodeButton} onPress={handleGenerateCode} disabled={isGeneratingCode}>
|
|
774
|
+
{isGeneratingCode ? (
|
|
775
|
+
<ActivityIndicator size="small" color="#0F172A" />
|
|
776
|
+
) : (
|
|
777
|
+
<Text style={styles.generateCodeButtonText}>
|
|
778
|
+
{state.currentLanguage === 'en' ? 'Generate 5-Digit Link Code' : 'Générer un code à 5 chiffres'}
|
|
779
|
+
</Text>
|
|
780
|
+
)}
|
|
781
|
+
</TouchableOpacity>
|
|
782
|
+
)}
|
|
783
|
+
</View>
|
|
784
|
+
|
|
785
|
+
{isWebScanningRestricted && (
|
|
786
|
+
<View style={styles.storeLinksContainer}>
|
|
787
|
+
<Text style={styles.storeLinksTitle}>
|
|
788
|
+
{state.currentLanguage === 'en' ? 'Or click to open directly:' : 'Ou cliquez pour ouvrir directement :'}
|
|
789
|
+
</Text>
|
|
790
|
+
<View style={styles.storeButtonsRow}>
|
|
791
|
+
<TouchableOpacity style={styles.storeButton} onPress={() => window.open('https://play.google.com/store/apps/details?id=transfergratis.com&hl=en_CA&gl=US', '_blank')} >
|
|
792
|
+
<Text style={styles.storeButtonText}>Google Play</Text>
|
|
793
|
+
</TouchableOpacity>
|
|
794
|
+
<TouchableOpacity style={styles.storeButton} onPress={() => window.open('https://apps.apple.com/ca/app/transfergratis-money-transfer/id1605411731', '_blank')} >
|
|
795
|
+
<Text style={styles.storeButtonText}>App Store</Text>
|
|
796
|
+
</TouchableOpacity>
|
|
797
|
+
</View>
|
|
798
|
+
</View>
|
|
1162
799
|
)}
|
|
1163
|
-
</TouchableOpacity>
|
|
1164
|
-
)}
|
|
1165
|
-
</View>
|
|
1166
800
|
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
<Text style={styles.storeButtonText}>Google Play</Text>
|
|
1175
|
-
</TouchableOpacity>
|
|
1176
|
-
<TouchableOpacity style={styles.storeButton} onPress={() => window.open('https://apps.apple.com/ca/app/transfergratis-money-transfer/id1605411731', '_blank')} >
|
|
1177
|
-
<Text style={styles.storeButtonText}>App Store</Text>
|
|
1178
|
-
</TouchableOpacity>
|
|
801
|
+
<Button
|
|
802
|
+
title={state.currentLanguage === 'en' ? "Cancel & Stay on Laptop" : "Annuler et rester sur l'ordinateur"}
|
|
803
|
+
onPress={() => setShowQRModal(false)}
|
|
804
|
+
variant="outline"
|
|
805
|
+
fullWidth
|
|
806
|
+
/>
|
|
807
|
+
</View>
|
|
1179
808
|
</View>
|
|
1180
|
-
|
|
1181
|
-
)}
|
|
1182
|
-
|
|
1183
|
-
<Button
|
|
1184
|
-
title={state.currentLanguage === 'en' ? "Cancel & Stay on Laptop" : "Annuler et rester sur l'ordinateur"}
|
|
1185
|
-
onPress={() => setShowQRModal(false)}
|
|
1186
|
-
variant="outline"
|
|
1187
|
-
fullWidth
|
|
1188
|
-
/>
|
|
1189
|
-
</View>
|
|
1190
|
-
</View>
|
|
1191
|
-
)}
|
|
809
|
+
)}
|
|
1192
810
|
</View>
|
|
1193
811
|
);
|
|
1194
812
|
};
|