@myazahq/kyc-sdk-react-native 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/KycSdkReactNative.podspec +32 -0
  2. package/LICENSE +21 -0
  3. package/README.md +164 -0
  4. package/android/CMakeLists.txt +29 -0
  5. package/android/build.gradle +110 -0
  6. package/android/src/main/AndroidManifest.xml +12 -0
  7. package/android/src/main/cpp/cpp-adapter.cpp +12 -0
  8. package/android/src/main/java/co/myazahq/kyc/rn/MyazaFaceDetectorPackage.kt +36 -0
  9. package/android/src/main/java/co/myazahq/kyc/rn/MyazaStatusBarModule.kt +64 -0
  10. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +134 -0
  11. package/app.plugin.js +55 -0
  12. package/expo-module.config.json +6 -0
  13. package/ios/HybridMyazaFaceDetector.swift +233 -0
  14. package/package.json +84 -0
  15. package/react-native.config.js +23 -0
  16. package/src/MyazaKYC.tsx +235 -0
  17. package/src/__tests__/cardCrop.test.ts +39 -0
  18. package/src/__tests__/deviceMetadata.test.ts +34 -0
  19. package/src/__tests__/errors.test.ts +34 -0
  20. package/src/__tests__/flow.test.ts +61 -0
  21. package/src/__tests__/gestureDetector.test.ts +37 -0
  22. package/src/__tests__/liveness.test.ts +112 -0
  23. package/src/__tests__/resolveUrl.test.ts +64 -0
  24. package/src/__tests__/validators.test.ts +38 -0
  25. package/src/assets/liveness/Blink.gif +0 -0
  26. package/src/assets/liveness/Nod.gif +0 -0
  27. package/src/assets/liveness/Smile.gif +0 -0
  28. package/src/assets/liveness/Turn.gif +0 -0
  29. package/src/components/CameraPermissionView.tsx +116 -0
  30. package/src/components/CameraViewfinder.tsx +156 -0
  31. package/src/components/CountryFlag.tsx +52 -0
  32. package/src/components/DocumentCropper.tsx +325 -0
  33. package/src/components/GlassIconButton.tsx +92 -0
  34. package/src/components/Icon.tsx +125 -0
  35. package/src/components/KycFlow.tsx +205 -0
  36. package/src/components/KycSheet.tsx +224 -0
  37. package/src/components/MyazaAlert.tsx +57 -0
  38. package/src/components/MyazaButton.tsx +101 -0
  39. package/src/components/MyazaCard.tsx +48 -0
  40. package/src/components/MyazaInput.tsx +112 -0
  41. package/src/components/MyazaPulseLoader.tsx +71 -0
  42. package/src/components/StatusBarController.tsx +42 -0
  43. package/src/components/StepHeader.tsx +56 -0
  44. package/src/components/StepIndicator.tsx +74 -0
  45. package/src/components/Typography.tsx +69 -0
  46. package/src/components/fonts.ts +49 -0
  47. package/src/components/glass/GlassGroup.tsx +34 -0
  48. package/src/components/glass/GlassSurface.tsx +64 -0
  49. package/src/components/runtime.tsx +93 -0
  50. package/src/components/toast.tsx +154 -0
  51. package/src/components/useBranding.ts +27 -0
  52. package/src/components/useVideoRecorder.ts +122 -0
  53. package/src/config/captureSettings.ts +67 -0
  54. package/src/config/idTypes.ts +79 -0
  55. package/src/config/theme.ts +186 -0
  56. package/src/index.ts +54 -0
  57. package/src/liveness/challengeManager.ts +130 -0
  58. package/src/liveness/faceDetector.ts +79 -0
  59. package/src/liveness/gestureDetector.ts +80 -0
  60. package/src/liveness/speech.ts +66 -0
  61. package/src/liveness/types.ts +99 -0
  62. package/src/liveness/useLiveness.ts +484 -0
  63. package/src/liveness/visionCameraFaceDetector.ts +118 -0
  64. package/src/screens/ConsentStep.tsx +164 -0
  65. package/src/screens/DocumentCaptureStep.tsx +500 -0
  66. package/src/screens/IdInputStep.tsx +79 -0
  67. package/src/screens/IdTypeStep.tsx +142 -0
  68. package/src/screens/LivenessAvatar.tsx +69 -0
  69. package/src/screens/LivenessStep.tsx +615 -0
  70. package/src/screens/SubmittedStep.tsx +177 -0
  71. package/src/services/api.ts +291 -0
  72. package/src/services/cardCrop.ts +52 -0
  73. package/src/services/deviceMetadata.ts +185 -0
  74. package/src/services/errors.ts +92 -0
  75. package/src/services/mediaCompress.ts +129 -0
  76. package/src/services/resolveUrl.ts +98 -0
  77. package/src/services/retry.ts +70 -0
  78. package/src/services/validators.ts +103 -0
  79. package/src/specs/MyazaFaceDetector.nitro.ts +44 -0
  80. package/src/store/kycStore.ts +288 -0
  81. package/src/store/serverConfig.ts +78 -0
  82. package/src/types/config.ts +239 -0
  83. package/src/types/country-flag-icons.d.ts +6 -0
  84. package/src/types/verification.ts +79 -0
  85. package/src/utils/platform.ts +23 -0
  86. package/src/utils/tokens.ts +10 -0
  87. package/src/utils/uuid.ts +31 -0
