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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import React, { useState, useRef, useEffect } from 'react';
2
- import { View, Text, StyleSheet, TextInput, TouchableOpacity, Alert, Pressable, Modal, FlatList } from 'react-native';
2
+ import { View, Text, StyleSheet, TextInput, TouchableOpacity, Alert, Pressable, Modal, FlatList, Platform } from 'react-native';
3
3
  import { TemplateComponent, LocalizedText } from '../../types/KYC.types';
4
4
  import { useTemplateKYCFlowContext } from '../../hooks/useTemplateKYCFlow';
5
5
  import { useI18n } from '../../hooks/useI18n';
@@ -17,7 +17,6 @@ interface PhoneVerificationTemplateProps {
17
17
  type VerificationStep = 'phone' | 'otp';
18
18
  const CODE_LENGTH = 6;
19
19
 
20
- // 🌍 The Country Codes Array
21
20
  const COUNTRY_CODES = [
22
21
  { code: '+254', label: '🇰🇪 Kenya (+254)' },
23
22
  { code: '+255', label: '🇹🇿 Tanzania (+255)' },
@@ -38,7 +37,6 @@ export const PhoneVerificationTemplate: React.FC<PhoneVerificationTemplateProps>
38
37
  }) => {
39
38
  const { actions, getLocalizedText, state, apiKey } = useTemplateKYCFlowContext();
40
39
  const { t } = useI18n();
41
-
42
40
  const auth = apiKey ? { apiKey } : (state.session?.token ? { token: state.session.token } : undefined);
43
41
  const sessionId = state.session?.session_id || '';
44
42
 
@@ -47,19 +45,16 @@ export const PhoneVerificationTemplate: React.FC<PhoneVerificationTemplateProps>
47
45
  const [countryCode, setCountryCode] = useState('+254');
48
46
  const [phone, setPhone] = useState('');
49
47
  const [showCountryPicker, setShowCountryPicker] = useState(false);
50
-
51
48
  const [otp, setOtp] = useState('');
52
49
  const [localError, setLocalError] = useState<string | null>(null);
53
50
  const [isSimulating, setIsSimulating] = useState(false);
54
51
 
55
52
  // Track actual focus state for visual feedback
56
- const [isInputFocused, setIsInputFocused] = useState(false);
57
-
53
+ const [isInputFocused, setIsInputFocused] = useState(false);
58
54
  const inputRef = useRef<TextInput>(null);
59
55
 
60
56
  const title = getLocalizedText(component.labels as LocalizedText);
61
57
  const instructions = getLocalizedText(component.instructions as LocalizedText);
62
-
63
58
  const verifyButtonText = getLocalizedText((component.ui as any).buttonText) || t('common.verify') || 'Verify';
64
59
  const sendButtonText = t('common.sendCode') || 'Send Verification Code';
65
60
  const buttonText = step === 'phone' ? sendButtonText : verifyButtonText;
@@ -74,14 +69,11 @@ export const PhoneVerificationTemplate: React.FC<PhoneVerificationTemplateProps>
74
69
  // --- SAFE AUTO FOCUS LOGIC ---
75
70
  useEffect(() => {
76
71
  let focusTimer: ReturnType<typeof setTimeout>;
77
-
78
- // Only attempt focus when we are on the OTP step AND the loading state is completely finished
79
72
  if (step === 'otp' && !isSimulating) {
80
73
  focusTimer = setTimeout(() => {
81
74
  inputRef.current?.focus();
82
- }, 300); // 300ms guarantees view is painted before requesting keyboard
75
+ }, 300);
83
76
  }
84
-
85
77
  return () => {
86
78
  if (focusTimer) clearTimeout(focusTimer);
87
79
  };
@@ -93,10 +85,8 @@ export const PhoneVerificationTemplate: React.FC<PhoneVerificationTemplateProps>
93
85
  setLocalError(t('errors.invalidPhone') || 'Please enter a valid phone number');
94
86
  return;
95
87
  }
96
-
97
88
  setLocalError(null);
98
89
  setIsSimulating(true);
99
-
100
90
  const fullPhoneNumber = `${countryCode}${trimmedPhone}`;
101
91
 
