@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.
Files changed (104) hide show
  1. package/README.md +128 -4
  2. package/android/build.gradle +30 -16
  3. package/android/consumer-rules.pro +22 -0
  4. package/android/src/main/AndroidManifest.xml +17 -6
  5. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +27 -72
  6. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizer.kt +14 -0
  7. package/android/src/main/java/com/margelo/nitro/myazakyc/MlKitModelReadiness.kt +154 -0
  8. package/app.plugin.js +57 -2
  9. package/ios/HybridMyazaTextRecognizer.swift +8 -0
  10. package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.cpp +21 -0
  11. package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.hpp +2 -0
  12. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizerSpec.kt +8 -0
  13. package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.hpp +16 -0
  14. package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec.swift +2 -0
  15. package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec_cxx.swift +31 -0
  16. package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.cpp +2 -0
  17. package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.hpp +2 -0
  18. package/package.json +24 -14
  19. package/src/MyazaKYC.tsx +4 -11
  20. package/src/assets/fonts/Karla_400Regular.ttf +0 -0
  21. package/src/assets/fonts/Karla_500Medium.ttf +0 -0
  22. package/src/assets/fonts/Karla_600SemiBold.ttf +0 -0
  23. package/src/assets/fonts/Karla_700Bold.ttf +0 -0
  24. package/src/assets/fonts/OFL-Karla.txt +93 -0
  25. package/src/assets/fonts/OFL-SpaceGrotesk.txt +93 -0
  26. package/src/assets/fonts/README.md +12 -0
  27. package/src/assets/fonts/SpaceGrotesk_500Medium.ttf +0 -0
  28. package/src/assets/fonts/SpaceGrotesk_600SemiBold.ttf +0 -0
  29. package/src/assets/fonts/SpaceGrotesk_700Bold.ttf +0 -0
  30. package/src/capture/useAutoCapture.ts +8 -1
  31. package/src/components/DocumentCropper.tsx +30 -24
  32. package/src/components/DocumentReview.tsx +11 -1
  33. package/src/components/DocumentReviewSide.tsx +6 -2
  34. package/src/components/DocumentReviewZoom.tsx +5 -1
  35. package/src/components/GlassIconButton.tsx +2 -5
  36. package/src/components/Icon.tsx +17 -13
  37. package/src/components/KycFlow.tsx +10 -2
  38. package/src/components/KycSheet.tsx +0 -1
  39. package/src/components/MyazaButton.tsx +9 -0
  40. package/src/components/RequiredDocumentPill.tsx +114 -0
  41. package/src/components/StepHeader.tsx +23 -3
  42. package/src/components/StepView.tsx +3 -0
  43. package/src/components/documentReviewCopy.ts +39 -0
  44. package/src/components/fonts.ts +14 -17
  45. package/src/components/icons/glyphs.ts +35 -0
  46. package/src/components/icons/index.ts +3 -0
  47. package/src/components/icons/map.ts +165 -0
  48. package/src/components/icons/names.ts +86 -0
  49. package/src/components/stepHeaderMeta.tsx +21 -1
  50. package/src/config/documentCaptureMethods.ts +31 -0
  51. package/src/config/stepOrder.ts +20 -5
  52. package/src/config/supportingDocuments.ts +131 -0
  53. package/src/config/theme.ts +29 -3
  54. package/src/config/workflowMerge.ts +15 -0
  55. package/src/index.ts +5 -10
  56. package/src/lib/documentCaptureCheck.ts +136 -0
  57. package/src/lib/model-ready.ts +82 -0
  58. package/src/lib/prime-models.ts +50 -0
  59. package/src/lib/resubmit.ts +36 -7
  60. package/src/lib/selfie-sharpness.ts +211 -0
  61. package/src/lib/supportingDocumentsIntro.ts +35 -0
  62. package/src/liveness/avatarSource.ts +59 -0
  63. package/src/liveness/useModelReady.ts +9 -55
  64. package/src/mrz/extract.ts +71 -4
  65. package/src/mrz/textRecognizer.ts +36 -0
  66. package/src/mrz/useTextModelReady.ts +21 -0
  67. package/src/screens/BusinessDocumentSlot.tsx +37 -8
  68. package/src/screens/DocumentCaptureStep.tsx +177 -93
  69. package/src/screens/LivenessAvatar.tsx +18 -13
  70. package/src/screens/LivenessStep.tsx +37 -0
  71. package/src/screens/MrzScanView.tsx +33 -0
  72. package/src/screens/SupportingDocumentCard.tsx +134 -0
  73. package/src/screens/SupportingDocumentsStep.tsx +203 -0
  74. package/src/screens/consent/model.ts +22 -5
  75. package/src/screens/document/CaptureCheckNotice.tsx +77 -0
  76. package/src/screens/document/UploadPhase.tsx +177 -0
  77. package/src/screens/document/useDocumentCamera.ts +109 -0
  78. package/src/screens/liveness/CaptureRing.tsx +31 -17
  79. package/src/screens/liveness/LivenessOutcome.tsx +4 -1
  80. package/src/screens/liveness/SelfiePreview.tsx +19 -4
  81. package/src/screens/supportingDocumentParts.tsx +126 -0
  82. package/src/screens/useBusinessDocumentAttach.ts +20 -9
  83. package/src/services/api-types.ts +31 -2
  84. package/src/services/api.ts +19 -3
  85. package/src/services/deviceMetadata.ts +1 -1
  86. package/src/services/mediaCompress.ts +23 -5
  87. package/src/specs/MyazaTextRecognizer.nitro.ts +29 -0
  88. package/src/store/derive.ts +11 -0
  89. package/src/store/kycStore.ts +23 -2
  90. package/src/store/session.ts +18 -0
  91. package/src/store/state.ts +26 -0
  92. package/src/store/submit.ts +11 -0
  93. package/src/types/config.ts +25 -0
  94. package/src/types/workflow.ts +39 -0
  95. package/src/MyazaBiometricAuth.tsx +0 -119
  96. package/src/assets/liveness/Blink.gif +0 -0
  97. package/src/assets/liveness/Nod.gif +0 -0
  98. package/src/assets/liveness/Smile.gif +0 -0
  99. package/src/assets/liveness/Turn.gif +0 -0
  100. package/src/components/icon-map.ts +0 -176
  101. package/src/lib/biometric-auth.ts +0 -52
  102. package/src/screens/biometric/BiometricAuthFlow.tsx +0 -170
  103. package/src/services/api-biometric.ts +0 -37
  104. 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
- [...(SCOPE_BULLETS[scope] ?? [])]
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
+ }