@@ -0,0 +1,79 @@
1
+ import React, { useMemo, useState } from 'react';
2
+ import { View } from 'react-native';
3
+
4
+ import { spacing } from '../config/theme';
5
+ import { ID_TYPES } from '../config/idTypes';
6
+ import { validateIdNumber } from '../services/validators';
7
+ import type { IdType } from '../types/config';
8
+ import { useKyc, useKycConfig } from '../components/runtime';
9
+ import { MyazaText } from '../components/Typography';
10
+ import { MyazaInput } from '../components/MyazaInput';
11
+ import { MyazaButton } from '../components/MyazaButton';
12
+
13
+ // Number-only ID entry (BVN / NIN / vNIN) — 1:1 with the Flutter SDK's
14
+ // IdInputScreen. Client-side format validation only; no OCR pre-fill. The step
15
+ // title/description live in the header.
16
+
17
+ function hintFor(def: { label: string; digits?: number } | null): string {
18
+ if (!def) return 'Enter your ID number';
19
+ if (def.digits != null) return `Enter ${def.digits}-digit ${def.label}`;
20
+ return `Enter your ${def.label}`;
21
+ }
22
+
23
+ export function IdInputStep(): React.ReactElement {
24
+ const config = useKycConfig();
25
+ const selectedIdType = useKyc((s) => s.selectedIdType);
26
+ const setIdNumber = useKyc((s) => s.setIdNumber);
27
+ const nextStep = useKyc((s) => s.nextStep);
28
+
29
+ const [value, setValue] = useState('');
30
+ const [error, setError] = useState<string | null>(null);
31
+
32
+ const def = useMemo(() => Object.values(ID_TYPES).flat().find((t) => t.key === selectedIdType) ?? null, [selectedIdType]);
33
+ const isDigits = def?.digits !== undefined;
34
+
35
+ const valid = useMemo(() => {
36
+ if (!selectedIdType || !value.trim()) return false;
37
+ return validateIdNumber(config.country, selectedIdType as IdType, value).valid;
38
+ }, [config.country, selectedIdType, value]);
39
+
40
+ const handleChange = (text: string) => {
41
+ // Match Flutter: BVN/NIN are digits-only.
42
+ const next = isDigits ? text.replace(/[^0-9]/g, '') : text;
43
+ setValue(next);
44
+ if (selectedIdType && next.trim()) {
45
+ const result = validateIdNumber(config.country, selectedIdType as IdType, next);
46
+ setError(result.valid ? null : result.message ?? null);
47
+ } else {
48
+ setError(null);
49
+ }
50
+ };
51
+
52
+ const handleContinue = () => {
53
+ if (!valid) return;
54
+ setIdNumber(value.trim());
55
+ nextStep();
56
+ };
57
+
58
+ return (
59
+ <View>
60
+ {def ? (
61
+ <MyazaText variant="label" style={{ marginBottom: spacing.sm }}>
62
+ {def.label}
63
+ </MyazaText>
64
+ ) : null}
65
+ <MyazaInput
66
+ value={value}
67
+ onChangeText={handleChange}
68
+ placeholder={hintFor(def)}
69
+ error={error}
70
+ keyboardType={isDigits ? 'number-pad' : 'default'}
71
+ autoCapitalize={isDigits ? 'none' : 'characters'}
72
+ maxLength={def?.digits}
73
+ autoFocus
74
+ />
75
+ <View style={{ height: spacing.xl }} />
76
+ <MyazaButton label="Continue" onPress={valid ? handleContinue : undefined} disabled={!valid} />
77
+ </View>
78
+ );
79
+ }
@@ -0,0 +1,142 @@
1
+ import React, { useMemo } from 'react';
2
+ import { Pressable, View } from 'react-native';
3
+
4
+ import { radius, spacing } from '../config/theme';
5
+ import { ID_TYPES } from '../config/idTypes';
6
+ import type { IdType, IdTypeDefinition } from '../types/config';
7
+ import { useKyc, useKycConfig, useTheme } from '../components/runtime';
8
+ import { MyazaText } from '../components/Typography';
9
+ import { MyazaButton } from '../components/MyazaButton';
10
+ import { MyazaPulseLoader } from '../components/MyazaPulseLoader';
11
+ import { Icon, type IconName } from '../components/Icon';
12
+
13
+ // ID-type picker — 1:1 with the Flutter SDK's IdTypeScreen: a list of cards
14
+ // (icon tile + label + radio circle), select-on-tap, Continue to advance.
15
+
16
+ const ICON_FOR: Record<string, IconName> = {
17
+ bvn: 'landmark', // Bank Verification Number → bank/landmark
18
+ nin: 'fingerprint',
19
+ vnin: 'fingerprint',
20
+ passport: 'passport',
21
+ 'drivers-license': 'id-card',
22
+ pvc: 'contact', // Permanent Voter's Card
23
+ 'ghana-card': 'id-card',
24
+ voters: 'contact',
25
+ ssnit: 'id-card',
26
+ 'national-id': 'id-card',
27
+ cni: 'id-card',
28
+ 'residence-card': 'id-card',
29
+ };
30
+
31
+ export function IdTypeStep(): React.ReactElement {
32
+ const { colors } = useTheme();
33
+ const config = useKycConfig();
34
+ const serverConfig = useKyc((s) => s.serverConfig);
35
+ const selectedIdType = useKyc((s) => s.selectedIdType);
36
+ const setIdType = useKyc((s) => s.setIdType);
37
+ const nextStep = useKyc((s) => s.nextStep);
38
+
39
+ const available = useMemo<IdTypeDefinition[]>(() => {
40
+ const allForCountry = ID_TYPES[config.country] ?? [];
41
+ const requested = config.idTypes?.length
42
+ ? allForCountry.filter((t) => (config.idTypes as IdType[]).includes(t.key))
43
+ : [...allForCountry];
44
+ if (serverConfig.status === 'ready') {
45
+ const granted = new Set(
46
+ serverConfig.idTypes.filter((t) => t.country === config.country).map((t) => t.idType),
47
+ );
48
+ return requested.filter((t) => granted.has(t.key));
49
+ }
50
+ if (serverConfig.status === 'loading') return [];
51
+ return requested;
52
+ }, [config.country, config.idTypes, serverConfig]);
53
+
54
+ if (serverConfig.status === 'loading') {
55
+ return (
56
+ <View style={{ paddingVertical: spacing.xl, alignItems: 'center' }}>
57
+ <MyazaPulseLoader />
58
+ </View>
59
+ );
60
+ }
61
+
62
+ if (serverConfig.status === 'ready' && available.length === 0) {
63
+ return (
64
+ <View
65
+ style={{
66
+ padding: spacing.md,
67
+ borderRadius: radius.md,
68
+ backgroundColor: colors.errorBg,
69
+ borderWidth: 1,
70
+ borderColor: `${colors.error}4D`,
71
+ }}
72
+ >
73
+ <MyazaText variant="bodyMedium" color={colors.error}>
74
+ No ID types are enabled for your organization. Contact your administrator to request access.
75
+ </MyazaText>
76
+ </View>
77
+ );
78
+ }
79
+
80
+ return (
81
+ <View>
82
+ {available.map((t) => {
83
+ const selected = selectedIdType === t.key;
84
+ return (
85
+ <Pressable
86
+ key={t.key}
87
+ onPress={() => setIdType(t.key)}
88
+ style={{
89
+ marginBottom: spacing.sm,
90
+ borderRadius: radius.md,
91
+ backgroundColor: selected ? colors.primary50 : colors.background,
92
+ borderWidth: selected ? 1.5 : 1,
93
+ borderColor: selected ? colors.primary : colors.border,
94
+ padding: spacing.md,
95
+ flexDirection: 'row',
96
+ alignItems: 'center',
97
+ shadowColor: colors.textDark,
98
+ shadowOpacity: 0.05,
99
+ shadowRadius: 8,
100
+ shadowOffset: { width: 0, height: 2 },
101
+ elevation: 1,
102
+ }}
103
+ >
104
+ <View
105
+ style={{
106
+ width: 44,
107
+ height: 44,
108
+ borderRadius: radius.sm,
109
+ backgroundColor: selected ? colors.primary100 : colors.primary50,
110
+ alignItems: 'center',
111
+ justifyContent: 'center',
112
+ }}
113
+ >
114
+ <Icon name={ICON_FOR[t.key] ?? 'credit-card'} size={22} color={selected ? colors.primary : colors.textSecondary} />
115
+ </View>
116
+ <View style={{ width: spacing.md }} />
117
+ <MyazaText variant="label" style={{ flex: 1, fontWeight: '600' }}>
118
+ {t.label}
119
+ </MyazaText>
120
+ <View style={{ width: spacing.md }} />
121
+ <View
122
+ style={{
123
+ width: 22,
124
+ height: 22,
125
+ borderRadius: 11,
126
+ borderWidth: 1.5,
127
+ borderColor: selected ? colors.primary : colors.gray300,
128
+ alignItems: 'center',
129
+ justifyContent: 'center',
130
+ }}
131
+ >
132
+ {selected ? <View style={{ width: 12, height: 12, borderRadius: 6, backgroundColor: colors.primary }} /> : null}
133
+ </View>
134
+ </Pressable>
135
+ );
136
+ })}
137
+
138
+ <View style={{ height: spacing.lg }} />
139
+ <MyazaButton label="Continue" onPress={selectedIdType ? nextStep : undefined} disabled={!selectedIdType} />
140
+ </View>
141
+ );
142
+ }
@@ -0,0 +1,69 @@
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import { Animated, Easing, Image } from 'react-native';
3
+
4
+ import { useTheme } from '../components/runtime';
5
+ import { Icon } from '../components/Icon';
6
+ import type { LivenessChallenge } from '../liveness/types';
7
+
8
+ // Animated GIF avatar demonstrating the requested gesture — the RN mirror of the
9
+ // web/Flutter `LivenessAvatar`. Shows the same Nod/Turn/Blink/Smile GIFs in a
10
+ // circular badge (primary-tinted, like Flutter), sliding up + fading in when the
11
+ // challenge changes. RN's <Image> animates GIFs natively.
12
+
13
+ const GIFS: Record<LivenessChallenge, number> = {
14
+ nod: require('../assets/liveness/Nod.gif'),
15
+ turn: require('../assets/liveness/Turn.gif'),
16
+ blink: require('../assets/liveness/Blink.gif'),
17
+ smile: require('../assets/liveness/Smile.gif'),
18
+ };
19
+
20
+ const SIZE = 96;
21
+
22
+ export function LivenessAvatar({ challenge }: { challenge: LivenessChallenge }): React.ReactElement {
23
+ const { colors } = useTheme();
24
+ const [displayed, setDisplayed] = useState<LivenessChallenge>(challenge);
25
+ const [broken, setBroken] = useState(false);
26
+ const anim = useRef(new Animated.Value(1)).current;
27
+
28
+ // Slide-up + fade transition when the challenge changes (mirrors Flutter's
29
+ // AnimatedSwitcher: Offset(0, 0.4) → 0, easeOutCubic, ~350ms).
30
+ useEffect(() => {
31
+ if (challenge === displayed) return;
32
+ anim.setValue(0);
33
+ setDisplayed(challenge);
34
+ setBroken(false);
35
+ Animated.timing(anim, {
36
+ toValue: 1,
37
+ duration: 350,
38
+ easing: Easing.out(Easing.cubic),
39
+ useNativeDriver: true,
40
+ }).start();
41
+ }, [challenge, displayed, anim]);
42
+
43
+ return (
44
+ <Animated.View
45
+ style={{
46
+ width: SIZE,
47
+ height: SIZE,
48
+ borderRadius: SIZE / 2,
49
+ backgroundColor: `${colors.primary}1A`, // ~10% primary (primary100 tint)
50
+ overflow: 'hidden',
51
+ alignItems: 'center',
52
+ justifyContent: 'center',
53
+ opacity: anim,
54
+ transform: [{ translateY: anim.interpolate({ inputRange: [0, 1], outputRange: [SIZE * 0.4, 0] }) }],
55
+ }}
56
+ >
57
+ {broken ? (
58
+ <Icon name="scan-face" size={40} color={colors.primary} />
59
+ ) : (
60
+ <Image
61
+ source={GIFS[displayed]}
62
+ style={{ width: '100%', height: '100%' }}
63
+ resizeMode="cover"
64
+ onError={() => setBroken(true)}
65
+ />
66
+ )}
67
+ </Animated.View>
68
+ );
69
+ }