@myazahq/kyc-sdk-react-native 2.6.0 → 3.1.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/README.md +128 -4
- package/android/build.gradle +30 -16
- package/android/consumer-rules.pro +22 -0
- package/android/src/main/AndroidManifest.xml +17 -6
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +27 -72
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizer.kt +14 -0
- package/android/src/main/java/com/margelo/nitro/myazakyc/MlKitModelReadiness.kt +154 -0
- package/app.plugin.js +57 -2
- package/ios/HybridMyazaTextRecognizer.swift +8 -0
- package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.cpp +21 -0
- package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.hpp +2 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizerSpec.kt +8 -0
- package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.hpp +16 -0
- package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec.swift +2 -0
- package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec_cxx.swift +31 -0
- package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.cpp +2 -0
- package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.hpp +2 -0
- package/package.json +24 -14
- package/src/MyazaKYC.tsx +4 -11
- package/src/assets/fonts/Karla_400Regular.ttf +0 -0
- package/src/assets/fonts/Karla_500Medium.ttf +0 -0
- package/src/assets/fonts/Karla_600SemiBold.ttf +0 -0
- package/src/assets/fonts/Karla_700Bold.ttf +0 -0
- package/src/assets/fonts/OFL-Karla.txt +93 -0
- package/src/assets/fonts/OFL-SpaceGrotesk.txt +93 -0
- package/src/assets/fonts/README.md +12 -0
- package/src/assets/fonts/SpaceGrotesk_500Medium.ttf +0 -0
- package/src/assets/fonts/SpaceGrotesk_600SemiBold.ttf +0 -0
- package/src/assets/fonts/SpaceGrotesk_700Bold.ttf +0 -0
- package/src/capture/useAutoCapture.ts +8 -1
- package/src/components/DocumentCropper.tsx +30 -24
- package/src/components/DocumentReview.tsx +11 -1
- package/src/components/DocumentReviewSide.tsx +6 -2
- package/src/components/DocumentReviewZoom.tsx +5 -1
- package/src/components/GlassIconButton.tsx +2 -5
- package/src/components/Icon.tsx +17 -13
- package/src/components/KycFlow.tsx +10 -2
- package/src/components/KycSheet.tsx +0 -1
- package/src/components/MyazaButton.tsx +9 -0
- package/src/components/RequiredDocumentPill.tsx +114 -0
- package/src/components/StepHeader.tsx +23 -3
- package/src/components/StepView.tsx +3 -0
- package/src/components/documentReviewCopy.ts +39 -0
- package/src/components/fonts.ts +14 -17
- package/src/components/icons/glyphs.ts +35 -0
- package/src/components/icons/index.ts +3 -0
- package/src/components/icons/map.ts +165 -0
- package/src/components/icons/names.ts +86 -0
- package/src/components/stepHeaderMeta.tsx +21 -1
- package/src/config/documentCaptureMethods.ts +31 -0
- package/src/config/stepOrder.ts +20 -5
- package/src/config/supportingDocuments.ts +131 -0
- package/src/config/theme.ts +29 -3
- package/src/config/workflowMerge.ts +15 -0
- package/src/index.ts +5 -10
- package/src/lib/documentCaptureCheck.ts +136 -0
- package/src/lib/model-ready.ts +82 -0
- package/src/lib/prime-models.ts +50 -0
- package/src/lib/resubmit.ts +36 -7
- package/src/lib/selfie-sharpness.ts +211 -0
- package/src/lib/supportingDocumentsIntro.ts +35 -0
- package/src/liveness/avatarSource.ts +59 -0
- package/src/liveness/useModelReady.ts +9 -55
- package/src/mrz/extract.ts +71 -4
- package/src/mrz/textRecognizer.ts +36 -0
- package/src/mrz/useTextModelReady.ts +21 -0
- package/src/screens/BusinessDocumentSlot.tsx +37 -8
- package/src/screens/DocumentCaptureStep.tsx +177 -93
- package/src/screens/LivenessAvatar.tsx +18 -13
- package/src/screens/LivenessStep.tsx +37 -0
- package/src/screens/MrzScanView.tsx +33 -0
- package/src/screens/SupportingDocumentCard.tsx +134 -0
- package/src/screens/SupportingDocumentsStep.tsx +203 -0
- package/src/screens/consent/model.ts +22 -5
- package/src/screens/document/CaptureCheckNotice.tsx +77 -0
- package/src/screens/document/UploadPhase.tsx +177 -0
- package/src/screens/document/useDocumentCamera.ts +109 -0
- package/src/screens/liveness/CaptureRing.tsx +31 -17
- package/src/screens/liveness/LivenessOutcome.tsx +4 -1
- package/src/screens/liveness/SelfiePreview.tsx +19 -4
- package/src/screens/supportingDocumentParts.tsx +126 -0
- package/src/screens/useBusinessDocumentAttach.ts +20 -9
- package/src/services/api-types.ts +31 -2
- package/src/services/api.ts +19 -3
- package/src/services/deviceMetadata.ts +1 -1
- package/src/services/mediaCompress.ts +23 -5
- package/src/specs/MyazaTextRecognizer.nitro.ts +29 -0
- package/src/store/derive.ts +11 -0
- package/src/store/kycStore.ts +23 -2
- package/src/store/session.ts +18 -0
- package/src/store/state.ts +26 -0
- package/src/store/submit.ts +11 -0
- package/src/types/config.ts +25 -0
- package/src/types/workflow.ts +39 -0
- package/src/MyazaBiometricAuth.tsx +0 -119
- 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/icon-map.ts +0 -176
- package/src/lib/biometric-auth.ts +0 -52
- package/src/screens/biometric/BiometricAuthFlow.tsx +0 -170
- package/src/services/api-biometric.ts +0 -37
- package/src/services/api-types-biometric.ts +0 -41
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { radius, spacing } from '../config/theme';
|
|
5
|
+
import { useTheme } from '../components/runtime';
|
|
6
|
+
import { MyazaText } from '../components/Typography';
|
|
7
|
+
import { BusinessDocumentSlot } from './BusinessDocumentSlot';
|
|
8
|
+
import {
|
|
9
|
+
DocumentMarker,
|
|
10
|
+
DocumentReads,
|
|
11
|
+
DocumentStatePill,
|
|
12
|
+
} from './supportingDocumentParts';
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// One requested supporting document: what it is, what it is being taken FOR,
|
|
16
|
+
// and the upload that satisfies it.
|
|
17
|
+
//
|
|
18
|
+
// The middle part is the point. A supporting document is whatever the
|
|
19
|
+
// organisation named it, so an upload slot with a title on it tells the
|
|
20
|
+
// applicant almost nothing; naming the values that will be read off it says
|
|
21
|
+
// what the document is actually for, and is the honest thing to show somebody
|
|
22
|
+
// before they hand over a bank statement.
|
|
23
|
+
//
|
|
24
|
+
// MIRRORS the web SDK's SupportingDocumentCard and the Flutter one. Keep the
|
|
25
|
+
// three in step.
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
export function SupportingDocumentCard({
|
|
29
|
+
position,
|
|
30
|
+
total,
|
|
31
|
+
label,
|
|
32
|
+
description,
|
|
33
|
+
required,
|
|
34
|
+
reads,
|
|
35
|
+
fileName,
|
|
36
|
+
uploading,
|
|
37
|
+
previewUri = null,
|
|
38
|
+
isPdf = false,
|
|
39
|
+
error = null,
|
|
40
|
+
onPick,
|
|
41
|
+
onRemove,
|
|
42
|
+
}: {
|
|
43
|
+
/** 1-based, so the list reads as a checklist rather than a pile. */
|
|
44
|
+
position: number;
|
|
45
|
+
total: number;
|
|
46
|
+
label: string;
|
|
47
|
+
/** Guidance the organisation wrote: which document, and what it has to show. */
|
|
48
|
+
description: string | null;
|
|
49
|
+
required: boolean;
|
|
50
|
+
/** The named values the server will read off it, in the author's words. */
|
|
51
|
+
reads: readonly string[];
|
|
52
|
+
fileName: string | null;
|
|
53
|
+
uploading: boolean;
|
|
54
|
+
previewUri?: string | null;
|
|
55
|
+
isPdf?: boolean;
|
|
56
|
+
error?: string | null;
|
|
57
|
+
onPick: () => void;
|
|
58
|
+
onRemove: () => void;
|
|
59
|
+
}): React.ReactElement {
|
|
60
|
+
const { colors } = useTheme();
|
|
61
|
+
const done = fileName !== null && !uploading;
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<View
|
|
65
|
+
accessibilityLabel={label}
|
|
66
|
+
style={{
|
|
67
|
+
marginBottom: spacing.md,
|
|
68
|
+
borderWidth: 1,
|
|
69
|
+
borderRadius: radius.md,
|
|
70
|
+
overflow: 'hidden',
|
|
71
|
+
// The card is the LIFTED surface and the wells inside it are the page
|
|
72
|
+
// colour — the web SDK's arrangement (a `bg-secondary` card over a
|
|
73
|
+
// `bg-background` well), which Flutter also takes.
|
|
74
|
+
backgroundColor: done ? colors.primary50 : colors.backgroundSecondary,
|
|
75
|
+
borderColor: done ? colors.primary200 : colors.border,
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
<View style={{ padding: spacing.md }}>
|
|
79
|
+
<View style={{ flexDirection: 'row', alignItems: 'flex-start' }}>
|
|
80
|
+
<DocumentMarker done={done} position={position} total={total} />
|
|
81
|
+
<View style={{ width: spacing.sm + 4 }} />
|
|
82
|
+
<View style={{ flex: 1, minWidth: 0 }}>
|
|
83
|
+
<MyazaText variant="label" style={{ fontWeight: '600' }}>
|
|
84
|
+
{label}
|
|
85
|
+
</MyazaText>
|
|
86
|
+
{description ? (
|
|
87
|
+
<MyazaText
|
|
88
|
+
variant="bodySmall"
|
|
89
|
+
color={colors.textDark}
|
|
90
|
+
style={{ marginTop: 4, opacity: 0.75 }}
|
|
91
|
+
>
|
|
92
|
+
{description}
|
|
93
|
+
</MyazaText>
|
|
94
|
+
) : null}
|
|
95
|
+
</View>
|
|
96
|
+
<View style={{ width: spacing.sm }} />
|
|
97
|
+
<DocumentStatePill required={required} />
|
|
98
|
+
</View>
|
|
99
|
+
|
|
100
|
+
{reads.length > 0 ? (
|
|
101
|
+
<>
|
|
102
|
+
<View style={{ height: spacing.sm + 4 }} />
|
|
103
|
+
<DocumentReads reads={reads} />
|
|
104
|
+
</>
|
|
105
|
+
) : null}
|
|
106
|
+
</View>
|
|
107
|
+
|
|
108
|
+
<View style={{ height: 1, backgroundColor: colors.border }} />
|
|
109
|
+
|
|
110
|
+
<View style={{ padding: spacing.sm + 4 }}>
|
|
111
|
+
<BusinessDocumentSlot
|
|
112
|
+
label={label}
|
|
113
|
+
required={required}
|
|
114
|
+
fileName={fileName}
|
|
115
|
+
uploading={uploading}
|
|
116
|
+
previewUri={previewUri}
|
|
117
|
+
isPdf={isPdf}
|
|
118
|
+
onPick={onPick}
|
|
119
|
+
onRemove={onRemove}
|
|
120
|
+
compact
|
|
121
|
+
/>
|
|
122
|
+
{error ? (
|
|
123
|
+
<MyazaText
|
|
124
|
+
variant="bodySmall"
|
|
125
|
+
color={colors.error}
|
|
126
|
+
style={{ marginTop: spacing.sm }}
|
|
127
|
+
>
|
|
128
|
+
{error}
|
|
129
|
+
</MyazaText>
|
|
130
|
+
) : null}
|
|
131
|
+
</View>
|
|
132
|
+
</View>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import React, { useCallback, useMemo, useState } from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { spacing } from '../config/theme';
|
|
5
|
+
import { useKyc, useKycConfig, useKycStore, useTheme } from '../components/runtime';
|
|
6
|
+
import { MyazaText } from '../components/Typography';
|
|
7
|
+
import { MyazaButton } from '../components/MyazaButton';
|
|
8
|
+
import { MediaSourceSheet } from '../components/MediaSourceSheet';
|
|
9
|
+
import { SupportingDocumentCard } from './SupportingDocumentCard';
|
|
10
|
+
import { withRetry } from '../services/retry';
|
|
11
|
+
import { compressDocumentImage } from '../services/mediaCompress';
|
|
12
|
+
import {
|
|
13
|
+
resolveSupportingDocuments,
|
|
14
|
+
verifiedIdsFor,
|
|
15
|
+
type RequestedSupportingDocument,
|
|
16
|
+
} from '../config/supportingDocuments';
|
|
17
|
+
import { useBusinessDocumentAttach } from './useBusinessDocumentAttach';
|
|
18
|
+
import { supportingDocumentsIntro } from '../lib/supportingDocumentsIntro';
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Supporting documents — artefacts the organisation keeps ON FILE.
|
|
22
|
+
//
|
|
23
|
+
// These are NOT the identity evidence this verification is decided on. The
|
|
24
|
+
// person has already been checked against the government record by the time
|
|
25
|
+
// they reach this screen, so a document that cannot be read costs them
|
|
26
|
+
// nothing: the server records it and the verification stands on the lookup.
|
|
27
|
+
//
|
|
28
|
+
// WHICH documents are asked for depends on the ID they picked (a document may
|
|
29
|
+
// exist only because they used a particular ID), so the screen is only in the
|
|
30
|
+
// order when that resolution produced something — see store/derive.ts.
|
|
31
|
+
//
|
|
32
|
+
// Layout mirrors the web SDK's SupportingDocumentCard and the Flutter one 1:1:
|
|
33
|
+
// a card per document naming what it is being taken FOR, over the same source
|
|
34
|
+
// sheet and the same Continue rule as the business-documents screen.
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
/** The header, from the slots the flow actually resolved for this applicant.
|
|
38
|
+
* A function because the line carries the COUNTS: how many documents have to
|
|
39
|
+
* be produced before they can go on. */
|
|
40
|
+
export const supportingDocumentsMeta = (
|
|
41
|
+
slots: ReadonlyArray<{ required: boolean }>,
|
|
42
|
+
): { title: string; description: string } => ({
|
|
43
|
+
title: 'Supporting documents',
|
|
44
|
+
description: supportingDocumentsIntro(slots),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export function SupportingDocumentsStep(): React.ReactElement {
|
|
48
|
+
const config = useKycConfig();
|
|
49
|
+
const store = useKycStore();
|
|
50
|
+
const { colors } = useTheme();
|
|
51
|
+
const uploaded = useKyc((s) => s.supportingDocuments);
|
|
52
|
+
const selectedCountry = useKyc((s) => s.selectedCountry);
|
|
53
|
+
const selectedIdType = useKyc((s) => s.selectedIdType);
|
|
54
|
+
const multiIdSlots = useKyc((s) => s.multiIdSlots);
|
|
55
|
+
const [busySlot, setBusySlot] = useState<string | null>(null);
|
|
56
|
+
const [pickingSlot, setPickingSlot] = useState<RequestedSupportingDocument | null>(null);
|
|
57
|
+
const [error, setError] = useState<string | null>(null);
|
|
58
|
+
// Which card the message belongs to. A failed upload is about ONE document
|
|
59
|
+
// and reads as noise anywhere else; a picker error belongs to no card and
|
|
60
|
+
// keeps its place under the list.
|
|
61
|
+
const [errorSlot, setErrorSlot] = useState<string | null>(null);
|
|
62
|
+
|
|
63
|
+
const slots = useMemo(
|
|
64
|
+
() =>
|
|
65
|
+
resolveSupportingDocuments(
|
|
66
|
+
config.supportingDocuments,
|
|
67
|
+
verifiedIdsFor({
|
|
68
|
+
country: selectedCountry ?? config.country,
|
|
69
|
+
idType: selectedIdType,
|
|
70
|
+
multiIdSlots,
|
|
71
|
+
}),
|
|
72
|
+
),
|
|
73
|
+
[config.supportingDocuments, config.country, selectedCountry, selectedIdType, multiIdSlots],
|
|
74
|
+
);
|
|
75
|
+
const missing = slots.filter((s) => s.required && !uploaded.some((d) => d.type === s.key));
|
|
76
|
+
|
|
77
|
+
const attach = useCallback(
|
|
78
|
+
async (slot: RequestedSupportingDocument, uri: string, mimeType: string | undefined, name: string) => {
|
|
79
|
+
setBusySlot(slot.key);
|
|
80
|
+
setError(null);
|
|
81
|
+
setErrorSlot(null);
|
|
82
|
+
try {
|
|
83
|
+
// A PDF is passed through untouched — re-encoding would destroy the
|
|
84
|
+
// text the server reads off it.
|
|
85
|
+
const isPdf = (mimeType ?? '').toLowerCase().startsWith('application/pdf');
|
|
86
|
+
const finalUri = isPdf ? uri : await compressDocumentImage(uri).catch(() => uri);
|
|
87
|
+
const mediaId = await withRetry(() =>
|
|
88
|
+
store.getState().api.upload({ uri: finalUri, type: mimeType, name }, 'supporting_document'),
|
|
89
|
+
);
|
|
90
|
+
store.getState().setSupportingDocument({
|
|
91
|
+
type: slot.key,
|
|
92
|
+
mediaId,
|
|
93
|
+
fileName: name,
|
|
94
|
+
// Seeing the file back is how somebody catches the wrong photo from
|
|
95
|
+
// the camera roll before submitting. A PDF has no preview to show.
|
|
96
|
+
...(isPdf ? { isPdf: true } : { previewUri: finalUri }),
|
|
97
|
+
});
|
|
98
|
+
} catch {
|
|
99
|
+
setError(`We could not upload ${slot.label.toLowerCase()}. Please try again.`);
|
|
100
|
+
setErrorSlot(slot.key);
|
|
101
|
+
} finally {
|
|
102
|
+
setBusySlot(null);
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
[store],
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// A picker failure belongs to no card, so it clears the slot the last upload
|
|
109
|
+
// error was pinned to — otherwise it would surface under an unrelated
|
|
110
|
+
// document.
|
|
111
|
+
const reportPickerError = useCallback((message: string | null) => {
|
|
112
|
+
setErrorSlot(null);
|
|
113
|
+
setError(message);
|
|
114
|
+
}, []);
|
|
115
|
+
|
|
116
|
+
const { takePhoto, choosePhoto, chooseFile } = useBusinessDocumentAttach(attach, reportPickerError);
|
|
117
|
+
|
|
118
|
+
const handleContinue = (): void => {
|
|
119
|
+
if (missing.length > 0 || busySlot !== null) return;
|
|
120
|
+
store.getState().nextStep();
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const optionalOnly = slots.every((slot) => !slot.required);
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<View>
|
|
127
|
+
{slots.map((slot, i) => {
|
|
128
|
+
const doc = uploaded.find((d) => d.type === slot.key);
|
|
129
|
+
return (
|
|
130
|
+
<SupportingDocumentCard
|
|
131
|
+
key={slot.key}
|
|
132
|
+
position={i + 1}
|
|
133
|
+
total={slots.length}
|
|
134
|
+
label={slot.label}
|
|
135
|
+
description={slot.description}
|
|
136
|
+
required={slot.required}
|
|
137
|
+
reads={slot.reads}
|
|
138
|
+
fileName={doc?.fileName ?? null}
|
|
139
|
+
uploading={busySlot === slot.key}
|
|
140
|
+
previewUri={doc?.previewUri ?? null}
|
|
141
|
+
isPdf={doc?.isPdf ?? false}
|
|
142
|
+
error={errorSlot === slot.key ? error : null}
|
|
143
|
+
onPick={() => setPickingSlot(slot)}
|
|
144
|
+
onRemove={() => store.getState().removeSupportingDocument(slot.key)}
|
|
145
|
+
/>
|
|
146
|
+
);
|
|
147
|
+
})}
|
|
148
|
+
|
|
149
|
+
{error && errorSlot === null ? (
|
|
150
|
+
<MyazaText variant="bodySmall" color={colors.error} style={{ marginBottom: spacing.sm }}>
|
|
151
|
+
{error}
|
|
152
|
+
</MyazaText>
|
|
153
|
+
) : null}
|
|
154
|
+
|
|
155
|
+
<View style={{ height: spacing.xs }} />
|
|
156
|
+
<MyazaButton
|
|
157
|
+
label={optionalOnly && uploaded.length === 0 ? 'Skip' : 'Continue'}
|
|
158
|
+
onPress={handleContinue}
|
|
159
|
+
disabled={missing.length > 0 || busySlot !== null}
|
|
160
|
+
/>
|
|
161
|
+
|
|
162
|
+
{/* THE SAME sheet as Proof of Address and the business documents, so
|
|
163
|
+
uploading a document feels the same everywhere in the flow. */}
|
|
164
|
+
<MediaSourceSheet
|
|
165
|
+
open={pickingSlot !== null}
|
|
166
|
+
title="Upload your document"
|
|
167
|
+
onClose={() => setPickingSlot(null)}
|
|
168
|
+
options={[
|
|
169
|
+
{
|
|
170
|
+
icon: 'image',
|
|
171
|
+
label: 'Photo library',
|
|
172
|
+
caption: 'Pick a photo you already have',
|
|
173
|
+
onPress: () => {
|
|
174
|
+
const slot = pickingSlot;
|
|
175
|
+
setPickingSlot(null);
|
|
176
|
+
if (slot) void choosePhoto(slot);
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
icon: 'camera',
|
|
181
|
+
label: 'Take a photo',
|
|
182
|
+
caption: 'Photograph the document now',
|
|
183
|
+
onPress: () => {
|
|
184
|
+
const slot = pickingSlot;
|
|
185
|
+
setPickingSlot(null);
|
|
186
|
+
if (slot) void takePhoto(slot);
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
icon: 'file-text',
|
|
191
|
+
label: 'Choose a file',
|
|
192
|
+
caption: 'A PDF or image from your files',
|
|
193
|
+
onPress: () => {
|
|
194
|
+
const slot = pickingSlot;
|
|
195
|
+
setPickingSlot(null);
|
|
196
|
+
if (slot) void chooseFile(slot);
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
]}
|
|
200
|
+
/>
|
|
201
|
+
</View>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
@@ -5,6 +5,7 @@ import { fillTokens } from '../../utils/tokens';
|
|
|
5
5
|
import { hasEmailVerificationStep, hasPhoneVerificationStep } from '../../config/contact';
|
|
6
6
|
import { hasActiveQuestionnaire } from '../../config/questionnaire';
|
|
7
7
|
import { hasProofOfAddressStep } from '../../config/proofOfAddress';
|
|
8
|
+
import { mayAskSupportingDocuments } from '../../config/supportingDocuments';
|
|
8
9
|
import { hasAddressCollectionStep } from '../../config/addressCollection';
|
|
9
10
|
import {
|
|
10
11
|
hasApplicantVerification,
|
|
@@ -70,11 +71,17 @@ const SCOPE_DESCRIPTIONS: Record<string, string> = {
|
|
|
70
71
|
'We need to re-confirm the email address and phone number on your account. This takes a minute and is secure.',
|
|
71
72
|
};
|
|
72
73
|
|
|
74
|
+
// The address scope's bullets are NOT a fixed pair: it verifies an address by
|
|
75
|
+
// the pin, by a document, or by both, so promising a map on a flow that only
|
|
76
|
+
// asks for a document is a promise the flow never keeps. Gated below on the
|
|
77
|
+
// same step-order predicate the flow itself walks; the document's own bullet is
|
|
78
|
+
// appended by the shared post-capture block, like every other flow's.
|
|
79
|
+
const ADDRESS_PIN_BULLETS: ConsentProcessStep[] = [
|
|
80
|
+
{ icon: 'map-pin-house', label: 'Pin your home address on a map' },
|
|
81
|
+
{ icon: 'badge-check', label: 'Confirm the details only you can know' },
|
|
82
|
+
];
|
|
83
|
+
|
|
73
84
|
const SCOPE_BULLETS: Record<string, ConsentProcessStep[]> = {
|
|
74
|
-
address: [
|
|
75
|
-
{ icon: 'map-pin-house', label: 'Pin your home address on a map' },
|
|
76
|
-
{ icon: 'badge-check', label: 'Confirm the details only you can know' },
|
|
77
|
-
],
|
|
78
85
|
'biometric-authentication': [
|
|
79
86
|
{ icon: 'scan-face', label: 'Take a quick selfie with liveness checks' },
|
|
80
87
|
{ icon: 'badge-check', label: 'We match it against your enrolled face' },
|
|
@@ -123,7 +130,11 @@ export function buildConsentModel(config: MyazaKYCConfig): ConsentModel {
|
|
|
123
130
|
: scope
|
|
124
131
|
? // COPY the catalogue entry: pushing below would otherwise mutate the
|
|
125
132
|
// shared constant, appending one more bullet per re-render.
|
|
126
|
-
|
|
133
|
+
scope === 'address'
|
|
134
|
+
? hasAddressCollectionStep(config.addressCollection)
|
|
135
|
+
? [...ADDRESS_PIN_BULLETS]
|
|
136
|
+
: []
|
|
137
|
+
: [...(SCOPE_BULLETS[scope] ?? [])]
|
|
127
138
|
: [
|
|
128
139
|
{ icon: 'badge-check', label: 'Verify your government-issued ID' },
|
|
129
140
|
{ icon: 'user', label: 'Collect basic personal information' },
|
|
@@ -153,6 +164,12 @@ export function buildConsentModel(config: MyazaKYCConfig): ConsentModel {
|
|
|
153
164
|
// Post-capture features, in the order the flow runs them. Each is gated on
|
|
154
165
|
// the flow that actually asks for it, and skipped where a scope's own
|
|
155
166
|
// catalogue bullet already covers the same step.
|
|
167
|
+
// `mayAsk`, NOT the step-order predicate: that one resolves against the
|
|
168
|
+
// verified IDs and consent runs before an ID is picked, so every scoped
|
|
169
|
+
// document would answer "nothing to ask for" and go undisclosed.
|
|
170
|
+
if (!isBusiness && mayAskSupportingDocuments(config.supportingDocuments)) {
|
|
171
|
+
steps.push({ icon: 'file-text', label: 'Upload supporting documents' });
|
|
172
|
+
}
|
|
156
173
|
if (!isBusiness && hasProofOfAddressStep(config.proofOfAddress)) {
|
|
157
174
|
steps.push({ icon: 'file-text', label: 'Upload a proof of address document' });
|
|
158
175
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { spacing } from '../../config/theme';
|
|
5
|
+
import { MyazaAlert } from '../../components/MyazaAlert';
|
|
6
|
+
import { MyazaButton } from '../../components/MyazaButton';
|
|
7
|
+
import { documentReviewCopy } from '../../components/documentReviewCopy';
|
|
8
|
+
import {
|
|
9
|
+
CAPTURE_CHECK_CONTINUE_ANYWAY,
|
|
10
|
+
CAPTURE_CHECK_TITLE,
|
|
11
|
+
captureProblemMessage,
|
|
12
|
+
captureRetakeLabel,
|
|
13
|
+
captureRetakeSides,
|
|
14
|
+
type CaptureProblem,
|
|
15
|
+
} from '../../lib/documentCaptureCheck';
|
|
16
|
+
import type { DocumentCaptureSide } from '../../services/api-types';
|
|
17
|
+
|
|
18
|
+
// ─── The review footer when a document photo will not read ───────────────────
|
|
19
|
+
//
|
|
20
|
+
// Shown in place of Continue after the server's capture check found no face on
|
|
21
|
+
// the printed photo or no readable barcode (lib/documentCaptureCheck). The
|
|
22
|
+
// words come from that module so they stay in lockstep with the web and
|
|
23
|
+
// Flutter SDKs.
|
|
24
|
+
//
|
|
25
|
+
// A notice, never a gate: the detectors can miss, so "Continue anyway" is
|
|
26
|
+
// always offered and moves on with the photos already uploaded. The first
|
|
27
|
+
// retake is the primary action because a retake is the likelier fix.
|
|
28
|
+
|
|
29
|
+
export function CaptureCheckNotice({
|
|
30
|
+
problems,
|
|
31
|
+
uploadOnly,
|
|
32
|
+
onRetake,
|
|
33
|
+
onContinueAnyway,
|
|
34
|
+
}: {
|
|
35
|
+
problems: readonly CaptureProblem[];
|
|
36
|
+
/** The sides were picked from the device, so they are replaced, not retaken. */
|
|
37
|
+
uploadOnly: boolean;
|
|
38
|
+
onRetake: (side: DocumentCaptureSide) => void;
|
|
39
|
+
onContinueAnyway: () => void;
|
|
40
|
+
}): React.ReactElement {
|
|
41
|
+
// Once per kind: two sides with the same problem need one sentence, not two.
|
|
42
|
+
const messages = [...new Set(problems.map((p) => captureProblemMessage(p.kind)))];
|
|
43
|
+
const sides = captureRetakeSides(problems);
|
|
44
|
+
const copy = documentReviewCopy(uploadOnly ? 'upload' : 'scan', true);
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<View style={{ gap: spacing.sm }}>
|
|
48
|
+
{/* Grouped and announced: the notice replaces the button the applicant
|
|
49
|
+
just pressed, so a screen reader has to say why nothing moved on. */}
|
|
50
|
+
<View
|
|
51
|
+
accessible
|
|
52
|
+
accessibilityRole="alert"
|
|
53
|
+
accessibilityLiveRegion="polite"
|
|
54
|
+
accessibilityLabel={`${CAPTURE_CHECK_TITLE}. ${messages.join(' ')}`}
|
|
55
|
+
>
|
|
56
|
+
<MyazaAlert variant="warning" title={CAPTURE_CHECK_TITLE} message={messages.join('\n\n')} />
|
|
57
|
+
</View>
|
|
58
|
+
{sides.map((side, i) => (
|
|
59
|
+
<MyazaButton
|
|
60
|
+
key={side}
|
|
61
|
+
label={captureRetakeLabel(side, uploadOnly)}
|
|
62
|
+
variant={i === 0 ? 'primary' : 'outline'}
|
|
63
|
+
leadingIcon="refresh"
|
|
64
|
+
accessibilityLabel={copy.redoAccessibility(side === 'front' ? 'Front' : 'Back')}
|
|
65
|
+
onPress={() => onRetake(side)}
|
|
66
|
+
/>
|
|
67
|
+
))}
|
|
68
|
+
<MyazaButton
|
|
69
|
+
label={CAPTURE_CHECK_CONTINUE_ANYWAY}
|
|
70
|
+
variant="outline"
|
|
71
|
+
accessibilityLabel={CAPTURE_CHECK_CONTINUE_ANYWAY}
|
|
72
|
+
accessibilityHint="Carries on with these photos as they are"
|
|
73
|
+
onPress={onContinueAnyway}
|
|
74
|
+
/>
|
|
75
|
+
</View>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { ActivityIndicator, Pressable, View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { radius, spacing } from '../../config/theme';
|
|
5
|
+
import { useTheme } from '../../components/runtime';
|
|
6
|
+
import { MyazaText } from '../../components/Typography';
|
|
7
|
+
import { Icon, type IconName } from '../../components/Icon';
|
|
8
|
+
import { DashedBorder } from '../../components/DashedBorder';
|
|
9
|
+
import type { DocumentCapturePhase } from '../../store/state';
|
|
10
|
+
import { RequiredPill } from './RequiredPill';
|
|
11
|
+
|
|
12
|
+
// ─── Document capture without the camera ──────────────────────────────────────
|
|
13
|
+
//
|
|
14
|
+
// One side of the document when the workflow switches scanning off
|
|
15
|
+
// (`allowDocumentScan: false`): a photo chosen from the device, never a camera
|
|
16
|
+
// shot. It renders in place of the camera gates, and nothing here touches
|
|
17
|
+
// VisionCamera, so an upload-only flow never asks for camera access and never
|
|
18
|
+
// records a capture video.
|
|
19
|
+
//
|
|
20
|
+
// Only the ASK differs. What happens after the pick is the step's own and shared
|
|
21
|
+
// with the camera path: the interactive cropper, the compress, the MRZ read off
|
|
22
|
+
// the front, the front preview and the review.
|
|
23
|
+
|
|
24
|
+
/** The sheet header for an upload-only capture phase (see documentCaptureMeta). */
|
|
25
|
+
export function documentUploadMeta(
|
|
26
|
+
phase: DocumentCapturePhase,
|
|
27
|
+
documentLabel: string,
|
|
28
|
+
): { title: string; description: string } {
|
|
29
|
+
switch (phase) {
|
|
30
|
+
case 'front':
|
|
31
|
+
return {
|
|
32
|
+
title: `Upload Your ${documentLabel}`,
|
|
33
|
+
description: `Choose a clear photo of your ${documentLabel} from your device.`,
|
|
34
|
+
};
|
|
35
|
+
case 'front-preview':
|
|
36
|
+
return {
|
|
37
|
+
title: 'Front Side Added',
|
|
38
|
+
description: 'Looks good? Tap Next to add a photo of the back.',
|
|
39
|
+
};
|
|
40
|
+
case 'back':
|
|
41
|
+
return {
|
|
42
|
+
title: 'Upload Back Side',
|
|
43
|
+
description: `Now choose a photo of the back of your ${documentLabel}.`,
|
|
44
|
+
};
|
|
45
|
+
case 'review':
|
|
46
|
+
default:
|
|
47
|
+
return {
|
|
48
|
+
title: `Review Your ${documentLabel}`,
|
|
49
|
+
description: 'Tap Continue to upload and submit your document.',
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const TIPS: ReadonlyArray<{ icon: IconName; label: string }> = [
|
|
55
|
+
{ icon: 'id-card', label: 'The whole document in view, all four corners' },
|
|
56
|
+
{ icon: 'sun', label: 'Sharp and evenly lit, with no glare' },
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
export interface UploadPhaseProps {
|
|
60
|
+
isBack: boolean;
|
|
61
|
+
documentLabel: string;
|
|
62
|
+
isTwoSided: boolean;
|
|
63
|
+
/** A picked photo is being cropped and compressed. */
|
|
64
|
+
busy: boolean;
|
|
65
|
+
onUpload: () => void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function UploadPhase(p: UploadPhaseProps): React.ReactElement {
|
|
69
|
+
const { colors } = useTheme();
|
|
70
|
+
const subject = !p.isTwoSided ? `your ${p.documentLabel}` : p.isBack ? 'the back' : 'the front';
|
|
71
|
+
const ask = `Add a photo of ${subject}`;
|
|
72
|
+
const choose = () => {
|
|
73
|
+
if (!p.busy) p.onUpload();
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<View>
|
|
78
|
+
<RequiredPill
|
|
79
|
+
documentLabel={p.documentLabel}
|
|
80
|
+
sideBadge={p.isTwoSided ? (p.isBack ? 'Back Side' : 'Front Side') : undefined}
|
|
81
|
+
stepLabel={p.isTwoSided ? (p.isBack ? 'Step 2 of 2' : 'Step 1 of 2') : undefined}
|
|
82
|
+
/>
|
|
83
|
+
{p.isBack ? (
|
|
84
|
+
<View style={{ flexDirection: 'row', alignItems: 'center', marginTop: spacing.sm, marginBottom: spacing.xs }}>
|
|
85
|
+
<Icon name="credit-card" size={14} color={colors.primary} />
|
|
86
|
+
<View style={{ width: 4 }} />
|
|
87
|
+
<MyazaText variant="bodySmall" color={colors.primary} style={{ fontWeight: '500' }}>
|
|
88
|
+
Turn the card over and add a photo of the other side
|
|
89
|
+
</MyazaText>
|
|
90
|
+
</View>
|
|
91
|
+
) : null}
|
|
92
|
+
<View style={{ height: spacing.md }} />
|
|
93
|
+
|
|
94
|
+
{/* The whole card is the target and there is no button beside it: on a
|
|
95
|
+
screen whose only job is to open the photo picker, the card is where
|
|
96
|
+
the thumb goes. A press deepens the fill and solidifies the border;
|
|
97
|
+
the primary line at the bottom says what a tap does. */}
|
|
98
|
+
<Pressable
|
|
99
|
+
testID={`kyc.document.upload.${p.isBack ? 'back' : 'front'}`}
|
|
100
|
+
onPress={choose}
|
|
101
|
+
disabled={p.busy}
|
|
102
|
+
accessibilityRole="button"
|
|
103
|
+
accessibilityLabel={p.busy ? 'Preparing your photo' : ask}
|
|
104
|
+
accessibilityHint="Opens your photos"
|
|
105
|
+
accessibilityState={{ busy: p.busy, disabled: p.busy }}
|
|
106
|
+
style={({ pressed }) => ({
|
|
107
|
+
alignItems: 'center',
|
|
108
|
+
paddingVertical: spacing.xl,
|
|
109
|
+
paddingHorizontal: spacing.lg,
|
|
110
|
+
borderRadius: radius.md,
|
|
111
|
+
backgroundColor: pressed ? `${colors.primary}1A` : `${colors.primary}0D`,
|
|
112
|
+
transform: [{ scale: pressed ? 0.99 : 1 }],
|
|
113
|
+
})}
|
|
114
|
+
>
|
|
115
|
+
{({ pressed }) => (
|
|
116
|
+
<>
|
|
117
|
+
<DashedBorder
|
|
118
|
+
color={pressed ? colors.primary : `${colors.primary}66`}
|
|
119
|
+
radius={radius.md}
|
|
120
|
+
strokeWidth={1.5}
|
|
121
|
+
/>
|
|
122
|
+
<View
|
|
123
|
+
style={{
|
|
124
|
+
width: 64,
|
|
125
|
+
height: 64,
|
|
126
|
+
borderRadius: radius.full,
|
|
127
|
+
backgroundColor: `${colors.primary}1A`,
|
|
128
|
+
alignItems: 'center',
|
|
129
|
+
justifyContent: 'center',
|
|
130
|
+
}}
|
|
131
|
+
>
|
|
132
|
+
{p.busy ? (
|
|
133
|
+
<ActivityIndicator size="small" color={colors.primary} />
|
|
134
|
+
) : (
|
|
135
|
+
<Icon name="image" size={30} color={colors.primary} />
|
|
136
|
+
)}
|
|
137
|
+
</View>
|
|
138
|
+
<View style={{ height: spacing.md }} />
|
|
139
|
+
<MyazaText variant="heading3" style={{ textAlign: 'center' }}>
|
|
140
|
+
{p.busy ? 'Preparing your photo…' : ask}
|
|
141
|
+
</MyazaText>
|
|
142
|
+
<View style={{ height: spacing.xs }} />
|
|
143
|
+
<MyazaText variant="bodySmall" color={colors.textSecondary} style={{ textAlign: 'center' }}>
|
|
144
|
+
Choose a clear photo from your device. You can crop it on the next screen.
|
|
145
|
+
</MyazaText>
|
|
146
|
+
<View style={{ height: spacing.md }} />
|
|
147
|
+
{/* Stays in the layout while busy so the card keeps its height. */}
|
|
148
|
+
<View
|
|
149
|
+
style={{ flexDirection: 'row', alignItems: 'center', opacity: p.busy ? 0 : 1 }}
|
|
150
|
+
accessibilityElementsHidden
|
|
151
|
+
importantForAccessibility="no-hide-descendants"
|
|
152
|
+
>
|
|
153
|
+
<Icon name="upload" size={16} color={colors.primary} />
|
|
154
|
+
<View style={{ width: spacing.xs }} />
|
|
155
|
+
<MyazaText variant="label" color={colors.primary} style={{ fontWeight: '600' }}>
|
|
156
|
+
Tap to choose a photo
|
|
157
|
+
</MyazaText>
|
|
158
|
+
<View style={{ width: 2 }} />
|
|
159
|
+
<Icon name="chevron-right" size={16} color={colors.primary} />
|
|
160
|
+
</View>
|
|
161
|
+
</>
|
|
162
|
+
)}
|
|
163
|
+
</Pressable>
|
|
164
|
+
|
|
165
|
+
<View style={{ height: spacing.md }} />
|
|
166
|
+
{TIPS.map((tip) => (
|
|
167
|
+
<View key={tip.label} style={{ flexDirection: 'row', alignItems: 'center', marginBottom: spacing.xs }}>
|
|
168
|
+
<Icon name={tip.icon} size={16} color={colors.textMuted} />
|
|
169
|
+
<View style={{ width: spacing.sm }} />
|
|
170
|
+
<MyazaText variant="bodySmall" color={colors.textSecondary} style={{ flexShrink: 1 }}>
|
|
171
|
+
{tip.label}
|
|
172
|
+
</MyazaText>
|
|
173
|
+
</View>
|
|
174
|
+
))}
|
|
175
|
+
</View>
|
|
176
|
+
);
|
|
177
|
+
}
|