@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.
@@ -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
+ }
@@ -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
- next.primary50 = alphaBlend(appearance.primaryColor, background, 0.04);
201
- next.primary100 = alphaBlend(appearance.primaryColor, background, 0.1);
202
- next.primary200 = alphaBlend(appearance.primaryColor, background, 0.2);
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
- <MyazaText variant="bodySmall" color={colors.textMuted}>
110
- {label}
111
- </MyazaText>
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={1}>
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
+ }