@myazahq/kyc-sdk-react-native 2.5.0 → 2.6.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 +151 -5
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +6 -4
- package/app.plugin.js +89 -8
- package/ios/HybridMyazaFaceDetector.swift +8 -3
- package/package.json +14 -2
- package/src/MyazaBiometricAuth.tsx +119 -0
- 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/FramedMapPicker.tsx +122 -0
- package/src/components/GeoBadge.tsx +34 -0
- package/src/components/Icon.tsx +2 -185
- package/src/components/KycFlow.tsx +60 -32
- 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/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/StepView.tsx +17 -0
- package/src/components/StickyActions.tsx +56 -0
- package/src/components/dialCodeRows.ts +64 -0
- package/src/components/icon-map.ts +176 -0
- package/src/components/stepHeaderMeta.tsx +42 -4
- 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/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 +11 -5
- package/src/index.ts +54 -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-auth.ts +52 -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/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/poa-country-gate.ts +27 -0
- 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-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/useLiveness.ts +6 -1
- 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/LivenessAvatar.tsx +15 -3
- package/src/screens/LivenessStep.tsx +154 -52
- 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/biometric/BiometricAuthFlow.tsx +170 -0
- package/src/screens/consent/model.ts +79 -10
- 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 +4 -3
- package/src/screens/liveness/SelfiePreview.tsx +13 -4
- 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-biometric.ts +37 -0
- package/src/services/api-types-biometric.ts +41 -0
- package/src/services/api-types.ts +93 -1
- package/src/services/api-verify-types.ts +37 -0
- package/src/services/api.ts +133 -3
- package/src/services/deviceMetadata.ts +1 -1
- package/src/services/location.ts +234 -0
- package/src/services/uploadErrors.ts +30 -0
- package/src/services/workflowGate.ts +7 -0
- package/src/store/address.ts +97 -0
- package/src/store/derive.ts +29 -4
- package/src/store/kycStore.ts +112 -3
- 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 +48 -1
- package/src/types/verification.ts +12 -0
- package/src/types/workflow.ts +97 -1
|
@@ -1,67 +1,58 @@
|
|
|
1
|
-
import React, { useEffect, useRef, useState } from 'react';
|
|
2
|
-
import { View } from 'react-native';
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
3
2
|
|
|
4
|
-
import { radius, spacing } from '../config/theme';
|
|
5
3
|
import { mapToKycError, safeReportError } from '../services/errors';
|
|
6
4
|
import { contactStepFor, expiredContactChannels } from '../lib/contact-recovery';
|
|
7
|
-
import
|
|
8
|
-
import { useKyc, useKycConfig, useKycStore
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import { useAwaitingPeople } from './useAwaitingPeople';
|
|
5
|
+
import { KYCError, type KYCSubmission } from '../types/verification';
|
|
6
|
+
import { useKyc, useKycConfig, useKycStore } from '../components/runtime';
|
|
7
|
+
import { configScope } from '../lib/scope';
|
|
8
|
+
import { describeWaiting } from '../lib/result-copy';
|
|
9
|
+
import { biometricCopyFor } from '../lib/biometric-copy';
|
|
10
|
+
import { awaitSelfieUpload, IDLE_SELFIE_UPLOAD } from '../lib/selfie-upload-wait';
|
|
11
|
+
import { showsDoneButton, showsSelfieReview, waitsForResult } from '../config/biometricOptions';
|
|
12
|
+
import { SubmittedWaiting } from './SubmittedWaiting';
|
|
13
|
+
import { SubmittedResult } from './SubmittedResult';
|
|
14
|
+
import { SubmittedSuccess } from './SubmittedSuccess';
|
|
15
|
+
import { SubmittedError } from './SubmittedError';
|
|
19
16
|
|
|
20
|
-
// Terminal
|
|
21
|
-
//
|
|
22
|
-
//
|
|
17
|
+
// Terminal step — 1:1 with the Flutter SDK's SubmittedScreen. Calls submitAsync
|
|
18
|
+
// on mount and renders one of: the waiting screen, the success screen, the
|
|
19
|
+
// error screen, or (on a flow that waits for its verdict) the result screen,
|
|
20
|
+
// which owns the whole wait from the first render. The views live in their
|
|
21
|
+
// own files (200-line rule); this file is the orchestration only. The header
|
|
22
|
+
// title is empty for this step.
|
|
23
23
|
|
|
24
24
|
type Phase = 'submitting' | 'success' | 'error';
|
|
25
25
|
|
|
26
|
-
const DEFAULT_SUCCESS_TITLE = 'Verification Submitted!';
|
|
27
|
-
// The default description depends on WHAT was submitted. A KYB applicant told
|
|
28
|
-
// "your identity verification has been submitted" is being told about the wrong
|
|
29
|
-
// thing: they submitted a company. Mirrors the web SDK's successDescription.
|
|
30
|
-
const defaultSuccessDescription = (isBusiness: boolean): string =>
|
|
31
|
-
isBusiness
|
|
32
|
-
? "Your business verification has been submitted for review. You'll be notified of the result."
|
|
33
|
-
: "Your identity verification has been submitted for review. You'll be notified of the result.";
|
|
34
|
-
|
|
35
|
-
const ERROR_TITLES: Record<string, string> = {
|
|
36
|
-
insufficient_credits: 'Credits Exhausted',
|
|
37
|
-
invalid_api_key: 'Authentication Failed',
|
|
38
|
-
feature_disabled: 'Verification Unavailable',
|
|
39
|
-
upload_failed: 'Upload Failed',
|
|
40
|
-
network_error: 'Connection Failed',
|
|
41
|
-
};
|
|
42
|
-
|
|
43
26
|
export function SubmittedStep({ onClose }: { onClose: () => void }): React.ReactElement {
|
|
44
|
-
const { colors } = useTheme();
|
|
45
27
|
const config = useKycConfig();
|
|
46
28
|
const store = useKycStore();
|
|
47
29
|
const existingResult = useKyc((s) => s.submissionResult);
|
|
48
30
|
|
|
49
31
|
const [phase, setPhase] = useState<Phase>('submitting');
|
|
50
|
-
// The people list comes from the SERVER once registry discovery settles —
|
|
51
|
-
// the submit-time invites are a first draft the register can contradict
|
|
52
|
-
// (it adds people the applicant never listed, including ones they removed).
|
|
53
|
-
const sessionId = useKyc((s) => s.sessionId);
|
|
54
|
-
const settled = useAwaitingPeople(store.getState().api, sessionId, phase === 'success');
|
|
55
32
|
const [error, setError] = useState<KYCError | null>(null);
|
|
56
|
-
const [keepLinksOpen, setKeepLinksOpen] = useState(false);
|
|
57
33
|
const [retry, setRetry] = useState<{ attempt: number; total: number } | null>(null);
|
|
58
34
|
const reportedRef = useRef(false);
|
|
59
35
|
const kickedRef = useRef(false);
|
|
60
36
|
|
|
61
|
-
const submit =
|
|
37
|
+
const submit = useCallback(async () => {
|
|
62
38
|
setPhase('submitting');
|
|
63
39
|
setError(null);
|
|
64
40
|
setRetry(null);
|
|
41
|
+
// The biometric scopes hand over BEFORE the selfie upload lands (the
|
|
42
|
+
// review is off, so nothing on the liveness step gated on it): wait for
|
|
43
|
+
// the upload's own record here, under the same loading screen. A failed
|
|
44
|
+
// upload was already reported to onError by the hook that ran it.
|
|
45
|
+
if (!showsSelfieReview(config)) {
|
|
46
|
+
const upload = await awaitSelfieUpload({
|
|
47
|
+
read: () => ({ selfieUpload: store.getState().selfieUpload, selfieMediaId: store.getState().mediaIds.selfie }),
|
|
48
|
+
subscribe: (listener) => store.subscribe(listener),
|
|
49
|
+
});
|
|
50
|
+
if (!upload.ok) {
|
|
51
|
+
setError(new KYCError('upload_failed', upload.message));
|
|
52
|
+
setPhase('error');
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
65
56
|
try {
|
|
66
57
|
const result = await store.getState().submitAsync((attempt, total) => setRetry({ attempt, total }));
|
|
67
58
|
const submission: KYCSubmission = {
|
|
@@ -105,134 +96,50 @@ export function SubmittedStep({ onClose }: { onClose: () => void }): React.React
|
|
|
105
96
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
106
97
|
}, []);
|
|
107
98
|
|
|
108
|
-
|
|
109
|
-
|
|
99
|
+
// Try Again after a failed selfie upload re-enters the liveness step, whose
|
|
100
|
+
// mount resumes the interrupted upload and hands straight back here. The
|
|
101
|
+
// record is reset first so this step waits for the NEW attempt rather than
|
|
102
|
+
// reading the old failure a second time.
|
|
103
|
+
const retryUpload = () => {
|
|
104
|
+
store.getState().setSelfieUpload(IDLE_SELFIE_UPLOAD);
|
|
105
|
+
store.getState().goToStep('liveness');
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
if (phase === 'error' && error) {
|
|
110
109
|
return (
|
|
111
|
-
<
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
</MyazaText>
|
|
117
|
-
<View style={{ height: spacing.sm }} />
|
|
118
|
-
<MyazaText variant="bodyMedium" style={{ textAlign: 'center' }}>
|
|
119
|
-
{retrying ? `Connection issue — retrying (${retry!.attempt}/${retry!.total})…` : 'Please wait a moment.'}
|
|
120
|
-
</MyazaText>
|
|
121
|
-
</View>
|
|
110
|
+
<SubmittedError
|
|
111
|
+
error={error}
|
|
112
|
+
onRetry={error.code === 'upload_failed' ? retryUpload : error.code === 'network_error' ? () => void submit() : null}
|
|
113
|
+
onClose={onClose}
|
|
114
|
+
/>
|
|
122
115
|
);
|
|
123
116
|
}
|
|
124
117
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
118
|
+
// A flow that waits for its verdict (a biometric re-authentication, by
|
|
119
|
+
// default) renders the result screen from the FIRST render: it shows the
|
|
120
|
+
// one loading screen through the upload, the submission and the poll, then
|
|
121
|
+
// the verdict. onSubmit has already fired by the time the id lands: the
|
|
122
|
+
// submission is a fact whichever screen follows it.
|
|
123
|
+
if (waitsForResult(config)) {
|
|
128
124
|
return (
|
|
129
|
-
<
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
<View style={{ height: spacing.lg }} />
|
|
136
|
-
<MyazaText variant="heading1" style={{ textAlign: 'center' }}>
|
|
137
|
-
{title}
|
|
138
|
-
</MyazaText>
|
|
139
|
-
<View style={{ height: spacing.md }} />
|
|
140
|
-
<View style={{ width: '100%', paddingHorizontal: spacing.md }}>
|
|
141
|
-
<MyazaAlert variant="error" title="What happened" message={error.message} />
|
|
142
|
-
</View>
|
|
143
|
-
</View>
|
|
144
|
-
<View style={{ flexDirection: 'row', gap: spacing.md }}>
|
|
145
|
-
{canRetry ? (
|
|
146
|
-
<View style={{ flex: 1 }}>
|
|
147
|
-
<MyazaButton label="Try Again" variant="outline" leadingIcon="refresh" onPress={() => void submit()} />
|
|
148
|
-
</View>
|
|
149
|
-
) : null}
|
|
150
|
-
<View style={{ flex: 1 }}>
|
|
151
|
-
<MyazaButton label="Close" onPress={onClose} />
|
|
152
|
-
</View>
|
|
153
|
-
</View>
|
|
154
|
-
</View>
|
|
125
|
+
<SubmittedResult
|
|
126
|
+
verificationId={phase === 'success' ? (existingResult?.verificationId ?? null) : null}
|
|
127
|
+
retry={retry}
|
|
128
|
+
showDone={showsDoneButton(config)}
|
|
129
|
+
onClose={onClose}
|
|
130
|
+
/>
|
|
155
131
|
);
|
|
156
132
|
}
|
|
157
133
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
// People still owing a check when the applicant leaves. The settled list is
|
|
168
|
-
// authoritative once it arrives; before that, minted invites are the signal.
|
|
169
|
-
const outstanding = settled
|
|
170
|
-
? settled.some((r) => r.status === 'pending' || r.status === 'failed')
|
|
171
|
-
: invitesExpected;
|
|
172
|
-
const sessionUrl = store.getState().sessionUrl;
|
|
173
|
-
// Tapping Done with links still live: offer the web page those links live
|
|
174
|
-
// on, because this screen dies with the app and the links die with it.
|
|
175
|
-
// Workflow opt-out: `keyPeopleLinkRecovery: false` (on by default).
|
|
176
|
-
const offerRecovery =
|
|
177
|
-
outstanding && !!sessionUrl && config.keyPeopleLinkRecovery !== false;
|
|
178
|
-
|
|
179
|
-
return (
|
|
180
|
-
<View style={{ flex: 1, justifyContent: 'space-between' }}>
|
|
181
|
-
<View style={{ alignItems: 'center' }}>
|
|
182
|
-
<View style={{ height: spacing.xl }} />
|
|
183
|
-
<Badge bg={colors.successBg} border={`${colors.success}4D`}>
|
|
184
|
-
<Icon name="check" size={44} color={colors.success} />
|
|
185
|
-
</Badge>
|
|
186
|
-
<View style={{ height: spacing.lg }} />
|
|
187
|
-
<MyazaText variant="heading1" style={{ textAlign: 'center' }}>
|
|
188
|
-
{title}
|
|
189
|
-
</MyazaText>
|
|
190
|
-
<View style={{ height: spacing.sm }} />
|
|
191
|
-
<MyazaText variant="bodyMedium" style={{ textAlign: 'center' }}>
|
|
192
|
-
{description}
|
|
193
|
-
</MyazaText>
|
|
194
|
-
{settled
|
|
195
|
-
? settled.length > 0
|
|
196
|
-
? <KeyPeopleAwaitList rows={rowsFromServer(settled)} />
|
|
197
|
-
: null
|
|
198
|
-
: invitesExpected
|
|
199
|
-
? <KeyPeoplePending />
|
|
200
|
-
: null}
|
|
201
|
-
</View>
|
|
202
|
-
<MyazaButton
|
|
203
|
-
label="Done"
|
|
204
|
-
onPress={() => (offerRecovery ? setKeepLinksOpen(true) : onClose())}
|
|
205
|
-
/>
|
|
206
|
-
{offerRecovery ? (
|
|
207
|
-
<KeepLinksSheet
|
|
208
|
-
open={keepLinksOpen}
|
|
209
|
-
url={sessionUrl!}
|
|
210
|
-
onClose={() => setKeepLinksOpen(false)}
|
|
211
|
-
onDone={() => {
|
|
212
|
-
setKeepLinksOpen(false);
|
|
213
|
-
onClose();
|
|
214
|
-
}}
|
|
215
|
-
/>
|
|
216
|
-
) : null}
|
|
217
|
-
</View>
|
|
218
|
-
);
|
|
219
|
-
}
|
|
134
|
+
if (phase === 'submitting') {
|
|
135
|
+
const copy = describeWaiting({
|
|
136
|
+
scope: configScope(config),
|
|
137
|
+
waitsForResult: false,
|
|
138
|
+
retry,
|
|
139
|
+
override: biometricCopyFor(config).waiting,
|
|
140
|
+
});
|
|
141
|
+
return <SubmittedWaiting title={copy.title} description={copy.description} retrying={retry != null} />;
|
|
142
|
+
}
|
|
220
143
|
|
|
221
|
-
|
|
222
|
-
return (
|
|
223
|
-
<View
|
|
224
|
-
style={{
|
|
225
|
-
width: 88,
|
|
226
|
-
height: 88,
|
|
227
|
-
borderRadius: radius.full,
|
|
228
|
-
backgroundColor: bg,
|
|
229
|
-
borderWidth: 2,
|
|
230
|
-
borderColor: border,
|
|
231
|
-
alignItems: 'center',
|
|
232
|
-
justifyContent: 'center',
|
|
233
|
-
}}
|
|
234
|
-
>
|
|
235
|
-
{children}
|
|
236
|
-
</View>
|
|
237
|
-
);
|
|
144
|
+
return <SubmittedSuccess showDone={showsDoneButton(config)} onClose={onClose} />;
|
|
238
145
|
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import React, { 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 { Icon } from '../components/Icon';
|
|
9
|
+
import { fillTokens } from '../utils/tokens';
|
|
10
|
+
import { configScope } from '../lib/scope';
|
|
11
|
+
import { KeepLinksSheet } from './KeepLinksSheet';
|
|
12
|
+
import { KeyPeopleAwaitList, rowsFromServer } from './KeyPeopleAwaitList';
|
|
13
|
+
import { PresenceExpectations } from '../components/PresenceBlocks';
|
|
14
|
+
import { StaggerIn } from '../components/StaggerIn';
|
|
15
|
+
import { KeyPeoplePending } from './KeyPeoplePending';
|
|
16
|
+
import { useAwaitingPeople } from './useAwaitingPeople';
|
|
17
|
+
import { Badge } from './SubmittedBadge';
|
|
18
|
+
|
|
19
|
+
// The submitted step's success screen (split from SubmittedStep, 200-line
|
|
20
|
+
// rule): the org's success copy, the presence expectations, the KYB key
|
|
21
|
+
// people still owing a check, and Done. `showDone` is the biometric scopes'
|
|
22
|
+
// `doneButton` option: off when the host app closes the flow itself.
|
|
23
|
+
|
|
24
|
+
const DEFAULT_SUCCESS_TITLE = 'Verification Submitted!';
|
|
25
|
+
// The default description depends on WHAT was submitted. A KYB applicant told
|
|
26
|
+
// "your identity verification has been submitted" is being told about the wrong
|
|
27
|
+
// thing: they submitted a company, and an address-only applicant submitted a
|
|
28
|
+
// pin. Mirrors the web SDK's successDescription (scope map included).
|
|
29
|
+
const SCOPE_DESCRIPTIONS: Record<string, string> = {
|
|
30
|
+
address: "Your address verification has been submitted. You'll be notified of the result.",
|
|
31
|
+
'biometric-authentication': "Your face check has been submitted. You'll be notified of the result.",
|
|
32
|
+
'biometric-enrollment': "Your selfie is saved as the reference for your future face checks.",
|
|
33
|
+
questionnaire: "Your answers have been submitted. You'll be notified of the result.",
|
|
34
|
+
contact: "Your contact verification has been submitted. You'll be notified of the result.",
|
|
35
|
+
};
|
|
36
|
+
const defaultSuccessDescription = (isBusiness: boolean, scope: string | null): string => {
|
|
37
|
+
if (scope && SCOPE_DESCRIPTIONS[scope]) return SCOPE_DESCRIPTIONS[scope]!;
|
|
38
|
+
return isBusiness
|
|
39
|
+
? "Your business verification has been submitted for review. You'll be notified of the result."
|
|
40
|
+
: "Your identity verification has been submitted for review. You'll be notified of the result.";
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function SubmittedSuccess({ showDone, onClose }: { showDone: boolean; onClose: () => void }): React.ReactElement {
|
|
44
|
+
const { colors } = useTheme();
|
|
45
|
+
const config = useKycConfig();
|
|
46
|
+
const store = useKycStore();
|
|
47
|
+
// The people list comes from the SERVER once registry discovery settles —
|
|
48
|
+
// the submit-time invites are a first draft the register can contradict
|
|
49
|
+
// (it adds people the applicant never listed, including ones they removed).
|
|
50
|
+
const sessionId = useKyc((s) => s.sessionId);
|
|
51
|
+
const settled = useAwaitingPeople(store.getState().api, sessionId, true);
|
|
52
|
+
const [keepLinksOpen, setKeepLinksOpen] = useState(false);
|
|
53
|
+
|
|
54
|
+
const title = config.success?.title ? fillTokens(config.success.title, config.userData) : DEFAULT_SUCCESS_TITLE;
|
|
55
|
+
const description = config.success?.description
|
|
56
|
+
? fillTokens(config.success.description, config.userData)
|
|
57
|
+
: defaultSuccessDescription(config.subjectType === 'business', configScope(config));
|
|
58
|
+
// KYB: whether a people list is COMING (the submit minted invites). The list
|
|
59
|
+
// itself renders from the server's reconciled view, never from this draft.
|
|
60
|
+
const invitesExpected = store.getState().keyPeopleInvites.length > 0;
|
|
61
|
+
|
|
62
|
+
// People still owing a check when the applicant leaves. The settled list is
|
|
63
|
+
// authoritative once it arrives; before that, minted invites are the signal.
|
|
64
|
+
const outstanding = settled
|
|
65
|
+
? settled.some((r) => r.status === 'pending' || r.status === 'failed')
|
|
66
|
+
: invitesExpected;
|
|
67
|
+
const sessionUrl = store.getState().sessionUrl;
|
|
68
|
+
// Tapping Done with links still live: offer the web page those links live
|
|
69
|
+
// on, because this screen dies with the app and the links die with it.
|
|
70
|
+
// Workflow opt-out: `keyPeopleLinkRecovery: false` (on by default).
|
|
71
|
+
const offerRecovery = outstanding && !!sessionUrl && config.keyPeopleLinkRecovery !== false;
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<View style={{ flex: 1, justifyContent: 'space-between' }}>
|
|
75
|
+
<View style={{ alignItems: 'center' }}>
|
|
76
|
+
<View style={{ height: spacing.xl }} />
|
|
77
|
+
{/* Lands in the order Flutter's success view does: badge, title,
|
|
78
|
+
description, then Done (delays 0 / 200 / 320 / 600 ms). */}
|
|
79
|
+
<StaggerIn>
|
|
80
|
+
<Badge bg={colors.successBg} border={`${colors.success}4D`}>
|
|
81
|
+
<Icon name="check" size={44} color={colors.success} />
|
|
82
|
+
</Badge>
|
|
83
|
+
</StaggerIn>
|
|
84
|
+
<View style={{ height: spacing.lg }} />
|
|
85
|
+
<StaggerIn delayMs={200}>
|
|
86
|
+
<MyazaText variant="heading1" style={{ textAlign: 'center' }}>
|
|
87
|
+
{title}
|
|
88
|
+
</MyazaText>
|
|
89
|
+
</StaggerIn>
|
|
90
|
+
<View style={{ height: spacing.sm }} />
|
|
91
|
+
<StaggerIn delayMs={320}>
|
|
92
|
+
<MyazaText variant="bodyMedium" style={{ textAlign: 'center' }}>
|
|
93
|
+
{description}
|
|
94
|
+
</MyazaText>
|
|
95
|
+
</StaggerIn>
|
|
96
|
+
{config.addressCollection?.presence?.enabled === true && store.getState().address ? (
|
|
97
|
+
<PresenceExpectations />
|
|
98
|
+
) : null}
|
|
99
|
+
{settled
|
|
100
|
+
? settled.length > 0
|
|
101
|
+
? <KeyPeopleAwaitList rows={rowsFromServer(settled)} />
|
|
102
|
+
: null
|
|
103
|
+
: invitesExpected
|
|
104
|
+
? <KeyPeoplePending />
|
|
105
|
+
: null}
|
|
106
|
+
</View>
|
|
107
|
+
{showDone ? (
|
|
108
|
+
<StaggerIn delayMs={600}>
|
|
109
|
+
<MyazaButton label="Done" onPress={() => (offerRecovery ? setKeepLinksOpen(true) : onClose())} />
|
|
110
|
+
</StaggerIn>
|
|
111
|
+
) : (
|
|
112
|
+
<View />
|
|
113
|
+
)}
|
|
114
|
+
{offerRecovery ? (
|
|
115
|
+
<KeepLinksSheet
|
|
116
|
+
open={keepLinksOpen}
|
|
117
|
+
url={sessionUrl!}
|
|
118
|
+
onClose={() => setKeepLinksOpen(false)}
|
|
119
|
+
onDone={() => {
|
|
120
|
+
setKeepLinksOpen(false);
|
|
121
|
+
onClose();
|
|
122
|
+
}}
|
|
123
|
+
/>
|
|
124
|
+
) : null}
|
|
125
|
+
</View>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { spacing } from '../config/theme';
|
|
5
|
+
import { useTheme } from '../components/runtime';
|
|
6
|
+
import { MyazaText } from '../components/Typography';
|
|
7
|
+
import { MyazaPulseLoader } from '../components/MyazaPulseLoader';
|
|
8
|
+
|
|
9
|
+
// ─── The one loading screen ─────────────────────────────────────────────────
|
|
10
|
+
//
|
|
11
|
+
// Every wait after the capture renders THIS, with copy from
|
|
12
|
+
// lib/result-copy.ts: the selfie upload, the submission and (on a flow that
|
|
13
|
+
// waits for its verdict) the status poll all show one loader under one title.
|
|
14
|
+
// Three screens with three sentences read as three things going wrong; one
|
|
15
|
+
// screen reads as the check running. A retry in flight swaps only the
|
|
16
|
+
// description, in the warning colour.
|
|
17
|
+
|
|
18
|
+
export function SubmittedWaiting({
|
|
19
|
+
title,
|
|
20
|
+
description,
|
|
21
|
+
retrying = false,
|
|
22
|
+
}: {
|
|
23
|
+
title: string;
|
|
24
|
+
description: string;
|
|
25
|
+
retrying?: boolean;
|
|
26
|
+
}): React.ReactElement {
|
|
27
|
+
const { colors } = useTheme();
|
|
28
|
+
return (
|
|
29
|
+
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', paddingVertical: spacing.xl }}>
|
|
30
|
+
<MyazaPulseLoader />
|
|
31
|
+
<View style={{ height: spacing.lg }} />
|
|
32
|
+
<MyazaText variant="heading3" style={{ textAlign: 'center' }}>
|
|
33
|
+
{title}
|
|
34
|
+
</MyazaText>
|
|
35
|
+
<View style={{ height: spacing.sm }} />
|
|
36
|
+
<MyazaText
|
|
37
|
+
variant="bodyMedium"
|
|
38
|
+
color={retrying ? colors.warning : undefined}
|
|
39
|
+
style={{ textAlign: 'center', paddingHorizontal: spacing.lg }}
|
|
40
|
+
>
|
|
41
|
+
{description}
|
|
42
|
+
</MyazaText>
|
|
43
|
+
</View>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { spacing } from '../../config/theme';
|
|
5
|
+
import { useKyc, 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 { StickyActions } from '../../components/StickyActions';
|
|
10
|
+
import { withRetry } from '../../services/retry';
|
|
11
|
+
import { compressDocumentImage } from '../../services/mediaCompress';
|
|
12
|
+
import { addressVendorsStubbed } from '../../lib/address-flow';
|
|
13
|
+
import { streetViewFrameUrlOf } from '../../lib/map-frame';
|
|
14
|
+
import { useAddressPhotoAttach, type AddressPhotoPick } from '../useAddressPhotoAttach';
|
|
15
|
+
import { useAddressFlow } from './use-address-flow';
|
|
16
|
+
import { EntranceDropzone } from './EntranceDropzone';
|
|
17
|
+
import { EntrancePlaceholder } from './EntranceFraming';
|
|
18
|
+
import { FramedStreetView } from './FramedStreetView';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The entrance step: Street View FIRST — it opens automatically wherever
|
|
22
|
+
* Google has photographed the street, because framing beats fumbling for a
|
|
23
|
+
* camera — and the applicant's own photo is the fallback (no coverage, or
|
|
24
|
+
* they skip). Capturing a frame advances straight to review. The panorama
|
|
25
|
+
* reaches a phone the way the map does: the hosted /embed/street-view page in
|
|
26
|
+
* a WebView on the app grant (FramedStreetView). Mirrors the web and Flutter
|
|
27
|
+
* AddressEntranceStep.
|
|
28
|
+
*/
|
|
29
|
+
export function AddressEntranceStep(): React.ReactElement | null {
|
|
30
|
+
const store = useKycStore();
|
|
31
|
+
const flow = useAddressFlow();
|
|
32
|
+
const mapsFrameUrl = useKyc((s) => s.serverConfig.mapsFrameUrl ?? null);
|
|
33
|
+
const vendorsStubbed = addressVendorsStubbed({
|
|
34
|
+
environment: useKyc((s) => s.serverConfig.environment ?? null),
|
|
35
|
+
});
|
|
36
|
+
const svFrameUrl = mapsFrameUrl ? streetViewFrameUrlOf(mapsFrameUrl) : null;
|
|
37
|
+
// Street View can be shown: offered by the flow, a pin to look from, and
|
|
38
|
+
// either the sandbox stand-in or a framed page to load.
|
|
39
|
+
const streetView = flow.streetViewOffered && Boolean(flow.pin) && (vendorsStubbed || Boolean(svFrameUrl));
|
|
40
|
+
const [mode, setMode] = useState<'framing' | 'photo'>(() => (streetView ? 'framing' : 'photo'));
|
|
41
|
+
const [skipped, setSkipped] = useState(false);
|
|
42
|
+
const framing = mode === 'framing' && streetView && Boolean(flow.pin);
|
|
43
|
+
|
|
44
|
+
// The sheet header describes the framing while it is up, the photo after.
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
store.getState().setAddressEntranceFraming(framing);
|
|
47
|
+
return () => store.getState().setAddressEntranceFraming(false);
|
|
48
|
+
}, [framing, store]);
|
|
49
|
+
|
|
50
|
+
// Fell back to the photo with the photo input off: nothing left to capture
|
|
51
|
+
// here, carry on. (The never-offered case is `recoverAddressStep`'s.)
|
|
52
|
+
const { goNext } = flow;
|
|
53
|
+
const emptyAfterFallback = !framing && flow.streetViewOffered && flow.photoMode === 'off';
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (emptyAfterFallback) goNext();
|
|
56
|
+
}, [emptyAfterFallback, goNext]);
|
|
57
|
+
|
|
58
|
+
// Nothing to capture at all: this step is absent from the flow, and the
|
|
59
|
+
// address flow's own recovery moves the applicant, not this screen.
|
|
60
|
+
if (flow.photoMode === 'off' && !flow.streetViewOffered) return null;
|
|
61
|
+
|
|
62
|
+
if (framing && flow.pin) {
|
|
63
|
+
const svRequired = flow.cfg?.streetView === 'required';
|
|
64
|
+
const toPhoto = (wasSkip: boolean) => {
|
|
65
|
+
setSkipped(wasSkip);
|
|
66
|
+
setMode('photo');
|
|
67
|
+
};
|
|
68
|
+
if (vendorsStubbed) {
|
|
69
|
+
return <EntrancePlaceholder hideSkip={svRequired} onSkip={() => toPhoto(true)} onUse={() => flow.goNext()} />;
|
|
70
|
+
}
|
|
71
|
+
return (
|
|
72
|
+
<FramedStreetView
|
|
73
|
+
frameUrl={svFrameUrl!}
|
|
74
|
+
pin={flow.pin}
|
|
75
|
+
hideSkip={svRequired}
|
|
76
|
+
onCaptured={(frame) => {
|
|
77
|
+
const current = store.getState().address;
|
|
78
|
+
if (current) store.getState().setAddress({ ...current, streetView: frame });
|
|
79
|
+
flow.goNext();
|
|
80
|
+
}}
|
|
81
|
+
onSkip={() => toPhoto(true)}
|
|
82
|
+
onUnavailable={() => toPhoto(false)}
|
|
83
|
+
/>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
if (emptyAfterFallback) return null;
|
|
87
|
+
return <EntrancePhoto flow={flow} skipped={skipped} />;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The photo half, split so the framing branch above stays hook-free. */
|
|
91
|
+
function EntrancePhoto({
|
|
92
|
+
flow,
|
|
93
|
+
skipped,
|
|
94
|
+
}: {
|
|
95
|
+
flow: ReturnType<typeof useAddressFlow>;
|
|
96
|
+
skipped: boolean;
|
|
97
|
+
}): React.ReactElement {
|
|
98
|
+
const store = useKycStore();
|
|
99
|
+
const { colors } = useTheme();
|
|
100
|
+
const photoId = useKyc((s) => s.mediaIds.addressPhoto);
|
|
101
|
+
const previewUri = useKyc((s) => s.addressPhotoPreview);
|
|
102
|
+
const { pickPhoto, setError } = flow;
|
|
103
|
+
|
|
104
|
+
const upload = useCallback(
|
|
105
|
+
async ({ uri, mimeType, name }: AddressPhotoPick) => {
|
|
106
|
+
await pickPhoto(async () => {
|
|
107
|
+
const finalUri = await compressDocumentImage(uri).catch(() => uri);
|
|
108
|
+
return withRetry(() => store.getState().api.upload({ uri: finalUri, type: mimeType, name }, 'address_photo'));
|
|
109
|
+
}, uri);
|
|
110
|
+
},
|
|
111
|
+
[pickPhoto, store],
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const { pick, sheetOpen, closeSheet, choosePhoto, takePhoto } = useAddressPhotoAttach(upload, setError);
|
|
115
|
+
|
|
116
|
+
const uploaded = Boolean(photoId);
|
|
117
|
+
const required = flow.photoMode === 'required';
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<StickyActions
|
|
121
|
+
actions={
|
|
122
|
+
<MyazaButton
|
|
123
|
+
label={uploaded || required ? 'Continue' : 'Continue without a photo'}
|
|
124
|
+
disabled={flow.uploading || (required && !uploaded)}
|
|
125
|
+
onPress={() => flow.goNext()}
|
|
126
|
+
/>
|
|
127
|
+
}
|
|
128
|
+
>
|
|
129
|
+
<MediaSourceSheet
|
|
130
|
+
open={sheetOpen}
|
|
131
|
+
onClose={closeSheet}
|
|
132
|
+
title="Add a photo of the entrance"
|
|
133
|
+
options={[
|
|
134
|
+
{ icon: 'camera', label: 'Take a photo', caption: 'Photograph the entrance now', onPress: () => void takePhoto() },
|
|
135
|
+
{ icon: 'image', label: 'Photo library', caption: 'Pick a photo you already have', onPress: () => void choosePhoto() },
|
|
136
|
+
]}
|
|
137
|
+
/>
|
|
138
|
+
|
|
139
|
+
{skipped ? (
|
|
140
|
+
<MyazaText variant="bodySmall" color={colors.textSecondary} style={{ marginBottom: spacing.sm }}>
|
|
141
|
+
No problem. A quick photo of the entrance works just as well.
|
|
142
|
+
</MyazaText>
|
|
143
|
+
) : null}
|
|
144
|
+
|
|
145
|
+
<EntranceDropzone
|
|
146
|
+
uploaded={uploaded}
|
|
147
|
+
uploading={flow.uploading}
|
|
148
|
+
previewUri={previewUri}
|
|
149
|
+
required={required}
|
|
150
|
+
onPick={() => pick()}
|
|
151
|
+
onRemove={flow.removePhoto}
|
|
152
|
+
/>
|
|
153
|
+
|
|
154
|
+
{flow.error ? (
|
|
155
|
+
<MyazaText variant="bodySmall" color={colors.error} style={{ marginTop: spacing.sm }}>
|
|
156
|
+
{flow.error}
|
|
157
|
+
</MyazaText>
|
|
158
|
+
) : null}
|
|
159
|
+
</StickyActions>
|
|
160
|
+
);
|
|
161
|
+
}
|