@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,154 @@
|
|
|
1
|
+
import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { Animated, Easing, Platform, Pressable, StatusBar, View } from 'react-native';
|
|
3
|
+
import { initialWindowMetrics } from 'react-native-safe-area-context';
|
|
4
|
+
|
|
5
|
+
import { radius, spacing } from '../config/theme';
|
|
6
|
+
import { useTheme } from './runtime';
|
|
7
|
+
import { MyazaText } from './Typography';
|
|
8
|
+
import { Icon } from './Icon';
|
|
9
|
+
|
|
10
|
+
// Top toast — the RN SDK surfaces technical errors (upload/network failures) as a
|
|
11
|
+
// toast that slides down from the top of the modal, rather than an inline alert.
|
|
12
|
+
// Mounted once (by KycFlow) so any step can raise one via `useToast()`.
|
|
13
|
+
|
|
14
|
+
type ToastVariant = 'error' | 'success' | 'warning' | 'info';
|
|
15
|
+
|
|
16
|
+
export interface ToastInput {
|
|
17
|
+
message: string;
|
|
18
|
+
title?: string;
|
|
19
|
+
variant?: ToastVariant;
|
|
20
|
+
/** Auto-dismiss after this many ms (0 = sticky). Default 4500. */
|
|
21
|
+
duration?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface ToastApi {
|
|
25
|
+
show: (toast: ToastInput) => void;
|
|
26
|
+
hide: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const ToastContext = createContext<ToastApi>({ show: () => {}, hide: () => {} });
|
|
30
|
+
|
|
31
|
+
/** Raise/dismiss the top toast. No-op outside the SDK modal. */
|
|
32
|
+
export function useToast(): ToastApi {
|
|
33
|
+
return useContext(ToastContext);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function ToastProvider({ children }: { children: React.ReactNode }): React.ReactElement {
|
|
37
|
+
const [toast, setToast] = useState<(ToastInput & { id: number }) | null>(null);
|
|
38
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
39
|
+
const idRef = useRef(0);
|
|
40
|
+
|
|
41
|
+
const hide = useCallback(() => {
|
|
42
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
43
|
+
timerRef.current = null;
|
|
44
|
+
setToast(null);
|
|
45
|
+
}, []);
|
|
46
|
+
|
|
47
|
+
const show = useCallback((input: ToastInput) => {
|
|
48
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
49
|
+
idRef.current += 1;
|
|
50
|
+
setToast({ ...input, id: idRef.current });
|
|
51
|
+
const dur = input.duration ?? 4500;
|
|
52
|
+
if (dur > 0) {
|
|
53
|
+
timerRef.current = setTimeout(() => setToast(null), dur);
|
|
54
|
+
}
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
useEffect(() => () => {
|
|
58
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<ToastContext.Provider value={{ show, hide }}>
|
|
63
|
+
<View style={{ flex: 1 }}>
|
|
64
|
+
{children}
|
|
65
|
+
{toast ? <ToastView key={toast.id} {...toast} onDismiss={hide} /> : null}
|
|
66
|
+
</View>
|
|
67
|
+
</ToastContext.Provider>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function ToastView({
|
|
72
|
+
message,
|
|
73
|
+
title,
|
|
74
|
+
variant = 'error',
|
|
75
|
+
onDismiss,
|
|
76
|
+
}: ToastInput & { onDismiss: () => void }): React.ReactElement {
|
|
77
|
+
const { colors } = useTheme();
|
|
78
|
+
const anim = useRef(new Animated.Value(0)).current;
|
|
79
|
+
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
Animated.timing(anim, {
|
|
82
|
+
toValue: 1,
|
|
83
|
+
duration: 250,
|
|
84
|
+
easing: Easing.out(Easing.cubic),
|
|
85
|
+
useNativeDriver: true,
|
|
86
|
+
}).start();
|
|
87
|
+
}, [anim]);
|
|
88
|
+
|
|
89
|
+
// Slide in below the status bar / notch.
|
|
90
|
+
const topInset =
|
|
91
|
+
(initialWindowMetrics?.insets.top ?? (Platform.OS === 'android' ? StatusBar.currentHeight ?? 0 : 0)) +
|
|
92
|
+
spacing.sm;
|
|
93
|
+
|
|
94
|
+
const accent =
|
|
95
|
+
variant === 'success'
|
|
96
|
+
? colors.success
|
|
97
|
+
: variant === 'warning'
|
|
98
|
+
? colors.warning
|
|
99
|
+
: variant === 'info'
|
|
100
|
+
? colors.primary
|
|
101
|
+
: colors.error;
|
|
102
|
+
const iconName = variant === 'success' ? 'check' : 'alert';
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<Animated.View
|
|
106
|
+
pointerEvents="box-none"
|
|
107
|
+
style={{
|
|
108
|
+
position: 'absolute',
|
|
109
|
+
top: topInset,
|
|
110
|
+
left: spacing.md,
|
|
111
|
+
right: spacing.md,
|
|
112
|
+
opacity: anim,
|
|
113
|
+
transform: [{ translateY: anim.interpolate({ inputRange: [0, 1], outputRange: [-24, 0] }) }],
|
|
114
|
+
}}
|
|
115
|
+
>
|
|
116
|
+
<View
|
|
117
|
+
style={{
|
|
118
|
+
flexDirection: 'row',
|
|
119
|
+
alignItems: 'flex-start',
|
|
120
|
+
gap: spacing.sm,
|
|
121
|
+
backgroundColor: colors.background,
|
|
122
|
+
borderRadius: radius.md,
|
|
123
|
+
borderLeftWidth: 3,
|
|
124
|
+
borderLeftColor: accent,
|
|
125
|
+
paddingHorizontal: spacing.md,
|
|
126
|
+
paddingVertical: spacing.sm + 2,
|
|
127
|
+
// Subtle elevation so it reads as floating above the content.
|
|
128
|
+
shadowColor: '#000000',
|
|
129
|
+
shadowOpacity: 0.18,
|
|
130
|
+
shadowRadius: 12,
|
|
131
|
+
shadowOffset: { width: 0, height: 4 },
|
|
132
|
+
elevation: 6,
|
|
133
|
+
borderWidth: 1,
|
|
134
|
+
borderColor: colors.border,
|
|
135
|
+
}}
|
|
136
|
+
>
|
|
137
|
+
<Icon name={iconName} size={18} color={accent} />
|
|
138
|
+
<View style={{ flex: 1 }}>
|
|
139
|
+
{title ? (
|
|
140
|
+
<MyazaText variant="bodySmall" color={accent} style={{ fontWeight: '700' }}>
|
|
141
|
+
{title}
|
|
142
|
+
</MyazaText>
|
|
143
|
+
) : null}
|
|
144
|
+
<MyazaText variant="bodySmall" color={colors.textDark}>
|
|
145
|
+
{message}
|
|
146
|
+
</MyazaText>
|
|
147
|
+
</View>
|
|
148
|
+
<Pressable onPress={onDismiss} hitSlop={8} accessibilityRole="button" accessibilityLabel="Dismiss">
|
|
149
|
+
<Icon name="close" size={16} color={colors.textMuted} />
|
|
150
|
+
</Pressable>
|
|
151
|
+
</View>
|
|
152
|
+
</Animated.View>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { useKyc, useKycConfig } from './runtime';
|
|
2
|
+
|
|
3
|
+
// Resolves the org logo + company name shown in the persistent header brand.
|
|
4
|
+
// Mirrors the web SDK's `useBranding` and the Flutter logo-resolution logic:
|
|
5
|
+
// appearance.logo === 'default' → serverConfig.branding.logo (config endpoint)
|
|
6
|
+
// any other value → literal image URL
|
|
7
|
+
// The header brand is gated on a logo being present (no logo → no company name).
|
|
8
|
+
|
|
9
|
+
export interface ResolvedBranding {
|
|
10
|
+
logoUri: string | null;
|
|
11
|
+
companyName: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function useBranding(): ResolvedBranding {
|
|
15
|
+
const config = useKycConfig();
|
|
16
|
+
const branding = useKyc((s) => s.serverConfig.branding);
|
|
17
|
+
|
|
18
|
+
const appearanceLogo = config.appearance?.logo;
|
|
19
|
+
const logoUri =
|
|
20
|
+
appearanceLogo === 'default'
|
|
21
|
+
? branding?.logo ?? null
|
|
22
|
+
: appearanceLogo ?? null;
|
|
23
|
+
|
|
24
|
+
const companyName = config.appearance?.companyName ?? branding?.companyName ?? 'Myaza';
|
|
25
|
+
|
|
26
|
+
return { logoUri, companyName };
|
|
27
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { useCallback, useRef } from 'react';
|
|
2
|
+
import type { CameraVideoOutput, Recorder } from 'react-native-vision-camera';
|
|
3
|
+
|
|
4
|
+
import { MAX_VIDEO_DURATION_MS } from '../config/captureSettings';
|
|
5
|
+
|
|
6
|
+
// Shared best-effort video recorder for the VisionCamera v5 output model, used by
|
|
7
|
+
// both the liveness selfie video and the document-capture videos. Mirrors the
|
|
8
|
+
// Flutter SDK's "record while the camera is live, stop when the still is taken,
|
|
9
|
+
// upload best-effort" pattern.
|
|
10
|
+
//
|
|
11
|
+
// Three robustness concerns are handled here:
|
|
12
|
+
// • The video output connects to the camera session a moment after mount, so the
|
|
13
|
+
// first start can throw "VideoOutput is not yet connected" — we retry briefly.
|
|
14
|
+
// • The finished file path arrives via the `onRecordingFinished` callback (not the
|
|
15
|
+
// stopRecording promise), so we bridge the two and bound the wait.
|
|
16
|
+
// • A hard duration cap (MAX_VIDEO_DURATION_MS) auto-stops the recording so a long
|
|
17
|
+
// camera session can't bloat the file past the upload ceiling.
|
|
18
|
+
|
|
19
|
+
export interface VideoRecorderHandle {
|
|
20
|
+
/** Start recording (no-op if disabled or already recording). */
|
|
21
|
+
start: () => void;
|
|
22
|
+
/** Stop and resolve the recorded `file://` path, or null if nothing recorded. */
|
|
23
|
+
stop: () => Promise<string | null>;
|
|
24
|
+
/** True while a recording is in progress. */
|
|
25
|
+
isRecording: () => boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function useVideoRecorder(videoOutput: CameraVideoOutput, enabled: boolean): VideoRecorderHandle {
|
|
29
|
+
const recordingRef = useRef(false);
|
|
30
|
+
const recorderRef = useRef<Recorder | null>(null);
|
|
31
|
+
const pathRef = useRef<string | null>(null);
|
|
32
|
+
const finishedRef = useRef<Promise<string | null> | null>(null);
|
|
33
|
+
const resolveFinishedRef = useRef<((p: string | null) => void) | null>(null);
|
|
34
|
+
const stoppingRef = useRef(false);
|
|
35
|
+
const autoStopRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
36
|
+
|
|
37
|
+
const start = useCallback(() => {
|
|
38
|
+
if (recordingRef.current || !enabled) return;
|
|
39
|
+
recordingRef.current = true;
|
|
40
|
+
stoppingRef.current = false;
|
|
41
|
+
pathRef.current = null;
|
|
42
|
+
finishedRef.current = new Promise<string | null>((resolve) => {
|
|
43
|
+
resolveFinishedRef.current = resolve;
|
|
44
|
+
});
|
|
45
|
+
void (async () => {
|
|
46
|
+
const onFinished = (filePath: string) => {
|
|
47
|
+
pathRef.current = `file://${filePath}`;
|
|
48
|
+
resolveFinishedRef.current?.(pathRef.current);
|
|
49
|
+
resolveFinishedRef.current = null;
|
|
50
|
+
};
|
|
51
|
+
const onError = () => {
|
|
52
|
+
recordingRef.current = false;
|
|
53
|
+
recorderRef.current = null;
|
|
54
|
+
resolveFinishedRef.current?.(null);
|
|
55
|
+
resolveFinishedRef.current = null;
|
|
56
|
+
};
|
|
57
|
+
// Retry until the video output is connected to the session (~2.4s); the
|
|
58
|
+
// "not yet connected" error is transient. Any other error → bail (best-effort).
|
|
59
|
+
for (let attempt = 0; attempt < 12; attempt++) {
|
|
60
|
+
if (!recordingRef.current) return; // stopped before it ever started
|
|
61
|
+
try {
|
|
62
|
+
const recorder = await videoOutput.createRecorder({});
|
|
63
|
+
await recorder.startRecording(onFinished, onError);
|
|
64
|
+
recorderRef.current = recorder;
|
|
65
|
+
// Hard length cap — stop the recording (path arrives via onFinished); a
|
|
66
|
+
// later stop() will just await the already-resolved path.
|
|
67
|
+
autoStopRef.current = setTimeout(() => {
|
|
68
|
+
if (recorderRef.current && !stoppingRef.current) {
|
|
69
|
+
stoppingRef.current = true;
|
|
70
|
+
void recorderRef.current.stopRecording().catch(() => {});
|
|
71
|
+
}
|
|
72
|
+
}, MAX_VIDEO_DURATION_MS);
|
|
73
|
+
return; // recording is live
|
|
74
|
+
} catch (e) {
|
|
75
|
+
const msg = String(e);
|
|
76
|
+
if (msg.includes('not yet connected') || msg.includes('CameraSession')) {
|
|
77
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
recordingRef.current = false;
|
|
84
|
+
recorderRef.current = null;
|
|
85
|
+
resolveFinishedRef.current?.(null);
|
|
86
|
+
resolveFinishedRef.current = null;
|
|
87
|
+
})();
|
|
88
|
+
}, [videoOutput, enabled]);
|
|
89
|
+
|
|
90
|
+
const stop = useCallback(async (): Promise<string | null> => {
|
|
91
|
+
if (autoStopRef.current) {
|
|
92
|
+
clearTimeout(autoStopRef.current);
|
|
93
|
+
autoStopRef.current = null;
|
|
94
|
+
}
|
|
95
|
+
if (!recordingRef.current) return pathRef.current;
|
|
96
|
+
if (!recorderRef.current) {
|
|
97
|
+
// Still in the start-retry loop (output not connected yet) — cancel it.
|
|
98
|
+
recordingRef.current = false;
|
|
99
|
+
return pathRef.current;
|
|
100
|
+
}
|
|
101
|
+
if (!stoppingRef.current) {
|
|
102
|
+
stoppingRef.current = true;
|
|
103
|
+
try {
|
|
104
|
+
await recorderRef.current.stopRecording();
|
|
105
|
+
} catch {
|
|
106
|
+
/* already stopping / not started — fall through to the path wait */
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// The final path arrives via onRecordingFinished; bound the wait (~2s).
|
|
110
|
+
const path = await Promise.race([
|
|
111
|
+
finishedRef.current ?? Promise.resolve(pathRef.current),
|
|
112
|
+
new Promise<string | null>((resolve) => setTimeout(() => resolve(pathRef.current), 2000)),
|
|
113
|
+
]);
|
|
114
|
+
recordingRef.current = false;
|
|
115
|
+
recorderRef.current = null;
|
|
116
|
+
return path;
|
|
117
|
+
}, []);
|
|
118
|
+
|
|
119
|
+
const isRecording = useCallback(() => recordingRef.current, []);
|
|
120
|
+
|
|
121
|
+
return { start, stop, isRecording };
|
|
122
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// ===========================================================================
|
|
2
|
+
// Capture & encoding settings — compression strategy
|
|
3
|
+
// ===========================================================================
|
|
4
|
+
//
|
|
5
|
+
// Mirrors the web SDK's `lib/capture-settings.ts` and the Flutter SDK's
|
|
6
|
+
// `CaptureConfig`. Effort is scaled to how much quality each artifact needs:
|
|
7
|
+
//
|
|
8
|
+
// • VIDEO (liveness + document) — AGGRESSIVE: low res + low frame rate +
|
|
9
|
+
// a ~500 kbps bitrate cap. A few seconds stays well under the size target.
|
|
10
|
+
// • SELFIE still image — MODERATE: 640×480, JPEG quality 0.8.
|
|
11
|
+
// • DOCUMENT still image — CONSERVATIVE (OCR-critical): captured at a
|
|
12
|
+
// higher resolution, JPEG quality 0.9, never downscaled below 1080 px.
|
|
13
|
+
// ===========================================================================
|
|
14
|
+
|
|
15
|
+
// --- Liveness / selfie camera (video is recorded from this stream) ---------
|
|
16
|
+
export const CAPTURE_WIDTH = 640;
|
|
17
|
+
export const CAPTURE_HEIGHT = 480;
|
|
18
|
+
|
|
19
|
+
// --- Document camera (higher res so the OCR still stays readable) ----------
|
|
20
|
+
export const DOCUMENT_CAPTURE_WIDTH = 1920;
|
|
21
|
+
export const DOCUMENT_CAPTURE_HEIGHT = 1080;
|
|
22
|
+
|
|
23
|
+
// --- Frame rate (applies to every recorded stream) -------------------------
|
|
24
|
+
export const CAPTURE_FRAMERATE = 15;
|
|
25
|
+
export const CAPTURE_FRAMERATE_MAX = 20;
|
|
26
|
+
|
|
27
|
+
// --- Video size strategy (liveness + document) -----------------------------
|
|
28
|
+
// These videos are EVIDENCE ONLY — they're not compared against anything and may
|
|
29
|
+
// never be looked at, so we optimise purely for small file size (low res + low
|
|
30
|
+
// frame rate + low bitrate). Every clip must stay under MAX_VIDEO_BYTES (5 MB);
|
|
31
|
+
// the recorder also stops at MAX_VIDEO_DURATION_MS and the uploader skips anything
|
|
32
|
+
// still over the cap, so a video can never exceed 5 MB regardless of how long the
|
|
33
|
+
// user lingers on the camera.
|
|
34
|
+
export const LIVENESS_VIDEO_BITRATE = 350_000; // 350 kbps
|
|
35
|
+
export const DOCUMENT_VIDEO_BITRATE = 350_000; // 350 kbps
|
|
36
|
+
|
|
37
|
+
/** Recorded video resolution — small (360p-ish). Quality is irrelevant here. */
|
|
38
|
+
export const VIDEO_CAPTURE_RESOLUTION = { width: 360, height: 640 } as const;
|
|
39
|
+
|
|
40
|
+
// NOTE: VisionCamera v5 does NOT honour targetBitRate / targetResolution for
|
|
41
|
+
// recordings on iOS — the whole session uses ONE camera format, driven by the
|
|
42
|
+
// document photo output's `quality` prioritisation (4K, for OCR), so the raw clip
|
|
43
|
+
// is huge (a 6s capture was ~35 MB). We therefore TRANSCODE every recorded clip
|
|
44
|
+
// down with react-native-compressor before upload (see services/mediaCompress
|
|
45
|
+
// `compressVideo`) — the real file-size lever. The raw recording is duration-capped
|
|
46
|
+
// only to bound the temp file we hand to the transcoder.
|
|
47
|
+
export const MAX_VIDEO_DURATION_MS = 12_000; // bounds the raw 4K temp file
|
|
48
|
+
|
|
49
|
+
/** Transcode target — longest edge (≈480p) and bitrate for the uploaded clip. */
|
|
50
|
+
export const VIDEO_COMPRESS_MAX_SIZE = 640;
|
|
51
|
+
export const VIDEO_COMPRESS_BITRATE = 600_000; // 600 kbps → 12s ≈ 0.9 MB
|
|
52
|
+
|
|
53
|
+
/** Hard upload ceiling — any video still over this (transcode failed) is dropped. */
|
|
54
|
+
export const MAX_VIDEO_BYTES = 5 * 1024 * 1024; // 5 MB
|
|
55
|
+
|
|
56
|
+
/** Pause before the liveness selfie shutter so the user steadies after the final
|
|
57
|
+
* gesture (a head turn leaves them mid-motion) → a sharper, non-blurred selfie. */
|
|
58
|
+
export const SELFIE_SETTLE_MS = 1500;
|
|
59
|
+
|
|
60
|
+
// --- Still-image JPEG quality (0–1) ----------------------------------------
|
|
61
|
+
export const SELFIE_IMAGE_QUALITY = 0.8; // moderate
|
|
62
|
+
export const DOCUMENT_IMAGE_QUALITY = 0.9; // conservative — keep text sharp
|
|
63
|
+
|
|
64
|
+
/** Document stills are never scaled narrower than this (OCR-critical). */
|
|
65
|
+
export const DOCUMENT_MIN_WIDTH = 1080;
|
|
66
|
+
/** Upper bound for document stills — large enough to keep small text legible. */
|
|
67
|
+
export const DOCUMENT_MAX_DIMENSION = 2000;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { IdTypesByCountry } from '../types/config';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Source of truth for the supported `(country, idType)` matrix — identical to
|
|
5
|
+
* the web SDK's `utils/countries.ts` and the Flutter SDK's `id_types.dart`.
|
|
6
|
+
* `requiresDocumentCapture: false` ⇒ number-only path (no document scan).
|
|
7
|
+
*/
|
|
8
|
+
export const ID_TYPES: IdTypesByCountry = {
|
|
9
|
+
NG: [
|
|
10
|
+
{ key: 'bvn', label: 'BVN', digits: 11, requiresDocumentCapture: false },
|
|
11
|
+
{ key: 'nin', label: 'NIN', digits: 11, requiresDocumentCapture: false },
|
|
12
|
+
{ key: 'vnin', label: 'Virtual NIN (vNIN)', digits: 16, requiresDocumentCapture: false },
|
|
13
|
+
{ key: 'passport', label: 'International Passport', pattern: /^[A-Z]\d{8}$/, requiresDocumentCapture: true, scanSides: 'front_only' },
|
|
14
|
+
{ key: 'drivers-license', label: "Driver's License", pattern: /^[A-Z]{3}\d{5,12}$/, requiresDocumentCapture: true, scanSides: 'front_and_back' },
|
|
15
|
+
{ key: 'pvc', label: "Permanent Voter's Card", pattern: /^\d{19}$/, requiresDocumentCapture: true, scanSides: 'front_and_back' },
|
|
16
|
+
],
|
|
17
|
+
GH: [
|
|
18
|
+
{ key: 'ghana-card', label: 'Ghana Card', pattern: /^GHA-\d{9}-\d$/, requiresDocumentCapture: true, scanSides: 'front_and_back' },
|
|
19
|
+
{ key: 'voters', label: "Voter's Card", digits: 10, requiresDocumentCapture: true, scanSides: 'front_and_back' },
|
|
20
|
+
{ key: 'drivers-license', label: "Driver's License", requiresDocumentCapture: true, scanSides: 'front_and_back' },
|
|
21
|
+
{ key: 'ssnit', label: 'SSNIT', digits: 13, requiresDocumentCapture: true, scanSides: 'front_only' },
|
|
22
|
+
{ key: 'passport', label: 'Passport', pattern: /^[A-Z]\d{7}$/, requiresDocumentCapture: true, scanSides: 'front_only' },
|
|
23
|
+
],
|
|
24
|
+
KE: [
|
|
25
|
+
{ key: 'national-id', label: 'National ID', digits: 8, requiresDocumentCapture: true, scanSides: 'front_and_back' },
|
|
26
|
+
{ key: 'passport', label: 'Passport', requiresDocumentCapture: true, scanSides: 'front_only' },
|
|
27
|
+
],
|
|
28
|
+
ZA: [
|
|
29
|
+
{ key: 'national-id', label: 'National ID', digits: 13, requiresDocumentCapture: true, scanSides: 'front_and_back' },
|
|
30
|
+
],
|
|
31
|
+
CI: [
|
|
32
|
+
{ key: 'cni', label: "CNI (Carte Nationale d'Identité)", requiresDocumentCapture: true, scanSides: 'front_and_back' },
|
|
33
|
+
{ key: 'residence-card', label: 'Residence Card', requiresDocumentCapture: true, scanSides: 'front_and_back' },
|
|
34
|
+
],
|
|
35
|
+
} as const;
|
|
36
|
+
|
|
37
|
+
const ALL_ID_TYPES = Object.values(ID_TYPES).flat();
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Returns true for IDs that skip document capture and go straight to the
|
|
41
|
+
* id-input form (user types their number manually). Nigeria: BVN, NIN, vNIN.
|
|
42
|
+
*/
|
|
43
|
+
export function isNumberOnlyIdType(idType: string): boolean {
|
|
44
|
+
const def = ALL_ID_TYPES.find((t) => t.key === idType);
|
|
45
|
+
return def ? !def.requiresDocumentCapture : false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Returns true when the selected ID type requires a physical document scan. */
|
|
49
|
+
export function requiresDocumentCapture(idType: string): boolean {
|
|
50
|
+
const def = ALL_ID_TYPES.find((t) => t.key === idType);
|
|
51
|
+
return def ? def.requiresDocumentCapture : true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Returns the scan-sides configuration for a document ID type. Defaults to
|
|
56
|
+
* `'front_only'` when not explicitly set.
|
|
57
|
+
*/
|
|
58
|
+
export function getScanSides(idType: string): 'front_only' | 'front_and_back' {
|
|
59
|
+
const def = ALL_ID_TYPES.find((t) => t.key === idType);
|
|
60
|
+
return def?.scanSides ?? 'front_only';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Card-guide / crop aspect (width ÷ height) for the live camera. Mirrors the
|
|
64
|
+
// Flutter SDK's documentGuideAspect: ID-1 cards use 1.586; passports use a taller
|
|
65
|
+
// 1.42 so the data page's bottom MRZ band isn't cropped off.
|
|
66
|
+
export const CARD_GUIDE_ASPECT = 85.6 / 53.98; // ISO/IEC 7810 ID-1 ≈ 1.586
|
|
67
|
+
export const PASSPORT_GUIDE_ASPECT = 1.42;
|
|
68
|
+
|
|
69
|
+
export function documentGuideAspect(idType: string | null): number {
|
|
70
|
+
return idType === 'passport' ? PASSPORT_GUIDE_ASPECT : CARD_GUIDE_ASPECT;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const COUNTRY_LABELS: Record<string, string> = {
|
|
74
|
+
NG: 'Nigeria',
|
|
75
|
+
GH: 'Ghana',
|
|
76
|
+
KE: 'Kenya',
|
|
77
|
+
ZA: 'South Africa',
|
|
78
|
+
CI: "Côte d'Ivoire",
|
|
79
|
+
};
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Design tokens — ported from the Flutter SDK's `theme.dart` (MyazaColorScheme)
|
|
3
|
+
// and the web SDK's `globals.css`. Token-based so `appearance` overrides cascade
|
|
4
|
+
// (mirrors Flutter's `_applyAppearance`): set `primaryColor` and the whole tint
|
|
5
|
+
// family follows.
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
import type { KYCAppearance } from '../types/config';
|
|
9
|
+
|
|
10
|
+
export interface MyazaColorScheme {
|
|
11
|
+
background: string;
|
|
12
|
+
backgroundSecondary: string;
|
|
13
|
+
textDark: string;
|
|
14
|
+
textSecondary: string;
|
|
15
|
+
textMuted: string;
|
|
16
|
+
border: string;
|
|
17
|
+
primary: string;
|
|
18
|
+
/** Text/icon color on top of `primary` (e.g. button labels). */
|
|
19
|
+
onPrimary: string;
|
|
20
|
+
primary50: string;
|
|
21
|
+
primary100: string;
|
|
22
|
+
primary200: string;
|
|
23
|
+
gray300: string;
|
|
24
|
+
gray400: string;
|
|
25
|
+
success: string;
|
|
26
|
+
successBg: string;
|
|
27
|
+
error: string;
|
|
28
|
+
errorBg: string;
|
|
29
|
+
warning: string;
|
|
30
|
+
warningBg: string;
|
|
31
|
+
info: string;
|
|
32
|
+
infoBg: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const LIGHT_COLORS: MyazaColorScheme = {
|
|
36
|
+
background: '#FFFFFF',
|
|
37
|
+
backgroundSecondary: '#F6F5FE',
|
|
38
|
+
textDark: '#070330',
|
|
39
|
+
textSecondary: '#5A5775',
|
|
40
|
+
textMuted: '#828197',
|
|
41
|
+
border: '#D3CFFC',
|
|
42
|
+
primary: '#5645F5',
|
|
43
|
+
onPrimary: '#FFFFFF',
|
|
44
|
+
primary50: '#F6F5FE',
|
|
45
|
+
primary100: '#E9E7FE',
|
|
46
|
+
primary200: '#D3CFFC',
|
|
47
|
+
gray300: '#CDCDD6',
|
|
48
|
+
gray400: '#ACABBA',
|
|
49
|
+
success: '#0DA211',
|
|
50
|
+
successBg: '#D8F4DC',
|
|
51
|
+
error: '#BD1B09',
|
|
52
|
+
errorBg: '#FCD8D8',
|
|
53
|
+
warning: '#FFC107',
|
|
54
|
+
warningBg: '#FFF1D6',
|
|
55
|
+
info: '#004FAF',
|
|
56
|
+
infoBg: '#C8E9FF',
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const DARK_COLORS: MyazaColorScheme = {
|
|
60
|
+
background: '#040218',
|
|
61
|
+
backgroundSecondary: '#0F0C2E',
|
|
62
|
+
textDark: '#F6F5FE',
|
|
63
|
+
textSecondary: '#ACABBA',
|
|
64
|
+
textMuted: '#5A5775',
|
|
65
|
+
border: '#302D53',
|
|
66
|
+
primary: '#7B6EF7',
|
|
67
|
+
onPrimary: '#FFFFFF',
|
|
68
|
+
primary50: '#1A1730',
|
|
69
|
+
primary100: '#2A2651',
|
|
70
|
+
primary200: '#3D3870',
|
|
71
|
+
gray300: '#302D53',
|
|
72
|
+
gray400: '#5A5775',
|
|
73
|
+
success: '#0DA211',
|
|
74
|
+
successBg: '#0B2B0C',
|
|
75
|
+
error: '#BD1B09',
|
|
76
|
+
errorBg: '#2B0A0A',
|
|
77
|
+
warning: '#FFC107',
|
|
78
|
+
warningBg: '#2B1B00',
|
|
79
|
+
info: '#004FAF',
|
|
80
|
+
infoBg: '#001B3B',
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export type ThemeMode = 'light' | 'dark';
|
|
84
|
+
|
|
85
|
+
export const radius = { xs: 8, sm: 12, md: 16, lg: 20, xl: 24, full: 999 } as const;
|
|
86
|
+
export const spacing = { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 } as const;
|
|
87
|
+
export const sizing = {
|
|
88
|
+
buttonHeight: 48,
|
|
89
|
+
inputHeight: 48,
|
|
90
|
+
iconSize: 24,
|
|
91
|
+
avatarSize: 80,
|
|
92
|
+
cameraCircleSize: 260,
|
|
93
|
+
} as const;
|
|
94
|
+
|
|
95
|
+
/** Font families — registered via `expo-font` (see `loadMyazaFonts`). */
|
|
96
|
+
export const fonts = {
|
|
97
|
+
heading: 'SpaceGrotesk_700Bold',
|
|
98
|
+
headingSemibold: 'SpaceGrotesk_600SemiBold',
|
|
99
|
+
body: 'Karla_400Regular',
|
|
100
|
+
bodyMedium: 'Karla_500Medium',
|
|
101
|
+
bodySemibold: 'Karla_600SemiBold',
|
|
102
|
+
} as const;
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Color helpers — hex parsing + alpha blend (for deriving tints from primary)
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
interface Rgb {
|
|
109
|
+
r: number;
|
|
110
|
+
g: number;
|
|
111
|
+
b: number;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function parseHex(hex: string): Rgb | null {
|
|
115
|
+
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
|
|
116
|
+
if (!m) return null;
|
|
117
|
+
const n = parseInt(m[1]!, 16);
|
|
118
|
+
return { r: (n >> 16) & 0xff, g: (n >> 8) & 0xff, b: n & 0xff };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function toHex({ r, g, b }: Rgb): string {
|
|
122
|
+
const h = (v: number) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, '0');
|
|
123
|
+
return `#${h(r)}${h(g)}${h(b)}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Alpha-blends `fg` over `bg` at opacity `alpha` (0–1). */
|
|
127
|
+
function alphaBlend(fg: string, bg: string, alpha: number): string {
|
|
128
|
+
const f = parseHex(fg);
|
|
129
|
+
const b = parseHex(bg);
|
|
130
|
+
if (!f || !b) return fg;
|
|
131
|
+
return toHex({
|
|
132
|
+
r: f.r * alpha + b.r * (1 - alpha),
|
|
133
|
+
g: f.g * alpha + b.g * (1 - alpha),
|
|
134
|
+
b: f.b * alpha + b.b * (1 - alpha),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// Appearance mapping — overlay KYCAppearance onto a base scheme.
|
|
140
|
+
// Mirrors Flutter's `_applyAppearance`: when `primaryColor` is set, derive the
|
|
141
|
+
// 50/100/200 tint family from it (alpha-blended over the background); an explicit
|
|
142
|
+
// `accentColor` overrides the 100 tint.
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
export function applyAppearance(
|
|
146
|
+
base: MyazaColorScheme,
|
|
147
|
+
appearance: KYCAppearance | undefined,
|
|
148
|
+
): MyazaColorScheme {
|
|
149
|
+
if (!appearance) return base;
|
|
150
|
+
|
|
151
|
+
const background = appearance.backgroundColor ?? base.background;
|
|
152
|
+
const next: MyazaColorScheme = {
|
|
153
|
+
...base,
|
|
154
|
+
background,
|
|
155
|
+
backgroundSecondary: appearance.surfaceColor ?? base.backgroundSecondary,
|
|
156
|
+
border: appearance.borderColor ?? base.border,
|
|
157
|
+
textDark: appearance.textColor ?? base.textDark,
|
|
158
|
+
onPrimary: appearance.primaryTextColor ?? base.onPrimary,
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
if (appearance.primaryColor) {
|
|
162
|
+
next.primary = appearance.primaryColor;
|
|
163
|
+
next.primary50 = alphaBlend(appearance.primaryColor, background, 0.04);
|
|
164
|
+
next.primary100 = alphaBlend(appearance.primaryColor, background, 0.1);
|
|
165
|
+
next.primary200 = alphaBlend(appearance.primaryColor, background, 0.2);
|
|
166
|
+
}
|
|
167
|
+
if (appearance.accentColor) {
|
|
168
|
+
next.primary100 = appearance.accentColor;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return next;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Resolves the active color scheme for a mode + appearance override. */
|
|
175
|
+
export function resolveColors(mode: ThemeMode, appearance?: KYCAppearance): MyazaColorScheme {
|
|
176
|
+
return applyAppearance(mode === 'dark' ? DARK_COLORS : LIGHT_COLORS, appearance);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Surface tint for the KYC header chrome — mirrors Flutter's `kycHeaderSurface`.
|
|
181
|
+
* Dark themes get a branded lift over the background; light themes use the
|
|
182
|
+
* subtle secondary surface.
|
|
183
|
+
*/
|
|
184
|
+
export function headerSurface(colors: MyazaColorScheme, mode: ThemeMode): string {
|
|
185
|
+
return mode === 'dark' ? alphaBlend(colors.primary, colors.background, 0.18) : colors.backgroundSecondary;
|
|
186
|
+
}
|