@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.
- package/dist/index.d.ts +125 -0
- package/dist/index.esm.js +1198 -0
- package/dist/index.esm.js.map +1 -0
- package/dist/index.js +1200 -0
- package/dist/index.js.map +1 -0
- package/package.json +51 -0
- package/src/KernaqVerify.tsx +329 -0
- package/src/hooks/useCamera.ts +73 -0
- package/src/hooks/usePermissions.ts +67 -0
- package/src/index.ts +32 -0
- package/src/locale.ts +33 -0
- package/src/steps/DocumentStep.tsx +141 -0
- package/src/steps/IntroStep.tsx +82 -0
- package/src/steps/LivenessStep.tsx +197 -0
- package/src/steps/ProcessingStep.tsx +66 -0
- package/src/steps/ResultStep.tsx +108 -0
- package/src/steps/SelfieStep.tsx +138 -0
- package/src/theme.ts +24 -0
- package/src/types.ts +124 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'
|
|
3
|
+
import type { ResolvedTheme } from '../theme'
|
|
4
|
+
import type { VerifyLocale, VerifyResult, VerifyVerdict } from '../types'
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
theme: ResolvedTheme
|
|
8
|
+
locale: VerifyLocale
|
|
9
|
+
result: VerifyResult | null
|
|
10
|
+
error: string | null
|
|
11
|
+
onRetry: () => void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const ICONS: Record<VerifyVerdict, string> = {
|
|
15
|
+
pass: '✅',
|
|
16
|
+
fail: '❌',
|
|
17
|
+
review: '⏳',
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const ICON_BG: Record<VerifyVerdict, string> = {
|
|
21
|
+
pass: '#d1fae5',
|
|
22
|
+
fail: '#fee2e2',
|
|
23
|
+
review: '#fef3c7',
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function ResultStep({ theme, locale, result, error, onRetry }: Props) {
|
|
27
|
+
const s = styles(theme)
|
|
28
|
+
|
|
29
|
+
if (!result) {
|
|
30
|
+
return (
|
|
31
|
+
<View style={s.container}>
|
|
32
|
+
<View style={[s.iconWrap, { backgroundColor: '#fee2e2' }]}>
|
|
33
|
+
<Text style={s.icon}>❌</Text>
|
|
34
|
+
</View>
|
|
35
|
+
<Text style={s.title}>{locale.result_fail_title}</Text>
|
|
36
|
+
<Text style={s.body}>{error ?? locale.result_fail_body}</Text>
|
|
37
|
+
<TouchableOpacity style={s.btn} onPress={onRetry} activeOpacity={0.85}>
|
|
38
|
+
<Text style={s.btnText}>{locale.result_retry_btn}</Text>
|
|
39
|
+
</TouchableOpacity>
|
|
40
|
+
</View>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const titles: Record<VerifyVerdict, string> = {
|
|
45
|
+
pass: locale.result_pass_title,
|
|
46
|
+
fail: locale.result_fail_title,
|
|
47
|
+
review: locale.result_review_title,
|
|
48
|
+
}
|
|
49
|
+
const bodies: Record<VerifyVerdict, string> = {
|
|
50
|
+
pass: locale.result_pass_body,
|
|
51
|
+
fail: locale.result_fail_body,
|
|
52
|
+
review: locale.result_review_body,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<View style={s.container}>
|
|
57
|
+
<View style={[s.iconWrap, { backgroundColor: ICON_BG[result.verdict] }]}>
|
|
58
|
+
<Text style={s.icon}>{ICONS[result.verdict]}</Text>
|
|
59
|
+
</View>
|
|
60
|
+
<Text style={s.title}>{titles[result.verdict]}</Text>
|
|
61
|
+
<Text style={s.body}>{bodies[result.verdict]}</Text>
|
|
62
|
+
{result.verdict === 'fail' && (
|
|
63
|
+
<TouchableOpacity style={s.btn} onPress={onRetry} activeOpacity={0.85}>
|
|
64
|
+
<Text style={s.btnText}>{locale.result_retry_btn}</Text>
|
|
65
|
+
</TouchableOpacity>
|
|
66
|
+
)}
|
|
67
|
+
</View>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const styles = (t: ResolvedTheme) =>
|
|
72
|
+
StyleSheet.create({
|
|
73
|
+
container: {
|
|
74
|
+
padding: 40,
|
|
75
|
+
alignItems: 'center',
|
|
76
|
+
gap: 12,
|
|
77
|
+
},
|
|
78
|
+
iconWrap: {
|
|
79
|
+
width: 76,
|
|
80
|
+
height: 76,
|
|
81
|
+
borderRadius: 38,
|
|
82
|
+
alignItems: 'center',
|
|
83
|
+
justifyContent: 'center',
|
|
84
|
+
marginBottom: 8,
|
|
85
|
+
},
|
|
86
|
+
icon: { fontSize: 34 },
|
|
87
|
+
title: {
|
|
88
|
+
fontSize: 20,
|
|
89
|
+
fontWeight: '700',
|
|
90
|
+
color: t.text,
|
|
91
|
+
textAlign: 'center',
|
|
92
|
+
},
|
|
93
|
+
body: {
|
|
94
|
+
fontSize: 14,
|
|
95
|
+
color: t.subtext,
|
|
96
|
+
textAlign: 'center',
|
|
97
|
+
lineHeight: 21,
|
|
98
|
+
},
|
|
99
|
+
btn: {
|
|
100
|
+
marginTop: 8,
|
|
101
|
+
backgroundColor: t.accent,
|
|
102
|
+
borderRadius: t.radius,
|
|
103
|
+
paddingVertical: 14,
|
|
104
|
+
paddingHorizontal: 32,
|
|
105
|
+
alignItems: 'center',
|
|
106
|
+
},
|
|
107
|
+
btnText: { color: '#fff', fontSize: 15, fontWeight: '600' },
|
|
108
|
+
})
|
|
@@ -0,0 +1,138 @@
|
|
|
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
|
+
onBack: () => void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function SelfieStep({ theme, locale, onCapture, onBack }: Props) {
|
|
23
|
+
const { cameraRef, device, capturePhoto } = useCamera('front')
|
|
24
|
+
const [preview, setPreview] = React.useState<string | null>(null)
|
|
25
|
+
const [capturing, setCapturing] = React.useState(false)
|
|
26
|
+
const s = styles(theme)
|
|
27
|
+
|
|
28
|
+
const handleCapture = async () => {
|
|
29
|
+
setCapturing(true)
|
|
30
|
+
const result = await capturePhoto()
|
|
31
|
+
setCapturing(false)
|
|
32
|
+
if (result) setPreview(result.uri)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!device) return <ActivityIndicator color={theme.accent} style={{ margin: 40 }} />
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<View style={s.container}>
|
|
39
|
+
<Text style={s.title}>{locale.selfie_title}</Text>
|
|
40
|
+
<Text style={s.subtitle}>{locale.selfie_instruction}</Text>
|
|
41
|
+
|
|
42
|
+
{preview ? (
|
|
43
|
+
<>
|
|
44
|
+
<Image source={{ uri: preview }} style={s.preview} resizeMode="cover" />
|
|
45
|
+
<TouchableOpacity style={s.btnPrimary} onPress={() => onCapture(preview)} activeOpacity={0.85}>
|
|
46
|
+
<Text style={s.btnText}>{locale.selfie_next_btn}</Text>
|
|
47
|
+
</TouchableOpacity>
|
|
48
|
+
<TouchableOpacity style={s.btnSecondary} onPress={() => setPreview(null)} activeOpacity={0.75}>
|
|
49
|
+
<Text style={s.btnSecondaryText}>{locale.selfie_retake_btn}</Text>
|
|
50
|
+
</TouchableOpacity>
|
|
51
|
+
</>
|
|
52
|
+
) : (
|
|
53
|
+
<>
|
|
54
|
+
<View style={s.viewport}>
|
|
55
|
+
<Camera
|
|
56
|
+
ref={cameraRef}
|
|
57
|
+
style={StyleSheet.absoluteFill}
|
|
58
|
+
device={device}
|
|
59
|
+
isActive={true}
|
|
60
|
+
photo={true}
|
|
61
|
+
/>
|
|
62
|
+
{/* Oval face guide */}
|
|
63
|
+
<View style={s.ovalGuide} pointerEvents="none" />
|
|
64
|
+
</View>
|
|
65
|
+
<TouchableOpacity
|
|
66
|
+
style={[s.btnPrimary, capturing && s.btnDisabled]}
|
|
67
|
+
onPress={handleCapture}
|
|
68
|
+
disabled={capturing}
|
|
69
|
+
activeOpacity={0.85}
|
|
70
|
+
>
|
|
71
|
+
{capturing
|
|
72
|
+
? <ActivityIndicator color="#fff" />
|
|
73
|
+
: <Text style={s.btnText}>{locale.selfie_capture_btn}</Text>
|
|
74
|
+
}
|
|
75
|
+
</TouchableOpacity>
|
|
76
|
+
<TouchableOpacity style={s.btnSecondary} onPress={onBack} activeOpacity={0.75}>
|
|
77
|
+
<Text style={s.btnSecondaryText}>{locale.doc_retake_btn}</Text>
|
|
78
|
+
</TouchableOpacity>
|
|
79
|
+
</>
|
|
80
|
+
)}
|
|
81
|
+
</View>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const styles = (t: ResolvedTheme) =>
|
|
86
|
+
StyleSheet.create({
|
|
87
|
+
container: { padding: 24 },
|
|
88
|
+
title: { fontSize: 20, fontWeight: '700', color: t.text, marginBottom: 8 },
|
|
89
|
+
subtitle: { fontSize: 14, color: t.subtext, lineHeight: 20, marginBottom: 16 },
|
|
90
|
+
viewport: {
|
|
91
|
+
width: '100%',
|
|
92
|
+
aspectRatio: 3 / 4,
|
|
93
|
+
borderRadius: t.radius,
|
|
94
|
+
overflow: 'hidden',
|
|
95
|
+
backgroundColor: '#000',
|
|
96
|
+
marginBottom: 16,
|
|
97
|
+
position: 'relative',
|
|
98
|
+
alignSelf: 'center',
|
|
99
|
+
maxWidth: 280,
|
|
100
|
+
},
|
|
101
|
+
ovalGuide: {
|
|
102
|
+
position: 'absolute',
|
|
103
|
+
top: '8%',
|
|
104
|
+
left: '12%',
|
|
105
|
+
right: '12%',
|
|
106
|
+
bottom: '8%',
|
|
107
|
+
borderWidth: 2,
|
|
108
|
+
borderColor: 'rgba(255,255,255,0.7)',
|
|
109
|
+
borderRadius: 999,
|
|
110
|
+
},
|
|
111
|
+
preview: {
|
|
112
|
+
width: 240,
|
|
113
|
+
height: 320,
|
|
114
|
+
borderRadius: t.radius,
|
|
115
|
+
marginBottom: 16,
|
|
116
|
+
alignSelf: 'center',
|
|
117
|
+
borderWidth: 1,
|
|
118
|
+
borderColor: t.border,
|
|
119
|
+
},
|
|
120
|
+
btnPrimary: {
|
|
121
|
+
backgroundColor: t.accent,
|
|
122
|
+
borderRadius: t.radius,
|
|
123
|
+
paddingVertical: 14,
|
|
124
|
+
alignItems: 'center',
|
|
125
|
+
marginBottom: 10,
|
|
126
|
+
},
|
|
127
|
+
btnSecondary: {
|
|
128
|
+
backgroundColor: t.card,
|
|
129
|
+
borderRadius: t.radius,
|
|
130
|
+
paddingVertical: 14,
|
|
131
|
+
alignItems: 'center',
|
|
132
|
+
borderWidth: 1,
|
|
133
|
+
borderColor: t.border,
|
|
134
|
+
},
|
|
135
|
+
btnDisabled: { opacity: 0.6 },
|
|
136
|
+
btnText: { color: '#fff', fontSize: 15, fontWeight: '600' },
|
|
137
|
+
btnSecondaryText: { color: t.text, fontSize: 15, fontWeight: '600' },
|
|
138
|
+
})
|
package/src/theme.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { VerifyTheme } from './types'
|
|
2
|
+
|
|
3
|
+
export interface ResolvedTheme {
|
|
4
|
+
accent: string
|
|
5
|
+
bg: string
|
|
6
|
+
text: string
|
|
7
|
+
subtext: string
|
|
8
|
+
border: string
|
|
9
|
+
card: string
|
|
10
|
+
radius: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function resolveTheme(theme?: VerifyTheme): ResolvedTheme {
|
|
14
|
+
const dark = theme?.mode === 'dark'
|
|
15
|
+
return {
|
|
16
|
+
accent: theme?.accentColor ?? '#111827',
|
|
17
|
+
bg: theme?.backgroundColor ?? (dark ? '#1a1a2e' : '#ffffff'),
|
|
18
|
+
text: theme?.textColor ?? (dark ? '#f9fafb' : '#111827'),
|
|
19
|
+
subtext: theme?.subtextColor ?? (dark ? '#9ca3af' : '#6b7280'),
|
|
20
|
+
border: theme?.borderColor ?? (dark ? '#374151' : '#e5e7eb'),
|
|
21
|
+
card: theme?.cardColor ?? (dark ? '#111827' : '#f9fafb'),
|
|
22
|
+
radius: theme?.borderRadius ?? 12,
|
|
23
|
+
}
|
|
24
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @kernaq/verify-react-native — public types
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type VerifyStep =
|
|
6
|
+
| 'intro'
|
|
7
|
+
| 'document'
|
|
8
|
+
| 'selfie'
|
|
9
|
+
| 'liveness'
|
|
10
|
+
| 'processing'
|
|
11
|
+
| 'result'
|
|
12
|
+
|
|
13
|
+
export type DocumentType =
|
|
14
|
+
| 'national_id'
|
|
15
|
+
| 'passport'
|
|
16
|
+
| 'driver_license'
|
|
17
|
+
| 'alien_card'
|
|
18
|
+
|
|
19
|
+
export type VerifyVerdict = 'pass' | 'fail' | 'review'
|
|
20
|
+
|
|
21
|
+
export interface VerifyResult {
|
|
22
|
+
verificationId: string
|
|
23
|
+
reference: string
|
|
24
|
+
verdict: VerifyVerdict
|
|
25
|
+
score: number
|
|
26
|
+
faceMatch: boolean
|
|
27
|
+
isLive: boolean
|
|
28
|
+
documentFields?: Record<string, string>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface VerifyTheme {
|
|
32
|
+
/** Primary accent colour. Default: #111827 */
|
|
33
|
+
accentColor?: string
|
|
34
|
+
/** Modal background. Default: #ffffff */
|
|
35
|
+
backgroundColor?: string
|
|
36
|
+
/** Primary text colour. Default: #111827 */
|
|
37
|
+
textColor?: string
|
|
38
|
+
/** Subtle text colour. Default: #6b7280 */
|
|
39
|
+
subtextColor?: string
|
|
40
|
+
/** Border/divider colour. Default: #e5e7eb */
|
|
41
|
+
borderColor?: string
|
|
42
|
+
/** Card/surface colour. Default: #f9fafb */
|
|
43
|
+
cardColor?: string
|
|
44
|
+
/** Border radius for buttons and cards. Default: 12 */
|
|
45
|
+
borderRadius?: number
|
|
46
|
+
/** 'light' | 'dark' — overrides bg/text when set */
|
|
47
|
+
mode?: 'light' | 'dark'
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface VerifyLocale {
|
|
51
|
+
intro_title: string
|
|
52
|
+
intro_body: string
|
|
53
|
+
intro_cta: string
|
|
54
|
+
doc_title: string
|
|
55
|
+
doc_instruction: string
|
|
56
|
+
doc_capture_btn: string
|
|
57
|
+
doc_retake_btn: string
|
|
58
|
+
doc_next_btn: string
|
|
59
|
+
selfie_title: string
|
|
60
|
+
selfie_instruction: string
|
|
61
|
+
selfie_capture_btn: string
|
|
62
|
+
selfie_retake_btn: string
|
|
63
|
+
selfie_next_btn: string
|
|
64
|
+
liveness_title: string
|
|
65
|
+
liveness_instruction: string
|
|
66
|
+
liveness_start_btn: string
|
|
67
|
+
liveness_recording: string
|
|
68
|
+
processing_title: string
|
|
69
|
+
processing_subtitle: string
|
|
70
|
+
result_pass_title: string
|
|
71
|
+
result_pass_body: string
|
|
72
|
+
result_fail_title: string
|
|
73
|
+
result_fail_body: string
|
|
74
|
+
result_review_title: string
|
|
75
|
+
result_review_body: string
|
|
76
|
+
result_retry_btn: string
|
|
77
|
+
error_camera_denied: string
|
|
78
|
+
error_quality: string
|
|
79
|
+
error_network: string
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface VerifyConfig {
|
|
83
|
+
/**
|
|
84
|
+
* Your Kernaq publishable API key (k_test_... or k_live_...).
|
|
85
|
+
* For production use a backend proxy and pass backendUrl instead.
|
|
86
|
+
*/
|
|
87
|
+
apiKey?: string
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* URL of your backend proxy that forwards to the Kernaq Identity API.
|
|
91
|
+
* POST /verify with multipart/form-data.
|
|
92
|
+
*/
|
|
93
|
+
backendUrl?: string
|
|
94
|
+
|
|
95
|
+
/** Document types the user can select. Default: ['national_id', 'passport'] */
|
|
96
|
+
documentTypes?: DocumentType[]
|
|
97
|
+
|
|
98
|
+
/** Country code (ISO 3166-1 alpha-3). Default: 'KEN' */
|
|
99
|
+
country?: string
|
|
100
|
+
|
|
101
|
+
/** Your internal reference ID for this user/session */
|
|
102
|
+
reference?: string
|
|
103
|
+
|
|
104
|
+
/** Use sandbox mode — no billing, test responses */
|
|
105
|
+
sandbox?: boolean
|
|
106
|
+
|
|
107
|
+
/** Liveness recording duration in ms. Default: 3000 */
|
|
108
|
+
livenessDuration?: number
|
|
109
|
+
|
|
110
|
+
/** Theming */
|
|
111
|
+
theme?: VerifyTheme
|
|
112
|
+
|
|
113
|
+
/** Override any displayed string */
|
|
114
|
+
locale?: Partial<VerifyLocale>
|
|
115
|
+
|
|
116
|
+
/** Called when the user dismisses without completing */
|
|
117
|
+
onCancel?: () => void
|
|
118
|
+
|
|
119
|
+
/** Called when verification is complete */
|
|
120
|
+
onComplete?: (result: VerifyResult) => void
|
|
121
|
+
|
|
122
|
+
/** Called on unrecoverable error */
|
|
123
|
+
onError?: (error: { code: string; message: string }) => void
|
|
124
|
+
}
|