@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,44 @@
|
|
|
1
|
+
import type { HybridObject } from 'react-native-nitro-modules';
|
|
2
|
+
import type { Frame } from 'react-native-vision-camera';
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Nitro spec for the on-device face detector (VisionCamera v5).
|
|
6
|
+
//
|
|
7
|
+
// v5 replaced the old `FrameProcessorPlugin` + `VISION_EXPORT_SWIFT_FRAME_PROCESSOR`
|
|
8
|
+
// macro / `FrameProcessorPluginRegistry` model with **Nitro HybridObjects**. This
|
|
9
|
+
// `.nitro.ts` spec is the single source of truth: nitrogen generates the Swift
|
|
10
|
+
// (`HybridMyazaFaceDetectorSpec`) and Kotlin (`HybridMyazaFaceDetectorSpec`) base
|
|
11
|
+
// classes from it, and `nitro.json` autolinks the concrete impls
|
|
12
|
+
// (`HybridMyazaFaceDetector`) — iOS = Apple Vision, Android = Google ML Kit.
|
|
13
|
+
//
|
|
14
|
+
// The detector runs inside the camera-thread worklet (`useFrameOutput`): the
|
|
15
|
+
// boxed HybridObject is unboxed in the worklet and `detectFace(frame)` is called
|
|
16
|
+
// synchronously per frame (the frame never crosses the JS bridge).
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Per-frame face signals, identical in shape to {@link LivenessFaceData} in
|
|
21
|
+
* `../liveness/types`. `faceCount === 0` means "no face" (the worklet maps that
|
|
22
|
+
* to `onNoFace()` — Nitro return types are non-nullable, so we use the count as
|
|
23
|
+
* the presence signal rather than returning `null`).
|
|
24
|
+
*/
|
|
25
|
+
export interface FaceResult {
|
|
26
|
+
headEulerAngleX: number; // pitch (nod), degrees; negative = down
|
|
27
|
+
headEulerAngleY: number; // yaw (turn), degrees; positive = left
|
|
28
|
+
headEulerAngleZ: number; // roll, degrees
|
|
29
|
+
smilingProbability: number; // 0–1
|
|
30
|
+
leftEyeOpenProbability: number; // 0–1
|
|
31
|
+
rightEyeOpenProbability: number; // 0–1
|
|
32
|
+
faceSizeRatio: number; // face width / frame width (0–1)
|
|
33
|
+
faceCount: number; // # faces in frame; 0 = no face
|
|
34
|
+
brightness: number; // mean luma of the frame (0–255), for the low-light gate
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface MyazaFaceDetector extends HybridObject<{ ios: 'swift'; android: 'kotlin' }> {
|
|
38
|
+
/**
|
|
39
|
+
* Detect the dominant face in a camera {@link Frame} and return its gesture
|
|
40
|
+
* signals. Called per-frame from the VisionCamera worklet. Returns
|
|
41
|
+
* `faceCount: 0` when no face is present.
|
|
42
|
+
*/
|
|
43
|
+
detectFace(frame: Frame): FaceResult;
|
|
44
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// kycStore — the main flow/state store (mirrors Flutter's KYCNotifier + KYCState).
|
|
3
|
+
//
|
|
4
|
+
// One store is created per modal instance via `createKycStore(config)` so the
|
|
5
|
+
// flow state (and the resolved API client) is scoped to a single launch. The
|
|
6
|
+
// React layer (Step 2) exposes it through context + a `useStore` selector hook.
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
import { createStore, type StoreApi } from 'zustand/vanilla';
|
|
10
|
+
|
|
11
|
+
import { createKYCApi, type KYCApi, type VerifyRequest } from '../services/api';
|
|
12
|
+
import { resolveBaseUrl, normalizeDevAssetUrl } from '../services/resolveUrl';
|
|
13
|
+
import { withRetry } from '../services/retry';
|
|
14
|
+
import { collectDeviceMetadata } from '../services/deviceMetadata';
|
|
15
|
+
import { requiresDocumentCapture } from '../config/idTypes';
|
|
16
|
+
import { generateRequestId } from '../utils/uuid';
|
|
17
|
+
import type { IdType, KYCStep, MyazaKYCConfig } from '../types/config';
|
|
18
|
+
import {
|
|
19
|
+
INITIAL_SERVER_CONFIG,
|
|
20
|
+
describeConfigError,
|
|
21
|
+
featuresFor,
|
|
22
|
+
type ServerConfigState,
|
|
23
|
+
} from './serverConfig';
|
|
24
|
+
|
|
25
|
+
export interface KYCMediaIds {
|
|
26
|
+
documentFront?: string;
|
|
27
|
+
documentBack?: string;
|
|
28
|
+
selfie?: string;
|
|
29
|
+
documentFrontVideo?: string;
|
|
30
|
+
documentBackVideo?: string;
|
|
31
|
+
livenessVideo?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface KYCSubmissionResult {
|
|
35
|
+
verificationId: string;
|
|
36
|
+
status: 'pending';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type DocumentScanPhase = 'front' | 'back' | 'complete';
|
|
40
|
+
|
|
41
|
+
/** Document-capture sub-phase — drives the sheet header title/description. */
|
|
42
|
+
export type DocumentCapturePhase = 'front' | 'front-preview' | 'back' | 'review';
|
|
43
|
+
|
|
44
|
+
/** The mediaIds keys settable via `setMediaId`. */
|
|
45
|
+
export type MediaIdKey = keyof KYCMediaIds;
|
|
46
|
+
|
|
47
|
+
export interface KycState {
|
|
48
|
+
config: MyazaKYCConfig;
|
|
49
|
+
api: KYCApi;
|
|
50
|
+
|
|
51
|
+
currentStep: KYCStep;
|
|
52
|
+
selectedIdType: IdType | null;
|
|
53
|
+
idNumber: string | null;
|
|
54
|
+
mediaIds: KYCMediaIds;
|
|
55
|
+
submissionResult: KYCSubmissionResult | null;
|
|
56
|
+
serverConfig: ServerConfigState;
|
|
57
|
+
documentScanPhase: DocumentScanPhase;
|
|
58
|
+
/** Sub-phase of the document-capture step — synced by the screen so the header
|
|
59
|
+
* title/description can be phase-aware (mirrors Flutter's docReviewPhase). */
|
|
60
|
+
documentCapturePhase: DocumentCapturePhase;
|
|
61
|
+
isLoading: boolean;
|
|
62
|
+
error: string | null;
|
|
63
|
+
|
|
64
|
+
// Actions
|
|
65
|
+
loadServerConfig: () => Promise<void>;
|
|
66
|
+
setIdType: (idType: IdType) => void;
|
|
67
|
+
setIdNumber: (idNumber: string) => void;
|
|
68
|
+
setMediaId: (key: MediaIdKey, mediaId: string) => void;
|
|
69
|
+
setDocumentMediaId: (mediaId: string, side: 'front' | 'back') => void;
|
|
70
|
+
setDocumentCapturePhase: (phase: DocumentCapturePhase) => void;
|
|
71
|
+
nextStep: () => void;
|
|
72
|
+
previousStep: () => void;
|
|
73
|
+
goToStep: (step: KYCStep) => void;
|
|
74
|
+
submitAsync: (onRetry?: (attempt: number, total: number) => void) => Promise<KYCSubmissionResult>;
|
|
75
|
+
reset: () => void;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type KycStore = StoreApi<KycState>;
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Flow navigation — single source of truth for the 5-step sequence.
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
function livenessEnabled(state: KycState): boolean {
|
|
85
|
+
// Consumer baseline: liveness is on unless explicitly disabled.
|
|
86
|
+
if (state.config.enableLiveness === false) return false;
|
|
87
|
+
const idType = state.selectedIdType;
|
|
88
|
+
if (!idType) return true;
|
|
89
|
+
// Server flag wins when present; otherwise keep the consumer baseline (on).
|
|
90
|
+
const features = featuresFor(state.serverConfig, state.config.country, idType);
|
|
91
|
+
return features ? features.livenessCheck : true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The step that follows `step`, given the current selection + flags. */
|
|
95
|
+
function nextStepAfter(step: KYCStep, state: KycState): KYCStep {
|
|
96
|
+
switch (step) {
|
|
97
|
+
case 'consent':
|
|
98
|
+
return 'id-type';
|
|
99
|
+
case 'id-type':
|
|
100
|
+
return state.selectedIdType && requiresDocumentCapture(state.selectedIdType)
|
|
101
|
+
? 'document-capture'
|
|
102
|
+
: 'id-input';
|
|
103
|
+
case 'document-capture':
|
|
104
|
+
case 'id-input':
|
|
105
|
+
return livenessEnabled(state) ? 'liveness' : 'submitted';
|
|
106
|
+
case 'liveness':
|
|
107
|
+
return 'submitted';
|
|
108
|
+
case 'submitted':
|
|
109
|
+
return 'submitted';
|
|
110
|
+
default:
|
|
111
|
+
return step;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The step before `step` (for the back button). */
|
|
116
|
+
function previousStepBefore(step: KYCStep, state: KycState): KYCStep {
|
|
117
|
+
switch (step) {
|
|
118
|
+
case 'id-type':
|
|
119
|
+
return 'consent';
|
|
120
|
+
case 'document-capture':
|
|
121
|
+
case 'id-input':
|
|
122
|
+
return 'id-type';
|
|
123
|
+
case 'liveness':
|
|
124
|
+
return state.selectedIdType && requiresDocumentCapture(state.selectedIdType)
|
|
125
|
+
? 'document-capture'
|
|
126
|
+
: 'id-input';
|
|
127
|
+
case 'submitted':
|
|
128
|
+
// Submitted is terminal; back is a no-op in practice.
|
|
129
|
+
return livenessEnabled(state) ? 'liveness' : step;
|
|
130
|
+
case 'consent':
|
|
131
|
+
default:
|
|
132
|
+
return 'consent';
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Store factory
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
export function createKycStore(config: MyazaKYCConfig): KycStore {
|
|
141
|
+
const baseUrl = resolveBaseUrl(config.apiKey, config.devUrl);
|
|
142
|
+
const api = createKYCApi(baseUrl, config.apiKey);
|
|
143
|
+
|
|
144
|
+
return createStore<KycState>((set, get) => {
|
|
145
|
+
function emitStepChange(step: KYCStep): void {
|
|
146
|
+
config.onStepChange?.(step);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
config,
|
|
151
|
+
api,
|
|
152
|
+
|
|
153
|
+
currentStep: 'consent',
|
|
154
|
+
selectedIdType: null,
|
|
155
|
+
idNumber: null,
|
|
156
|
+
mediaIds: {},
|
|
157
|
+
submissionResult: null,
|
|
158
|
+
serverConfig: INITIAL_SERVER_CONFIG,
|
|
159
|
+
documentScanPhase: 'front',
|
|
160
|
+
documentCapturePhase: 'front',
|
|
161
|
+
isLoading: false,
|
|
162
|
+
error: null,
|
|
163
|
+
|
|
164
|
+
async loadServerConfig() {
|
|
165
|
+
set((s) => ({ serverConfig: { ...s.serverConfig, status: 'loading' } }));
|
|
166
|
+
try {
|
|
167
|
+
const res = await api.config();
|
|
168
|
+
// Make a local dev server's hardcoded `localhost` logo URL reachable on
|
|
169
|
+
// the Android emulator (rewrites the origin to the SDK base; no-op for
|
|
170
|
+
// production CDN URLs).
|
|
171
|
+
const branding = res.branding
|
|
172
|
+
? { ...res.branding, logo: normalizeDevAssetUrl(res.branding.logo, baseUrl) }
|
|
173
|
+
: res.branding;
|
|
174
|
+
set({
|
|
175
|
+
serverConfig: {
|
|
176
|
+
status: 'ready',
|
|
177
|
+
idTypes: res.idTypes,
|
|
178
|
+
branding,
|
|
179
|
+
environment: res.environment,
|
|
180
|
+
fatal: false,
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
} catch (err) {
|
|
184
|
+
const described = describeConfigError(err);
|
|
185
|
+
set((s) => ({
|
|
186
|
+
serverConfig: { ...s.serverConfig, status: 'error', ...described },
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
setIdType(idType) {
|
|
192
|
+
set({ selectedIdType: idType });
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
setIdNumber(idNumber) {
|
|
196
|
+
set({ idNumber });
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
setMediaId(key, mediaId) {
|
|
200
|
+
set((s) => ({ mediaIds: { ...s.mediaIds, [key]: mediaId } }));
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
setDocumentMediaId(mediaId, side) {
|
|
204
|
+
set((s) => ({
|
|
205
|
+
mediaIds: {
|
|
206
|
+
...s.mediaIds,
|
|
207
|
+
[side === 'front' ? 'documentFront' : 'documentBack']: mediaId,
|
|
208
|
+
},
|
|
209
|
+
documentScanPhase: side === 'front' ? 'back' : 'complete',
|
|
210
|
+
}));
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
setDocumentCapturePhase(phase) {
|
|
214
|
+
if (get().documentCapturePhase !== phase) set({ documentCapturePhase: phase });
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
nextStep() {
|
|
218
|
+
const next = nextStepAfter(get().currentStep, get());
|
|
219
|
+
if (next !== get().currentStep) {
|
|
220
|
+
set({ currentStep: next });
|
|
221
|
+
emitStepChange(next);
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
previousStep() {
|
|
226
|
+
const prev = previousStepBefore(get().currentStep, get());
|
|
227
|
+
if (prev !== get().currentStep) {
|
|
228
|
+
set({ currentStep: prev });
|
|
229
|
+
emitStepChange(prev);
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
goToStep(step) {
|
|
234
|
+
if (step !== get().currentStep) {
|
|
235
|
+
set({ currentStep: step });
|
|
236
|
+
emitStepChange(step);
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
|
|
240
|
+
async submitAsync(onRetry) {
|
|
241
|
+
set({ isLoading: true, error: null });
|
|
242
|
+
const state = get();
|
|
243
|
+
const request: VerifyRequest = {
|
|
244
|
+
country: state.config.country,
|
|
245
|
+
idType: state.selectedIdType ?? '',
|
|
246
|
+
idNumber: state.idNumber ?? undefined,
|
|
247
|
+
userData: state.config.userData,
|
|
248
|
+
mediaIds: state.mediaIds,
|
|
249
|
+
metadata: {
|
|
250
|
+
requestId: generateRequestId(),
|
|
251
|
+
device: collectDeviceMetadata() as unknown as Record<string, unknown>,
|
|
252
|
+
...(state.config.metadata ?? {}),
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
try {
|
|
256
|
+
const res = await withRetry(() => api.verify(request), { onRetry });
|
|
257
|
+
const result: KYCSubmissionResult = {
|
|
258
|
+
verificationId: res.verificationId,
|
|
259
|
+
status: 'pending',
|
|
260
|
+
};
|
|
261
|
+
set({ submissionResult: result, isLoading: false });
|
|
262
|
+
return result;
|
|
263
|
+
} catch (err) {
|
|
264
|
+
set({ isLoading: false, error: err instanceof Error ? err.message : 'Submission failed' });
|
|
265
|
+
throw err;
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
reset() {
|
|
270
|
+
set({
|
|
271
|
+
currentStep: 'consent',
|
|
272
|
+
selectedIdType: null,
|
|
273
|
+
idNumber: null,
|
|
274
|
+
mediaIds: {},
|
|
275
|
+
submissionResult: null,
|
|
276
|
+
documentScanPhase: 'front',
|
|
277
|
+
documentCapturePhase: 'front',
|
|
278
|
+
isLoading: false,
|
|
279
|
+
error: null,
|
|
280
|
+
});
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Re-export the flow helpers for tests + screens that need to reason about
|
|
287
|
+
// navigation without mutating the store.
|
|
288
|
+
export { livenessEnabled, nextStepAfter, previousStepBefore };
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Server-driven config state (GET /api/kyc/config) — mirrors the Flutter SDK's
|
|
3
|
+
// serverConfig handling on KYCState. Holds the org's allowed (country, idType)
|
|
4
|
+
// list, per-ID feature flags, branding, and an error classification.
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
import { KYCApiError } from '../services/api';
|
|
8
|
+
import type { SdkConfigBranding, SdkConfigIdType } from '../services/api';
|
|
9
|
+
|
|
10
|
+
export type ServerConfigStatus = 'loading' | 'ready' | 'error';
|
|
11
|
+
|
|
12
|
+
export interface IdTypeFeatures {
|
|
13
|
+
documentVerification: boolean;
|
|
14
|
+
livenessCheck: boolean;
|
|
15
|
+
govDbCheck: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ServerConfigState {
|
|
19
|
+
status: ServerConfigStatus;
|
|
20
|
+
idTypes: SdkConfigIdType[];
|
|
21
|
+
branding?: SdkConfigBranding;
|
|
22
|
+
environment?: 'DEVELOPMENT' | 'SANDBOX' | 'PRODUCTION';
|
|
23
|
+
/** HTTP status of a failed config fetch (if any). */
|
|
24
|
+
statusCode?: number;
|
|
25
|
+
/** A fatal failure (401/403) blocks the flow; non-fatal falls back to the prop list. */
|
|
26
|
+
fatal: boolean;
|
|
27
|
+
/** User-facing message for a fatal failure. */
|
|
28
|
+
message?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const INITIAL_SERVER_CONFIG: ServerConfigState = {
|
|
32
|
+
status: 'loading',
|
|
33
|
+
idTypes: [],
|
|
34
|
+
fatal: false,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Returns the per-ID feature flags for a `(country, idType)` pair, or `null`
|
|
39
|
+
* when the ID isn't granted or config hasn't loaded. Mirrors Flutter's
|
|
40
|
+
* `featuresFor` — callers must NOT replicate precedence logic elsewhere.
|
|
41
|
+
*/
|
|
42
|
+
export function featuresFor(
|
|
43
|
+
config: ServerConfigState,
|
|
44
|
+
country: string,
|
|
45
|
+
idType: string,
|
|
46
|
+
): IdTypeFeatures | null {
|
|
47
|
+
if (config.status !== 'ready') return null;
|
|
48
|
+
const match = config.idTypes.find((t) => t.country === country && t.idType === idType);
|
|
49
|
+
return match ? match.features : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Classifies a config-fetch error. 401 (invalid key) and 403 (not permitted) are
|
|
54
|
+
* FATAL — they block the flow and report to onError once. Everything else
|
|
55
|
+
* (network blips, 5xx) is non-fatal — the SDK trusts the consumer's `idTypes`
|
|
56
|
+
* prop and the server still 403s anything actually disabled. Mirrors Flutter's
|
|
57
|
+
* `_describeConfigError`.
|
|
58
|
+
*/
|
|
59
|
+
export function describeConfigError(err: unknown): Pick<ServerConfigState, 'statusCode' | 'fatal' | 'message'> {
|
|
60
|
+
if (err instanceof KYCApiError) {
|
|
61
|
+
if (err.statusCode === 401) {
|
|
62
|
+
return {
|
|
63
|
+
statusCode: 401,
|
|
64
|
+
fatal: true,
|
|
65
|
+
message: 'Invalid API key. Please contact support.',
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
if (err.statusCode === 403) {
|
|
69
|
+
return {
|
|
70
|
+
statusCode: 403,
|
|
71
|
+
fatal: true,
|
|
72
|
+
message: "Your organization isn't permitted to start verification. Contact your administrator.",
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return { statusCode: err.statusCode, fatal: false };
|
|
76
|
+
}
|
|
77
|
+
return { fatal: false };
|
|
78
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import type { KYCSubmission, KYCError } from './verification';
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Supported countries & ID types (identical set to the web + Flutter SDKs)
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
export type SupportedCountry = 'NG' | 'GH' | 'KE' | 'ZA' | 'CI';
|
|
8
|
+
|
|
9
|
+
export type NigeriaIdType = 'bvn' | 'nin' | 'vnin' | 'passport' | 'drivers-license' | 'pvc';
|
|
10
|
+
export type GhanaIdType = 'ghana-card' | 'voters' | 'drivers-license' | 'ssnit' | 'passport';
|
|
11
|
+
export type KenyaIdType = 'national-id' | 'passport';
|
|
12
|
+
export type SouthAfricaIdType = 'national-id';
|
|
13
|
+
export type IvoryCoastIdType = 'cni' | 'residence-card';
|
|
14
|
+
|
|
15
|
+
export type IdType =
|
|
16
|
+
| NigeriaIdType
|
|
17
|
+
| GhanaIdType
|
|
18
|
+
| KenyaIdType
|
|
19
|
+
| SouthAfricaIdType
|
|
20
|
+
| IvoryCoastIdType;
|
|
21
|
+
|
|
22
|
+
/** Maps a country code to the ID types available in that country. */
|
|
23
|
+
export type IdTypeForCountry<C extends SupportedCountry> =
|
|
24
|
+
C extends 'NG' ? NigeriaIdType :
|
|
25
|
+
C extends 'GH' ? GhanaIdType :
|
|
26
|
+
C extends 'KE' ? KenyaIdType :
|
|
27
|
+
C extends 'ZA' ? SouthAfricaIdType :
|
|
28
|
+
C extends 'CI' ? IvoryCoastIdType :
|
|
29
|
+
never;
|
|
30
|
+
|
|
31
|
+
export interface IdTypeDefinition {
|
|
32
|
+
key: IdType;
|
|
33
|
+
label: string;
|
|
34
|
+
digits?: number;
|
|
35
|
+
pattern?: RegExp;
|
|
36
|
+
/** Whether this ID type requires photographing/uploading a physical document. */
|
|
37
|
+
requiresDocumentCapture: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* How many sides of the document need to be scanned. Only present when
|
|
40
|
+
* `requiresDocumentCapture` is true.
|
|
41
|
+
* - `front_only` — single scan (passports, data-page only)
|
|
42
|
+
* - `front_and_back` — both sides required
|
|
43
|
+
*/
|
|
44
|
+
scanSides?: 'front_only' | 'front_and_back';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type IdTypesByCountry = {
|
|
48
|
+
[K in SupportedCountry]: readonly IdTypeDefinition[];
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// KYC flow steps
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
export type KYCStep =
|
|
56
|
+
| 'consent'
|
|
57
|
+
| 'id-type'
|
|
58
|
+
| 'id-input'
|
|
59
|
+
| 'document-capture'
|
|
60
|
+
| 'liveness'
|
|
61
|
+
| 'submitted';
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Appearance / theming
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
export interface KYCAppearance {
|
|
68
|
+
/** Brand color — drives buttons, selected states, focus rings. */
|
|
69
|
+
primaryColor?: string;
|
|
70
|
+
/** Text/icon color rendered on top of `primaryColor` (e.g. button labels). */
|
|
71
|
+
primaryTextColor?: string;
|
|
72
|
+
/** Accent color for subtle hover/active surfaces. */
|
|
73
|
+
accentColor?: string;
|
|
74
|
+
/** Modal/sheet background color. */
|
|
75
|
+
backgroundColor?: string;
|
|
76
|
+
/** Elevated surface color for cards/panels. */
|
|
77
|
+
surfaceColor?: string;
|
|
78
|
+
/** Border + input outline color. */
|
|
79
|
+
borderColor?: string;
|
|
80
|
+
/** Primary text color. */
|
|
81
|
+
textColor?: string;
|
|
82
|
+
companyName?: string;
|
|
83
|
+
/**
|
|
84
|
+
* Logo to show in the flow.
|
|
85
|
+
* - An image URL renders that logo.
|
|
86
|
+
* - The literal `'default'` renders the org's own logo from the server config
|
|
87
|
+
* response (falls back to the built-in shield if the org has none set).
|
|
88
|
+
* - Omitted renders the built-in shield badge.
|
|
89
|
+
*/
|
|
90
|
+
logo?: string;
|
|
91
|
+
/** Initial light/dark mode. Applied on mount; the theme toggle can flip it. */
|
|
92
|
+
theme?: 'light' | 'dark';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Consent / success screen content
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
export interface KYCConsentContent {
|
|
100
|
+
/** Consent heading. Supports `{firstName}` / `{lastName}` tokens. */
|
|
101
|
+
title?: string;
|
|
102
|
+
/** Sub-text under the heading. Same tokens. */
|
|
103
|
+
description?: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface KYCSuccessContent {
|
|
107
|
+
/** Success heading. Supports `{firstName}` / `{lastName}` tokens. */
|
|
108
|
+
title?: string;
|
|
109
|
+
/** Sub-text under the heading. Same tokens. */
|
|
110
|
+
description?: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Voice guidance (spoken liveness instructions — TTS output only, no mic)
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Configuration for the spoken liveness instructions. TTS **output** for
|
|
119
|
+
* accessibility — it never records audio, so no microphone permission is
|
|
120
|
+
* involved. An object (not a bare boolean) so a `language` can be added later
|
|
121
|
+
* without a breaking change.
|
|
122
|
+
*/
|
|
123
|
+
export interface VoiceGuidanceConfig {
|
|
124
|
+
/** Whether spoken guidance plays. Default `true`. */
|
|
125
|
+
enabled?: boolean;
|
|
126
|
+
/** BCP-47 voice tag (e.g. `'en-US'`, `'fr-FR'`). Default `'en-US'`. */
|
|
127
|
+
language?: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Accepts a bare boolean for ergonomics or the full {@link VoiceGuidanceConfig}. */
|
|
131
|
+
export type VoiceGuidanceOption = boolean | VoiceGuidanceConfig;
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// Client-side SDK config (MyazaKYC.show() / useMyazaKYC options)
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
export interface MyazaKYCConfig<C extends SupportedCountry = SupportedCountry> {
|
|
138
|
+
/**
|
|
139
|
+
* Bearer token. The key prefix is the single source of truth for the
|
|
140
|
+
* environment — the SDK derives it (and the base URL) automatically:
|
|
141
|
+
* `pk_dev_…` → development, `pk_test_…` → sandbox, `pk_live_…` → production.
|
|
142
|
+
* An unrecognized prefix throws.
|
|
143
|
+
*/
|
|
144
|
+
apiKey: string;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Dev-only base-URL override. Only applied for **development** keys
|
|
148
|
+
* (`pk_dev_…`); defaults to a platform-aware localhost (`10.0.2.2:3001` on
|
|
149
|
+
* Android emulators, `localhost:3001` elsewhere). Ignored for sandbox /
|
|
150
|
+
* production keys.
|
|
151
|
+
*/
|
|
152
|
+
devUrl?: string;
|
|
153
|
+
|
|
154
|
+
/** Two-letter country code. */
|
|
155
|
+
country: C;
|
|
156
|
+
|
|
157
|
+
/** Subset of ID types to offer. Only types valid for the country are accepted. */
|
|
158
|
+
idTypes?: IdTypeForCountry<C>[];
|
|
159
|
+
|
|
160
|
+
/** Pre-populated user data. Fields provided here won't be collected again. */
|
|
161
|
+
userData?: {
|
|
162
|
+
firstName?: string;
|
|
163
|
+
lastName?: string;
|
|
164
|
+
dateOfBirth?: string;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/** Enable the live-selfie capture step. */
|
|
168
|
+
enableSelfie?: boolean;
|
|
169
|
+
|
|
170
|
+
/** Enable the document-photo capture step. */
|
|
171
|
+
enableDocumentCapture?: boolean;
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Allow picking a document photo from the device gallery as an alternative to
|
|
175
|
+
* the live camera capture. Default `true`. When `false`, the "upload a photo
|
|
176
|
+
* instead" affordances are hidden during normal capture.
|
|
177
|
+
*/
|
|
178
|
+
allowDocumentUpload?: boolean;
|
|
179
|
+
|
|
180
|
+
/** Enable liveness detection during selfie capture. Default `true`. */
|
|
181
|
+
enableLiveness?: boolean;
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Spoken liveness instructions (accessibility). `true`/omitted = on,
|
|
185
|
+
* `false` = off, or a {@link VoiceGuidanceConfig}. TTS output only — no
|
|
186
|
+
* microphone is used. Default: on.
|
|
187
|
+
*/
|
|
188
|
+
voiceGuidance?: VoiceGuidanceOption;
|
|
189
|
+
|
|
190
|
+
/** Show a light/dark mode toggle button inside the modal header. Default `true`. */
|
|
191
|
+
showThemeToggle?: boolean;
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Hide the close (X) button and block all user-initiated dismissal of the
|
|
195
|
+
* sheet — the X button, Android hardware back, and the iOS swipe-down drag.
|
|
196
|
+
* When `true`, the flow can only be closed programmatically via the `close()`
|
|
197
|
+
* returned by {@link useMyazaKYC}. Default `false`. The terminal "Submitted"
|
|
198
|
+
* step is already non-dismissible regardless of this flag.
|
|
199
|
+
*/
|
|
200
|
+
disableClose?: boolean;
|
|
201
|
+
|
|
202
|
+
/** Visual customisation. */
|
|
203
|
+
appearance?: KYCAppearance;
|
|
204
|
+
|
|
205
|
+
/** Override the consent (welcome) screen copy. */
|
|
206
|
+
consent?: KYCConsentContent;
|
|
207
|
+
|
|
208
|
+
/** Override the success (submitted) screen copy. */
|
|
209
|
+
success?: KYCSuccessContent;
|
|
210
|
+
|
|
211
|
+
/** Arbitrary metadata forwarded with every verification request. */
|
|
212
|
+
metadata?: Record<string, string>;
|
|
213
|
+
|
|
214
|
+
// Callbacks
|
|
215
|
+
onStart?: () => void;
|
|
216
|
+
onStepChange?: (step: KYCStep) => void;
|
|
217
|
+
/**
|
|
218
|
+
* Fires immediately after the user submits. The submission is always
|
|
219
|
+
* status: 'pending' — results arrive async via webhook.
|
|
220
|
+
*/
|
|
221
|
+
onSubmit?: (submission: KYCSubmission) => void;
|
|
222
|
+
onClose?: () => void;
|
|
223
|
+
/**
|
|
224
|
+
* Fires for technical errors only. Receives a {@link KYCError} carrying a
|
|
225
|
+
* typed `code`. Verification *outcomes* never come through here.
|
|
226
|
+
*/
|
|
227
|
+
onError?: (error: KYCError) => void;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
// useMyazaKYC() hook return type
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
export interface UseMyazaKYCReturn {
|
|
235
|
+
open: () => void;
|
|
236
|
+
close: () => void;
|
|
237
|
+
isOpen: boolean;
|
|
238
|
+
currentStep: KYCStep | null;
|
|
239
|
+
}
|