@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.
- package/KycSdkReactNative.podspec +32 -0
- package/LICENSE +21 -0
- package/README.md +164 -0
- package/android/CMakeLists.txt +29 -0
- package/android/build.gradle +110 -0
- package/android/src/main/AndroidManifest.xml +12 -0
- package/android/src/main/cpp/cpp-adapter.cpp +12 -0
- package/android/src/main/java/co/myazahq/kyc/rn/MyazaFaceDetectorPackage.kt +36 -0
- package/android/src/main/java/co/myazahq/kyc/rn/MyazaStatusBarModule.kt +64 -0
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +134 -0
- package/app.plugin.js +55 -0
- package/expo-module.config.json +6 -0
- package/ios/HybridMyazaFaceDetector.swift +233 -0
- package/package.json +84 -0
- package/react-native.config.js +23 -0
- package/src/MyazaKYC.tsx +235 -0
- package/src/__tests__/cardCrop.test.ts +39 -0
- package/src/__tests__/deviceMetadata.test.ts +34 -0
- package/src/__tests__/errors.test.ts +34 -0
- package/src/__tests__/flow.test.ts +61 -0
- package/src/__tests__/gestureDetector.test.ts +37 -0
- package/src/__tests__/liveness.test.ts +112 -0
- package/src/__tests__/resolveUrl.test.ts +64 -0
- package/src/__tests__/validators.test.ts +38 -0
- package/src/assets/liveness/Blink.gif +0 -0
- package/src/assets/liveness/Nod.gif +0 -0
- package/src/assets/liveness/Smile.gif +0 -0
- package/src/assets/liveness/Turn.gif +0 -0
- package/src/components/CameraPermissionView.tsx +116 -0
- package/src/components/CameraViewfinder.tsx +156 -0
- package/src/components/CountryFlag.tsx +52 -0
- package/src/components/DocumentCropper.tsx +325 -0
- package/src/components/GlassIconButton.tsx +92 -0
- package/src/components/Icon.tsx +125 -0
- package/src/components/KycFlow.tsx +205 -0
- package/src/components/KycSheet.tsx +224 -0
- package/src/components/MyazaAlert.tsx +57 -0
- package/src/components/MyazaButton.tsx +101 -0
- package/src/components/MyazaCard.tsx +48 -0
- package/src/components/MyazaInput.tsx +112 -0
- package/src/components/MyazaPulseLoader.tsx +71 -0
- package/src/components/StatusBarController.tsx +42 -0
- package/src/components/StepHeader.tsx +56 -0
- package/src/components/StepIndicator.tsx +74 -0
- package/src/components/Typography.tsx +69 -0
- package/src/components/fonts.ts +49 -0
- package/src/components/glass/GlassGroup.tsx +34 -0
- package/src/components/glass/GlassSurface.tsx +64 -0
- package/src/components/runtime.tsx +93 -0
- package/src/components/toast.tsx +154 -0
- package/src/components/useBranding.ts +27 -0
- package/src/components/useVideoRecorder.ts +122 -0
- package/src/config/captureSettings.ts +67 -0
- package/src/config/idTypes.ts +79 -0
- package/src/config/theme.ts +186 -0
- package/src/index.ts +54 -0
- package/src/liveness/challengeManager.ts +130 -0
- package/src/liveness/faceDetector.ts +79 -0
- package/src/liveness/gestureDetector.ts +80 -0
- package/src/liveness/speech.ts +66 -0
- package/src/liveness/types.ts +99 -0
- package/src/liveness/useLiveness.ts +484 -0
- package/src/liveness/visionCameraFaceDetector.ts +118 -0
- package/src/screens/ConsentStep.tsx +164 -0
- package/src/screens/DocumentCaptureStep.tsx +500 -0
- package/src/screens/IdInputStep.tsx +79 -0
- package/src/screens/IdTypeStep.tsx +142 -0
- package/src/screens/LivenessAvatar.tsx +69 -0
- package/src/screens/LivenessStep.tsx +615 -0
- package/src/screens/SubmittedStep.tsx +177 -0
- package/src/services/api.ts +291 -0
- package/src/services/cardCrop.ts +52 -0
- package/src/services/deviceMetadata.ts +185 -0
- package/src/services/errors.ts +92 -0
- package/src/services/mediaCompress.ts +129 -0
- package/src/services/resolveUrl.ts +98 -0
- package/src/services/retry.ts +70 -0
- package/src/services/validators.ts +103 -0
- package/src/specs/MyazaFaceDetector.nitro.ts +44 -0
- package/src/store/kycStore.ts +288 -0
- package/src/store/serverConfig.ts +78 -0
- package/src/types/config.ts +239 -0
- package/src/types/country-flag-icons.d.ts +6 -0
- package/src/types/verification.ts +79 -0
- package/src/utils/platform.ts +23 -0
- package/src/utils/tokens.ts +10 -0
- 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
|
+
}
|