102
92
  try {
@@ -115,15 +105,12 @@ export const PhoneVerificationTemplate: React.FC<PhoneVerificationTemplateProps>
115
105
  setLocalError(t('errors.invalidCode') || 'Please enter the 6-digit code');
116
106
  return;
117
107
  }
118
-
119
108
  setLocalError(null);
120
109
  setIsSimulating(true);
121
-
122
110
  const fullPhoneNumber = `${countryCode}${phone.trim()}`;
123
111
 
124
112
  try {
125
113
  await kycService.verifyWhatsAppCode(sessionId, otp.trim(), fullPhoneNumber, auth);
126
-
127
114
  const data = { phone: fullPhoneNumber, otp, verified: true };
128
115
  onValueChange(data);
129
116
  actions.nextComponent(data);
@@ -131,7 +118,7 @@ export const PhoneVerificationTemplate: React.FC<PhoneVerificationTemplateProps>
131
118
  const msg = errorMessage(err) ?? err?.message ?? (t('errors.wrongCode') || 'Invalid verification code');
132
119
  setLocalError(typeof msg === 'string' ? msg : JSON.stringify(msg));
133
120
  setOtp('');
134
- inputRef.current?.focus();
121
+ inputRef.current?.focus();
135
122
  } finally {
136
123
  setIsSimulating(false);
137
124
  }
