@myazahq/kyc-sdk-react-native 2.4.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/build.gradle +35 -3
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +77 -0
- package/app.plugin.js +89 -8
- package/ios/HybridMyazaFaceDetector.swift +16 -3
- package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.cpp +23 -0
- package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.hpp +2 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaFaceDetectorSpec.kt +9 -0
- package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.cpp +8 -0
- package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.hpp +43 -0
- package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.hpp +17 -0
- package/nitrogen/generated/ios/swift/Func_void_bool.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec.swift +2 -0
- package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec_cxx.swift +31 -0
- package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.cpp +2 -0
- package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.hpp +3 -0
- package/package.json +14 -2
- package/src/MyazaBiometricAuth.tsx +119 -0
- package/src/MyazaKYC.tsx +12 -0
- package/src/components/CountryField.tsx +8 -0
- package/src/components/CountryRegionPicker.tsx +184 -0
- package/src/components/DashedBorder.tsx +55 -0
- package/src/components/DialCodePicker.tsx +53 -67
- 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 -176
- 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/MultiIdProgress.tsx +107 -0
- package/src/components/MyazaInput.tsx +39 -4
- package/src/components/MyazaSelect.tsx +2 -1
- package/src/components/OptionRow.tsx +22 -3
- package/src/components/OwnershipSlider.tsx +102 -0
- package/src/components/PhoneNumberInput.tsx +45 -4
- package/src/components/PoweredBy.tsx +5 -5
- package/src/components/PresenceBlocks.tsx +153 -0
- package/src/components/StaggerIn.tsx +63 -0
- package/src/components/StepView.tsx +43 -0
- package/src/components/StickyActions.tsx +56 -0
- package/src/components/dialCodeRows.ts +64 -0
- package/src/components/glass/FloatingSheet.tsx +73 -21
- package/src/components/glass/GlassSheet.tsx +14 -2
- package/src/components/icon-map.ts +176 -0
- package/src/components/stepHeaderMeta.tsx +45 -5
- package/src/config/addressCollection.ts +125 -0
- package/src/config/biometricOptions.ts +100 -0
- package/src/config/business.ts +26 -7
- package/src/config/businessDetailsValidity.ts +52 -0
- package/src/config/businessPrefill.ts +72 -0
- package/src/config/businessSteps.ts +20 -3
- package/src/config/consentStep.ts +19 -0
- package/src/config/keyPeople.ts +111 -7
- package/src/config/keyPeopleCorporate.ts +19 -0
- package/src/config/keyPeopleOwnerTypes.ts +12 -0
- package/src/config/keyPeopleOwners.ts +26 -0
- package/src/config/keyPeoplePrefill.ts +138 -0
- package/src/config/keyPeopleSectionDefs.ts +65 -0
- package/src/config/keyPeopleSections.ts +167 -0
- package/src/config/proofOfAddress.ts +54 -11
- package/src/config/regions.ts +24 -0
- package/src/config/registrationHint.ts +1 -1
- package/src/config/stepOrder.ts +100 -6
- package/src/config/theme.ts +8 -2
- package/src/config/uploadLimits.ts +35 -0
- package/src/config/website.ts +36 -0
- package/src/config/workflowMerge.ts +16 -5
- package/src/emrtd/activeAuth.ts +100 -0
- package/src/emrtd/extras.ts +76 -0
- package/src/emrtd/files.ts +5 -0
- package/src/emrtd/index.ts +1 -0
- package/src/emrtd/read.ts +8 -1
- package/src/emrtd/session.ts +41 -0
- package/src/emrtd/stages.ts +11 -0
- 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/contact-recovery.ts +44 -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/multi-id.ts +210 -0
- package/src/lib/poa-country-gate.ts +27 -0
- package/src/lib/resubmit.ts +130 -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/screen-corners.ts +50 -0
- package/src/lib/selfie-upload-wait.ts +79 -0
- package/src/lib/step-log.ts +24 -3
- package/src/lib/street-view-fov.ts +42 -0
- package/src/lib/use-multi-id-plan.ts +29 -0
- package/src/lib/webview-available.ts +47 -0
- package/src/liveness/useLiveness.ts +6 -1
- package/src/liveness/useModelReady.ts +65 -0
- package/src/liveness/visionCameraFaceDetector.ts +35 -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/ApplicantRoleStep.tsx +9 -5
- package/src/screens/BusinessCheckPanel.tsx +89 -0
- package/src/screens/BusinessDetailsFields.tsx +112 -0
- package/src/screens/BusinessDetailsStep.tsx +133 -145
- package/src/screens/BusinessDocumentSlot.tsx +5 -5
- package/src/screens/BusinessDocumentsStep.tsx +5 -56
- package/src/screens/BusinessKeyPeopleStep.tsx +103 -71
- package/src/screens/BusinessPickedCard.tsx +86 -0
- package/src/screens/BusinessPickedSection.tsx +51 -0
- package/src/screens/BusinessRegistryPickers.tsx +67 -0
- package/src/screens/BusinessSandboxToggle.tsx +114 -0
- package/src/screens/BusinessSearch.tsx +199 -0
- package/src/screens/BusinessSearchResults.tsx +115 -0
- package/src/screens/CompanyInfoFields.tsx +91 -51
- package/src/screens/ConsentStep.tsx +37 -17
- package/src/screens/ContactActions.tsx +54 -0
- package/src/screens/ContactDestinationField.tsx +5 -0
- package/src/screens/ContactEntryPanel.tsx +75 -0
- package/src/screens/ContactVerificationStep.tsx +43 -42
- package/src/screens/CountrySelectStep.tsx +13 -147
- package/src/screens/IdTypeStep.tsx +12 -5
- package/src/screens/KeepLinksSheet.tsx +88 -0
- package/src/screens/KeyPeopleAwaitCard.tsx +204 -0
- package/src/screens/KeyPeopleAwaitList.tsx +126 -0
- package/src/screens/KeyPeoplePending.tsx +154 -0
- package/src/screens/KeyPeopleSection.tsx +162 -0
- package/src/screens/KeyPeopleSectionsList.tsx +103 -0
- package/src/screens/KeyPeopleUboExemption.tsx +72 -0
- package/src/screens/KeyPersonCard.tsx +36 -4
- package/src/screens/KeyPersonForm.tsx +195 -36
- package/src/screens/KeyPersonKindToggle.tsx +68 -0
- package/src/screens/KeyPersonOwners.tsx +109 -0
- package/src/screens/KeyPersonRoleChips.tsx +68 -0
- package/src/screens/KeyPersonSheet.tsx +92 -108
- package/src/screens/LivenessAvatar.tsx +15 -3
- package/src/screens/LivenessStep.tsx +172 -52
- package/src/screens/NfcStep.tsx +14 -0
- package/src/screens/PoaDocumentTypeList.tsx +66 -0
- package/src/screens/ProofOfAddressParts.tsx +151 -0
- package/src/screens/ProofOfAddressStep.tsx +58 -96
- 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 +82 -119
- 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/NfcReadProgress.tsx +1 -0
- package/src/screens/nfc/NfcScanIllustration.tsx +7 -18
- package/src/screens/useAddressPhotoAttach.ts +77 -0
- package/src/screens/useAwaitingPeople.ts +90 -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 +251 -3
- package/src/services/api-verify-types.ts +90 -1
- package/src/services/api.ts +237 -3
- package/src/services/deviceMetadata.ts +1 -1
- package/src/services/errors.ts +10 -2
- package/src/services/location.ts +234 -0
- package/src/services/uploadErrors.ts +30 -0
- package/src/services/workflowGate.ts +8 -0
- package/src/specs/MyazaFaceDetector.nitro.ts +29 -0
- package/src/store/address.ts +97 -0
- package/src/store/businessCheck.ts +113 -0
- package/src/store/derive.ts +44 -4
- package/src/store/kycStore.ts +352 -16
- package/src/store/serverConfig.ts +20 -0
- package/src/store/session.ts +227 -0
- package/src/store/state.ts +289 -2
- package/src/store/submit.ts +80 -13
- package/src/store/submitApplicant.ts +5 -11
- package/src/types/business.ts +23 -2
- package/src/types/config.ts +70 -2
- package/src/types/verification.ts +13 -1
- package/src/types/workflow.ts +112 -1
- package/src/screens/KeyPeopleInviteLinks.tsx +0 -239
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import { Animated, Easing, Image, useWindowDimensions, View } from 'react-native';
|
|
3
3
|
import Svg, { Ellipse, Path } from 'react-native-svg';
|
|
4
4
|
import {
|
|
5
|
-
Camera,
|
|
6
5
|
useCameraDevice,
|
|
7
6
|
useCameraPermission,
|
|
8
7
|
useFrameOutput,
|
|
@@ -31,12 +30,15 @@ import {
|
|
|
31
30
|
import { Icon } from '../components/Icon';
|
|
32
31
|
import { useToast } from '../components/toast';
|
|
33
32
|
import { CameraPermissionView, CameraUnavailableView, CameraPermissionPrimingView } from '../components/CameraPermissionView';
|
|
33
|
+
import { useFaceModelReady } from '../liveness/useModelReady';
|
|
34
34
|
import { ReadyPrimer } from '../components/ReadyPrimer';
|
|
35
35
|
import { READY_LIVENESS } from '../components/readyPrimerContent';
|
|
36
36
|
import { LivenessAvatar } from './LivenessAvatar';
|
|
37
|
+
import { livenessLayout } from '../lib/livenessLayout';
|
|
37
38
|
import { detectFaceOnFrame } from '../liveness/visionCameraFaceDetector';
|
|
38
39
|
import { buildLivenessIntegrity } from '../liveness/integritySignals';
|
|
39
40
|
import { DEFAULT_LIVENESS_CONFIG } from '../liveness/types';
|
|
41
|
+
import { livenessProgress } from '../lib/captureRing';
|
|
40
42
|
import { useFlashSequence } from './useFlashSequence';
|
|
41
43
|
import { useFlashHole } from './useFlashHole';
|
|
42
44
|
import {
|
|
@@ -44,13 +46,19 @@ import {
|
|
|
44
46
|
INSTRUCTION_HEIGHT,
|
|
45
47
|
LightingBanner,
|
|
46
48
|
ProgressDots,
|
|
49
|
+
CaptureRing,
|
|
50
|
+
ShutterFlash,
|
|
47
51
|
SelfiePreview,
|
|
48
52
|
StyleAbsFill,
|
|
49
53
|
resolveGuidance,
|
|
50
54
|
useSelfieUpload,
|
|
55
|
+
LivenessCamera,
|
|
51
56
|
LivenessComplete,
|
|
52
57
|
LivenessFailed,
|
|
58
|
+
LivenessHandover,
|
|
59
|
+
useSelfieAutoAdvance,
|
|
53
60
|
} from './liveness';
|
|
61
|
+
import { showsSelfieReview } from '../config/biometricOptions';
|
|
54
62
|
import {
|
|
55
63
|
lightingGuidanceText,
|
|
56
64
|
positionGuidanceText,
|
|
@@ -84,6 +92,7 @@ export function LivenessStep(): React.ReactElement {
|
|
|
84
92
|
const setMediaId = useKyc((s) => s.setMediaId);
|
|
85
93
|
const setCaptureIntegrity = useKyc((s) => s.setCaptureIntegrity);
|
|
86
94
|
const nextStep = useKyc((s) => s.nextStep);
|
|
95
|
+
const clearSelfie = useKyc((s) => s.clearSelfie);
|
|
87
96
|
|
|
88
97
|
const device = useCameraDevice('front');
|
|
89
98
|
const { hasPermission, requestPermission } = useCameraPermission();
|
|
@@ -113,6 +122,11 @@ export function LivenessStep(): React.ReactElement {
|
|
|
113
122
|
const [perm, setPerm] = useState<'priming' | 'requesting' | 'granted' | 'denied'>(
|
|
114
123
|
hasPermission ? 'granted' : 'priming',
|
|
115
124
|
);
|
|
125
|
+
|
|
126
|
+
// Android fetches the face model through Play Services rather than bundling
|
|
127
|
+
// it, so it can be absent on a fresh install. Normally already 'ready' here —
|
|
128
|
+
// the flow primes the download at open, behind consent and ID-type selection.
|
|
129
|
+
const modelState = useFaceModelReady();
|
|
116
130
|
const permReportedRef = useRef(false);
|
|
117
131
|
|
|
118
132
|
// Camera-availability grace (a simulator has no front camera).
|
|
@@ -170,6 +184,16 @@ export function LivenessStep(): React.ReactElement {
|
|
|
170
184
|
uploadSelfieAndVideo,
|
|
171
185
|
} = upload;
|
|
172
186
|
|
|
187
|
+
// A stored selfie with no uploaded mediaId is an interrupted upload (failed,
|
|
188
|
+
// or the user left mid-flight): resume it once on mount. Idempotent — with a
|
|
189
|
+
// mediaId there is nothing to do.
|
|
190
|
+
useEffect(() => {
|
|
191
|
+
if (selfieUri && !selfieIdRef.current && !uploading && !uploadError) {
|
|
192
|
+
void uploadSelfieAndVideo(selfieUri, videoPathRef.current);
|
|
193
|
+
}
|
|
194
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
195
|
+
}, []);
|
|
196
|
+
|
|
173
197
|
// ── Liveness state machine ─────────────────────────────────────────────────
|
|
174
198
|
// `handleCapture` is created after `liveness` (it calls liveness.markComplete),
|
|
175
199
|
// so the machine gets it via a ref to avoid a definition cycle.
|
|
@@ -268,6 +292,66 @@ export function LivenessStep(): React.ReactElement {
|
|
|
268
292
|
// always reads the latest state-machine callbacks.
|
|
269
293
|
const livenessRef = useRef(liveness);
|
|
270
294
|
livenessRef.current = liveness;
|
|
295
|
+
|
|
296
|
+
// The circle and the gesture avatar are sized from the WINDOW, not the width
|
|
297
|
+
// alone, so a short phone keeps the avatar on screen (lib/livenessLayout).
|
|
298
|
+
// A hook, so it lives up here with the ring refs, above the gates below.
|
|
299
|
+
const windowSize = useWindowDimensions();
|
|
300
|
+
const layout = livenessLayout(windowSize);
|
|
301
|
+
|
|
302
|
+
// Progress for the ring, read every frame from a ref — never from render.
|
|
303
|
+
// Declared HERE, above the early returns below: a hook after a conditional
|
|
304
|
+
// return renders "more hooks than during the previous render" the moment the
|
|
305
|
+
// gate dismisses, which is exactly what the ready primer does on its way out.
|
|
306
|
+
const phaseStartedRef = useRef(Date.now());
|
|
307
|
+
const phaseKeyRef = useRef(`${liveness.phase}:${liveness.completedCount}`);
|
|
308
|
+
const phaseKey = `${liveness.phase}:${liveness.completedCount}`;
|
|
309
|
+
if (phaseKeyRef.current !== phaseKey) {
|
|
310
|
+
phaseKeyRef.current = phaseKey;
|
|
311
|
+
phaseStartedRef.current = Date.now();
|
|
312
|
+
}
|
|
313
|
+
const timeoutRef = useRef(
|
|
314
|
+
liveness.timeoutRemaining || DEFAULT_LIVENESS_CONFIG.timeoutPerChallenge,
|
|
315
|
+
);
|
|
316
|
+
if (liveness.phase === 'challenge' && liveness.timeoutRemaining > timeoutRef.current) {
|
|
317
|
+
timeoutRef.current = liveness.timeoutRemaining;
|
|
318
|
+
}
|
|
319
|
+
const getRingTarget = useCallback(() => {
|
|
320
|
+
const l = livenessRef.current;
|
|
321
|
+
return livenessProgress({
|
|
322
|
+
phase: l.phase,
|
|
323
|
+
completedCount: l.completedCount,
|
|
324
|
+
totalCount: l.totalCount,
|
|
325
|
+
elapsedInPhase: (Date.now() - phaseStartedRef.current) / 1000,
|
|
326
|
+
challengeTimeout: timeoutRef.current,
|
|
327
|
+
});
|
|
328
|
+
}, []);
|
|
329
|
+
|
|
330
|
+
// The selfie URI lands BEFORE the machine reaches `complete` (capture →
|
|
331
|
+
// compress → setSelfieUri → markComplete), and the review is gated on that
|
|
332
|
+
// URI — so the camera circle used to unmount on the very frame the ring's
|
|
333
|
+
// target reached the end: the close, the green, the shutter, none of it
|
|
334
|
+
// was ever seen. Hold the frame for the closing beat, then hand over.
|
|
335
|
+
const [closeSeen, setCloseSeen] = useState(false);
|
|
336
|
+
useEffect(() => {
|
|
337
|
+
if (liveness.phase !== 'complete') return;
|
|
338
|
+
const t = setTimeout(() => setCloseSeen(true), 700);
|
|
339
|
+
return () => clearTimeout(t);
|
|
340
|
+
}, [liveness.phase]);
|
|
341
|
+
const holdingForClose =
|
|
342
|
+
(liveness.phase === 'capturing' || liveness.phase === 'complete') && !closeSeen;
|
|
343
|
+
// The biometric scopes skip the review by default: hand over the moment the
|
|
344
|
+
// ring has closed, WITHOUT waiting for the upload (see LivenessHandover).
|
|
345
|
+
// The upload reports to the store and the submitted step waits on it, so
|
|
346
|
+
// the person sees one loading screen rather than one per step. A restored
|
|
347
|
+
// selfie (media id, no local file) is ready at once; an upload that already
|
|
348
|
+
// failed keeps the review, whose Try Again is the recovery.
|
|
349
|
+
const selfieReview = showsSelfieReview(config);
|
|
350
|
+
useSelfieAutoAdvance({
|
|
351
|
+
enabled: !selfieReview,
|
|
352
|
+
ready: (!!selfieUri || !!selfieIdRef.current) && !holdingForClose && !uploadError,
|
|
353
|
+
onAdvance: nextStep,
|
|
354
|
+
});
|
|
271
355
|
// A plain JS function the worklet hands the per-frame result to. It is wrapped
|
|
272
356
|
// with `runOnJS(...)` INSIDE the worklet (the canonical react-native-worklets
|
|
273
357
|
// pattern) rather than pre-wrapped — pre-wrapping produced a "non-worklet
|
|
@@ -347,10 +431,54 @@ export function LivenessStep(): React.ReactElement {
|
|
|
347
431
|
},
|
|
348
432
|
});
|
|
349
433
|
|
|
434
|
+
// ── Review (selfie already exists) ──────────────────────────────────────────
|
|
435
|
+
// Checked BEFORE every capture-flow gate (ready primer, permission, model):
|
|
436
|
+
// a selfie captured on an earlier visit — or a restored session where only
|
|
437
|
+
// the uploaded mediaId survived — means there is nothing to capture, so the
|
|
438
|
+
// step opens on review exactly as the document steps reopen on theirs.
|
|
439
|
+
// A restored selfie (earlier visit) opens straight on review; the one being
|
|
440
|
+
// captured RIGHT NOW waits for the ring to close first.
|
|
441
|
+
if (selfieIdRef.current || (selfieUri && !holdingForClose)) {
|
|
442
|
+
// Review hidden: the hook above advances on this render; the view is the
|
|
443
|
+
// submitted step's own loading screen, so the hand-over is invisible. A
|
|
444
|
+
// FAILED upload still gets the review, for its retry.
|
|
445
|
+
if (!selfieReview && !uploadError) {
|
|
446
|
+
return <LivenessHandover />;
|
|
447
|
+
}
|
|
448
|
+
return (
|
|
449
|
+
<LivenessComplete
|
|
450
|
+
selfieUri={selfieUri}
|
|
451
|
+
upload={upload}
|
|
452
|
+
onRetake={() => {
|
|
453
|
+
setSelfieUri(null);
|
|
454
|
+
setCloseSeen(false);
|
|
455
|
+
setUploadError(null);
|
|
456
|
+
selfieIdRef.current = null;
|
|
457
|
+
videoPathRef.current = null;
|
|
458
|
+
clearSelfie();
|
|
459
|
+
liveness.reset();
|
|
460
|
+
}}
|
|
461
|
+
onContinue={nextStep}
|
|
462
|
+
/>
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
350
466
|
// ── Error states ────────────────────────────────────────────────────────────
|
|
351
467
|
if (cameraUnavailable) {
|
|
352
468
|
return <CameraUnavailableView />;
|
|
353
469
|
}
|
|
470
|
+
// The face model is fetched on demand on Android (see useModelReady), so it
|
|
471
|
+
// can be absent on a fresh install or a device without Play Services. Checked
|
|
472
|
+
// here, before the camera opens, because detectFace reports a missing model
|
|
473
|
+
// and an empty frame identically — gating on frames would leave the user on
|
|
474
|
+
// "position your face" forever with nothing to explain it.
|
|
475
|
+
if (modelState === 'unavailable') {
|
|
476
|
+
return (
|
|
477
|
+
<CameraUnavailableView
|
|
478
|
+
message="Face verification couldn't start on this device. Check your connection and try again, or use a different device."
|
|
479
|
+
/>
|
|
480
|
+
);
|
|
481
|
+
}
|
|
354
482
|
// "Here's what happens next", BEFORE the OS prompt — and shown even when
|
|
355
483
|
// permission is already granted, because it is about what the step will ask
|
|
356
484
|
// of the user, not about access. Opening the camera unannounced is what makes
|
|
@@ -365,24 +493,6 @@ export function LivenessStep(): React.ReactElement {
|
|
|
365
493
|
return <CameraPermissionView onRetry={() => setPerm('requesting')} />;
|
|
366
494
|
}
|
|
367
495
|
|
|
368
|
-
// ── Review (selfie captured) ─────────────────────────────────────────────────
|
|
369
|
-
if (liveness.phase === 'complete' && selfieUri) {
|
|
370
|
-
return (
|
|
371
|
-
<LivenessComplete
|
|
372
|
-
selfieUri={selfieUri}
|
|
373
|
-
upload={upload}
|
|
374
|
-
onRetake={() => {
|
|
375
|
-
setSelfieUri(null);
|
|
376
|
-
setUploadError(null);
|
|
377
|
-
selfieIdRef.current = null;
|
|
378
|
-
videoPathRef.current = null;
|
|
379
|
-
liveness.reset();
|
|
380
|
-
}}
|
|
381
|
-
onContinue={nextStep}
|
|
382
|
-
/>
|
|
383
|
-
);
|
|
384
|
-
}
|
|
385
|
-
|
|
386
496
|
if (liveness.phase === 'failed') {
|
|
387
497
|
return <LivenessFailed reason={liveness.failureReason} onRetry={liveness.reset} />;
|
|
388
498
|
}
|
|
@@ -398,31 +508,32 @@ export function LivenessStep(): React.ReactElement {
|
|
|
398
508
|
liveness.lightingGuidance != null;
|
|
399
509
|
const isCamLoading = phase === 'loading';
|
|
400
510
|
|
|
511
|
+
// The ring is on the frame from positioning to the shutter, so for that whole
|
|
512
|
+
// stretch the border is its TRACK. Phase colour stays on the instruction text
|
|
513
|
+
// and the passed flash; a warning still turns the track red under the arc.
|
|
514
|
+
// `failed` has already returned above, so loading is the only phase without it.
|
|
515
|
+
const ringOnFrame = !isCamLoading;
|
|
401
516
|
const ringColor = isCamLoading
|
|
402
517
|
? colors.gray300
|
|
403
518
|
: hasWarning
|
|
404
519
|
? colors.error
|
|
405
|
-
:
|
|
406
|
-
? colors.
|
|
407
|
-
:
|
|
408
|
-
? colors.warning
|
|
409
|
-
: liveness.faceDetected
|
|
410
|
-
? colors.primary
|
|
411
|
-
: colors.gray300;
|
|
520
|
+
: ringOnFrame
|
|
521
|
+
? `${colors.primary}33`
|
|
522
|
+
: colors.gray300;
|
|
412
523
|
|
|
413
524
|
const guidance = resolveGuidance(liveness);
|
|
414
525
|
const instrColor = isCamLoading
|
|
415
526
|
? colors.textMuted
|
|
416
527
|
: guidance.tone === 'error'
|
|
417
528
|
? colors.error
|
|
418
|
-
: phase === 'challenge_passed'
|
|
529
|
+
: phase === 'challenge_passed' || phase === 'complete'
|
|
419
530
|
? colors.success
|
|
420
531
|
: phase === 'challenge'
|
|
421
532
|
? colors.warning
|
|
422
533
|
: colors.textDark;
|
|
423
534
|
|
|
424
535
|
// Fixed circle, sized to fit the sheet width (caps at 300).
|
|
425
|
-
const CIRCLE =
|
|
536
|
+
const CIRCLE = layout.circle;
|
|
426
537
|
const lighting = liveness.lightingGuidance;
|
|
427
538
|
|
|
428
539
|
// Rendered INSIDE the sheet, so the SDK header and the "powered by" footer
|
|
@@ -438,7 +549,10 @@ export function LivenessStep(): React.ReactElement {
|
|
|
438
549
|
{phase === 'flash' ? '' : guidance.text}
|
|
439
550
|
</MyazaText>
|
|
440
551
|
|
|
441
|
-
{/* Circular camera
|
|
552
|
+
{/* Circular camera. Two boxes: the inner one CLIPS the preview to a circle,
|
|
553
|
+
the outer one lets the progress ring sit on that border rather than be
|
|
554
|
+
clipped by it. */}
|
|
555
|
+
<View style={{ width: CIRCLE, height: CIRCLE }}>
|
|
442
556
|
<View
|
|
443
557
|
ref={flashHoleRef}
|
|
444
558
|
collapsable={false}
|
|
@@ -452,21 +566,14 @@ export function LivenessStep(): React.ReactElement {
|
|
|
452
566
|
backgroundColor: '#111111',
|
|
453
567
|
}}
|
|
454
568
|
>
|
|
455
|
-
{
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
mirrorMode="on"
|
|
464
|
-
/>
|
|
465
|
-
) : (
|
|
466
|
-
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
|
467
|
-
<ActivityIndicator color="#FFFFFF" />
|
|
468
|
-
</View>
|
|
469
|
-
)}
|
|
569
|
+
{/* The Camera lives in its own borderless wrapper — see LivenessCamera
|
|
570
|
+
for why a bordered parent shifts the Android preview by one border. */}
|
|
571
|
+
<LivenessCamera
|
|
572
|
+
device={device}
|
|
573
|
+
active={perm === 'granted'}
|
|
574
|
+
outputs={[photoOutput, videoOutput, frameOutput]}
|
|
575
|
+
circle={CIRCLE}
|
|
576
|
+
/>
|
|
470
577
|
|
|
471
578
|
{/* Dashed face-guide ellipse (taller than wide) */}
|
|
472
579
|
<Svg style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }} width={CIRCLE} height={CIRCLE}>
|
|
@@ -501,12 +608,9 @@ export function LivenessStep(): React.ReactElement {
|
|
|
501
608
|
</View>
|
|
502
609
|
) : null}
|
|
503
610
|
|
|
504
|
-
{/*
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
<MyazaText variant="heading3" color="#FFFFFF">Got it!</MyazaText>
|
|
508
|
-
</View>
|
|
509
|
-
) : null}
|
|
611
|
+
{/* The shutter: a white flash as the still is taken. It used to sit on
|
|
612
|
+
`capturing` reading "Got it!", announcing a photo not yet taken. */}
|
|
613
|
+
{phase === 'complete' ? <ShutterFlash /> : null}
|
|
510
614
|
|
|
511
615
|
{/* Timeout countdown */}
|
|
512
616
|
{phase === 'challenge' && liveness.timeoutRemaining > 0 ? (
|
|
@@ -516,6 +620,20 @@ export function LivenessStep(): React.ReactElement {
|
|
|
516
620
|
) : null}
|
|
517
621
|
</View>
|
|
518
622
|
|
|
623
|
+
{/* The ring — outside the clipping box, on the frame's own edge. One line,
|
|
624
|
+
building one way for the whole test; primary while it builds, green
|
|
625
|
+
only as it closes, on the same frame as the shutter. */}
|
|
626
|
+
{ringOnFrame ? (
|
|
627
|
+
<CaptureRing
|
|
628
|
+
size={CIRCLE}
|
|
629
|
+
getTarget={getRingTarget}
|
|
630
|
+
color={colors.primary}
|
|
631
|
+
successColor={colors.success}
|
|
632
|
+
complete={phase === 'complete'}
|
|
633
|
+
/>
|
|
634
|
+
) : null}
|
|
635
|
+
</View>
|
|
636
|
+
|
|
519
637
|
{/* Lighting warning banner — mirrors Flutter (amber-50/200/800 + lightbulb) */}
|
|
520
638
|
{lighting ? <LightingBanner text={lightingGuidanceText(lighting)} /> : null}
|
|
521
639
|
|
|
@@ -523,7 +641,9 @@ export function LivenessStep(): React.ReactElement {
|
|
|
523
641
|
<ProgressDots total={liveness.totalCount} completed={liveness.completedCount} active={phase === 'challenge' || phase === 'positioning' || phase === 'flash'} />
|
|
524
642
|
|
|
525
643
|
{/* Gesture demo avatar */}
|
|
526
|
-
{liveness.activeChallenge ?
|
|
644
|
+
{liveness.activeChallenge ? (
|
|
645
|
+
<LivenessAvatar challenge={liveness.activeChallenge} size={layout.avatar} iconSize={layout.avatarIcon} />
|
|
646
|
+
) : null}
|
|
527
647
|
</View>
|
|
528
648
|
);
|
|
529
649
|
}
|
package/src/screens/NfcStep.tsx
CHANGED
|
@@ -8,6 +8,7 @@ import { MyazaButton } from '../components/MyazaButton';
|
|
|
8
8
|
import { MyazaPulseLoader } from '../components/MyazaPulseLoader';
|
|
9
9
|
import { Icon } from '../components/Icon';
|
|
10
10
|
import { cancelChipRead, nfcUnavailableReason, readPassportChip, type EmrtdReadResult } from '../emrtd';
|
|
11
|
+
import { fromBase64 } from '../emrtd/bytes';
|
|
11
12
|
import { useNfcAvailability } from './nfc/useNfcAvailability';
|
|
12
13
|
import { readErrorMessage } from './nfc/readErrorMessage';
|
|
13
14
|
import { successHaptic } from '../services/haptics';
|
|
@@ -136,6 +137,18 @@ export function NfcStep(): React.ReactElement {
|
|
|
136
137
|
stageRef.current = 'waiting';
|
|
137
138
|
readingRef.current = true;
|
|
138
139
|
try {
|
|
140
|
+
// The anti-clone challenge, asked for BEFORE the read so the whole thing
|
|
141
|
+
// happens in one session on the document. Best-effort by design: the
|
|
142
|
+
// server may not answer, and a chip read without a challenge is exactly
|
|
143
|
+
// the read this SDK did before Active Authentication existed. It must
|
|
144
|
+
// never be the reason a passport cannot be scanned.
|
|
145
|
+
const aaChallenge = await store
|
|
146
|
+
.getState()
|
|
147
|
+
.api.nfcChallenge()
|
|
148
|
+
.then((res) => ({ id: res.challengeId, bytes: fromBase64(res.challenge) }))
|
|
149
|
+
.catch(() => undefined);
|
|
150
|
+
if (!liveRef.current || seq !== readSeqRef.current) return;
|
|
151
|
+
|
|
139
152
|
const result = await readPassportChip(
|
|
140
153
|
{
|
|
141
154
|
documentNumber: mrz.documentNumber,
|
|
@@ -143,6 +156,7 @@ export function NfcStep(): React.ReactElement {
|
|
|
143
156
|
dateOfExpiry: toMrzDate(mrz.dateOfExpiry),
|
|
144
157
|
},
|
|
145
158
|
{
|
|
159
|
+
...(aaChallenge ? { aaChallenge } : {}),
|
|
146
160
|
onStage: (s) => {
|
|
147
161
|
if (!liveRef.current || seq !== readSeqRef.current) return;
|
|
148
162
|
stageRef.current = s;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { useTheme } from '../components/runtime';
|
|
5
|
+
import { Icon, type IconName } from '../components/Icon';
|
|
6
|
+
import { OptionRow } from '../components/OptionRow';
|
|
7
|
+
import type { PoaDocumentType } from '../types/workflow';
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// The document kinds LISTED OUT as the questionnaire's multi-select cards (a
|
|
11
|
+
// square check, primary when picked, an icon beside the label) rather than
|
|
12
|
+
// hidden behind a select — the options are the information, and a sheet makes
|
|
13
|
+
// the person open it to find out what is on offer (user decision 2026-09-05).
|
|
14
|
+
// ONE kind is picked, because one document is uploaded: the cards are radios
|
|
15
|
+
// wearing the multi-select's square check. Locked once a file is attached, so
|
|
16
|
+
// the kind cannot drift from the document already uploaded. Mirrors the web
|
|
17
|
+
// SDK's steps/PoaDocumentTypeList and Flutter's proof_of_address_kinds.
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/** One glyph per kind — the SAME Lucide names the web and Flutter maps carry. */
|
|
21
|
+
export const POA_TYPE_ICONS: Record<PoaDocumentType, IconName> = {
|
|
22
|
+
utility_bill: 'zap',
|
|
23
|
+
bank_statement: 'landmark',
|
|
24
|
+
tenancy_agreement: 'house',
|
|
25
|
+
government_document: 'stamp',
|
|
26
|
+
other: 'file-text',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function PoaDocumentTypeList({
|
|
30
|
+
options,
|
|
31
|
+
value,
|
|
32
|
+
disabled,
|
|
33
|
+
onChange,
|
|
34
|
+
}: {
|
|
35
|
+
options: Array<{ value: PoaDocumentType; label: string }>;
|
|
36
|
+
value: PoaDocumentType;
|
|
37
|
+
disabled?: boolean;
|
|
38
|
+
onChange: (value: PoaDocumentType) => void;
|
|
39
|
+
}): React.ReactElement {
|
|
40
|
+
const { colors } = useTheme();
|
|
41
|
+
return (
|
|
42
|
+
<View>
|
|
43
|
+
{options.map((opt) => {
|
|
44
|
+
const selected = opt.value === value;
|
|
45
|
+
return (
|
|
46
|
+
<OptionRow
|
|
47
|
+
key={opt.value}
|
|
48
|
+
label={opt.label}
|
|
49
|
+
selected={selected}
|
|
50
|
+
multi
|
|
51
|
+
role="radio"
|
|
52
|
+
disabled={disabled}
|
|
53
|
+
icon={
|
|
54
|
+
<Icon
|
|
55
|
+
name={POA_TYPE_ICONS[opt.value]}
|
|
56
|
+
size={20}
|
|
57
|
+
color={selected ? colors.primary : colors.textSecondary}
|
|
58
|
+
/>
|
|
59
|
+
}
|
|
60
|
+
onPress={() => onChange(opt.value)}
|
|
61
|
+
/>
|
|
62
|
+
);
|
|
63
|
+
})}
|
|
64
|
+
</View>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { ActivityIndicator, Image, Pressable, View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { radius, spacing } from '../config/theme';
|
|
5
|
+
import { UPLOAD_HINT } from '../config/uploadLimits';
|
|
6
|
+
import { useTheme } from '../components/runtime';
|
|
7
|
+
import { MyazaText } from '../components/Typography';
|
|
8
|
+
import { Icon } from '../components/Icon';
|
|
9
|
+
import { DashedBorder } from '../components/DashedBorder';
|
|
10
|
+
import { CountryFlag } from '../components/CountryFlag';
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Proof of Address — the two states of the attachment area, split out of the
|
|
14
|
+
// step (200-line rule; mirrors the Flutter SDK's proof_of_address_parts).
|
|
15
|
+
// Pure presentation: the step owns the picking, uploading and removing.
|
|
16
|
+
//
|
|
17
|
+
// Both states carry the country the document is for (user decision
|
|
18
|
+
// 2026-09-05): the flag before the call to action on the drop zone, and before
|
|
19
|
+
// the document kind on the uploaded row, so a person on a multi-market flow
|
|
20
|
+
// sees which market the paper is being read against. Null when the flow does
|
|
21
|
+
// not know it yet (the address scope before a pick) — nothing is invented.
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
function Flag({ country, size }: { country: string | null; size: number }): React.ReactElement | null {
|
|
25
|
+
if (!country) return null;
|
|
26
|
+
return (
|
|
27
|
+
<>
|
|
28
|
+
<CountryFlag country={country} size={size} />
|
|
29
|
+
<View style={{ width: spacing.xs }} />
|
|
30
|
+
</>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function PoaUploadedRow({
|
|
35
|
+
preview,
|
|
36
|
+
fileName,
|
|
37
|
+
typeLabel,
|
|
38
|
+
country,
|
|
39
|
+
onRemove,
|
|
40
|
+
}: {
|
|
41
|
+
preview: { uri: string; isPdf: boolean } | null;
|
|
42
|
+
fileName: string | null;
|
|
43
|
+
typeLabel: string;
|
|
44
|
+
country: string | null;
|
|
45
|
+
onRemove: () => void;
|
|
46
|
+
}): React.ReactElement {
|
|
47
|
+
const { colors } = useTheme();
|
|
48
|
+
return (
|
|
49
|
+
<View
|
|
50
|
+
style={{
|
|
51
|
+
flexDirection: 'row',
|
|
52
|
+
alignItems: 'center',
|
|
53
|
+
padding: spacing.md,
|
|
54
|
+
borderRadius: radius.md,
|
|
55
|
+
borderWidth: 1,
|
|
56
|
+
borderColor: colors.border,
|
|
57
|
+
backgroundColor: colors.backgroundSecondary,
|
|
58
|
+
}}
|
|
59
|
+
>
|
|
60
|
+
{preview && !preview.isPdf ? (
|
|
61
|
+
<Image
|
|
62
|
+
source={{ uri: preview.uri }}
|
|
63
|
+
style={{ width: 48, height: 48, borderRadius: radius.sm }}
|
|
64
|
+
resizeMode="cover"
|
|
65
|
+
/>
|
|
66
|
+
) : (
|
|
67
|
+
<View
|
|
68
|
+
style={{
|
|
69
|
+
width: 48,
|
|
70
|
+
height: 48,
|
|
71
|
+
borderRadius: radius.sm,
|
|
72
|
+
borderWidth: 1,
|
|
73
|
+
borderColor: colors.border,
|
|
74
|
+
alignItems: 'center',
|
|
75
|
+
justifyContent: 'center',
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
<Icon name="file-text" size={22} color={colors.primary} />
|
|
79
|
+
</View>
|
|
80
|
+
)}
|
|
81
|
+
<View style={{ width: spacing.md, flexShrink: 0 }} />
|
|
82
|
+
<View style={{ flex: 1, minWidth: 0 }}>
|
|
83
|
+
<MyazaText variant="bodySmall" style={{ fontWeight: '600' }} numberOfLines={1}>
|
|
84
|
+
{fileName ?? 'Document uploaded'}
|
|
85
|
+
</MyazaText>
|
|
86
|
+
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
|
87
|
+
<Flag country={country} size={14} />
|
|
88
|
+
<MyazaText variant="bodySmall" color={colors.textSecondary} numberOfLines={1} style={{ flexShrink: 1 }}>
|
|
89
|
+
{typeLabel}
|
|
90
|
+
</MyazaText>
|
|
91
|
+
</View>
|
|
92
|
+
</View>
|
|
93
|
+
<Pressable
|
|
94
|
+
onPress={onRemove}
|
|
95
|
+
accessibilityRole="button"
|
|
96
|
+
accessibilityLabel="Remove document"
|
|
97
|
+
hitSlop={8}
|
|
98
|
+
>
|
|
99
|
+
<Icon name="x" size={18} color={colors.textSecondary} />
|
|
100
|
+
</Pressable>
|
|
101
|
+
</View>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The DASHED drop zone that NAMES the document being asked for. */
|
|
106
|
+
export function PoaDropzone({
|
|
107
|
+
busy,
|
|
108
|
+
typeLabel,
|
|
109
|
+
country,
|
|
110
|
+
onPress,
|
|
111
|
+
}: {
|
|
112
|
+
busy: boolean;
|
|
113
|
+
typeLabel: string;
|
|
114
|
+
country: string | null;
|
|
115
|
+
onPress: () => void;
|
|
116
|
+
}): React.ReactElement {
|
|
117
|
+
const { colors } = useTheme();
|
|
118
|
+
return (
|
|
119
|
+
<Pressable
|
|
120
|
+
onPress={() => {
|
|
121
|
+
if (!busy) onPress();
|
|
122
|
+
}}
|
|
123
|
+
accessibilityRole="button"
|
|
124
|
+
accessibilityLabel={`Upload your ${typeLabel.toLowerCase()}`}
|
|
125
|
+
style={{
|
|
126
|
+
alignItems: 'center',
|
|
127
|
+
justifyContent: 'center',
|
|
128
|
+
paddingVertical: spacing.xl,
|
|
129
|
+
paddingHorizontal: spacing.lg,
|
|
130
|
+
borderRadius: radius.md,
|
|
131
|
+
}}
|
|
132
|
+
>
|
|
133
|
+
<DashedBorder color={colors.border} radius={radius.md} strokeWidth={1.5} />
|
|
134
|
+
{busy ? (
|
|
135
|
+
<ActivityIndicator size="small" color={colors.primary} />
|
|
136
|
+
) : (
|
|
137
|
+
<Icon name="upload" size={30} color={colors.textSecondary} />
|
|
138
|
+
)}
|
|
139
|
+
<View style={{ height: spacing.sm }} />
|
|
140
|
+
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
|
141
|
+
<Flag country={country} size={18} />
|
|
142
|
+
<MyazaText variant="bodySmall" style={{ fontWeight: '600' }}>
|
|
143
|
+
{busy ? 'Uploading…' : `Upload your ${typeLabel.toLowerCase()}`}
|
|
144
|
+
</MyazaText>
|
|
145
|
+
</View>
|
|
146
|
+
<MyazaText variant="bodySmall" color={colors.textSecondary}>
|
|
147
|
+
{UPLOAD_HINT}
|
|
148
|
+
</MyazaText>
|
|
149
|
+
</Pressable>
|
|
150
|
+
);
|
|
151
|
+
}
|