@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.
Files changed (87) hide show
  1. package/KycSdkReactNative.podspec +32 -0
  2. package/LICENSE +21 -0
  3. package/README.md +164 -0
  4. package/android/CMakeLists.txt +29 -0
  5. package/android/build.gradle +110 -0
  6. package/android/src/main/AndroidManifest.xml +12 -0
  7. package/android/src/main/cpp/cpp-adapter.cpp +12 -0
  8. package/android/src/main/java/co/myazahq/kyc/rn/MyazaFaceDetectorPackage.kt +36 -0
  9. package/android/src/main/java/co/myazahq/kyc/rn/MyazaStatusBarModule.kt +64 -0
  10. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +134 -0
  11. package/app.plugin.js +55 -0
  12. package/expo-module.config.json +6 -0
  13. package/ios/HybridMyazaFaceDetector.swift +233 -0
  14. package/package.json +84 -0
  15. package/react-native.config.js +23 -0
  16. package/src/MyazaKYC.tsx +235 -0
  17. package/src/__tests__/cardCrop.test.ts +39 -0
  18. package/src/__tests__/deviceMetadata.test.ts +34 -0
  19. package/src/__tests__/errors.test.ts +34 -0
  20. package/src/__tests__/flow.test.ts +61 -0
  21. package/src/__tests__/gestureDetector.test.ts +37 -0
  22. package/src/__tests__/liveness.test.ts +112 -0
  23. package/src/__tests__/resolveUrl.test.ts +64 -0
  24. package/src/__tests__/validators.test.ts +38 -0
  25. package/src/assets/liveness/Blink.gif +0 -0
  26. package/src/assets/liveness/Nod.gif +0 -0
  27. package/src/assets/liveness/Smile.gif +0 -0
  28. package/src/assets/liveness/Turn.gif +0 -0
  29. package/src/components/CameraPermissionView.tsx +116 -0
  30. package/src/components/CameraViewfinder.tsx +156 -0
  31. package/src/components/CountryFlag.tsx +52 -0
  32. package/src/components/DocumentCropper.tsx +325 -0
  33. package/src/components/GlassIconButton.tsx +92 -0
  34. package/src/components/Icon.tsx +125 -0
  35. package/src/components/KycFlow.tsx +205 -0
  36. package/src/components/KycSheet.tsx +224 -0
  37. package/src/components/MyazaAlert.tsx +57 -0
  38. package/src/components/MyazaButton.tsx +101 -0
  39. package/src/components/MyazaCard.tsx +48 -0
  40. package/src/components/MyazaInput.tsx +112 -0
  41. package/src/components/MyazaPulseLoader.tsx +71 -0
  42. package/src/components/StatusBarController.tsx +42 -0
  43. package/src/components/StepHeader.tsx +56 -0
  44. package/src/components/StepIndicator.tsx +74 -0
  45. package/src/components/Typography.tsx +69 -0
  46. package/src/components/fonts.ts +49 -0
  47. package/src/components/glass/GlassGroup.tsx +34 -0
  48. package/src/components/glass/GlassSurface.tsx +64 -0
  49. package/src/components/runtime.tsx +93 -0
  50. package/src/components/toast.tsx +154 -0
  51. package/src/components/useBranding.ts +27 -0
  52. package/src/components/useVideoRecorder.ts +122 -0
  53. package/src/config/captureSettings.ts +67 -0
  54. package/src/config/idTypes.ts +79 -0
  55. package/src/config/theme.ts +186 -0
  56. package/src/index.ts +54 -0
  57. package/src/liveness/challengeManager.ts +130 -0
  58. package/src/liveness/faceDetector.ts +79 -0
  59. package/src/liveness/gestureDetector.ts +80 -0
  60. package/src/liveness/speech.ts +66 -0
  61. package/src/liveness/types.ts +99 -0
  62. package/src/liveness/useLiveness.ts +484 -0
  63. package/src/liveness/visionCameraFaceDetector.ts +118 -0
  64. package/src/screens/ConsentStep.tsx +164 -0
  65. package/src/screens/DocumentCaptureStep.tsx +500 -0
  66. package/src/screens/IdInputStep.tsx +79 -0
  67. package/src/screens/IdTypeStep.tsx +142 -0
  68. package/src/screens/LivenessAvatar.tsx +69 -0
  69. package/src/screens/LivenessStep.tsx +615 -0
  70. package/src/screens/SubmittedStep.tsx +177 -0
  71. package/src/services/api.ts +291 -0
  72. package/src/services/cardCrop.ts +52 -0
  73. package/src/services/deviceMetadata.ts +185 -0
  74. package/src/services/errors.ts +92 -0
  75. package/src/services/mediaCompress.ts +129 -0
  76. package/src/services/resolveUrl.ts +98 -0
  77. package/src/services/retry.ts +70 -0
  78. package/src/services/validators.ts +103 -0
  79. package/src/specs/MyazaFaceDetector.nitro.ts +44 -0
  80. package/src/store/kycStore.ts +288 -0
  81. package/src/store/serverConfig.ts +78 -0
  82. package/src/types/config.ts +239 -0
  83. package/src/types/country-flag-icons.d.ts +6 -0
  84. package/src/types/verification.ts +79 -0
  85. package/src/utils/platform.ts +23 -0
  86. package/src/utils/tokens.ts +10 -0
  87. package/src/utils/uuid.ts +31 -0