@@ -162,18 +149,15 @@ export const PhoneVerificationTemplate: React.FC<PhoneVerificationTemplateProps>
162
149
  {boxes.map((_, index) => {
163
150
  const digit = otp[index] || '';
164
151
  const isFilled = index < otp.length;
165
-
166
- // Only highlight if the input is ACTUALLY focused.
167
152
  const isActiveIndex = index === otp.length || (index === CODE_LENGTH - 1 && otp.length === CODE_LENGTH);
168
153
  const isCurrent = isInputFocused && isActiveIndex;
169
-
170
154
  return (
171
- <View
172
- key={index}
155
+ <View
156
+ key={index}
173
157
  style={[
174
- styles.otpBox,
158
+ styles.otpBox,
175
159
  isFilled && styles.otpBoxFilled,
176
- isCurrent && styles.otpBoxActive // Active overrides filled
160
+ isCurrent && styles.otpBoxActive
177
161
  ]}
178
162
  >
179
163
  <Text style={styles.otpBoxText}>{digit}</Text>
@@ -185,152 +169,169 @@ export const PhoneVerificationTemplate: React.FC<PhoneVerificationTemplateProps>
185
169
  };
186
170
 
187
171
  return (
188
- <View style={styles.container}>
189
- <Text style={styles.title}>{title}</Text>
190
- <Text style={styles.instructions}>
191
- {step === 'phone' ? instructions : (t('kyc.enterCodeSent') || `Please enter the code sent to ${countryCode} ${phone}`)}
192
- </Text>
193
-
194
- <View style={styles.contentContainer}>
195
- {step === 'phone' ? (
196
- <View style={styles.inputContainer}>
197
- <Text style={styles.label}>{t('common.phone') || 'Phone Number'}</Text>
198
-
199
- <View style={styles.phoneInputRow}>
200
- <TouchableOpacity
201
- style={styles.countryPickerBtn}
202
- onPress={() => setShowCountryPicker(true)}
203
- disabled={isSimulating}
204
- >
205
- <Text style={styles.countryPickerText}>{countryCode}</Text>
206
- <Text style={styles.dropdownIcon}>▼</Text>
207
- </TouchableOpacity>
172
+ <View style={styles.wrapper}>
173
+ <View style={styles.container}>
174
+ <Text style={styles.title}>{title}</Text>
175
+ <Text style={styles.instructions}>
176
+ {step === 'phone' ? instructions : (t('kyc.enterCodeSent') || `Please enter the code sent to ${countryCode} ${phone}`)}
177
+ </Text>
178
+
179
+ <View style={styles.contentContainer}>
180
+ {step === 'phone' ? (
181
+ <View style={styles.inputContainer}>
182
+ <Text style={styles.label}>{t('common.phone') || 'Phone Number'}</Text>
183
+ <View style={styles.phoneInputRow}>
184
+ <TouchableOpacity
185
+ style={styles.countryPickerBtn}
186
+ onPress={() => setShowCountryPicker(true)}
187
+ disabled={isSimulating}
188
+ activeOpacity={0.7}
189
+ >
190
+ <Text style={styles.countryPickerText}>{countryCode}</Text>
191
+ <Text style={styles.dropdownIcon}>▼</Text>
192
+ </TouchableOpacity>
208
193
 
209
- <TextInput
210
- style={styles.phoneInput}
211
- placeholder="712 345 678"
212
- value={phone}
213
- onChangeText={onChangePhone}
214
- keyboardType="phone-pad"
215
- autoComplete="tel"
216
- editable={!isSimulating}
217
- />
194
+ <TextInput
195
+ style={[
196
+ styles.phoneInput,
197
+ // 🚨 Removes browser focus ring on web
198
+ Platform.OS === 'web' && { outlineStyle: 'none' } as any
199
+ ]}
200
+ placeholder="712 345 678"
201
+ placeholderTextColor="#9CA3AF" // 🚨 Explicit light color so it looks like a placeholder
202
+ value={phone}
203
+ onChangeText={onChangePhone}
204
+ keyboardType="phone-pad"
205
+ autoComplete="tel"
206
+ editable={!isSimulating}
207
+ />
208
+ </View>
218
209
  </View>
219
- </View>
220
- ) : (
221
- <View style={styles.inputContainer}>
222
- <Text style={styles.label}>{t('common.verificationCode') || 'Verification Code'}</Text>
223
-
224
- <View style={styles.otpWrapper}>
225
- {renderOtpBoxes()}
226
-
227
- <TextInput
228
- ref={inputRef}
229
- style={styles.hiddenInput}
230
- value={otp}
231
- onChangeText={onChangeOtp}
232
- keyboardType="number-pad"
233
- maxLength={CODE_LENGTH}
234
- editable={!isSimulating}
235
- textContentType="oneTimeCode"
236
- caretHidden={true}
237
- onFocus={() => setIsInputFocused(true)}
238
- onBlur={() => setIsInputFocused(false)}
239
- />
210
+ ) : (
211
+ <View style={styles.inputContainer}>
212
+ <Text style={styles.label}>{t('common.verificationCode') || 'Verification Code'}</Text>
213
+ <View style={styles.otpWrapper}>
214
+ {renderOtpBoxes()}
215
+ <TextInput
216
+ ref={inputRef}
217
+ style={[
218
+ styles.hiddenInput,
219
+ Platform.OS === 'web' && { outlineStyle: 'none' } as any
220
+ ]}
221
+ value={otp}
222
+ onChangeText={onChangeOtp}
223
+ keyboardType="number-pad"
224
+ maxLength={CODE_LENGTH}
225
+ editable={!isSimulating}
226
+ textContentType="oneTimeCode"
227
+ caretHidden={true}
228
+ onFocus={() => setIsInputFocused(true)}
229
+ onBlur={() => setIsInputFocused(false)}
230
+ />
231
+ </View>
232
+ <TouchableOpacity onPress={handleBackToPhone} style={styles.changeLink} disabled={isSimulating}>
233
+ <Text style={styles.changeText}>{t('common.back') || 'Change number'}</Text>
234
+ </TouchableOpacity>
240
235
  </View>
241
-
242
- <TouchableOpacity onPress={handleBackToPhone} style={styles.changeLink} disabled={isSimulating}>
243
- <Text style={styles.changeText}>{t('common.back') || 'Change number'}</Text>
236
+ )}
237
+
238
+ {(localError || propError) && (
239
+ <Text style={styles.errorText}>{localError || propError}</Text>
240
+ )}
241
+
242
+ <Button
243
+ title={isSimulating ? (t('common.processing') || 'Processing...') : buttonText}
244
+ onPress={step === 'phone' ? handleSendCode : handleVerifyCode}
245
+ style={styles.button}
246
+ disabled={
247
+ isSimulating ||
248
+ (step === 'phone' ? !phone : otp.length < CODE_LENGTH)
249
+ }
250
+ />
251
+
252
+ {step === 'otp' && (
253
+ <TouchableOpacity
254
+ onPress={async () => {
255
+ if (isSimulating) return;
256
+ setLocalError(null);
257
+ setIsSimulating(true);
258
+ const fullPhoneNumber = `${countryCode}${phone.trim()}`;
259
+ try {
260
+ await kycService.sendWhatsAppVerificationCode(sessionId, fullPhoneNumber, auth);
261
+ Alert.alert(
262
+ t('common.codeResent') || 'Code Resent',
263
+ t('common.codeResentMessage', { email: fullPhoneNumber }) || 'Code resent to ' + fullPhoneNumber
264
+ );
265
+ inputRef.current?.focus();
266
+ } catch (err: any) {
267
+ const msg = errorMessage(err) ?? err?.message ?? (t('errors.sendCodeFailed') || 'Failed to send code');
268
+ setLocalError(typeof msg === 'string' ? msg : JSON.stringify(msg));
269
+ } finally {
270
+ setIsSimulating(false);
271
+ }
272
+ }}
273
+ style={styles.resendButton}
274
+ disabled={isSimulating}
275
+ >
276
+ <Text style={styles.resendText}>{t('common.resendCode') || 'Resend Code'}</Text>
244
277
  </TouchableOpacity>
245
- </View>
246
- )}
247
-
248
- {(localError || propError) && (
249
- <Text style={styles.errorText}>{localError || propError}</Text>
250
- )}
251
-
252
- <Button
253
- title={isSimulating ? (t('common.processing') || 'Processing...') : buttonText}
254
- onPress={step === 'phone' ? handleSendCode : handleVerifyCode}
255
- style={styles.button}
256
- disabled={
257
- isSimulating ||
258
- (step === 'phone' ? !phone : otp.length < CODE_LENGTH)
259
- }
260
- />
261
-
262
- {step === 'otp' && (
278
+ )}
279
+ </View>
280
+
281
+ {/* COUNTRY PICKER MODAL */}
282
+ <Modal
283
+ visible={showCountryPicker}
284
+ animationType="slide"
285
+ transparent={true}
286
+ onRequestClose={() => setShowCountryPicker(false)}
287
+ >
263
288
  <TouchableOpacity
264
- onPress={async () => {
265
- if (isSimulating) return;
266
- setLocalError(null);
267
- setIsSimulating(true);
268
- const fullPhoneNumber = `${countryCode}${phone.trim()}`;
269
- try {
270
- await kycService.sendWhatsAppVerificationCode(sessionId, fullPhoneNumber, auth);
271
- Alert.alert(
272
- t('common.codeResent') || 'Code Resent',
273
- t('common.codeResentMessage', { email: fullPhoneNumber }) || 'Code resent to ' + fullPhoneNumber
274
- );
275
- inputRef.current?.focus();
276
- } catch (err: any) {
277
- const msg = errorMessage(err) ?? err?.message ?? (t('errors.sendCodeFailed') || 'Failed to send code');
278
- setLocalError(typeof msg === 'string' ? msg : JSON.stringify(msg));
279
- } finally {
280
- setIsSimulating(false);
281
- }
282
- }}
283
- style={styles.resendButton}
284
- disabled={isSimulating}
289
+ style={styles.modalOverlay}
290
+ activeOpacity={1}
291
+ onPress={() => setShowCountryPicker(false)}
285
292
  >
286
- <Text style={styles.resendText}>{t('common.resendCode') || 'Resend Code'}</Text>
293
+ {/* 🚨 Added wrapper to center modal on desktop */}
294
+ <View style={styles.modalCenteredWrapper}>
295
+ <View style={styles.modalContent}>
296
+ <View style={styles.modalHeader}>
297
+ <Text style={styles.modalTitle}>Select Country</Text>
298
+ <TouchableOpacity onPress={() => setShowCountryPicker(false)}>
299
+ <Text style={styles.modalClose}>✕</Text>
300
+ </TouchableOpacity>
301
+ </View>
302
+ <FlatList
303
+ data={COUNTRY_CODES}
304
+ keyExtractor={(item) => item.code}
305
+ renderItem={({ item }) => (
306
+ <TouchableOpacity
307
+ style={styles.countryItem}
308
+ onPress={() => {
309
+ setCountryCode(item.code);
310
+ setShowCountryPicker(false);
311
+ }}
312
+ >
313
+ <Text style={styles.countryItemLabel}>{item.label}</Text>
314
+ {countryCode === item.code && <Text style={styles.checkMark}>✓</Text>}
315
+ </TouchableOpacity>
316
+ )}
317
+ />
318
+ </View>
319
+ </View>
287
320
  </TouchableOpacity>
288
- )}
321
+ </Modal>
289
322
  </View>
290
-
291
- {/* COUNTRY PICKER MODAL */}
292
- <Modal
293
- visible={showCountryPicker}
294
- animationType="slide"
295
- transparent={true}
296
- onRequestClose={() => setShowCountryPicker(false)}
297
- >
298
- <TouchableOpacity
299
- style={styles.modalOverlay}
300
- activeOpacity={1}
301
- onPress={() => setShowCountryPicker(false)}
302
- >
303
- <View style={styles.modalContent}>
304
- <View style={styles.modalHeader}>
305
- <Text style={styles.modalTitle}>Select Country</Text>
306
- <TouchableOpacity onPress={() => setShowCountryPicker(false)}>
307
- <Text style={styles.modalClose}>✕</Text>
308
- </TouchableOpacity>
309
- </View>
310
- <FlatList
311
- data={COUNTRY_CODES}
312
- keyExtractor={(item) => item.code}
313
- renderItem={({ item }) => (
314
- <TouchableOpacity
315
- style={styles.countryItem}
316
- onPress={() => {
317
- setCountryCode(item.code);
318
- setShowCountryPicker(false);
319
- }}
320
- >
321
- <Text style={styles.countryItemLabel}>{item.label}</Text>
322
- {countryCode === item.code && <Text style={styles.checkMark}>✓</Text>}
323
- </TouchableOpacity>
324
- )}
325
- />
326
- </View>
327
- </TouchableOpacity>
328
- </Modal>
329
323
  </View>
330
324
  );
331
325
  };
332
326
 
333
327
  const styles = StyleSheet.create({
328
+ // 🚨 Added wrapper to easily center the component on web
329
+ wrapper: {
330
+ flex: 1,
331
+ width: '100%',
332
+ alignItems: 'center',
333
+ justifyContent: 'center',
334
+ },
334
335
  container: {
335
336
  padding: 24,
336
337
  backgroundColor: 'white',
@@ -342,6 +343,8 @@ const styles = StyleSheet.create({
342
343
  shadowRadius: 12,
343
344
  elevation: 5,
344
345
  width: '95%',
346
+ // 🚨 Max width constraint for web so inputs don't stretch infinitely
347
+ ...(Platform.OS === 'web' ? { maxWidth: 450, paddingVertical: 40 } : {}),
345
348
  },
346
349
  title: {
347
350
  fontSize: 24,
@@ -357,7 +360,9 @@ const styles = StyleSheet.create({
357
360
  lineHeight: 24,
358
361
  textAlign: 'center',
359
362
  },
360
- contentContainer: {},
363
+ contentContainer: {
364
+ width: '100%',
365
+ },
361
366
  inputContainer: {
362
367
  marginBottom: 24,
363
368
  },
@@ -380,10 +385,11 @@ const styles = StyleSheet.create({
380
385
  borderWidth: 1,
381
386
  borderColor: '#e0e0e0',
382
387
  paddingHorizontal: 12,
383
- paddingVertical: 16,
388
+ paddingVertical: Platform.OS === 'web' ? 14 : 16, // Adjusted for web inputs
384
389
  borderRadius: 12,
385
390
  backgroundColor: '#f8f9fa',
386
- minWidth: 90,
391
+ minWidth: 100,
392
+ ...(Platform.OS === 'web' ? { cursor: 'pointer' } as any : {}),
387
393
  },
388
394
  countryPickerText: {
389
395
  fontSize: 16,
@@ -396,10 +402,10 @@ const styles = StyleSheet.create({
396
402
  marginLeft: 8,
397
403
  },
398
404
  phoneInput: {
399
- flex: 1,
405
+ flex: 1,
400
406
  borderWidth: 1,
401
407
  borderColor: '#e0e0e0',
402
- padding: 16,
408
+ padding: Platform.OS === 'web' ? 14 : 16,
403
409
  borderRadius: 12,
404
410
  fontSize: 16,
405
411
  backgroundColor: '#f8f9fa',
@@ -410,12 +416,19 @@ const styles = StyleSheet.create({
410
416
  backgroundColor: 'rgba(0,0,0,0.5)',
411
417
  justifyContent: 'flex-end',
412
418
  },
419
+ // 🚨 Centering wrapper for the modal on web
420
+ modalCenteredWrapper: {
421
+ width: '100%',
422
+ alignSelf: 'center',
423
+ ...(Platform.OS === 'web' ? { maxWidth: 500, justifyContent: 'center', flex: 1 } : {}),
424
+ },
413
425
  modalContent: {
414
426
  backgroundColor: 'white',
415
427
  borderTopLeftRadius: 24,
416
428
  borderTopRightRadius: 24,
417
- maxHeight: '60%',
418
- paddingBottom: 40,
429
+ maxHeight: '70%',
430
+ paddingBottom: Platform.OS === 'ios' ? 40 : 20,
431
+ ...(Platform.OS === 'web' ? { borderRadius: 24, maxHeight: 500 } : {}),
419
432
  },
420
433
  modalHeader: {
421
434
  flexDirection: 'row',
@@ -434,6 +447,7 @@ const styles = StyleSheet.create({
434
447
  fontSize: 20,
435
448
  color: '#666',
436
449
  padding: 5,
450
+ ...(Platform.OS === 'web' ? { cursor: 'pointer' } as any : {}),
437
451
  },
438
452
  countryItem: {
439
453
  flexDirection: 'row',
@@ -443,6 +457,7 @@ const styles = StyleSheet.create({
443
457
  paddingHorizontal: 24,
444
458
  borderBottomWidth: 1,
445
459
  borderBottomColor: '#f8f9fa',
460
+ ...(Platform.OS === 'web' ? { cursor: 'pointer' } as any : {}),
446
461
  },
447
462
  countryItemLabel: {
448
463
  fontSize: 16,
@@ -464,10 +479,12 @@ const styles = StyleSheet.create({
464
479
  alignItems: 'center',
465
480
  width: '100%',
466
481
  height: '100%',
482
+ ...(Platform.OS === 'web' ? { cursor: 'text' } as any : {}),
467
483
  },
468
484
  otpBox: {
469
485
  width: '14%',
470
- aspectRatio: 1,
486
+ aspectRatio: Platform.OS === 'web' ? undefined : 1, // Let height dictate aspect ratio on web
487
+ height: Platform.OS === 'web' ? 56 : undefined, // Fixed height prevents blowouts on web
471
488
  borderWidth: 1,
472
489
  borderColor: '#e0e0e0',
473
490
  borderRadius: 12,
@@ -496,6 +513,7 @@ const styles = StyleSheet.create({
496
513
  width: '100%',
497
514
  height: '100%',
498
515
  opacity: 0,
516
+ ...(Platform.OS === 'web' ? { cursor: 'text' } as any : {}),
499
517
  },
500
518
  errorText: {
501
519
  color: '#dc2626',
@@ -515,6 +533,7 @@ const styles = StyleSheet.create({
515
533
  changeLink: {
516
534
  alignSelf: 'flex-end',
517
535
  marginTop: 12,
536
+ ...(Platform.OS === 'web' ? { cursor: 'pointer' } as any : {}),
518
537
  },
519
538
  changeText: {
520
539
  color: '#2DBD60',
@@ -524,6 +543,7 @@ const styles = StyleSheet.create({
524
543
  resendButton: {
525
544
  marginTop: 16,
526
545
  alignItems: 'center',
546
+ ...(Platform.OS === 'web' ? { cursor: 'pointer' } as any : {}),
527
547
  },
528
548
  resendText: {
529
549
  color: '#666',
@@ -63,7 +63,7 @@ export class KYCService {
63
63
  private faceServiceURL = 'https://face-infera.sanctumkey.com';
64
64
  private textExtractionServiceURL = 'https://text-infera.sanctumkey.com';
65
65
  private mrzServiceURL = 'https://mrz-infera.sanctumkey.com';
66
- private barcodeServiceURL = 'https://kyc-engine.SanctumKey.net:8000';
66
+ private barcodeServiceURL = 'https://kyc-engine.SanctumKey.net';
67
67
  private orientationServiceURL = 'http://18.188.180.154:8080';
68
68
 
69
69
  // private faceServiceURL = 'https://kyc-engine.transfergratis.net:8000';