@myazahq/kyc-sdk-react-native 2.5.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +277 -7
- package/android/build.gradle +30 -16
- package/android/consumer-rules.pro +22 -0
- package/android/src/main/AndroidManifest.xml +17 -6
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +27 -70
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizer.kt +14 -0
- package/android/src/main/java/com/margelo/nitro/myazakyc/MlKitModelReadiness.kt +154 -0
- package/app.plugin.js +146 -10
- package/ios/HybridMyazaFaceDetector.swift +8 -3
- package/ios/HybridMyazaTextRecognizer.swift +8 -0
- package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.cpp +21 -0
- package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.hpp +2 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizerSpec.kt +8 -0
- package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.hpp +16 -0
- package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec.swift +2 -0
- package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec_cxx.swift +31 -0
- package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.cpp +2 -0
- package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.hpp +2 -0
- package/package.json +30 -9
- package/src/MyazaKYC.tsx +4 -11
- package/src/assets/fonts/Karla_400Regular.ttf +0 -0
- package/src/assets/fonts/Karla_500Medium.ttf +0 -0
- package/src/assets/fonts/Karla_600SemiBold.ttf +0 -0
- package/src/assets/fonts/Karla_700Bold.ttf +0 -0
- package/src/assets/fonts/OFL-Karla.txt +93 -0
- package/src/assets/fonts/OFL-SpaceGrotesk.txt +93 -0
- package/src/assets/fonts/README.md +12 -0
- package/src/assets/fonts/SpaceGrotesk_500Medium.ttf +0 -0
- package/src/assets/fonts/SpaceGrotesk_600SemiBold.ttf +0 -0
- package/src/assets/fonts/SpaceGrotesk_700Bold.ttf +0 -0
- package/src/capture/useAutoCapture.ts +8 -1
- package/src/components/CountryField.tsx +8 -0
- package/src/components/CountryRegionPicker.tsx +184 -0
- package/src/components/DialCodePicker.tsx +47 -68
- package/src/components/DialCodeRow.tsx +102 -0
- package/src/components/DocumentCropper.tsx +30 -24
- package/src/components/DocumentReview.tsx +11 -1
- package/src/components/DocumentReviewSide.tsx +6 -2
- package/src/components/DocumentReviewZoom.tsx +5 -1
- package/src/components/FramedMapPicker.tsx +122 -0
- package/src/components/GeoBadge.tsx +34 -0
- package/src/components/Icon.tsx +10 -192
- package/src/components/KycFlow.tsx +69 -34
- package/src/components/LineSkeleton.tsx +100 -0
- package/src/components/MapChrome.tsx +67 -0
- package/src/components/MapPinMarker.tsx +54 -0
- package/src/components/MapPinPicker.tsx +179 -0
- package/src/components/MilestoneTrack.tsx +155 -0
- package/src/components/MyazaButton.tsx +9 -0
- package/src/components/MyazaInput.tsx +17 -1
- package/src/components/OptionRow.tsx +22 -3
- package/src/components/PhoneNumberInput.tsx +8 -0
- package/src/components/PoweredBy.tsx +5 -5
- package/src/components/PresenceBlocks.tsx +153 -0
- package/src/components/RequiredDocumentPill.tsx +114 -0
- package/src/components/StepHeader.tsx +23 -3
- package/src/components/StepView.tsx +17 -0
- package/src/components/StickyActions.tsx +56 -0
- package/src/components/dialCodeRows.ts +64 -0
- package/src/components/documentReviewCopy.ts +39 -0
- package/src/components/fonts.ts +14 -17
- package/src/components/icon-map.ts +176 -0
- package/src/components/stepHeaderMeta.tsx +48 -5
- package/src/config/addressCollection.ts +125 -0
- package/src/config/biometricOptions.ts +100 -0
- package/src/config/business.ts +20 -1
- package/src/config/businessSteps.ts +8 -2
- package/src/config/consentStep.ts +19 -0
- package/src/config/documentCaptureMethods.ts +31 -0
- package/src/config/proofOfAddress.ts +54 -11
- package/src/config/regions.ts +24 -0
- package/src/config/stepOrder.ts +79 -5
- package/src/config/uploadLimits.ts +35 -0
- package/src/config/workflowMerge.ts +13 -5
- package/src/index.ts +49 -1
- package/src/lib/address-current-location.ts +171 -0
- package/src/lib/address-field-modes.ts +119 -0
- package/src/lib/address-flow.ts +172 -0
- package/src/lib/address-helpers.ts +48 -0
- package/src/lib/address-line.ts +76 -0
- package/src/lib/address-pin-move.ts +97 -0
- package/src/lib/address-step-recovery.ts +63 -0
- package/src/lib/authed-image.ts +78 -0
- package/src/lib/biometric-copy.ts +51 -0
- package/src/lib/captureRing.ts +83 -0
- package/src/lib/country-adoption.ts +89 -0
- package/src/lib/documentCaptureCheck.ts +136 -0
- package/src/lib/inferred-country.ts +65 -0
- package/src/lib/livenessLayout.ts +49 -0
- package/src/lib/map-frame.ts +159 -0
- package/src/lib/map-tiles.ts +143 -0
- package/src/lib/model-ready.ts +82 -0
- package/src/lib/poa-country-gate.ts +27 -0
- package/src/lib/prime-models.ts +50 -0
- package/src/lib/resubmit.ts +36 -7
- package/src/lib/result-copy.ts +116 -0
- package/src/lib/result-wait.ts +53 -0
- package/src/lib/review-map-surface.ts +26 -0
- package/src/lib/scope.ts +31 -0
- package/src/lib/selfie-sharpness.ts +211 -0
- package/src/lib/selfie-upload-wait.ts +79 -0
- package/src/lib/street-view-fov.ts +42 -0
- package/src/lib/webview-available.ts +47 -0
- package/src/liveness/avatarSource.ts +59 -0
- package/src/liveness/useLiveness.ts +6 -1
- package/src/liveness/useModelReady.ts +9 -55
- package/src/mrz/extract.ts +71 -4
- package/src/mrz/textRecognizer.ts +36 -0
- package/src/mrz/useTextModelReady.ts +21 -0
- package/src/presence/background-math.ts +100 -0
- package/src/presence/background-store.ts +82 -0
- package/src/presence/background.ts +165 -0
- package/src/presence/foreground-service.ts +193 -0
- package/src/presence/fs.ts +51 -0
- package/src/presence/geofence.ts +41 -0
- package/src/presence/math.ts +44 -0
- package/src/presence/post.ts +40 -0
- package/src/presence/report.ts +87 -0
- package/src/presence/sampler.ts +110 -0
- package/src/presence/status.ts +92 -0
- package/src/presence/store.ts +94 -0
- package/src/presence/tier.ts +36 -0
- package/src/presence/watch-wait.ts +81 -0
- package/src/screens/AddressCountryControl.tsx +115 -0
- package/src/screens/BusinessDetailsFields.tsx +3 -0
- package/src/screens/BusinessDetailsStep.tsx +2 -0
- package/src/screens/BusinessDocumentSlot.tsx +3 -2
- package/src/screens/BusinessDocumentsStep.tsx +4 -55
- package/src/screens/CompanyInfoFields.tsx +6 -1
- package/src/screens/ConsentStep.tsx +6 -17
- package/src/screens/ContactActions.tsx +54 -0
- package/src/screens/ContactDestinationField.tsx +4 -0
- package/src/screens/ContactEntryPanel.tsx +75 -0
- package/src/screens/ContactVerificationStep.tsx +26 -56
- package/src/screens/CountrySelectStep.tsx +13 -147
- package/src/screens/DocumentCaptureStep.tsx +177 -93
- package/src/screens/LivenessAvatar.tsx +33 -16
- package/src/screens/LivenessStep.tsx +188 -49
- package/src/screens/MrzScanView.tsx +33 -0
- package/src/screens/PoaDocumentTypeList.tsx +66 -0
- package/src/screens/ProofOfAddressParts.tsx +151 -0
- package/src/screens/ProofOfAddressStep.tsx +58 -99
- package/src/screens/SubmittedBadge.tsx +25 -0
- package/src/screens/SubmittedError.tsx +64 -0
- package/src/screens/SubmittedResult.tsx +117 -0
- package/src/screens/SubmittedStep.tsx +71 -164
- package/src/screens/SubmittedSuccess.tsx +127 -0
- package/src/screens/SubmittedWaiting.tsx +45 -0
- package/src/screens/address/AddressEntranceStep.tsx +161 -0
- package/src/screens/address/AddressIntroGate.tsx +148 -0
- package/src/screens/address/AddressMapStub.tsx +50 -0
- package/src/screens/address/AddressPinStep.tsx +178 -0
- package/src/screens/address/AddressReviewStep.tsx +162 -0
- package/src/screens/address/AddressSandboxTabs.tsx +158 -0
- package/src/screens/address/AddressSearchField.tsx +115 -0
- package/src/screens/address/AddressSearchStep.tsx +94 -0
- package/src/screens/address/CurrentLocationRow.tsx +135 -0
- package/src/screens/address/DetailsSheet.tsx +175 -0
- package/src/screens/address/DetailsSheetFields.tsx +184 -0
- package/src/screens/address/EntranceDropzone.tsx +178 -0
- package/src/screens/address/EntranceFraming.tsx +66 -0
- package/src/screens/address/EntrancePills.tsx +60 -0
- package/src/screens/address/FramedStreetView.tsx +155 -0
- package/src/screens/address/IntroDisclosures.tsx +210 -0
- package/src/screens/address/LabelDecisionRow.tsx +113 -0
- package/src/screens/address/PinSummaryRow.tsx +105 -0
- package/src/screens/address/ReviewAddressBand.tsx +111 -0
- package/src/screens/address/ReviewEntranceThumbs.tsx +64 -0
- package/src/screens/address/ReviewMapPicture.tsx +106 -0
- package/src/screens/address/SearchResults.tsx +105 -0
- package/src/screens/address/SearchScreen.tsx +176 -0
- package/src/screens/address/SkipForNow.tsx +43 -0
- package/src/screens/address/StreetViewChrome.tsx +81 -0
- package/src/screens/address/detail-values.ts +22 -0
- package/src/screens/address/fix-source.ts +27 -0
- package/src/screens/address/index.ts +8 -0
- package/src/screens/address/meta.ts +44 -0
- package/src/screens/address/use-address-flow.ts +200 -0
- package/src/screens/address/use-label-pin.ts +80 -0
- package/src/screens/address/use-pin-actions.ts +192 -0
- package/src/screens/consent/model.ts +79 -10
- package/src/screens/document/CaptureCheckNotice.tsx +77 -0
- package/src/screens/document/UploadPhase.tsx +177 -0
- package/src/screens/document/useDocumentCamera.ts +109 -0
- package/src/screens/liveness/CaptureRing.tsx +91 -0
- package/src/screens/liveness/LivenessCamera.tsx +63 -0
- package/src/screens/liveness/LivenessHandover.tsx +43 -0
- package/src/screens/liveness/LivenessOutcome.tsx +8 -4
- package/src/screens/liveness/SelfiePreview.tsx +31 -7
- package/src/screens/liveness/ShutterFlash.tsx +25 -0
- package/src/screens/liveness/index.ts +4 -0
- package/src/screens/liveness/useSelfieUpload.ts +23 -3
- package/src/screens/nfc/NfcScanIllustration.tsx +7 -18
- package/src/screens/useAddressPhotoAttach.ts +77 -0
- package/src/screens/useBusinessDocumentAttach.ts +93 -0
- package/src/screens/usePoaAttach.ts +12 -15
- package/src/services/api-types.ts +119 -1
- package/src/services/api-verify-types.ts +37 -0
- package/src/services/api.ts +149 -3
- package/src/services/deviceMetadata.ts +1 -1
- package/src/services/location.ts +234 -0
- package/src/services/mediaCompress.ts +23 -5
- package/src/services/uploadErrors.ts +30 -0
- package/src/services/workflowGate.ts +7 -0
- package/src/specs/MyazaTextRecognizer.nitro.ts +29 -0
- package/src/store/address.ts +97 -0
- package/src/store/derive.ts +29 -4
- package/src/store/kycStore.ts +120 -5
- package/src/store/serverConfig.ts +18 -0
- package/src/store/session.ts +23 -4
- package/src/store/state.ts +134 -0
- package/src/store/submit.ts +18 -1
- package/src/types/config.ts +63 -1
- package/src/types/verification.ts +12 -0
- package/src/types/workflow.ts +97 -1
- package/src/assets/liveness/Blink.gif +0 -0
- package/src/assets/liveness/Nod.gif +0 -0
- package/src/assets/liveness/Smile.gif +0 -0
- package/src/assets/liveness/Turn.gif +0 -0
|
@@ -1,13 +1,9 @@
|
|
|
1
|
-
import React, { useMemo
|
|
2
|
-
import {
|
|
1
|
+
import React, { useMemo } from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
3
|
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import { MyazaInput } from '../components/MyazaInput';
|
|
8
|
-
import { CountryFlag } from '../components/CountryFlag';
|
|
9
|
-
import { Icon } from '../components/Icon';
|
|
10
|
-
import { groupCountriesByRegion, regionCountryName } from '../config/regions';
|
|
4
|
+
import { useKyc, useKycConfig, useKycStore } from '../components/runtime';
|
|
5
|
+
import { CountryRegionPicker, CountryRow } from '../components/CountryRegionPicker';
|
|
6
|
+
import { regionCountryName } from '../config/regions';
|
|
11
7
|
import { countrySelectOptions } from '../store/derive';
|
|
12
8
|
|
|
13
9
|
// ---------------------------------------------------------------------------
|
|
@@ -54,7 +50,14 @@ export function CountrySelectStep(): React.ReactElement {
|
|
|
54
50
|
};
|
|
55
51
|
|
|
56
52
|
if (options.length > COUNTRY_SEARCH_THRESHOLD) {
|
|
57
|
-
return
|
|
53
|
+
return (
|
|
54
|
+
<CountryRegionPicker
|
|
55
|
+
countries={options}
|
|
56
|
+
selected={selected}
|
|
57
|
+
geoCountry={serverConfig.geoCountry}
|
|
58
|
+
onPick={pick}
|
|
59
|
+
/>
|
|
60
|
+
);
|
|
58
61
|
}
|
|
59
62
|
|
|
60
63
|
return (
|
|
@@ -71,140 +74,3 @@ export function CountrySelectStep(): React.ReactElement {
|
|
|
71
74
|
</View>
|
|
72
75
|
);
|
|
73
76
|
}
|
|
74
|
-
|
|
75
|
-
function CountryRegionPicker({
|
|
76
|
-
countries,
|
|
77
|
-
selected,
|
|
78
|
-
onPick,
|
|
79
|
-
}: {
|
|
80
|
-
countries: string[];
|
|
81
|
-
selected: string | null;
|
|
82
|
-
onPick: (country: string) => void;
|
|
83
|
-
}): React.ReactElement {
|
|
84
|
-
const { colors } = useTheme();
|
|
85
|
-
const [query, setQuery] = useState('');
|
|
86
|
-
|
|
87
|
-
const groups = useMemo(() => {
|
|
88
|
-
const needle = query.trim().toLowerCase();
|
|
89
|
-
const filtered = needle
|
|
90
|
-
? countries.filter(
|
|
91
|
-
(code) =>
|
|
92
|
-
code.toLowerCase().includes(needle) ||
|
|
93
|
-
regionCountryName(code).toLowerCase().includes(needle),
|
|
94
|
-
)
|
|
95
|
-
: countries;
|
|
96
|
-
return groupCountriesByRegion(filtered);
|
|
97
|
-
}, [countries, query]);
|
|
98
|
-
|
|
99
|
-
const empty = groups.length === 0;
|
|
100
|
-
|
|
101
|
-
return (
|
|
102
|
-
// The search box stays put and the list owns its own scroll, so a
|
|
103
|
-
// ~240-country flow does not scroll the search field off the top. That
|
|
104
|
-
// relies on the SHEET rendering this step with `fillsViewport` — inside a
|
|
105
|
-
// scroll view the nested list below would take an unbounded height, outgrow
|
|
106
|
-
// the viewport, and drag the search box off the top with it.
|
|
107
|
-
<View style={{ flex: 1 }}>
|
|
108
|
-
<MyazaInput
|
|
109
|
-
value={query}
|
|
110
|
-
onChangeText={setQuery}
|
|
111
|
-
placeholder="Search countries…"
|
|
112
|
-
autoCapitalize="none"
|
|
113
|
-
prefix={<Icon name="search" size={18} color={colors.textSecondary} />}
|
|
114
|
-
/>
|
|
115
|
-
<View style={{ height: spacing.md }} />
|
|
116
|
-
|
|
117
|
-
{empty ? (
|
|
118
|
-
<MyazaText variant="bodyMedium" style={{ textAlign: 'center', marginTop: spacing.lg }}>
|
|
119
|
-
No countries match your search.
|
|
120
|
-
</MyazaText>
|
|
121
|
-
) : (
|
|
122
|
-
// `nestedScrollEnabled` stays: Android refuses to scroll a nested
|
|
123
|
-
// same-direction list without it, and this is still nested on the
|
|
124
|
-
// flat-list path and inside any future scrolling parent.
|
|
125
|
-
<ScrollView
|
|
126
|
-
nestedScrollEnabled
|
|
127
|
-
keyboardShouldPersistTaps="handled"
|
|
128
|
-
showsVerticalScrollIndicator={false}
|
|
129
|
-
>
|
|
130
|
-
{groups.map((group) => (
|
|
131
|
-
<View key={group.region}>
|
|
132
|
-
{/* Uppercase, tracked-out section label — Flutter's region
|
|
133
|
-
header. Lowercase text at the default weight read as another
|
|
134
|
-
list row rather than a divider. */}
|
|
135
|
-
<MyazaText
|
|
136
|
-
variant="bodySmall"
|
|
137
|
-
color={colors.textSecondary}
|
|
138
|
-
style={{
|
|
139
|
-
fontWeight: '700',
|
|
140
|
-
letterSpacing: 0.6,
|
|
141
|
-
paddingHorizontal: spacing.xs,
|
|
142
|
-
paddingVertical: spacing.sm,
|
|
143
|
-
}}
|
|
144
|
-
>
|
|
145
|
-
{group.region.toUpperCase()}
|
|
146
|
-
</MyazaText>
|
|
147
|
-
{group.countries.map((entry) => (
|
|
148
|
-
<CountryRow
|
|
149
|
-
key={entry.code}
|
|
150
|
-
code={entry.code}
|
|
151
|
-
name={entry.name}
|
|
152
|
-
selected={entry.code === selected}
|
|
153
|
-
onPress={() => onPick(entry.code)}
|
|
154
|
-
/>
|
|
155
|
-
))}
|
|
156
|
-
</View>
|
|
157
|
-
))}
|
|
158
|
-
</ScrollView>
|
|
159
|
-
)}
|
|
160
|
-
</View>
|
|
161
|
-
);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
function CountryRow({
|
|
165
|
-
code,
|
|
166
|
-
name,
|
|
167
|
-
selected,
|
|
168
|
-
onPress,
|
|
169
|
-
}: {
|
|
170
|
-
code: string;
|
|
171
|
-
name: string;
|
|
172
|
-
selected: boolean;
|
|
173
|
-
onPress: () => void;
|
|
174
|
-
}): React.ReactElement {
|
|
175
|
-
const { colors } = useTheme();
|
|
176
|
-
return (
|
|
177
|
-
<Pressable
|
|
178
|
-
onPress={onPress}
|
|
179
|
-
accessibilityRole="radio"
|
|
180
|
-
accessibilityState={{ checked: selected }}
|
|
181
|
-
accessibilityLabel={name}
|
|
182
|
-
// Matches the Flutter SDK's CountryOptionTile exactly — the two had
|
|
183
|
-
// drifted (24px flag / 12px padding here vs 32 / 16 there), which is why
|
|
184
|
-
// the same screen looked like a different component on each platform. The
|
|
185
|
-
// spacing and radius scales are identical across the SDKs, so there is
|
|
186
|
-
// nothing platform-specific to reconcile: `md` means 16 on both.
|
|
187
|
-
style={{
|
|
188
|
-
flexDirection: 'row',
|
|
189
|
-
alignItems: 'center',
|
|
190
|
-
padding: spacing.md,
|
|
191
|
-
borderRadius: radius.md,
|
|
192
|
-
borderWidth: selected ? 1.5 : 1,
|
|
193
|
-
borderColor: selected ? colors.primary : colors.border,
|
|
194
|
-
backgroundColor: selected ? colors.primary50 : colors.background,
|
|
195
|
-
marginBottom: spacing.sm,
|
|
196
|
-
}}
|
|
197
|
-
>
|
|
198
|
-
<CountryFlag country={code} size={32} />
|
|
199
|
-
<View style={{ width: 12 }} />
|
|
200
|
-
<MyazaText variant="label" style={{ flex: 1, fontWeight: '500' }}>
|
|
201
|
-
{name}
|
|
202
|
-
</MyazaText>
|
|
203
|
-
<Icon
|
|
204
|
-
name="chevron-right"
|
|
205
|
-
size={18}
|
|
206
|
-
color={selected ? colors.primary : colors.textSecondary}
|
|
207
|
-
/>
|
|
208
|
-
</Pressable>
|
|
209
|
-
);
|
|
210
|
-
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
2
|
import { Image, Platform, Pressable, View } from 'react-native';
|
|
3
3
|
import * as ImagePicker from 'expo-image-picker';
|
|
4
|
-
import { useCameraDevice, useCameraPermission } from 'react-native-vision-camera';
|
|
5
4
|
import { initialWindowMetrics } from 'react-native-safe-area-context';
|
|
6
5
|
|
|
7
6
|
import { radius, spacing } from '../config/theme';
|
|
@@ -10,8 +9,8 @@ import { withRetry } from '../services/retry';
|
|
|
10
9
|
import { mapToKycError, safeReportError } from '../services/errors';
|
|
11
10
|
import { compressDocumentImage, compressVideo, cropCardRegion } from '../services/mediaCompress';
|
|
12
11
|
import { MAX_VIDEO_BYTES } from '../config/captureSettings';
|
|
13
|
-
import { KYCError } from '../types/verification';
|
|
14
12
|
import type { DocumentCapturePhase } from '../store/kycStore';
|
|
13
|
+
import { documentCaptureMethods } from '../config/documentCaptureMethods';
|
|
15
14
|
import { useEffectiveCountry, useKyc, useKycConfig, useKycStore, useTheme } from '../components/runtime';
|
|
16
15
|
import { MyazaText } from '../components/Typography';
|
|
17
16
|
import { MyazaButton } from '../components/MyazaButton';
|
|
@@ -20,17 +19,34 @@ import { MyazaPulseLoader } from '../components/MyazaPulseLoader';
|
|
|
20
19
|
import { CameraPhase } from './document/CameraPhase';
|
|
21
20
|
import { CameraPermissionView, CameraUnavailableView, CameraPermissionPrimingView } from '../components/CameraPermissionView';
|
|
22
21
|
import { ReadyPrimer } from '../components/ReadyPrimer';
|
|
22
|
+
import { RequiredDocumentPill } from '../components/RequiredDocumentPill';
|
|
23
23
|
import { READY_DOCUMENT } from '../components/readyPrimerContent';
|
|
24
24
|
import { DocumentCropper } from '../components/DocumentCropper';
|
|
25
25
|
import { DocumentReview } from '../components/DocumentReview';
|
|
26
26
|
import { scanMrzFromImage } from '../mrz/textRecognizer';
|
|
27
27
|
import { Icon } from '../components/Icon';
|
|
28
|
+
import { useDocumentCamera, type DocumentCamera } from './document/useDocumentCamera';
|
|
29
|
+
import { UploadPhase, documentUploadMeta } from './document/UploadPhase';
|
|
30
|
+
import { CaptureCheckNotice } from './document/CaptureCheckNotice';
|
|
31
|
+
import { isBusinessFlow } from '../config/business';
|
|
32
|
+
import { captureCheckProblems, runCaptureChecks, type CaptureProblem } from '../lib/documentCaptureCheck';
|
|
33
|
+
import type { DocumentCaptureCheckRequest, DocumentCaptureSide } from '../services/api';
|
|
28
34
|
|
|
29
35
|
// Document capture — the RN mirror of the Flutter/web DocumentCaptureStep.
|
|
30
36
|
// Phases: front → front-preview → back → review (two-sided); front → review
|
|
31
37
|
// (one-sided). Manual shutter (VisionCamera) or gallery upload; each side is
|
|
32
38
|
// compressed (OCR-conservative) then eagerly uploaded on Continue with retry.
|
|
33
39
|
//
|
|
40
|
+
// Which of the two ways in is offered comes from `documentCaptureMethods`
|
|
41
|
+
// (allowDocumentScan / allowDocumentUpload). With scanning off, each side is
|
|
42
|
+
// UploadPhase instead of the camera, and the camera's hooks are never mounted.
|
|
43
|
+
//
|
|
44
|
+
// Before moving on, each uploaded side goes past the server's capture check
|
|
45
|
+
// (lib/documentCaptureCheck): no face on the printed photo, or a barcode that
|
|
46
|
+
// will not read, turns the review footer into a retake notice. A notice, never
|
|
47
|
+
// a gate: "Continue anyway" always advances, and a failed or slow check reads
|
|
48
|
+
// as no problem.
|
|
49
|
+
//
|
|
34
50
|
// The per-phase title/description live in the SHEET HEADER (KycFlow reads the
|
|
35
51
|
// synced `documentCapturePhase` from the store and calls `documentCaptureMeta`),
|
|
36
52
|
// not in the body — mirroring Flutter's header `docReviewPhase` sync.
|
|
@@ -39,7 +55,10 @@ import { Icon } from '../components/Icon';
|
|
|
39
55
|
export function documentCaptureMeta(
|
|
40
56
|
phase: DocumentCapturePhase,
|
|
41
57
|
documentLabel: string,
|
|
58
|
+
mode: 'scan' | 'upload' = 'scan',
|
|
42
59
|
): { title: string; description: string } {
|
|
60
|
+
// An upload-only workflow asks for a photo, so it cannot say "scan".
|
|
61
|
+
if (mode === 'upload') return documentUploadMeta(phase, documentLabel);
|
|
43
62
|
// Copy matches the Flutter SDK's document-capture header meta exactly.
|
|
44
63
|
switch (phase) {
|
|
45
64
|
case 'front':
|
|
@@ -77,6 +96,33 @@ function cameraBottomInset(): number {
|
|
|
77
96
|
}
|
|
78
97
|
|
|
79
98
|
export function DocumentCaptureStep(): React.ReactElement {
|
|
99
|
+
const config = useKycConfig();
|
|
100
|
+
const methods = documentCaptureMethods(config);
|
|
101
|
+
// The camera's hooks live in their own wrapper so an upload-only workflow
|
|
102
|
+
// (`allowDocumentScan: false`) never mounts them: no permission read, no
|
|
103
|
+
// device enumeration, no prompt. The element types stay fixed for a given
|
|
104
|
+
// config, so the capture state below keeps the step's whole lifetime.
|
|
105
|
+
return methods.scan ? (
|
|
106
|
+
<ScanningDocumentCapture allowUpload={methods.upload} />
|
|
107
|
+
) : (
|
|
108
|
+
<DocumentCapture camera={null} allowUpload />
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function ScanningDocumentCapture({ allowUpload }: { allowUpload: boolean }): React.ReactElement {
|
|
113
|
+
const config = useKycConfig();
|
|
114
|
+
const camera = useDocumentCamera(config.onError);
|
|
115
|
+
return <DocumentCapture camera={camera} allowUpload={allowUpload} />;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function DocumentCapture({
|
|
119
|
+
camera,
|
|
120
|
+
allowUpload,
|
|
121
|
+
}: {
|
|
122
|
+
/** Null in upload-only mode, where each side is a photo from the device. */
|
|
123
|
+
camera: DocumentCamera | null;
|
|
124
|
+
allowUpload: boolean;
|
|
125
|
+
}): React.ReactElement {
|
|
80
126
|
const { colors } = useTheme();
|
|
81
127
|
const toast = useToast();
|
|
82
128
|
const config = useKycConfig();
|
|
@@ -90,12 +136,23 @@ export function DocumentCaptureStep(): React.ReactElement {
|
|
|
90
136
|
const setImmersiveCapture = useKyc((s) => s.setImmersiveCapture);
|
|
91
137
|
const store = useKycStore();
|
|
92
138
|
const nextStep = useKyc((s) => s.nextStep);
|
|
139
|
+
const sessionId = useKyc((s) => s.sessionId);
|
|
93
140
|
|
|
94
|
-
const allowUpload = config.allowDocumentUpload !== false;
|
|
95
141
|
const documentLabel =
|
|
96
142
|
(selectedIdType && Object.values(ID_TYPES).flat().find((t) => t.key === selectedIdType)?.label) || 'Document';
|
|
97
143
|
const scanSides = selectedIdType ? getScanSides(selectedIdType) : 'front_only';
|
|
98
144
|
const isTwoSided = scanSides === 'front_and_back';
|
|
145
|
+
|
|
146
|
+
// "Required: <document>" — shown on the screens either side of the camera,
|
|
147
|
+
// matching the web SDK's badge and Flutter's _RequiredPill. Not over a live
|
|
148
|
+
// viewfinder: that screen is full-bleed and owns the display.
|
|
149
|
+
const requiredPill = (side: 'front' | 'back' | null) => (
|
|
150
|
+
<RequiredDocumentPill
|
|
151
|
+
idTypeLabel={documentLabel}
|
|
152
|
+
sideBadge={isTwoSided && side ? (side === 'back' ? 'Back Side' : 'Front Side') : null}
|
|
153
|
+
stepLabel={isTwoSided && side ? (side === 'back' ? 'Step 2 of 2' : 'Step 1 of 2') : null}
|
|
154
|
+
/>
|
|
155
|
+
);
|
|
99
156
|
const guideAspect = documentGuideAspect(selectedIdType);
|
|
100
157
|
|
|
101
158
|
const [phase, setPhase] = useState<DocumentCapturePhase>('front');
|
|
@@ -106,6 +163,10 @@ export function DocumentCaptureStep(): React.ReactElement {
|
|
|
106
163
|
const [retryInfo, setRetryInfo] = useState<{ attempt: number; total: number } | null>(null);
|
|
107
164
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
|
108
165
|
const [cropUri, setCropUri] = useState<string | null>(null); // gallery photo awaiting crop
|
|
166
|
+
// What the server's capture check found wrong with the uploaded sides. Set
|
|
167
|
+
// only when there is something to retake; any new capture clears it, so the
|
|
168
|
+
// next Continue uploads and checks again.
|
|
169
|
+
const [captureProblems, setCaptureProblems] = useState<CaptureProblem[] | null>(null);
|
|
109
170
|
// Best-effort document videos recorded alongside each side's still (uploaded as
|
|
110
171
|
// document_front_video / document_back_video). Refs — they don't drive the UI.
|
|
111
172
|
const frontVideoRef = useRef<string | null>(null);
|
|
@@ -117,42 +178,14 @@ export function DocumentCaptureStep(): React.ReactElement {
|
|
|
117
178
|
}, [phase, setDocumentCapturePhase]);
|
|
118
179
|
|
|
119
180
|
|
|
120
|
-
// ── Camera permission
|
|
121
|
-
// `
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
// 'priming' shows the "Allow camera access" screen BEFORE the OS prompt
|
|
125
|
-
// (Stripe-style); the prompt only fires (→ 'requesting') once the user taps
|
|
126
|
-
// "Grant access".
|
|
127
|
-
const { hasPermission, requestPermission } = useCameraPermission();
|
|
128
|
-
const [perm, setPerm] = useState<'priming' | 'requesting' | 'granted' | 'denied'>(
|
|
129
|
-
hasPermission ? 'granted' : 'priming',
|
|
130
|
-
);
|
|
131
|
-
const permReportedRef = useRef(false);
|
|
132
|
-
|
|
133
|
-
// ── Camera availability ─────────────────────────────────────────────────────
|
|
134
|
-
// Even with permission granted, there may be no usable back camera (the iOS/
|
|
135
|
-
// Android simulator has none; a real device may fail to init). Give the device
|
|
136
|
-
// list a moment to resolve, then surface a proper "Camera not available" error
|
|
137
|
-
// with an upload fallback — on every iOS version (glass or not) and Android.
|
|
138
|
-
// Device enumeration does NOT need camera permission (iOS AVCaptureDevice /
|
|
139
|
-
// Android CameraManager list hardware regardless), so `!device` reliably means
|
|
140
|
-
// "no back-camera hardware" — true on every simulator. That's a different state
|
|
141
|
-
// from "permission denied": no hardware → nothing to grant.
|
|
142
|
-
const device = useCameraDevice('back');
|
|
143
|
-
const [cameraGrace, setCameraGrace] = useState(false);
|
|
144
|
-
useEffect(() => {
|
|
145
|
-
const t = setTimeout(() => setCameraGrace(true), 1500);
|
|
146
|
-
return () => clearTimeout(t);
|
|
147
|
-
}, []);
|
|
148
|
-
// No camera hardware at all → "Camera not available" (regardless of what the
|
|
149
|
-
// permission API says — on a camera-less sim it may even report denied).
|
|
150
|
-
const cameraUnavailable = cameraGrace && !device;
|
|
181
|
+
// ── Camera permission + availability ───────────────────────────────────────
|
|
182
|
+
// Owned by `useDocumentCamera` (called by ScanningDocumentCapture), so
|
|
183
|
+
// `camera` is null in upload-only mode and every camera gate below is skipped.
|
|
184
|
+
//
|
|
151
185
|
// Gate the camera path on the user acknowledging the primer. Shown once for
|
|
152
186
|
// the step, not per side — repeating it before the back of a card would be
|
|
153
187
|
// noise, not a warning.
|
|
154
188
|
const [ready, setReady] = useState(false);
|
|
155
|
-
const showPrimer = perm === 'priming' && !!device;
|
|
156
189
|
// Tell the shell when the full-bleed camera is on screen. Scoped tightly:
|
|
157
190
|
// only the live preview earns the whole display — the ready primer, the
|
|
158
191
|
// permission screens, the previews and the review all keep their chrome.
|
|
@@ -161,56 +194,21 @@ export function DocumentCaptureStep(): React.ReactElement {
|
|
|
161
194
|
// cleanup here would fire during any remount and fight the effect that had
|
|
162
195
|
// just raised it.
|
|
163
196
|
const cameraLive =
|
|
164
|
-
(phase === 'front' || phase === 'back') &&
|
|
197
|
+
(phase === 'front' || phase === 'back') &&
|
|
198
|
+
ready &&
|
|
199
|
+
!!camera &&
|
|
200
|
+
camera.perm === 'granted' &&
|
|
201
|
+
camera.hasDevice;
|
|
165
202
|
useEffect(() => {
|
|
166
203
|
setImmersiveCapture(cameraLive);
|
|
167
204
|
}, [cameraLive, setImmersiveCapture]);
|
|
168
205
|
|
|
169
|
-
// Reflect an externally-granted permission.
|
|
170
|
-
useEffect(() => {
|
|
171
|
-
if (hasPermission) setPerm('granted');
|
|
172
|
-
}, [hasPermission]);
|
|
173
|
-
|
|
174
|
-
// Fire the real OS prompt only after the user taps "Grant access" (or retry).
|
|
175
|
-
useEffect(() => {
|
|
176
|
-
if (perm !== 'requesting') return;
|
|
177
|
-
let cancelled = false;
|
|
178
|
-
void (async () => {
|
|
179
|
-
const granted = await requestPermission();
|
|
180
|
-
if (!cancelled) setPerm(granted ? 'granted' : 'denied');
|
|
181
|
-
})();
|
|
182
|
-
return () => {
|
|
183
|
-
cancelled = true;
|
|
184
|
-
};
|
|
185
|
-
}, [perm, requestPermission]);
|
|
186
|
-
|
|
187
|
-
// A *genuine* permission denial requires a camera to exist but be blocked. On a
|
|
188
|
-
// camera-less sim the OS may report denied — that's "not available", not a
|
|
189
|
-
// permission problem, so don't treat it as denied or report onError there.
|
|
190
|
-
const permissionDenied = perm === 'denied' && !!device;
|
|
191
|
-
useEffect(() => {
|
|
192
|
-
if (permissionDenied && !permReportedRef.current) {
|
|
193
|
-
permReportedRef.current = true;
|
|
194
|
-
safeReportError(
|
|
195
|
-
config.onError,
|
|
196
|
-
new KYCError(
|
|
197
|
-
'camera_permission_denied',
|
|
198
|
-
'Camera access is required to photograph your document. Allow camera access or upload a photo instead.',
|
|
199
|
-
),
|
|
200
|
-
);
|
|
201
|
-
}
|
|
202
|
-
if (!permissionDenied) permReportedRef.current = false;
|
|
203
|
-
}, [permissionDenied, config.onError]);
|
|
204
|
-
|
|
205
|
-
const retryPermission = useCallback(() => {
|
|
206
|
-
setPerm('requesting');
|
|
207
|
-
}, []);
|
|
208
|
-
|
|
209
206
|
// ── Capture → compress → store for the current side ────────────────────────
|
|
210
207
|
const storeCapture = useCallback(
|
|
211
208
|
async (rawUri: string) => {
|
|
212
209
|
setBusy(true);
|
|
213
210
|
setUploadError(null);
|
|
211
|
+
setCaptureProblems(null);
|
|
214
212
|
try {
|
|
215
213
|
const compressed = await compressDocumentImage(rawUri);
|
|
216
214
|
// Read the MRZ off the FRONT while we have it. It is the key that
|
|
@@ -317,25 +315,34 @@ export function DocumentCaptureStep(): React.ReactElement {
|
|
|
317
315
|
[api, setMediaId],
|
|
318
316
|
);
|
|
319
317
|
|
|
320
|
-
// ──
|
|
318
|
+
// ── Advance (KycFlow routes to liveness/submitted, or the next multi-ID ID) ─
|
|
319
|
+
// Shared by a clean Continue and "Continue anyway". The media ids are already
|
|
320
|
+
// in the store, so moving on never uploads or checks a second time.
|
|
321
|
+
const advance = useCallback(() => {
|
|
322
|
+
setCaptureProblems(null);
|
|
323
|
+
nextStep();
|
|
324
|
+
}, [nextStep]);
|
|
325
|
+
|
|
326
|
+
// ── Upload both sides, check they will read, then advance ──────────────────
|
|
321
327
|
const handleContinue = useCallback(async () => {
|
|
322
328
|
if (!frontUri) return;
|
|
323
329
|
setUploading(true);
|
|
324
330
|
setUploadError(null);
|
|
331
|
+
setCaptureProblems(null);
|
|
325
332
|
setRetryInfo(null);
|
|
326
333
|
const onRetry = (attempt: number, total: number) => setRetryInfo({ attempt, total });
|
|
334
|
+
const uploaded: { side: DocumentCaptureSide; mediaId: string }[] = [];
|
|
327
335
|
try {
|
|
328
336
|
const frontId = await withRetry(() => api.upload({ uri: frontUri, type: 'image/jpeg' }, 'document_front'), { onRetry });
|
|
329
337
|
setMediaId('documentFront', frontId);
|
|
338
|
+
uploaded.push({ side: 'front', mediaId: frontId });
|
|
330
339
|
await uploadDocVideo(frontVideoRef.current, 'document_front_video', 'documentFrontVideo');
|
|
331
340
|
if (isTwoSided && backUri) {
|
|
332
341
|
const backId = await withRetry(() => api.upload({ uri: backUri, type: 'image/jpeg' }, 'document_back'), { onRetry });
|
|
333
342
|
setMediaId('documentBack', backId);
|
|
343
|
+
uploaded.push({ side: 'back', mediaId: backId });
|
|
334
344
|
await uploadDocVideo(backVideoRef.current, 'document_back_video', 'documentBackVideo');
|
|
335
345
|
}
|
|
336
|
-
setRetryInfo(null);
|
|
337
|
-
setUploading(false);
|
|
338
|
-
nextStep();
|
|
339
346
|
} catch (err) {
|
|
340
347
|
setRetryInfo(null);
|
|
341
348
|
setUploading(false);
|
|
@@ -343,11 +350,51 @@ export function DocumentCaptureStep(): React.ReactElement {
|
|
|
343
350
|
setUploadError(kycError.message);
|
|
344
351
|
toast.show({ variant: 'error', title: 'Upload failed', message: kycError.message });
|
|
345
352
|
safeReportError(config.onError, kycError);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
setRetryInfo(null);
|
|
356
|
+
|
|
357
|
+
// The same workflow the submission will carry: a KYB flow's document is
|
|
358
|
+
// the applicant's own leg, which submits under the mapped applicant
|
|
359
|
+
// workflow (store/submitApplicant). Best-effort throughout:
|
|
360
|
+
// runCaptureChecks never throws and gives up on a side after 8s.
|
|
361
|
+
const workflowId = isBusinessFlow(config) ? config.applicantWorkflowId : config.workflowId;
|
|
362
|
+
const requests: DocumentCaptureCheckRequest[] = selectedIdType
|
|
363
|
+
? uploaded.map(({ side, mediaId }) => ({
|
|
364
|
+
mediaId,
|
|
365
|
+
side,
|
|
366
|
+
country,
|
|
367
|
+
idType: selectedIdType,
|
|
368
|
+
...(workflowId ? { workflowId } : {}),
|
|
369
|
+
...(sessionId ? { sessionId } : {}),
|
|
370
|
+
}))
|
|
371
|
+
: [];
|
|
372
|
+
const results = await runCaptureChecks(requests, (body, signal) => api.checkDocumentCapture(body, signal));
|
|
373
|
+
const problems = captureCheckProblems(results);
|
|
374
|
+
setUploading(false);
|
|
375
|
+
if (problems.length > 0) {
|
|
376
|
+
setCaptureProblems(problems);
|
|
377
|
+
return;
|
|
346
378
|
}
|
|
347
|
-
|
|
379
|
+
advance();
|
|
380
|
+
}, [
|
|
381
|
+
frontUri,
|
|
382
|
+
backUri,
|
|
383
|
+
isTwoSided,
|
|
384
|
+
api,
|
|
385
|
+
setMediaId,
|
|
386
|
+
config,
|
|
387
|
+
toast,
|
|
388
|
+
uploadDocVideo,
|
|
389
|
+
selectedIdType,
|
|
390
|
+
country,
|
|
391
|
+
sessionId,
|
|
392
|
+
advance,
|
|
393
|
+
]);
|
|
348
394
|
|
|
349
|
-
const retake = (side:
|
|
395
|
+
const retake = (side: DocumentCaptureSide) => {
|
|
350
396
|
setUploadError(null);
|
|
397
|
+
setCaptureProblems(null);
|
|
351
398
|
if (side === 'back') {
|
|
352
399
|
setBackUri(null);
|
|
353
400
|
backVideoRef.current = null;
|
|
@@ -377,7 +424,25 @@ export function DocumentCaptureStep(): React.ReactElement {
|
|
|
377
424
|
// states keep the title/description in the header, and always offer the gallery
|
|
378
425
|
// escape hatch). These never overlap: one needs a device, the other needs none.
|
|
379
426
|
if (phase === 'front' || phase === 'back') {
|
|
380
|
-
if (
|
|
427
|
+
if (!camera) {
|
|
428
|
+
// Upload-only: no ready primer (it describes a camera scan), no
|
|
429
|
+
// permission screens, no viewfinder. The pick runs the same cropper and
|
|
430
|
+
// store path a gallery photo takes from the camera screen.
|
|
431
|
+
return (
|
|
432
|
+
<>
|
|
433
|
+
{cropper}
|
|
434
|
+
<View style={{ marginBottom: spacing.md }}>{requiredPill(phase === 'back' ? 'back' : 'front')}</View>
|
|
435
|
+
<UploadPhase
|
|
436
|
+
isBack={phase === 'back'}
|
|
437
|
+
documentLabel={documentLabel}
|
|
438
|
+
isTwoSided={isTwoSided}
|
|
439
|
+
busy={busy}
|
|
440
|
+
onUpload={pickFromGallery}
|
|
441
|
+
/>
|
|
442
|
+
</>
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
if (camera.cameraUnavailable) {
|
|
381
446
|
// No back-camera hardware (e.g. a simulator) — permission is moot here.
|
|
382
447
|
return (
|
|
383
448
|
<>
|
|
@@ -393,28 +458,29 @@ export function DocumentCaptureStep(): React.ReactElement {
|
|
|
393
458
|
return (
|
|
394
459
|
<>
|
|
395
460
|
{cropper}
|
|
461
|
+
<View style={{ marginBottom: spacing.md }}>{requiredPill(phase === 'back' ? 'back' : 'front')}</View>
|
|
396
462
|
<ReadyPrimer content={READY_DOCUMENT} onReady={() => setReady(true)} />
|
|
397
463
|
</>
|
|
398
464
|
);
|
|
399
465
|
}
|
|
400
|
-
if (showPrimer) {
|
|
466
|
+
if (camera.showPrimer) {
|
|
401
467
|
// Primer before the OS prompt — camera not started yet.
|
|
402
468
|
return (
|
|
403
469
|
<>
|
|
404
470
|
{cropper}
|
|
405
471
|
<CameraPermissionPrimingView
|
|
406
472
|
message="When prompted, allow camera access to photograph your document."
|
|
407
|
-
onGrant={
|
|
473
|
+
onGrant={camera.requestAccess}
|
|
408
474
|
/>
|
|
409
475
|
</>
|
|
410
476
|
);
|
|
411
477
|
}
|
|
412
|
-
if (permissionDenied) {
|
|
478
|
+
if (camera.permissionDenied) {
|
|
413
479
|
// A real camera exists but the OS blocked access.
|
|
414
480
|
return (
|
|
415
481
|
<>
|
|
416
482
|
{cropper}
|
|
417
|
-
<CameraPermissionView onRetry={
|
|
483
|
+
<CameraPermissionView onRetry={camera.requestAccess} onUpload={pickFromGallery} />
|
|
418
484
|
</>
|
|
419
485
|
);
|
|
420
486
|
}
|
|
@@ -426,7 +492,7 @@ export function DocumentCaptureStep(): React.ReactElement {
|
|
|
426
492
|
<CameraPhase
|
|
427
493
|
isBack={isBack}
|
|
428
494
|
cameraLive={cameraLive}
|
|
429
|
-
active={perm === 'granted'}
|
|
495
|
+
active={camera.perm === 'granted'}
|
|
430
496
|
documentLabel={documentLabel}
|
|
431
497
|
guideAspect={guideAspect}
|
|
432
498
|
isTwoSided={isTwoSided}
|
|
@@ -453,13 +519,19 @@ export function DocumentCaptureStep(): React.ReactElement {
|
|
|
453
519
|
if (phase === 'front-preview') {
|
|
454
520
|
return (
|
|
455
521
|
<View>
|
|
522
|
+
<View style={{ marginBottom: spacing.md }}>{requiredPill('front')}</View>
|
|
456
523
|
<DocImage uri={frontUri!} />
|
|
457
524
|
<View style={{ flexDirection: 'row', gap: spacing.md, marginTop: spacing.md }}>
|
|
458
525
|
<View style={{ flex: 1 }}>
|
|
459
|
-
<MyazaButton
|
|
526
|
+
<MyazaButton
|
|
527
|
+
label={camera ? 'Retake' : 'Replace'}
|
|
528
|
+
variant="outline"
|
|
529
|
+
leadingIcon="refresh"
|
|
530
|
+
onPress={() => retake('front')}
|
|
531
|
+
/>
|
|
460
532
|
</View>
|
|
461
533
|
<View style={{ flex: 1 }}>
|
|
462
|
-
<MyazaButton label=
|
|
534
|
+
<MyazaButton label={camera ? 'Next: Scan Back' : 'Next: Add Back'} onPress={() => setPhase('back')} />
|
|
463
535
|
</View>
|
|
464
536
|
</View>
|
|
465
537
|
</View>
|
|
@@ -468,7 +540,10 @@ export function DocumentCaptureStep(): React.ReactElement {
|
|
|
468
540
|
|
|
469
541
|
// ── Review ─────────────────────────────────────────────────────────────────
|
|
470
542
|
return (
|
|
471
|
-
|
|
543
|
+
<>
|
|
544
|
+
<View style={{ marginBottom: spacing.md }}>{requiredPill(null)}</View>
|
|
545
|
+
<DocumentReview
|
|
546
|
+
mode={camera ? 'scan' : 'upload'}
|
|
472
547
|
frontUri={frontUri!}
|
|
473
548
|
backUri={isTwoSided ? backUri : null}
|
|
474
549
|
aspect={guideAspect}
|
|
@@ -494,18 +569,27 @@ export function DocumentCaptureStep(): React.ReactElement {
|
|
|
494
569
|
<>
|
|
495
570
|
{retryInfo && uploading ? (
|
|
496
571
|
<MyazaText variant="bodySmall" color={colors.warning} style={{ textAlign: 'center', marginBottom: spacing.sm }}>
|
|
497
|
-
{`Upload failed
|
|
572
|
+
{`Upload failed. Retrying (${retryInfo.attempt}/${retryInfo.total})…`}
|
|
498
573
|
</MyazaText>
|
|
499
574
|
) : null}
|
|
500
575
|
{uploadError ? (
|
|
501
576
|
// The error message is shown as a top toast; keep a retry action here.
|
|
502
577
|
<MyazaButton label="Try Again" onPress={handleContinue} loading={uploading} />
|
|
578
|
+
) : captureProblems ? (
|
|
579
|
+
// A side will not read: retake it, or carry on with what was sent.
|
|
580
|
+
<CaptureCheckNotice
|
|
581
|
+
problems={captureProblems}
|
|
582
|
+
uploadOnly={!camera}
|
|
583
|
+
onRetake={retake}
|
|
584
|
+
onContinueAnyway={advance}
|
|
585
|
+
/>
|
|
503
586
|
) : (
|
|
504
587
|
<MyazaButton label="Continue" onPress={handleContinue} loading={uploading} />
|
|
505
588
|
)}
|
|
506
589
|
</>
|
|
507
590
|
}
|
|
508
|
-
|
|
591
|
+
/>
|
|
592
|
+
</>
|
|
509
593
|
);
|
|
510
594
|
}
|
|
511
595
|
|