@myazahq/kyc-sdk-react-native 3.0.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/package.json +8 -7
- package/src/components/GlassIconButton.tsx +2 -5
- package/src/components/Icon.tsx +17 -14
- package/src/components/KycFlow.tsx +1 -0
- package/src/components/KycSheet.tsx +0 -1
- package/src/components/StepView.tsx +3 -0
- 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 +15 -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 +13 -0
- package/src/lib/supportingDocumentsIntro.ts +35 -0
- package/src/screens/BusinessDocumentSlot.tsx +37 -8
- 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/liveness/CaptureRing.tsx +31 -17
- package/src/screens/supportingDocumentParts.tsx +126 -0
- package/src/screens/useBusinessDocumentAttach.ts +20 -9
- package/src/services/api-types.ts +3 -0
- package/src/services/deviceMetadata.ts +1 -1
- package/src/store/derive.ts +11 -0
- package/src/store/kycStore.ts +15 -0
- 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 +10 -0
- package/src/types/workflow.ts +39 -0
- package/src/components/icon-map.ts +0 -176
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { SupportingDocumentsConfig } from '../types/workflow';
|
|
2
|
+
|
|
3
|
+
// Which supporting documents this flow asks for, given the ID the person
|
|
4
|
+
// actually verified with.
|
|
5
|
+
//
|
|
6
|
+
// MIRROR of the server's `requestedSupportingDocuments`
|
|
7
|
+
// (kyc-core src/lib/workflows/supporting-documents-config.ts) and of the web
|
|
8
|
+
// SDK's lib/supporting-documents.ts. Three copies of one rule: the mobile SDKs
|
|
9
|
+
// cannot import the web package, so a change here changes all three in the
|
|
10
|
+
// same commit. The server VALIDATES what the client produced, so a client that
|
|
11
|
+
// resolved differently just builds submissions the server refuses.
|
|
12
|
+
//
|
|
13
|
+
// There is no catalogue to mirror: a document is whatever the ORG named it, so
|
|
14
|
+
// the SDK renders the title and guidance the workflow sent rather than
|
|
15
|
+
// captioning a key it recognises.
|
|
16
|
+
|
|
17
|
+
export interface RequestedSupportingDocument {
|
|
18
|
+
key: string;
|
|
19
|
+
label: string;
|
|
20
|
+
/** Guidance under the slot, when the author wrote some. */
|
|
21
|
+
description: string | null;
|
|
22
|
+
required: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* The names of the values the server will read off it, for the card to show.
|
|
25
|
+
*
|
|
26
|
+
* DISPLAY ONLY, and deliberately not a mirror of the server's own field
|
|
27
|
+
* resolution: it drops blanks and repeats and stops there. The server decides
|
|
28
|
+
* what is actually read, and an extra name on a chip costs an applicant
|
|
29
|
+
* nothing.
|
|
30
|
+
*/
|
|
31
|
+
reads: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The names on one document's fields: what the applicant is told we read. */
|
|
35
|
+
function documentReads(fields: Array<{ label?: string }> | undefined): string[] {
|
|
36
|
+
const seen = new Set<string>();
|
|
37
|
+
const out: string[] = [];
|
|
38
|
+
for (const field of fields ?? []) {
|
|
39
|
+
const label = field.label?.trim();
|
|
40
|
+
if (!label || seen.has(label.toLowerCase())) continue;
|
|
41
|
+
seen.add(label.toLowerCase());
|
|
42
|
+
out.push(label);
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** `${country}/${idType}` — the composite a document's `idTypes` is written in. */
|
|
48
|
+
export function idComposite(country: string, idType: string): string {
|
|
49
|
+
return `${country.trim().toUpperCase()}/${idType.trim()}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The documents to ask for. An empty result means the step does not appear:
|
|
54
|
+
* a document that exists only because the person used a particular ID must not
|
|
55
|
+
* be put in front of somebody who used another.
|
|
56
|
+
*/
|
|
57
|
+
export function resolveSupportingDocuments(
|
|
58
|
+
config: SupportingDocumentsConfig | undefined | null,
|
|
59
|
+
verifiedIds: string[],
|
|
60
|
+
): RequestedSupportingDocument[] {
|
|
61
|
+
if (!config?.enabled) return [];
|
|
62
|
+
const wanted = new Set(verifiedIds.map((id) => id.toUpperCase()));
|
|
63
|
+
const seen = new Set<string>();
|
|
64
|
+
const out: RequestedSupportingDocument[] = [];
|
|
65
|
+
for (const entry of config.types ?? []) {
|
|
66
|
+
const label = entry.label?.trim();
|
|
67
|
+
// A nameless slot reaches nobody. Publish refuses one, so this only ever
|
|
68
|
+
// bites a draft mid-edit.
|
|
69
|
+
if (!label) continue;
|
|
70
|
+
const scoped = entry.idTypes && entry.idTypes.length > 0;
|
|
71
|
+
const inScope =
|
|
72
|
+
!scoped || entry.idTypes!.some((id) => wanted.has(id.trim().toUpperCase()));
|
|
73
|
+
// `alwaysAsk` keeps the slot on screen for everybody, so the scope decides
|
|
74
|
+
// only who must fill it: an out-of-scope applicant may hand the document
|
|
75
|
+
// over and is never blocked for not having one.
|
|
76
|
+
if (!inScope && entry.alwaysAsk !== true) continue;
|
|
77
|
+
if (seen.has(entry.key)) continue;
|
|
78
|
+
seen.add(entry.key);
|
|
79
|
+
out.push({
|
|
80
|
+
key: entry.key,
|
|
81
|
+
label,
|
|
82
|
+
description: entry.description?.trim() || null,
|
|
83
|
+
required: entry.required === true && inScope,
|
|
84
|
+
reads: documentReads(entry.fields),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The composites this attempt has committed — one per ID, multi-ID included. */
|
|
91
|
+
export function verifiedIdsFor(input: {
|
|
92
|
+
country?: string | null;
|
|
93
|
+
idType?: string | null;
|
|
94
|
+
multiIdSlots?: Array<{ idType: string }>;
|
|
95
|
+
}): string[] {
|
|
96
|
+
if (!input.country) return [];
|
|
97
|
+
const slots = input.multiIdSlots ?? [];
|
|
98
|
+
const ids = slots.length > 0 ? slots.map((s) => s.idType) : input.idType ? [input.idType] : [];
|
|
99
|
+
return [...new Set(ids.map((id) => idComposite(input.country!, id)))];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Whether the flow may ask for a supporting document AT ALL.
|
|
104
|
+
*
|
|
105
|
+
* The CONSENT screen's question, deliberately not the step order's.
|
|
106
|
+
* `hasSupportingDocumentsStep` resolves against the VERIFIED IDS, and consent
|
|
107
|
+
* runs before an ID is picked — so every SCOPED document answers "nothing to
|
|
108
|
+
* ask for" there, and a slip scoped to one ID (the commonest case there is)
|
|
109
|
+
* would go undisclosed.
|
|
110
|
+
*
|
|
111
|
+
* Consent is a DISCLOSURE of what MAY be collected: an applicant seeing a
|
|
112
|
+
* bullet for paperwork they are never asked for is the cheap error, and being
|
|
113
|
+
* asked for undisclosed paperwork is the real one.
|
|
114
|
+
*
|
|
115
|
+
* Still not a raw field check — a disabled step and a nameless entry each
|
|
116
|
+
* promise nothing, the same two gates the resolver applies.
|
|
117
|
+
*/
|
|
118
|
+
export function mayAskSupportingDocuments(
|
|
119
|
+
config: SupportingDocumentsConfig | undefined | null,
|
|
120
|
+
): boolean {
|
|
121
|
+
if (!config?.enabled) return false;
|
|
122
|
+
return (config.types ?? []).some((entry) => (entry.label ?? '').trim().length > 0);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Whether the step has anything to ask for on this attempt. */
|
|
126
|
+
export function hasSupportingDocumentsStep(
|
|
127
|
+
config: SupportingDocumentsConfig | undefined | null,
|
|
128
|
+
verifiedIds: string[],
|
|
129
|
+
): boolean {
|
|
130
|
+
return resolveSupportingDocuments(config, verifiedIds).length > 0;
|
|
131
|
+
}
|
package/src/config/theme.ts
CHANGED
|
@@ -161,6 +161,22 @@ function toHex({ r, g, b }: Rgb): string {
|
|
|
161
161
|
}
|
|
162
162
|
|
|
163
163
|
/** Alpha-blends `fg` over `bg` at opacity `alpha` (0–1). */
|
|
164
|
+
/**
|
|
165
|
+
* `color` at `alpha`, as an 8-digit hex React Native composites itself.
|
|
166
|
+
*
|
|
167
|
+
* Falls back to blending against `fallbackBg` when the colour is not hex (an
|
|
168
|
+
* `rgb()` or a named colour), which is the old behaviour rather than a crash.
|
|
169
|
+
*/
|
|
170
|
+
function withAlpha(color: string, alpha: number, fallbackBg: string): string {
|
|
171
|
+
const parsed = parseHex(color);
|
|
172
|
+
if (!parsed) return alphaBlend(color, fallbackBg, alpha);
|
|
173
|
+
const byte = Math.round(Math.min(Math.max(alpha, 0), 1) * 255)
|
|
174
|
+
.toString(16)
|
|
175
|
+
.padStart(2, '0');
|
|
176
|
+
const hex = (n: number) => Math.round(n).toString(16).padStart(2, '0');
|
|
177
|
+
return `#${hex(parsed.r)}${hex(parsed.g)}${hex(parsed.b)}${byte}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
164
180
|
function alphaBlend(fg: string, bg: string, alpha: number): string {
|
|
165
181
|
const f = parseHex(fg);
|
|
166
182
|
const b = parseHex(bg);
|
|
@@ -197,9 +213,19 @@ export function applyAppearance(
|
|
|
197
213
|
|
|
198
214
|
if (appearance.primaryColor) {
|
|
199
215
|
next.primary = appearance.primaryColor;
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
216
|
+
// Real alpha, not a pre-blend. These tints are drawn on several different
|
|
217
|
+
// surfaces - the sheet, a card, a pill - and pre-blending has to guess ONE
|
|
218
|
+
// of them. It guessed `background`, so on a card (backgroundSecondary) the
|
|
219
|
+
// 10% tint landed within 3/255 of the card it sat on and the supporting
|
|
220
|
+
// documents step drew its numbered markers invisibly (user report
|
|
221
|
+
// 2026-09-25). Carrying the alpha lets each one composite against whatever
|
|
222
|
+
// it is actually on, which is what the web SDK's `bg-primary/10` does and
|
|
223
|
+
// why it never had this bug. On `background` the result matches the old
|
|
224
|
+
// pre-blend to within 1/255 - an alpha byte cannot hold 0.1 exactly - so
|
|
225
|
+
// nothing that was already correct moves perceptibly.
|
|
226
|
+
next.primary50 = withAlpha(appearance.primaryColor, 0.04, background);
|
|
227
|
+
next.primary100 = withAlpha(appearance.primaryColor, 0.1, background);
|
|
228
|
+
next.primary200 = withAlpha(appearance.primaryColor, 0.2, background);
|
|
203
229
|
}
|
|
204
230
|
if (appearance.accentColor) {
|
|
205
231
|
next.primary100 = appearance.accentColor;
|
|
@@ -45,6 +45,7 @@ export const WORKFLOW_KEYS = [
|
|
|
45
45
|
'phoneVerification',
|
|
46
46
|
'questionnaire',
|
|
47
47
|
'proofOfAddress',
|
|
48
|
+
'supportingDocuments',
|
|
48
49
|
'addressCollection',
|
|
49
50
|
'nfc',
|
|
50
51
|
// Set only on a session a reviewer sent back, never on a published flow.
|
|
@@ -85,6 +86,18 @@ export function mergeWorkflowConfig<P extends Record<string, unknown>>(
|
|
|
85
86
|
}
|
|
86
87
|
}
|
|
87
88
|
|
|
89
|
+
// A multi-region flow declares its ID offering inside countries[], where a
|
|
90
|
+
// country pinning nothing already means "every granted ID for that country",
|
|
91
|
+
// and leaves the top-level list unset. The consumer's prop must not survive
|
|
92
|
+
// there: IdTypeStep falls back to the top-level list for a country that pins
|
|
93
|
+
// none of its own, so a hardcoded ['bvn','nin','passport'] silently reduced a
|
|
94
|
+
// 58-country flow to three NG types. A flow that sets its OWN top-level list
|
|
95
|
+
// still wins, so single-country flows are untouched.
|
|
96
|
+
const flowCountries = flowConfig['countries'];
|
|
97
|
+
if (flowConfig['idTypes'] === undefined && Array.isArray(flowCountries) && flowCountries.length > 0) {
|
|
98
|
+
delete merged['idTypes'];
|
|
99
|
+
}
|
|
100
|
+
|
|
88
101
|
// Business (KYB) workflows carry no top-level country — fall back to the
|
|
89
102
|
// registry country so downstream code that expects one never sees undefined.
|
|
90
103
|
// The business submission reads business.country anyway.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The line under "Supporting documents", from what the flow is actually
|
|
3
|
+
* asking for.
|
|
4
|
+
*
|
|
5
|
+
* It used to be one sentence about keeping documents on file plus a note that
|
|
6
|
+
* required ones are marked with an asterisk — which tells somebody how to read
|
|
7
|
+
* the screen rather than what is being asked of them, and the asterisk carries
|
|
8
|
+
* no information at all when every document is required. The counts are what
|
|
9
|
+
* a person wants: how many they have to produce before they can go on.
|
|
10
|
+
*
|
|
11
|
+
* MIRRORS the web SDK's supportingDocumentsIntro. Keep the wording in step.
|
|
12
|
+
*/
|
|
13
|
+
export function supportingDocumentsIntro(
|
|
14
|
+
slots: ReadonlyArray<{ required: boolean }>,
|
|
15
|
+
): string {
|
|
16
|
+
const total = slots.length;
|
|
17
|
+
const required = slots.filter((slot) => slot.required).length;
|
|
18
|
+
|
|
19
|
+
// Nothing is compulsory, so the honest line is that the step can be skipped.
|
|
20
|
+
if (required === 0) {
|
|
21
|
+
return total === 1
|
|
22
|
+
? 'Upload this document if you have it, so we can keep it on file. You can skip it.'
|
|
23
|
+
: 'Upload any of these you have, so we can keep them on file. You can skip the rest.';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (required === total) {
|
|
27
|
+
return total === 1
|
|
28
|
+
? 'We need this document to continue. Upload it below.'
|
|
29
|
+
: `We need all ${total} of these documents to continue. Upload one for each item below.`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Mixed, and the only case where the asterisk earns its place on the screen.
|
|
33
|
+
// The noun agrees with the TOTAL, which is always two or more here.
|
|
34
|
+
return `We need ${required} of these ${total} documents to continue, marked with *. Upload the others if you have them.`;
|
|
35
|
+
}
|
|
@@ -64,6 +64,7 @@ function UploadedThumb({ previewUri, isPdf }: { previewUri: string | null; isPdf
|
|
|
64
64
|
|
|
65
65
|
export function BusinessDocumentSlot({
|
|
66
66
|
label,
|
|
67
|
+
description = null,
|
|
67
68
|
required,
|
|
68
69
|
fileName,
|
|
69
70
|
uploading,
|
|
@@ -71,8 +72,16 @@ export function BusinessDocumentSlot({
|
|
|
71
72
|
isPdf = false,
|
|
72
73
|
onPick,
|
|
73
74
|
onRemove,
|
|
75
|
+
compact = false,
|
|
74
76
|
}: {
|
|
75
77
|
label: string;
|
|
78
|
+
/**
|
|
79
|
+
* Guidance the organisation wrote: which document, and what it has to show.
|
|
80
|
+
* Optional because the business-documents step names a fixed catalogue the
|
|
81
|
+
* applicant already recognises; a supporting document is whatever the org
|
|
82
|
+
* called it, so this is often the only thing that makes it findable.
|
|
83
|
+
*/
|
|
84
|
+
description?: string | null;
|
|
76
85
|
required: boolean;
|
|
77
86
|
/** Uploaded file name, when this slot already has a mediaId. */
|
|
78
87
|
fileName: string | null;
|
|
@@ -83,6 +92,15 @@ export function BusinessDocumentSlot({
|
|
|
83
92
|
/** Open the photo/file source sheet (also serves Replace). */
|
|
84
93
|
onPick: () => void;
|
|
85
94
|
onRemove: () => void;
|
|
95
|
+
/**
|
|
96
|
+
* The slot is the FOOTER of a card that already names the document.
|
|
97
|
+
*
|
|
98
|
+
* So it drops everything the card above it has already said — the label
|
|
99
|
+
* echo, the required star, the guidance — and its own bottom margin, and
|
|
100
|
+
* the empty state reads "Upload <document>" rather than repeating the title.
|
|
101
|
+
* Mirrors the Flutter slot's `compact`.
|
|
102
|
+
*/
|
|
103
|
+
compact?: boolean;
|
|
86
104
|
}): React.ReactElement {
|
|
87
105
|
const { colors } = useTheme();
|
|
88
106
|
|
|
@@ -97,7 +115,7 @@ export function BusinessDocumentSlot({
|
|
|
97
115
|
borderRadius: radius.sm,
|
|
98
116
|
backgroundColor: colors.backgroundSecondary,
|
|
99
117
|
padding: spacing.md,
|
|
100
|
-
marginBottom: spacing.sm + 4,
|
|
118
|
+
marginBottom: compact ? 0 : spacing.sm + 4,
|
|
101
119
|
}}
|
|
102
120
|
>
|
|
103
121
|
<UploadedThumb previewUri={previewUri} isPdf={isPdf} />
|
|
@@ -106,9 +124,11 @@ export function BusinessDocumentSlot({
|
|
|
106
124
|
<MyazaText variant="label" style={{ fontWeight: '600' }} numberOfLines={1}>
|
|
107
125
|
{fileName}
|
|
108
126
|
</MyazaText>
|
|
109
|
-
|
|
110
|
-
{
|
|
111
|
-
|
|
127
|
+
{compact ? null : (
|
|
128
|
+
<MyazaText variant="bodySmall" color={colors.textMuted}>
|
|
129
|
+
{label}
|
|
130
|
+
</MyazaText>
|
|
131
|
+
)}
|
|
112
132
|
</View>
|
|
113
133
|
<Pressable onPress={onPick} hitSlop={8} accessibilityRole="button">
|
|
114
134
|
<MyazaText variant="bodySmall" color={colors.primary} style={{ fontWeight: '600' }}>
|
|
@@ -138,7 +158,7 @@ export function BusinessDocumentSlot({
|
|
|
138
158
|
alignItems: 'center',
|
|
139
159
|
borderRadius: radius.sm,
|
|
140
160
|
padding: spacing.md,
|
|
141
|
-
marginBottom: spacing.sm + 4,
|
|
161
|
+
marginBottom: compact ? 0 : spacing.sm + 4,
|
|
142
162
|
opacity: uploading ? 0.7 : 1,
|
|
143
163
|
}}
|
|
144
164
|
>
|
|
@@ -150,14 +170,23 @@ export function BusinessDocumentSlot({
|
|
|
150
170
|
)}
|
|
151
171
|
<View style={{ width: spacing.sm + 4 }} />
|
|
152
172
|
<View style={{ flex: 1, minWidth: 0 }}>
|
|
153
|
-
<MyazaText variant="label" style={{ fontWeight: '600' }} numberOfLines={
|
|
154
|
-
{label}
|
|
155
|
-
{required ? (
|
|
173
|
+
<MyazaText variant="label" style={{ fontWeight: '600' }} numberOfLines={2}>
|
|
174
|
+
{compact ? `Upload ${label.toLowerCase()}` : label}
|
|
175
|
+
{required && !compact ? (
|
|
156
176
|
<MyazaText variant="label" color={colors.error} style={{ fontWeight: '600' }}>
|
|
157
177
|
{' *'}
|
|
158
178
|
</MyazaText>
|
|
159
179
|
) : null}
|
|
160
180
|
</MyazaText>
|
|
181
|
+
{description && !uploading && !compact ? (
|
|
182
|
+
<MyazaText
|
|
183
|
+
variant="bodySmall"
|
|
184
|
+
color={colors.textDark}
|
|
185
|
+
style={{ marginTop: 2, opacity: 0.75 }}
|
|
186
|
+
>
|
|
187
|
+
{description}
|
|
188
|
+
</MyazaText>
|
|
189
|
+
) : null}
|
|
161
190
|
<MyazaText variant="bodySmall" color={colors.textMuted}>
|
|
162
191
|
{uploading ? 'Uploading…' : UPLOAD_HINT}
|
|
163
192
|
</MyazaText>
|
|
@@ -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
|
+
}
|