@@ -0,0 +1,164 @@
1
+ import React, { useState } from 'react';
2
+ import { Pressable, View } from 'react-native';
3
+
4
+ import { radius, spacing } from '../config/theme';
5
+ import { useKyc, useKycConfig, useTheme } from '../components/runtime';
6
+ import { MyazaText } from '../components/Typography';
7
+ import { MyazaButton } from '../components/MyazaButton';
8
+ import { Icon, type IconName } from '../components/Icon';
9
+ import { fillTokens } from '../utils/tokens';
10
+
11
+ // Consent / welcome screen — 1:1 with the Flutter SDK's ConsentScreen:
12
+ // shield hero, token-filled greeting, a "DURING THIS PROCESS WE WILL" card,
13
+ // a consent checkbox (must agree), Continue (gated on agreement), lock footer.
14
+ // The header title is intentionally empty for this step; the screen owns its hero.
15
+
16
+ const DEFAULT_DESCRIPTION =
17
+ 'We need to verify your identity to comply with regulatory requirements. This process is quick and secure.';
18
+
19
+ interface ProcessStep {
20
+ icon: IconName;
21
+ label: string;
22
+ }
23
+
24
+ export function ConsentStep(): React.ReactElement {
25
+ const { colors } = useTheme();
26
+ const config = useKycConfig();
27
+ const nextStep = useKyc((s) => s.nextStep);
28
+ const [agreed, setAgreed] = useState(false);
29
+
30
+ const firstName = config.userData?.firstName ?? '';
31
+ const title = config.consent?.title
32
+ ? fillTokens(config.consent.title, config.userData)
33
+ : firstName
34
+ ? `Welcome, ${firstName}`
35
+ : 'Identity Verification';
36
+ const description = config.consent?.description
37
+ ? fillTokens(config.consent.description, config.userData)
38
+ : DEFAULT_DESCRIPTION;
39
+
40
+ const steps: ProcessStep[] = [
41
+ { icon: 'badge-check', label: 'Verify your government-issued ID' },
42
+ { icon: 'user', label: 'Collect basic personal information' },
43
+ ...(config.enableDocumentCapture
44
+ ? [{ icon: 'scan-line' as IconName, label: 'Capture a photo of your ID document' }]
45
+ : []),
46
+ ...(config.enableSelfie
47
+ ? [{ icon: 'scan-face' as IconName, label: 'Take a selfie for facial verification' }]
48
+ : []),
49
+ ];
50
+
51
+ return (
52
+ <View>
53
+ <View style={{ height: spacing.sm }} />
54
+
55
+ {/* Shield hero — concentric tinted rings + primary badge */}
56
+ <View style={{ alignItems: 'center' }}>
57
+ <View style={{ width: 80, height: 80, alignItems: 'center', justifyContent: 'center' }}>
58
+ <View style={{ position: 'absolute', width: 80, height: 80, borderRadius: 40, backgroundColor: `${colors.primary}1A` }} />
59
+ <View style={{ position: 'absolute', width: 64, height: 64, borderRadius: 32, backgroundColor: `${colors.primary}26` }} />
60
+ <View
61
+ style={{
62
+ width: 56,
63
+ height: 56,
64
+ borderRadius: 28,
65
+ backgroundColor: colors.primary,
66
+ alignItems: 'center',
67
+ justifyContent: 'center',
68
+ shadowColor: colors.primary,
69
+ shadowOpacity: 0.3,
70
+ shadowRadius: 16,
71
+ shadowOffset: { width: 0, height: 6 },
72
+ elevation: 6,
73
+ }}
74
+ >
75
+ <Icon name="shield" size={28} color={colors.onPrimary} />
76
+ </View>
77
+ </View>
78
+ </View>
79
+
80
+ <View style={{ height: spacing.lg }} />
81
+ <MyazaText variant="heading1" style={{ textAlign: 'center' }}>
82
+ {title}
83
+ </MyazaText>
84
+ <View style={{ height: spacing.sm }} />
85
+ <MyazaText variant="bodyMedium" style={{ textAlign: 'center' }}>
86
+ {description}
87
+ </MyazaText>
88
+ <View style={{ height: spacing.lg }} />
89
+
90
+ {/* Process steps card */}
91
+ <View style={{ backgroundColor: colors.backgroundSecondary, borderRadius: radius.lg, padding: spacing.md + 4 }}>
92
+ <MyazaText variant="bodySmall" color={colors.textMuted} style={{ fontWeight: '600', letterSpacing: 0.5 }}>
93
+ DURING THIS PROCESS WE WILL
94
+ </MyazaText>
95
+ <View style={{ height: spacing.md }} />
96
+ {steps.map((step, i) => (
97
+ <View key={step.label} style={{ flexDirection: 'row', alignItems: 'center', marginTop: i > 0 ? 14 : 0 }}>
98
+ <View
99
+ style={{
100
+ width: 36,
101
+ height: 36,
102
+ borderRadius: radius.xs,
103
+ backgroundColor: `${colors.primary}1A`,
104
+ alignItems: 'center',
105
+ justifyContent: 'center',
106
+ }}
107
+ >
108
+ <Icon name={step.icon} size={18} color={colors.primary} />
109
+ </View>
110
+ <View style={{ width: spacing.sm + 4 }} />
111
+ <MyazaText variant="bodyMedium" color={colors.textDark} style={{ flex: 1, fontWeight: '600' }}>
112
+ {step.label}
113
+ </MyazaText>
114
+ </View>
115
+ ))}
116
+ </View>
117
+
118
+ <View style={{ height: spacing.lg }} />
119
+
120
+ {/* Consent checkbox */}
121
+ <Pressable
122
+ onPress={() => setAgreed((v) => !v)}
123
+ style={{
124
+ flexDirection: 'row',
125
+ alignItems: 'flex-start',
126
+ padding: spacing.md,
127
+ borderRadius: radius.sm,
128
+ backgroundColor: agreed ? `${colors.primary}0F` : colors.backgroundSecondary,
129
+ }}
130
+ >
131
+ <View
132
+ style={{
133
+ width: 22,
134
+ height: 22,
135
+ borderRadius: radius.xs - 2,
136
+ backgroundColor: agreed ? colors.primary : 'transparent',
137
+ borderWidth: 1.5,
138
+ borderColor: agreed ? colors.primary : colors.gray400,
139
+ alignItems: 'center',
140
+ justifyContent: 'center',
141
+ marginTop: 1,
142
+ }}
143
+ >
144
+ {agreed ? <Icon name="check" size={14} color={colors.onPrimary} /> : null}
145
+ </View>
146
+ <View style={{ width: spacing.sm + 2 }} />
147
+ <MyazaText variant="bodyMedium" color={colors.textDark} style={{ flex: 1, fontWeight: '600' }}>
148
+ I consent to the collection and processing of my personal data for identity verification purposes.
149
+ </MyazaText>
150
+ </Pressable>
151
+
152
+ <View style={{ height: spacing.lg }} />
153
+ <MyazaButton label="Continue" onPress={agreed ? nextStep : undefined} disabled={!agreed} />
154
+ <View style={{ height: spacing.sm }} />
155
+
156
+ {/* Footer */}
157
+ <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center' }}>
158
+ <Icon name="lock" size={13} color={colors.textMuted} />
159
+ <View style={{ width: 6 }} />
160
+ <MyazaText variant="bodySmall">Your data is encrypted and securely processed</MyazaText>
161
+ </View>
162
+ </View>
163
+ );
164
+ }
@@ -0,0 +1,500 @@
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { Image, Pressable, View } from 'react-native';
3
+ import * as ImagePicker from 'expo-image-picker';
4
+ import { useCameraDevice, useCameraPermission } from 'react-native-vision-camera';
5
+
6
+ import { radius, spacing } from '../config/theme';
7
+ import { ID_TYPES, documentGuideAspect, getScanSides } from '../config/idTypes';
8
+ import { withRetry } from '../services/retry';
9
+ import { mapToKycError, safeReportError } from '../services/errors';
10
+ import { compressDocumentImage, compressVideo, cropCardRegion } from '../services/mediaCompress';
11
+ import { MAX_VIDEO_BYTES } from '../config/captureSettings';
12
+ import { KYCError } from '../types/verification';
13
+ import type { DocumentCapturePhase } from '../store/kycStore';
14
+ import { useKyc, useKycConfig, useTheme } from '../components/runtime';
15
+ import { MyazaText } from '../components/Typography';
16
+ import { MyazaButton } from '../components/MyazaButton';
17
+ import { useToast } from '../components/toast';
18
+ import { MyazaPulseLoader } from '../components/MyazaPulseLoader';
19
+ import { CameraViewfinder } from '../components/CameraViewfinder';
20
+ import { CameraPermissionView, CameraUnavailableView } from '../components/CameraPermissionView';
21
+ import { DocumentCropper } from '../components/DocumentCropper';
22
+ import { Icon } from '../components/Icon';
23
+
24
+ // Document capture — the RN mirror of the Flutter/web DocumentCaptureStep.
25
+ // Phases: front → front-preview → back → review (two-sided); front → review
26
+ // (one-sided). Manual shutter (VisionCamera) or gallery upload; each side is
27
+ // compressed (OCR-conservative) then eagerly uploaded on Continue with retry.
28
+ //
29
+ // The per-phase title/description live in the SHEET HEADER (KycFlow reads the
30
+ // synced `documentCapturePhase` from the store and calls `documentCaptureMeta`),
31
+ // not in the body — mirroring Flutter's header `docReviewPhase` sync.
32
+
33
+ /** The header title/description for a document-capture phase. Used by KycFlow. */
34
+ export function documentCaptureMeta(
35
+ phase: DocumentCapturePhase,
36
+ documentLabel: string,
37
+ ): { title: string; description: string } {
38
+ // Copy matches the Flutter SDK's document-capture header meta exactly.
39
+ switch (phase) {
40
+ case 'front':
41
+ return {
42
+ title: `Capture Your ${documentLabel}`,
43
+ description: `Photograph your ${documentLabel} — position it within the frame and hold steady.`,
44
+ };
45
+ case 'front-preview':
46
+ return {
47
+ title: 'Front Side Captured',
48
+ description: 'Looks good? Tap Next to flip the card and scan the back side.',
49
+ };
50
+ case 'back':
51
+ return {
52
+ title: 'Scan Back Side',
53
+ description: `Now place the BACK of your ${documentLabel} within the frame.`,
54
+ };
55
+ case 'review':
56
+ default:
57
+ return {
58
+ title: `Review Your ${documentLabel}`,
59
+ description: 'Tap Continue to upload and submit your document.',
60
+ };
61
+ }
62
+ }
63
+
64
+ export function DocumentCaptureStep(): React.ReactElement {
65
+ const { colors } = useTheme();
66
+ const toast = useToast();
67
+ const config = useKycConfig();
68
+ const selectedIdType = useKyc((s) => s.selectedIdType);
69
+ const api = useKyc((s) => s.api);
70
+ const setMediaId = useKyc((s) => s.setMediaId);
71
+ const setDocumentCapturePhase = useKyc((s) => s.setDocumentCapturePhase);
72
+ const nextStep = useKyc((s) => s.nextStep);
73
+
74
+ const allowUpload = config.allowDocumentUpload !== false;
75
+ const documentLabel =
76
+ (selectedIdType && Object.values(ID_TYPES).flat().find((t) => t.key === selectedIdType)?.label) || 'Document';
77
+ const scanSides = selectedIdType ? getScanSides(selectedIdType) : 'front_only';
78
+ const isTwoSided = scanSides === 'front_and_back';
79
+ const guideAspect = documentGuideAspect(selectedIdType);
80
+
81
+ const [phase, setPhase] = useState<DocumentCapturePhase>('front');
82
+ const [frontUri, setFrontUri] = useState<string | null>(null);
83
+ const [backUri, setBackUri] = useState<string | null>(null);
84
+ const [busy, setBusy] = useState(false); // compressing a fresh capture
85
+ const [uploading, setUploading] = useState(false);
86
+ const [retryInfo, setRetryInfo] = useState<{ attempt: number; total: number } | null>(null);
87
+ const [uploadError, setUploadError] = useState<string | null>(null);
88
+ const [cropUri, setCropUri] = useState<string | null>(null); // gallery photo awaiting crop
89
+ // Best-effort document videos recorded alongside each side's still (uploaded as
90
+ // document_front_video / document_back_video). Refs — they don't drive the UI.
91
+ const frontVideoRef = useRef<string | null>(null);
92
+ const backVideoRef = useRef<string | null>(null);
93
+
94
+ // Publish the sub-phase so the sheet header (KycFlow) shows the right title.
95
+ useEffect(() => {
96
+ setDocumentCapturePhase(phase);
97
+ }, [phase, setDocumentCapturePhase]);
98
+
99
+ // ── Camera permission ──────────────────────────────────────────────────────
100
+ // `perm` is derived from the ASYNC requestPermission result, not synchronously
101
+ // from `hasPermission` — otherwise the brief window while the OS prompt is open
102
+ // (hasPermission still false) would read as "denied" and fire onError early.
103
+ const { hasPermission, requestPermission } = useCameraPermission();
104
+ const [perm, setPerm] = useState<'checking' | 'granted' | 'denied'>(hasPermission ? 'granted' : 'checking');
105
+ const askedRef = useRef(false);
106
+ const permReportedRef = useRef(false);
107
+
108
+ // ── Camera availability ─────────────────────────────────────────────────────
109
+ // Even with permission granted, there may be no usable back camera (the iOS/
110
+ // Android simulator has none; a real device may fail to init). Give the device
111
+ // list a moment to resolve, then surface a proper "Camera not available" error
112
+ // with an upload fallback — on every iOS version (glass or not) and Android.
113
+ // Device enumeration does NOT need camera permission (iOS AVCaptureDevice /
114
+ // Android CameraManager list hardware regardless), so `!device` reliably means
115
+ // "no back-camera hardware" — true on every simulator. That's a different state
116
+ // from "permission denied": no hardware → nothing to grant.
117
+ const device = useCameraDevice('back');
118
+ const [cameraGrace, setCameraGrace] = useState(false);
119
+ useEffect(() => {
120
+ const t = setTimeout(() => setCameraGrace(true), 1500);
121
+ return () => clearTimeout(t);
122
+ }, []);
123
+ // No camera hardware at all → "Camera not available" (regardless of what the
124
+ // permission API says — on a camera-less sim it may even report denied).
125
+ const cameraUnavailable = cameraGrace && !device;
126
+
127
+ useEffect(() => {
128
+ if (hasPermission) {
129
+ setPerm('granted');
130
+ return;
131
+ }
132
+ if (askedRef.current) return;
133
+ askedRef.current = true;
134
+ void (async () => {
135
+ const granted = await requestPermission();
136
+ setPerm(granted ? 'granted' : 'denied');
137
+ })();
138
+ }, [hasPermission, requestPermission]);
139
+
140
+ // A *genuine* permission denial requires a camera to exist but be blocked. On a
141
+ // camera-less sim the OS may report denied — that's "not available", not a
142
+ // permission problem, so don't treat it as denied or report onError there.
143
+ const permissionDenied = perm === 'denied' && !!device;
144
+ useEffect(() => {
145
+ if (permissionDenied && !permReportedRef.current) {
146
+ permReportedRef.current = true;
147
+ safeReportError(
148
+ config.onError,
149
+ new KYCError(
150
+ 'camera_permission_denied',
151
+ 'Camera access is required to photograph your document. Allow camera access or upload a photo instead.',
152
+ ),
153
+ );
154
+ }
155
+ if (!permissionDenied) permReportedRef.current = false;
156
+ }, [permissionDenied, config.onError]);
157
+
158
+ const retryPermission = useCallback(() => {
159
+ askedRef.current = false;
160
+ setPerm('checking');
161
+ }, []);
162
+
163
+ // ── Capture → compress → store for the current side ────────────────────────
164
+ const storeCapture = useCallback(
165
+ async (rawUri: string) => {
166
+ setBusy(true);
167
+ setUploadError(null);
168
+ try {
169
+ const compressed = await compressDocumentImage(rawUri);
170
+ if (phase === 'back') {
171
+ setBackUri(compressed);
172
+ setPhase('review');
173
+ } else {
174
+ setFrontUri(compressed);
175
+ setPhase(isTwoSided ? 'front-preview' : 'review');
176
+ }
177
+ } finally {
178
+ setBusy(false);
179
+ }
180
+ },
181
+ [phase, isTwoSided],
182
+ );
183
+
184
+ // Gallery upload: pick the raw photo (no native editor), then open the SDK's
185
+ // interactive ID-card cropper — mirrors the Flutter SDK's _DocumentCropperScreen.
186
+ // Live-camera capture: crop the full frame to the card-guide rectangle first
187
+ // (mirrors Flutter's cropCardRegion), then store. Gallery photos skip this —
188
+ // they're already cropped by the interactive cropper.
189
+ const captureFromCamera = useCallback(
190
+ async (rawUri: string, videoPath: string | null) => {
191
+ setBusy(true);
192
+ try {
193
+ // Stash the side's video (best-effort) before cropping/storing the still.
194
+ if (phase === 'back') backVideoRef.current = videoPath;
195
+ else frontVideoRef.current = videoPath;
196
+ const carded = await cropCardRegion(rawUri, guideAspect).catch(() => rawUri);
197
+ await storeCapture(carded);
198
+ } finally {
199
+ setBusy(false);
200
+ }
201
+ },
202
+ [guideAspect, storeCapture, phase],
203
+ );
204
+
205
+ const pickFromGallery = useCallback(async () => {
206
+ const result = await ImagePicker.launchImageLibraryAsync({
207
+ mediaTypes: ['images'],
208
+ allowsEditing: false,
209
+ quality: 1,
210
+ });
211
+ if (!result.canceled && result.assets[0]) {
212
+ setCropUri(result.assets[0].uri);
213
+ }
214
+ }, []);
215
+
216
+ // Best-effort document video upload — never blocks or fails the flow.
217
+ const uploadDocVideo = useCallback(
218
+ async (
219
+ videoPath: string | null,
220
+ type: 'document_front_video' | 'document_back_video',
221
+ mediaKey: 'documentFrontVideo' | 'documentBackVideo',
222
+ ) => {
223
+ if (!videoPath) return;
224
+ try {
225
+ // Transcode the raw 4K recording down to a small evidence clip first.
226
+ const small = await compressVideo(videoPath);
227
+ const id = await withRetry(() => api.upload({ uri: small, type: 'video/mp4' }, type, MAX_VIDEO_BYTES));
228
+ setMediaId(mediaKey, id);
229
+ } catch {
230
+ /* supplementary — verification proceeds without the document video
231
+ (dropped if it failed to upload or exceeded the 5MB ceiling) */
232
+ }
233
+ },
234
+ [api, setMediaId],
235
+ );
236
+
237
+ // ── Upload both sides, then advance (KycFlow routes to liveness/submitted) ──
238
+ const handleContinue = useCallback(async () => {
239
+ if (!frontUri) return;
240
+ setUploading(true);
241
+ setUploadError(null);
242
+ setRetryInfo(null);
243
+ const onRetry = (attempt: number, total: number) => setRetryInfo({ attempt, total });
244
+ try {
245
+ const frontId = await withRetry(() => api.upload({ uri: frontUri, type: 'image/jpeg' }, 'document_front'), { onRetry });
246
+ setMediaId('documentFront', frontId);
247
+ await uploadDocVideo(frontVideoRef.current, 'document_front_video', 'documentFrontVideo');
248
+ if (isTwoSided && backUri) {
249
+ const backId = await withRetry(() => api.upload({ uri: backUri, type: 'image/jpeg' }, 'document_back'), { onRetry });
250
+ setMediaId('documentBack', backId);
251
+ await uploadDocVideo(backVideoRef.current, 'document_back_video', 'documentBackVideo');
252
+ }
253
+ setRetryInfo(null);
254
+ setUploading(false);
255
+ nextStep();
256
+ } catch (err) {
257
+ setRetryInfo(null);
258
+ setUploading(false);
259
+ const kycError = mapToKycError(err, 'upload');
260
+ setUploadError(kycError.message);
261
+ toast.show({ variant: 'error', title: 'Upload failed', message: kycError.message });
262
+ safeReportError(config.onError, kycError);
263
+ }
264
+ }, [frontUri, backUri, isTwoSided, api, setMediaId, nextStep, config.onError, toast, uploadDocVideo]);
265
+
266
+ const retake = (side: 'front' | 'back') => {
267
+ setUploadError(null);
268
+ if (side === 'back') {
269
+ setBackUri(null);
270
+ backVideoRef.current = null;
271
+ setPhase('back');
272
+ } else {
273
+ setFrontUri(null);
274
+ frontVideoRef.current = null;
275
+ setPhase('front');
276
+ }
277
+ };
278
+
279
+ // Interactive ID-card cropper for a gallery photo (Modal — overlays whatever
280
+ // capture sub-screen triggered the upload, including the sim's "camera unavailable").
281
+ const cropper = cropUri ? (
282
+ <DocumentCropper
283
+ uri={cropUri}
284
+ onCancel={() => setCropUri(null)}
285
+ onConfirm={(out) => {
286
+ setCropUri(null);
287
+ void storeCapture(out);
288
+ }}
289
+ />
290
+ ) : null;
291
+
292
+ // ── Capture (front/back) ───────────────────────────────────────────────────
293
+ // Two distinct error states, distinguished by hardware vs permission (both
294
+ // states keep the title/description in the header, and always offer the gallery
295
+ // escape hatch). These never overlap: one needs a device, the other needs none.
296
+ if (phase === 'front' || phase === 'back') {
297
+ if (cameraUnavailable) {
298
+ // No back-camera hardware (e.g. a simulator) — permission is moot here.
299
+ return (
300
+ <>
301
+ {cropper}
302
+ <CameraUnavailableView onUpload={pickFromGallery} />
303
+ </>
304
+ );
305
+ }
306
+ if (permissionDenied) {
307
+ // A real camera exists but the OS blocked access.
308
+ return (
309
+ <>
310
+ {cropper}
311
+ <CameraPermissionView onRetry={retryPermission} onUpload={pickFromGallery} />
312
+ </>
313
+ );
314
+ }
315
+ const isBack = phase === 'back';
316
+ return (
317
+ <View>
318
+ {cropper}
319
+ {/* Required pill: ID label + side badge + step label (two-sided) */}
320
+ <RequiredPill
321
+ documentLabel={documentLabel}
322
+ sideBadge={isTwoSided ? (isBack ? 'Back Side' : 'Front Side') : undefined}
323
+ stepLabel={isTwoSided ? (isBack ? 'Step 2 of 2' : 'Step 1 of 2') : undefined}
324
+ />
325
+ {isBack ? (
326
+ <View style={{ flexDirection: 'row', alignItems: 'center', marginTop: spacing.sm, marginBottom: spacing.xs }}>
327
+ <Icon name="credit-card" size={14} color={colors.primary} />
328
+ <View style={{ width: 4 }} />
329
+ <MyazaText variant="bodySmall" color={colors.primary} style={{ fontWeight: '500' }}>
330
+ Flip the card over and scan the other side
331
+ </MyazaText>
332
+ </View>
333
+ ) : null}
334
+ <View style={{ height: spacing.md }} />
335
+ <CameraViewfinder
336
+ active={perm === 'granted'}
337
+ side={isBack ? 'back' : 'front'}
338
+ documentLabel={documentLabel}
339
+ guideAspect={guideAspect}
340
+ onCapture={(uri, videoPath) => void captureFromCamera(uri, videoPath)}
341
+ busy={busy}
342
+ />
343
+ {!busy ? (
344
+ <>
345
+ <View style={{ height: spacing.md }} />
346
+ <MyazaText variant="bodySmall" style={{ textAlign: 'center' }}>
347
+ Tap the button to capture manually
348
+ </MyazaText>
349
+ {allowUpload ? (
350
+ <Pressable onPress={pickFromGallery} style={{ marginTop: spacing.sm }}>
351
+ <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center' }}>
352
+ <MyazaText variant="bodySmall">Having trouble? </MyazaText>
353
+ <Icon name="upload" size={14} color={colors.primary} />
354
+ <View style={{ width: 4 }} />
355
+ <MyazaText variant="bodySmall" color={colors.primary} style={{ fontWeight: '700' }}>
356
+ Upload a photo instead
357
+ </MyazaText>
358
+ </View>
359
+ </Pressable>
360
+ ) : null}
361
+ </>
362
+ ) : null}
363
+ </View>
364
+ );
365
+ }
366
+
367
+ // ── Front preview (two-sided) ──────────────────────────────────────────────
368
+ if (phase === 'front-preview') {
369
+ return (
370
+ <View>
371
+ <DocImage uri={frontUri!} />
372
+ <View style={{ flexDirection: 'row', gap: spacing.md, marginTop: spacing.md }}>
373
+ <View style={{ flex: 1 }}>
374
+ <MyazaButton label="Retake" variant="outline" leadingIcon="refresh" onPress={() => retake('front')} />
375
+ </View>
376
+ <View style={{ flex: 1 }}>
377
+ <MyazaButton label="Next — Scan Back" onPress={() => setPhase('back')} />
378
+ </View>
379
+ </View>
380
+ </View>
381
+ );
382
+ }
383
+
384
+ // ── Review ─────────────────────────────────────────────────────────────────
385
+ return (
386
+ <View>
387
+ <DocImage uri={frontUri!} label={isTwoSided ? 'Front' : undefined} uploading={uploading} />
388
+ <MyazaButton label="Retake" variant="ghost" leadingIcon="refresh" onPress={() => retake('front')} disabled={uploading} />
389
+ {isTwoSided && backUri ? (
390
+ <>
391
+ <View style={{ height: spacing.md }} />
392
+ <DocImage uri={backUri} label="Back" uploading={uploading} />
393
+ <MyazaButton label="Retake Back" variant="ghost" leadingIcon="refresh" onPress={() => retake('back')} disabled={uploading} />
394
+ </>
395
+ ) : null}
396
+
397
+ {retryInfo && uploading ? (
398
+ <MyazaText variant="bodySmall" color={colors.warning} style={{ textAlign: 'center', marginTop: spacing.sm }}>
399
+ {`Upload failed — retrying (${retryInfo.attempt}/${retryInfo.total})…`}
400
+ </MyazaText>
401
+ ) : null}
402
+
403
+ <View style={{ height: spacing.md }} />
404
+ {uploadError ? (
405
+ // The error message is shown as a top toast; keep a retry action here.
406
+ <MyazaButton label="Try Again" onPress={handleContinue} loading={uploading} />
407
+ ) : (
408
+ <MyazaButton label="Continue" onPress={handleContinue} loading={uploading} />
409
+ )}
410
+ </View>
411
+ );
412
+ }
413
+
414
+ // "Required: {label}" pill with optional side badge + step label — mirrors the
415
+ // Flutter SDK's _RequiredPill on the capture screen.
416
+ function RequiredPill({
417
+ documentLabel,
418
+ sideBadge,
419
+ stepLabel,
420
+ }: {
421
+ documentLabel: string;
422
+ sideBadge?: string;
423
+ stepLabel?: string;
424
+ }): React.ReactElement {
425
+ const { colors } = useTheme();
426
+ return (
427
+ <View style={{ flexDirection: 'row', alignItems: 'center' }}>
428
+ <View
429
+ style={{
430
+ flex: 1,
431
+ flexDirection: 'row',
432
+ alignItems: 'center',
433
+ flexWrap: 'wrap',
434
+ borderWidth: 1,
435
+ borderColor: `${colors.primary}33`,
436
+ backgroundColor: `${colors.primary}0D`,
437
+ borderRadius: radius.md,
438
+ paddingHorizontal: spacing.sm + 4,
439
+ paddingVertical: spacing.sm,
440
+ }}
441
+ >
442
+ <Icon name="credit-card" size={16} color={colors.primary} />
443
+ <View style={{ width: 8 }} />
444
+ <MyazaText variant="bodySmall" color={colors.primary} style={{ fontWeight: '600' }}>
445
+ Required:{' '}
446
+ </MyazaText>
447
+ <MyazaText variant="bodySmall" color={colors.primary} style={{ flexShrink: 1 }}>
448
+ {documentLabel}
449
+ </MyazaText>
450
+ {sideBadge ? (
451
+ <View style={{ backgroundColor: `${colors.primary}26`, borderRadius: radius.full, paddingHorizontal: 8, paddingVertical: 2, marginLeft: 8 }}>
452
+ <MyazaText variant="bodySmall" color={colors.primary} style={{ fontWeight: '600', fontSize: 11 }}>
453
+ {sideBadge}
454
+ </MyazaText>
455
+ </View>
456
+ ) : null}
457
+ </View>
458
+ {stepLabel ? (
459
+ <MyazaText variant="bodySmall" color={colors.textMuted} style={{ marginLeft: spacing.sm }}>
460
+ {stepLabel}
461
+ </MyazaText>
462
+ ) : null}
463
+ </View>
464
+ );
465
+ }
466
+
467
+ function DocImage({ uri, label, uploading }: { uri: string; label?: string; uploading?: boolean }): React.ReactElement {
468
+ const { colors } = useTheme();
469
+ return (
470
+ <View>
471
+ {label ? (
472
+ <MyazaText variant="bodySmall" color={colors.textMuted} style={{ textAlign: 'center', marginBottom: spacing.xs }}>
473
+ {label}
474
+ </MyazaText>
475
+ ) : null}
476
+ <View style={{ borderRadius: radius.md, overflow: 'hidden', borderWidth: 1, borderColor: colors.border }}>
477
+ <Image source={{ uri }} style={{ width: '100%', aspectRatio: 1.586 }} resizeMode="cover" />
478
+ {/* Standard upload loader — a dark scrim + the pulse-ring/spinner loader
479
+ rendered INSIDE the preview frame, mirroring the web/Flutter SDKs (and
480
+ the liveness selfie review). */}
481
+ {uploading ? (
482
+ <View
483
+ style={{
484
+ position: 'absolute',
485
+ top: 0,
486
+ left: 0,
487
+ right: 0,
488
+ bottom: 0,
489
+ alignItems: 'center',
490
+ justifyContent: 'center',
491
+ backgroundColor: 'rgba(0,0,0,0.45)',
492
+ }}
493
+ >
494
+ <MyazaPulseLoader size={64} />
495
+ </View>
496
+ ) : null}
497
+ </View>
498
+ </View>
499
+ );
500
+ }