@kernaq/verify-react-native 1.0.2

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.
@@ -0,0 +1,67 @@
1
+ import { useState, useCallback } from 'react'
2
+ import { Platform, Linking, Alert } from 'react-native'
3
+ import {
4
+ check,
5
+ request,
6
+ PERMISSIONS,
7
+ RESULTS,
8
+ type Permission,
9
+ } from 'react-native-permissions'
10
+
11
+ export type PermissionStatus = 'unknown' | 'granted' | 'denied' | 'blocked'
12
+
13
+ export function usePermissions() {
14
+ const [status, setStatus] = useState<PermissionStatus>('unknown')
15
+
16
+ const cameraPermission: Permission =
17
+ Platform.OS === 'ios'
18
+ ? PERMISSIONS.IOS.CAMERA
19
+ : PERMISSIONS.ANDROID.CAMERA
20
+
21
+ const micPermission: Permission =
22
+ Platform.OS === 'ios'
23
+ ? PERMISSIONS.IOS.MICROPHONE
24
+ : PERMISSIONS.ANDROID.RECORD_AUDIO
25
+
26
+ const requestPermissions = useCallback(async (): Promise<boolean> => {
27
+ try {
28
+ const camResult = await check(cameraPermission)
29
+
30
+ if (camResult === RESULTS.GRANTED) {
31
+ // Also request mic for liveness video
32
+ await request(micPermission)
33
+ setStatus('granted')
34
+ return true
35
+ }
36
+
37
+ if (camResult === RESULTS.BLOCKED) {
38
+ setStatus('blocked')
39
+ Alert.alert(
40
+ 'Camera Permission Required',
41
+ 'Camera access is blocked. Please enable it in your device settings.',
42
+ [
43
+ { text: 'Cancel', style: 'cancel' },
44
+ { text: 'Open Settings', onPress: () => Linking.openSettings() },
45
+ ]
46
+ )
47
+ return false
48
+ }
49
+
50
+ // Request camera
51
+ const result = await request(cameraPermission)
52
+ if (result === RESULTS.GRANTED) {
53
+ await request(micPermission)
54
+ setStatus('granted')
55
+ return true
56
+ }
57
+
58
+ setStatus('denied')
59
+ return false
60
+ } catch {
61
+ setStatus('denied')
62
+ return false
63
+ }
64
+ }, [cameraPermission, micPermission])
65
+
66
+ return { status, requestPermissions }
67
+ }
package/src/index.ts ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * @kernaq/verify-react-native
3
+ *
4
+ * Drop-in KYC verification UI for React Native.
5
+ * Works with bare React Native and Expo (requires expo prebuild).
6
+ *
7
+ * Usage:
8
+ * import { KernaqVerify } from '@kernaq/verify-react-native'
9
+ *
10
+ * <KernaqVerify
11
+ * visible={showVerify}
12
+ * apiKey="k_test_..."
13
+ * country="KEN"
14
+ * onComplete={(result) => console.log(result.verdict)}
15
+ * onCancel={() => setShowVerify(false)}
16
+ * />
17
+ *
18
+ * Peer dependencies:
19
+ * react-native-vision-camera >= 4
20
+ * react-native-permissions >= 4
21
+ */
22
+
23
+ export { KernaqVerify } from './KernaqVerify'
24
+ export type {
25
+ VerifyConfig,
26
+ VerifyResult,
27
+ VerifyStep,
28
+ VerifyVerdict,
29
+ VerifyTheme,
30
+ VerifyLocale,
31
+ DocumentType,
32
+ } from './types'
package/src/locale.ts ADDED
@@ -0,0 +1,33 @@
1
+ import type { VerifyLocale } from './types'
2
+
3
+ export const DEFAULT_LOCALE: VerifyLocale = {
4
+ intro_title: 'Verify your identity',
5
+ intro_body: "This takes about 60 seconds. You'll need your ID document and a clear view of your face.",
6
+ intro_cta: 'Get started',
7
+ doc_title: 'Scan your document',
8
+ doc_instruction: 'Hold your ID flat inside the frame. Make sure it\'s well-lit and all four corners are visible.',
9
+ doc_capture_btn: 'Capture',
10
+ doc_retake_btn: 'Retake',
11
+ doc_next_btn: 'Looks good',
12
+ selfie_title: 'Take a selfie',
13
+ selfie_instruction: 'Look straight at the camera. Keep your face inside the oval.',
14
+ selfie_capture_btn: 'Take selfie',
15
+ selfie_retake_btn: 'Retake',
16
+ selfie_next_btn: 'Looks good',
17
+ liveness_title: 'Liveness check',
18
+ liveness_instruction: 'Press start, then slowly turn your head left and right.',
19
+ liveness_start_btn: 'Start recording',
20
+ liveness_recording: 'Recording…',
21
+ processing_title: 'Verifying your identity',
22
+ processing_subtitle: 'This usually takes a few seconds.',
23
+ result_pass_title: 'Verification passed',
24
+ result_pass_body: 'Your identity has been verified successfully.',
25
+ result_fail_title: 'Verification failed',
26
+ result_fail_body: 'We were unable to verify your identity. Please try again.',
27
+ result_review_title: 'Under review',
28
+ result_review_body: 'Your submission is being reviewed. This usually takes a few minutes.',
29
+ result_retry_btn: 'Try again',
30
+ error_camera_denied: 'Camera access denied. Please allow camera permissions in settings.',
31
+ error_quality: 'Image quality too low. Try better lighting or hold the camera steady.',
32
+ error_network: 'Network error. Please check your connection and try again.',
33
+ }
@@ -0,0 +1,141 @@
1
+ import React from 'react'
2
+ import {
3
+ View,
4
+ Text,
5
+ TouchableOpacity,
6
+ Image,
7
+ StyleSheet,
8
+ ActivityIndicator,
9
+ } from 'react-native'
10
+ import { Camera } from 'react-native-vision-camera'
11
+ import { useCamera } from '../hooks/useCamera'
12
+ import type { ResolvedTheme } from '../theme'
13
+ import type { VerifyLocale } from '../types'
14
+
15
+ interface Props {
16
+ theme: ResolvedTheme
17
+ locale: VerifyLocale
18
+ onCapture: (uri: string) => void
19
+ }
20
+
21
+ export function DocumentStep({ theme, locale, onCapture }: Props) {
22
+ const { cameraRef, device, capturePhoto } = useCamera('back')
23
+ const [preview, setPreview] = React.useState<string | null>(null)
24
+ const [capturing, setCapturing] = React.useState(false)
25
+ const s = styles(theme)
26
+
27
+ const handleCapture = async () => {
28
+ setCapturing(true)
29
+ const result = await capturePhoto()
30
+ setCapturing(false)
31
+ if (result) setPreview(result.uri)
32
+ }
33
+
34
+ const handleConfirm = () => {
35
+ if (preview) onCapture(preview)
36
+ }
37
+
38
+ if (!device) {
39
+ return (
40
+ <View style={s.container}>
41
+ <ActivityIndicator color={theme.accent} />
42
+ </View>
43
+ )
44
+ }
45
+
46
+ return (
47
+ <View style={s.container}>
48
+ <Text style={s.title}>{locale.doc_title}</Text>
49
+ <Text style={s.subtitle}>{locale.doc_instruction}</Text>
50
+
51
+ {preview ? (
52
+ <>
53
+ <Image source={{ uri: preview }} style={s.preview} resizeMode="cover" />
54
+ <TouchableOpacity style={s.btnPrimary} onPress={handleConfirm} activeOpacity={0.85}>
55
+ <Text style={s.btnText}>{locale.doc_next_btn}</Text>
56
+ </TouchableOpacity>
57
+ <TouchableOpacity style={s.btnSecondary} onPress={() => setPreview(null)} activeOpacity={0.75}>
58
+ <Text style={s.btnSecondaryText}>{locale.doc_retake_btn}</Text>
59
+ </TouchableOpacity>
60
+ </>
61
+ ) : (
62
+ <>
63
+ <View style={s.viewport}>
64
+ <Camera
65
+ ref={cameraRef}
66
+ style={StyleSheet.absoluteFill}
67
+ device={device}
68
+ isActive={true}
69
+ photo={true}
70
+ />
71
+ {/* Document frame guide */}
72
+ <View style={s.frameGuide} pointerEvents="none" />
73
+ </View>
74
+ <TouchableOpacity
75
+ style={[s.btnPrimary, capturing && s.btnDisabled]}
76
+ onPress={handleCapture}
77
+ disabled={capturing}
78
+ activeOpacity={0.85}
79
+ >
80
+ {capturing
81
+ ? <ActivityIndicator color="#fff" />
82
+ : <Text style={s.btnText}>{locale.doc_capture_btn}</Text>
83
+ }
84
+ </TouchableOpacity>
85
+ </>
86
+ )}
87
+ </View>
88
+ )
89
+ }
90
+
91
+ const styles = (t: ResolvedTheme) =>
92
+ StyleSheet.create({
93
+ container: { padding: 24 },
94
+ title: { fontSize: 20, fontWeight: '700', color: t.text, marginBottom: 8 },
95
+ subtitle: { fontSize: 14, color: t.subtext, lineHeight: 20, marginBottom: 16 },
96
+ viewport: {
97
+ width: '100%',
98
+ aspectRatio: 4 / 3,
99
+ borderRadius: t.radius,
100
+ overflow: 'hidden',
101
+ backgroundColor: '#000',
102
+ marginBottom: 16,
103
+ position: 'relative',
104
+ },
105
+ frameGuide: {
106
+ position: 'absolute',
107
+ top: '8%',
108
+ left: '5%',
109
+ right: '5%',
110
+ bottom: '8%',
111
+ borderWidth: 2,
112
+ borderColor: 'rgba(255,255,255,0.7)',
113
+ borderRadius: 8,
114
+ },
115
+ preview: {
116
+ width: '100%',
117
+ aspectRatio: 4 / 3,
118
+ borderRadius: t.radius,
119
+ marginBottom: 16,
120
+ borderWidth: 1,
121
+ borderColor: t.border,
122
+ },
123
+ btnPrimary: {
124
+ backgroundColor: t.accent,
125
+ borderRadius: t.radius,
126
+ paddingVertical: 14,
127
+ alignItems: 'center',
128
+ marginBottom: 10,
129
+ },
130
+ btnSecondary: {
131
+ backgroundColor: t.card,
132
+ borderRadius: t.radius,
133
+ paddingVertical: 14,
134
+ alignItems: 'center',
135
+ borderWidth: 1,
136
+ borderColor: t.border,
137
+ },
138
+ btnDisabled: { opacity: 0.6 },
139
+ btnText: { color: '#fff', fontSize: 15, fontWeight: '600' },
140
+ btnSecondaryText: { color: t.text, fontSize: 15, fontWeight: '600' },
141
+ })
@@ -0,0 +1,82 @@
1
+ import React from 'react'
2
+ import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'
3
+ import type { ResolvedTheme } from '../theme'
4
+ import type { VerifyLocale } from '../types'
5
+
6
+ interface Props {
7
+ theme: ResolvedTheme
8
+ locale: VerifyLocale
9
+ onStart: () => void
10
+ }
11
+
12
+ export function IntroStep({ theme, locale, onStart }: Props) {
13
+ const s = styles(theme)
14
+ return (
15
+ <View style={s.container}>
16
+ <View style={s.steps}>
17
+ {[
18
+ { icon: '🪪', label: 'Document' },
19
+ { icon: '🤳', label: 'Selfie' },
20
+ { icon: '👁️', label: 'Liveness' },
21
+ ].map((item) => (
22
+ <View key={item.label} style={s.step}>
23
+ <View style={s.stepIcon}>
24
+ <Text style={s.stepEmoji}>{item.icon}</Text>
25
+ </View>
26
+ <Text style={s.stepLabel}>{item.label}</Text>
27
+ </View>
28
+ ))}
29
+ </View>
30
+
31
+ <Text style={s.title}>{locale.intro_title}</Text>
32
+ <Text style={s.body}>{locale.intro_body}</Text>
33
+
34
+ <TouchableOpacity style={s.btn} onPress={onStart} activeOpacity={0.85}>
35
+ <Text style={s.btnText}>{locale.intro_cta}</Text>
36
+ </TouchableOpacity>
37
+ </View>
38
+ )
39
+ }
40
+
41
+ const styles = (t: ResolvedTheme) =>
42
+ StyleSheet.create({
43
+ container: { padding: 24 },
44
+ steps: {
45
+ flexDirection: 'row',
46
+ justifyContent: 'center',
47
+ gap: 20,
48
+ marginBottom: 28,
49
+ },
50
+ step: { alignItems: 'center', gap: 8 },
51
+ stepIcon: {
52
+ width: 56,
53
+ height: 56,
54
+ borderRadius: 14,
55
+ backgroundColor: t.card,
56
+ borderWidth: 1,
57
+ borderColor: t.border,
58
+ alignItems: 'center',
59
+ justifyContent: 'center',
60
+ },
61
+ stepEmoji: { fontSize: 24 },
62
+ stepLabel: { fontSize: 12, color: t.subtext },
63
+ title: {
64
+ fontSize: 22,
65
+ fontWeight: '700',
66
+ color: t.text,
67
+ marginBottom: 10,
68
+ },
69
+ body: {
70
+ fontSize: 14,
71
+ color: t.subtext,
72
+ lineHeight: 21,
73
+ marginBottom: 28,
74
+ },
75
+ btn: {
76
+ backgroundColor: t.accent,
77
+ borderRadius: t.radius,
78
+ paddingVertical: 14,
79
+ alignItems: 'center',
80
+ },
81
+ btnText: { color: '#fff', fontSize: 15, fontWeight: '600' },
82
+ })
@@ -0,0 +1,197 @@
1
+ import React, { useState, useEffect, useRef } from 'react'
2
+ import {
3
+ View,
4
+ Text,
5
+ TouchableOpacity,
6
+ StyleSheet,
7
+ ActivityIndicator,
8
+ Animated,
9
+ } from 'react-native'
10
+ import { Camera } from 'react-native-vision-camera'
11
+ import { useCamera } from '../hooks/useCamera'
12
+ import type { ResolvedTheme } from '../theme'
13
+ import type { VerifyLocale } from '../types'
14
+
15
+ interface Props {
16
+ theme: ResolvedTheme
17
+ locale: VerifyLocale
18
+ duration: number // ms
19
+ onCapture: (uri: string) => void
20
+ onBack: () => void
21
+ }
22
+
23
+ export function LivenessStep({ theme, locale, duration, onCapture, onBack }: Props) {
24
+ const { cameraRef, device, startRecording, stopRecording } = useCamera('front')
25
+ const [recording, setRecording] = useState(false)
26
+ const [countdown, setCountdown] = useState(Math.ceil(duration / 1000))
27
+ const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
28
+ const pulseAnim = useRef(new Animated.Value(1)).current
29
+ const s = styles(theme)
30
+
31
+ // Pulse animation for recording indicator
32
+ useEffect(() => {
33
+ if (recording) {
34
+ Animated.loop(
35
+ Animated.sequence([
36
+ Animated.timing(pulseAnim, { toValue: 0.3, duration: 600, useNativeDriver: true }),
37
+ Animated.timing(pulseAnim, { toValue: 1, duration: 600, useNativeDriver: true }),
38
+ ])
39
+ ).start()
40
+ } else {
41
+ pulseAnim.setValue(1)
42
+ }
43
+ }, [recording, pulseAnim])
44
+
45
+ const handleStart = () => {
46
+ if (!cameraRef.current) return
47
+ setRecording(true)
48
+ setCountdown(Math.ceil(duration / 1000))
49
+
50
+ startRecording(
51
+ (video) => {
52
+ setRecording(false)
53
+ onCapture(video.uri)
54
+ },
55
+ () => setRecording(false)
56
+ )
57
+
58
+ // Countdown tick
59
+ let remaining = duration / 1000
60
+ timerRef.current = setInterval(() => {
61
+ remaining -= 0.1
62
+ setCountdown(Math.max(0, Math.ceil(remaining)))
63
+ if (remaining <= 0) {
64
+ if (timerRef.current) clearInterval(timerRef.current)
65
+ stopRecording()
66
+ }
67
+ }, 100)
68
+ }
69
+
70
+ useEffect(() => {
71
+ return () => {
72
+ if (timerRef.current) clearInterval(timerRef.current)
73
+ }
74
+ }, [])
75
+
76
+ if (!device) return <ActivityIndicator color={theme.accent} style={{ margin: 40 }} />
77
+
78
+ return (
79
+ <View style={s.container}>
80
+ <Text style={s.title}>{locale.liveness_title}</Text>
81
+ <Text style={s.subtitle}>
82
+ {recording ? locale.liveness_recording : locale.liveness_instruction}
83
+ </Text>
84
+
85
+ <View style={s.viewport}>
86
+ <Camera
87
+ ref={cameraRef}
88
+ style={StyleSheet.absoluteFill}
89
+ device={device}
90
+ isActive={true}
91
+ video={true}
92
+ audio={true}
93
+ />
94
+ <View style={s.ovalGuide} pointerEvents="none" />
95
+
96
+ {recording && (
97
+ <>
98
+ <View style={s.recBadge}>
99
+ <Animated.View style={[s.recDot, { opacity: pulseAnim }]} />
100
+ <Text style={s.recText}>REC</Text>
101
+ </View>
102
+ <View style={s.countdownBadge}>
103
+ <Text style={s.countdownText}>{countdown}</Text>
104
+ </View>
105
+ </>
106
+ )}
107
+ </View>
108
+
109
+ {!recording && (
110
+ <>
111
+ <TouchableOpacity style={s.btnPrimary} onPress={handleStart} activeOpacity={0.85}>
112
+ <Text style={s.btnText}>{locale.liveness_start_btn}</Text>
113
+ </TouchableOpacity>
114
+ <TouchableOpacity style={s.btnSecondary} onPress={onBack} activeOpacity={0.75}>
115
+ <Text style={s.btnSecondaryText}>{locale.selfie_retake_btn}</Text>
116
+ </TouchableOpacity>
117
+ </>
118
+ )}
119
+ </View>
120
+ )
121
+ }
122
+
123
+ const styles = (t: ResolvedTheme) =>
124
+ StyleSheet.create({
125
+ container: { padding: 24 },
126
+ title: { fontSize: 20, fontWeight: '700', color: t.text, marginBottom: 8 },
127
+ subtitle: { fontSize: 14, color: t.subtext, lineHeight: 20, marginBottom: 16 },
128
+ viewport: {
129
+ width: '100%',
130
+ aspectRatio: 3 / 4,
131
+ borderRadius: t.radius,
132
+ overflow: 'hidden',
133
+ backgroundColor: '#000',
134
+ marginBottom: 16,
135
+ alignSelf: 'center',
136
+ maxWidth: 280,
137
+ position: 'relative',
138
+ },
139
+ ovalGuide: {
140
+ position: 'absolute',
141
+ top: '8%',
142
+ left: '12%',
143
+ right: '12%',
144
+ bottom: '8%',
145
+ borderWidth: 2,
146
+ borderColor: 'rgba(255,255,255,0.7)',
147
+ borderRadius: 999,
148
+ },
149
+ recBadge: {
150
+ position: 'absolute',
151
+ top: 10,
152
+ left: 10,
153
+ flexDirection: 'row',
154
+ alignItems: 'center',
155
+ backgroundColor: 'rgba(220,38,38,0.85)',
156
+ borderRadius: 999,
157
+ paddingHorizontal: 10,
158
+ paddingVertical: 4,
159
+ gap: 6,
160
+ },
161
+ recDot: {
162
+ width: 8,
163
+ height: 8,
164
+ borderRadius: 4,
165
+ backgroundColor: '#fff',
166
+ },
167
+ recText: { color: '#fff', fontSize: 12, fontWeight: '700' },
168
+ countdownBadge: {
169
+ position: 'absolute',
170
+ bottom: 12,
171
+ alignSelf: 'center',
172
+ width: 44,
173
+ height: 44,
174
+ borderRadius: 22,
175
+ backgroundColor: 'rgba(0,0,0,0.5)',
176
+ alignItems: 'center',
177
+ justifyContent: 'center',
178
+ },
179
+ countdownText: { color: '#fff', fontSize: 18, fontWeight: '700' },
180
+ btnPrimary: {
181
+ backgroundColor: t.accent,
182
+ borderRadius: t.radius,
183
+ paddingVertical: 14,
184
+ alignItems: 'center',
185
+ marginBottom: 10,
186
+ },
187
+ btnSecondary: {
188
+ backgroundColor: t.card,
189
+ borderRadius: t.radius,
190
+ paddingVertical: 14,
191
+ alignItems: 'center',
192
+ borderWidth: 1,
193
+ borderColor: t.border,
194
+ },
195
+ btnText: { color: '#fff', fontSize: 15, fontWeight: '600' },
196
+ btnSecondaryText: { color: t.text, fontSize: 15, fontWeight: '600' },
197
+ })
@@ -0,0 +1,66 @@
1
+ import React, { useEffect, useRef } from 'react'
2
+ import { View, Text, StyleSheet, Animated } from 'react-native'
3
+ import type { ResolvedTheme } from '../theme'
4
+ import type { VerifyLocale } from '../types'
5
+
6
+ interface Props {
7
+ theme: ResolvedTheme
8
+ locale: VerifyLocale
9
+ }
10
+
11
+ export function ProcessingStep({ theme, locale }: Props) {
12
+ const spinAnim = useRef(new Animated.Value(0)).current
13
+ const s = styles(theme)
14
+
15
+ useEffect(() => {
16
+ Animated.loop(
17
+ Animated.timing(spinAnim, {
18
+ toValue: 1,
19
+ duration: 800,
20
+ useNativeDriver: true,
21
+ })
22
+ ).start()
23
+ }, [spinAnim])
24
+
25
+ const spin = spinAnim.interpolate({
26
+ inputRange: [0, 1],
27
+ outputRange: ['0deg', '360deg'],
28
+ })
29
+
30
+ return (
31
+ <View style={s.container}>
32
+ <Animated.View style={[s.spinner, { transform: [{ rotate: spin }] }]} />
33
+ <Text style={s.title}>{locale.processing_title}</Text>
34
+ <Text style={s.subtitle}>{locale.processing_subtitle}</Text>
35
+ </View>
36
+ )
37
+ }
38
+
39
+ const styles = (t: ResolvedTheme) =>
40
+ StyleSheet.create({
41
+ container: {
42
+ padding: 48,
43
+ alignItems: 'center',
44
+ gap: 16,
45
+ },
46
+ spinner: {
47
+ width: 52,
48
+ height: 52,
49
+ borderRadius: 26,
50
+ borderWidth: 4,
51
+ borderColor: t.border,
52
+ borderTopColor: t.accent,
53
+ marginBottom: 8,
54
+ },
55
+ title: {
56
+ fontSize: 18,
57
+ fontWeight: '700',
58
+ color: t.text,
59
+ textAlign: 'center',
60
+ },
61
+ subtitle: {
62
+ fontSize: 14,
63
+ color: t.subtext,
64
+ textAlign: 'center',
65
+ },
66
+ })