@myazahq/kyc-sdk-react-native 2.0.2 → 2.2.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 +152 -8
- package/android/build.gradle +6 -0
- package/android/src/main/AndroidManifest.xml +14 -4
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaEmrtd.kt +358 -0
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +88 -1
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaRectDetector.kt +34 -0
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizer.kt +118 -0
- package/android/src/main/java/com/margelo/nitro/myazakyc/Jp2Decoder.kt +117 -0
- package/app.plugin.js +102 -13
- package/ios/HybridMyazaEmrtd.swift +447 -0
- package/ios/HybridMyazaFaceDetector.swift +104 -2
- package/ios/HybridMyazaRectDetector.swift +103 -0
- package/ios/HybridMyazaTextRecognizer.swift +111 -0
- package/nitrogen/generated/.gitattributes +1 -0
- package/nitrogen/generated/android/KycSdkReactNative+autolinking.cmake +87 -0
- package/nitrogen/generated/android/KycSdkReactNative+autolinking.gradle +27 -0
- package/nitrogen/generated/android/KycSdkReactNativeOnLoad.cpp +102 -0
- package/nitrogen/generated/android/KycSdkReactNativeOnLoad.hpp +34 -0
- package/nitrogen/generated/android/c++/JDetectedRect.hpp +81 -0
- package/nitrogen/generated/android/c++/JEmrtdApduResponse.hpp +61 -0
- package/nitrogen/generated/android/c++/JEmrtdTagInfo.hpp +61 -0
- package/nitrogen/generated/android/c++/JFaceResult.hpp +113 -0
- package/nitrogen/generated/android/c++/JHybridMyazaEmrtdSpec.cpp +176 -0
- package/nitrogen/generated/android/c++/JHybridMyazaEmrtdSpec.hpp +75 -0
- package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.cpp +60 -0
- package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.hpp +63 -0
- package/nitrogen/generated/android/c++/JHybridMyazaRectDetectorSpec.cpp +60 -0
- package/nitrogen/generated/android/c++/JHybridMyazaRectDetectorSpec.hpp +63 -0
- package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.cpp +80 -0
- package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.hpp +64 -0
- package/nitrogen/generated/android/c++/JTextResult.hpp +76 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/DetectedRect.kt +81 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/EmrtdApduResponse.kt +56 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/EmrtdTagInfo.kt +56 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/FaceResult.kt +121 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaEmrtdSpec.kt +103 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaFaceDetectorSpec.kt +55 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaRectDetectorSpec.kt +55 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizerSpec.kt +60 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/KycSdkReactNativeOnLoad.kt +35 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/TextResult.kt +51 -0
- package/nitrogen/generated/ios/KycSdkReactNative+autolinking.rb +62 -0
- package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.cpp +143 -0
- package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.hpp +423 -0
- package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Umbrella.hpp +81 -0
- package/nitrogen/generated/ios/KycSdkReactNativeAutolinking.mm +57 -0
- package/nitrogen/generated/ios/KycSdkReactNativeAutolinking.swift +62 -0
- package/nitrogen/generated/ios/c++/HybridMyazaEmrtdSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridMyazaEmrtdSpecSwift.hpp +184 -0
- package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.hpp +87 -0
- package/nitrogen/generated/ios/c++/HybridMyazaRectDetectorSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridMyazaRectDetectorSpecSwift.hpp +87 -0
- package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.hpp +98 -0
- package/nitrogen/generated/ios/swift/DetectedRect.swift +59 -0
- package/nitrogen/generated/ios/swift/EmrtdApduResponse.swift +34 -0
- package/nitrogen/generated/ios/swift/EmrtdTagInfo.swift +34 -0
- package/nitrogen/generated/ios/swift/FaceResult.swift +99 -0
- package/nitrogen/generated/ios/swift/Func_void.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_EmrtdApduResponse.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_EmrtdTagInfo.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_TextResult.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__string.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridMyazaEmrtdSpec.swift +67 -0
- package/nitrogen/generated/ios/swift/HybridMyazaEmrtdSpec_cxx.swift +317 -0
- package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec.swift +56 -0
- package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec_cxx.swift +143 -0
- package/nitrogen/generated/ios/swift/HybridMyazaRectDetectorSpec.swift +56 -0
- package/nitrogen/generated/ios/swift/HybridMyazaRectDetectorSpec_cxx.swift +143 -0
- package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec.swift +57 -0
- package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec_cxx.swift +162 -0
- package/nitrogen/generated/ios/swift/TextResult.swift +35 -0
- package/nitrogen/generated/shared/c++/DetectedRect.hpp +107 -0
- package/nitrogen/generated/shared/c++/EmrtdApduResponse.hpp +87 -0
- package/nitrogen/generated/shared/c++/EmrtdTagInfo.hpp +87 -0
- package/nitrogen/generated/shared/c++/FaceResult.hpp +139 -0
- package/nitrogen/generated/shared/c++/HybridMyazaEmrtdSpec.cpp +33 -0
- package/nitrogen/generated/shared/c++/HybridMyazaEmrtdSpec.hpp +80 -0
- package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.cpp +21 -0
- package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.hpp +67 -0
- package/nitrogen/generated/shared/c++/HybridMyazaRectDetectorSpec.cpp +21 -0
- package/nitrogen/generated/shared/c++/HybridMyazaRectDetectorSpec.hpp +67 -0
- package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.cpp +22 -0
- package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.hpp +70 -0
- package/nitrogen/generated/shared/c++/TextResult.hpp +84 -0
- package/package.json +16 -4
- package/src/MyazaKYC.tsx +115 -22
- package/src/capture/documentIdentity.ts +153 -0
- package/src/capture/documentSignals.ts +177 -0
- package/src/capture/documentTextGate.ts +171 -0
- package/src/capture/hints.ts +57 -0
- package/src/capture/index.ts +24 -0
- package/src/capture/rectDetector.ts +84 -0
- package/src/capture/rectGate.ts +91 -0
- package/src/capture/useAutoCapture.ts +198 -0
- package/src/components/CameraViewfinder.tsx +226 -70
- package/src/components/ContactCodeEntry.tsx +139 -0
- package/src/components/CountryField.tsx +87 -0
- package/src/components/CountryFlag.tsx +15 -15
- package/src/components/DialCodePicker.tsx +186 -0
- package/src/components/DocumentCropper.tsx +1 -1
- package/src/components/DocumentReview.tsx +142 -0
- package/src/components/DocumentReviewSide.tsx +135 -0
- package/src/components/DocumentReviewZoom.tsx +119 -0
- package/src/components/ExpiryCountdown.tsx +52 -0
- package/src/components/FlashOverlay.tsx +156 -0
- package/src/components/Icon.tsx +70 -1
- package/src/components/KycFlow.tsx +66 -94
- package/src/components/KycSheet.tsx +148 -18
- package/src/components/MediaSourceSheet.tsx +163 -0
- package/src/components/MyazaButton.tsx +14 -2
- package/src/components/MyazaDateField.tsx +240 -0
- package/src/components/MyazaInput.tsx +73 -26
- package/src/components/MyazaSelect.tsx +283 -0
- package/src/components/MyazaWordmark.tsx +33 -0
- package/src/components/OptionRow.tsx +132 -0
- package/src/components/PhoneNumberInput.tsx +149 -0
- package/src/components/PoweredBy.tsx +89 -0
- package/src/components/ReadyPrimer.tsx +149 -0
- package/src/components/StepView.tsx +91 -0
- package/src/components/Typography.tsx +18 -3
- package/src/components/VerifiedNotice.tsx +38 -0
- package/src/components/WhatsAppIcon.tsx +29 -0
- package/src/components/WorkflowGate.tsx +128 -0
- package/src/components/brand-font.ts +113 -0
- package/src/components/flashHoleGeometry.ts +35 -0
- package/src/components/flow/FatalConfigError.tsx +56 -0
- package/src/components/fonts.ts +2 -19
- package/src/components/readyPrimerContent.ts +52 -0
- package/src/components/runtime.tsx +38 -43
- package/src/components/stepHeaderMeta.tsx +87 -0
- package/src/components/theme-provider.tsx +89 -0
- package/src/components/useWorkflowMount.ts +116 -0
- package/src/components/viewfinder/CardGuide.tsx +60 -0
- package/src/components/viewfinder/DocumentGhost.tsx +149 -0
- package/src/components/viewfinder/ImmersiveBottomBar.tsx +172 -0
- package/src/components/viewfinder/ImmersiveControls.tsx +127 -0
- package/src/components/viewfinder/ImmersiveGuide.tsx +174 -0
- package/src/components/viewfinder/ImmersiveOverlay.tsx +79 -0
- package/src/components/viewfinder/ViewfinderControls.tsx +154 -0
- package/src/config/brand.ts +97 -0
- package/src/config/business.ts +179 -0
- package/src/config/businessSteps.ts +105 -0
- package/src/config/contact.ts +159 -0
- package/src/config/countryNames.g.ts +254 -0
- package/src/config/currencyFlags.ts +144 -0
- package/src/config/font-resolve.ts +78 -0
- package/src/config/idTypes.ts +96 -1
- package/src/config/keyPeople.ts +162 -0
- package/src/config/phone.ts +39 -0
- package/src/config/proofOfAddress.ts +75 -0
- package/src/config/questionnaire.ts +153 -0
- package/src/config/regions.ts +118 -0
- package/src/config/registrationHint.ts +87 -0
- package/src/config/stepOrder.ts +118 -0
- package/src/config/theme.ts +38 -2
- package/src/config/workflowMerge.ts +124 -0
- package/src/emrtd/bac.ts +128 -0
- package/src/emrtd/bytes.ts +127 -0
- package/src/emrtd/crypto.ts +163 -0
- package/src/emrtd/der.ts +192 -0
- package/src/emrtd/dg2.ts +111 -0
- package/src/emrtd/files.ts +215 -0
- package/src/emrtd/index.ts +30 -0
- package/src/emrtd/mrzKey.ts +54 -0
- package/src/emrtd/native.ts +79 -0
- package/src/emrtd/optionalRead.ts +42 -0
- package/src/emrtd/read.ts +224 -0
- package/src/emrtd/retry.ts +98 -0
- package/src/emrtd/secureMessaging.ts +178 -0
- package/src/emrtd/session.ts +200 -0
- package/src/emrtd/stages.ts +122 -0
- package/src/index.ts +110 -0
- package/src/lib/calendar.ts +71 -0
- package/src/lib/use-keyboard-inset.ts +34 -0
- package/src/liveness/challengeManager.ts +27 -1
- package/src/liveness/faceContinuity.ts +153 -0
- package/src/liveness/flashDetector.ts +145 -0
- package/src/liveness/flashReadyGate.ts +106 -0
- package/src/liveness/flashRunner.ts +118 -0
- package/src/liveness/integritySignals.ts +60 -0
- package/src/liveness/livenessState.ts +139 -0
- package/src/liveness/speech.ts +19 -1
- package/src/liveness/types.ts +48 -1
- package/src/liveness/useFlashPhase.ts +78 -0
- package/src/liveness/useLiveness.ts +260 -75
- package/src/liveness/visionCameraFaceDetector.ts +14 -0
- package/src/mrz/extract.ts +101 -0
- package/src/mrz/parse.ts +178 -0
- package/src/mrz/textRecognizer.ts +149 -0
- package/src/screens/ApplicantRoleStep.tsx +322 -0
- package/src/screens/BusinessDetailsStep.tsx +212 -0
- package/src/screens/BusinessDocumentSlot.tsx +167 -0
- package/src/screens/BusinessDocumentsStep.tsx +213 -0
- package/src/screens/BusinessKeyPeopleStep.tsx +207 -0
- package/src/screens/CompanyInfoFields.tsx +83 -0
- package/src/screens/ConsentStep.tsx +68 -68
- package/src/screens/ContactChannelChoice.tsx +133 -0
- package/src/screens/ContactCodeStep.tsx +130 -0
- package/src/screens/ContactDestinationField.tsx +70 -0
- package/src/screens/ContactFooterNote.tsx +34 -0
- package/src/screens/ContactVerificationStep.tsx +199 -0
- package/src/screens/ContactVerifiedPanel.tsx +36 -0
- package/src/screens/CountrySelectStep.tsx +210 -0
- package/src/screens/DocumentCaptureStep.tsx +156 -132
- package/src/screens/IdInputStep.tsx +13 -5
- package/src/screens/IdTypeStep.tsx +74 -33
- package/src/screens/KeyPeopleInviteLinks.tsx +239 -0
- package/src/screens/KeyPersonCard.tsx +119 -0
- package/src/screens/KeyPersonForm.tsx +148 -0
- package/src/screens/KeyPersonSheet.tsx +184 -0
- package/src/screens/LivenessStep.tsx +155 -247
- package/src/screens/MrzScanView.tsx +173 -0
- package/src/screens/NfcStep.tsx +290 -0
- package/src/screens/ProofOfAddressStep.tsx +238 -0
- package/src/screens/QuestionnaireField.tsx +219 -0
- package/src/screens/QuestionnaireMoneyField.tsx +134 -0
- package/src/screens/QuestionnaireStep.tsx +76 -0
- package/src/screens/SubmittedStep.tsx +5 -0
- package/src/screens/consent/model.ts +109 -0
- package/src/screens/document/CameraPhase.tsx +150 -0
- package/src/screens/document/RequiredPill.tsx +60 -0
- package/src/screens/liveness/LightingBanner.tsx +50 -0
- package/src/screens/liveness/LivenessOutcome.tsx +109 -0
- package/src/screens/liveness/ProgressDots.tsx +67 -0
- package/src/screens/liveness/SelfiePreview.tsx +39 -0
- package/src/screens/liveness/constants.ts +31 -0
- package/src/screens/liveness/index.ts +8 -0
- package/src/screens/liveness/resolveGuidance.ts +27 -0
- package/src/screens/liveness/useSelfieUpload.ts +107 -0
- package/src/screens/nfc/NfcMrzPrompt.tsx +40 -0
- package/src/screens/nfc/NfcReadBody.tsx +63 -0
- package/src/screens/nfc/NfcReadProgress.tsx +96 -0
- package/src/screens/nfc/NfcScanIllustration.tsx +244 -0
- package/src/screens/nfc/NfcScannedSummary.tsx +74 -0
- package/src/screens/nfc/NfcSuccessPanel.tsx +162 -0
- package/src/screens/nfc/availability.ts +73 -0
- package/src/screens/nfc/readErrorMessage.ts +93 -0
- package/src/screens/nfc/useChipPortrait.ts +58 -0
- package/src/screens/nfc/useNfcAvailability.ts +82 -0
- package/src/screens/useFlashHole.ts +105 -0
- package/src/screens/useFlashSequence.ts +96 -0
- package/src/screens/usePoaAttach.ts +121 -0
- package/src/services/api-types.ts +164 -0
- package/src/services/api-verify-types.ts +99 -0
- package/src/services/api.ts +72 -102
- package/src/services/cardCrop.ts +25 -10
- package/src/services/contactErrors.ts +43 -0
- package/src/services/deviceMetadata.ts +1 -1
- package/src/services/documentPicker.ts +20 -0
- package/src/services/fingerprint-sources.ts +129 -0
- package/src/services/fingerprint.ts +101 -0
- package/src/services/haptics.ts +38 -0
- package/src/services/mediaCompress.ts +7 -2
- package/src/services/resolveUrl.ts +2 -2
- package/src/services/workflowGate.ts +138 -0
- package/src/specs/MyazaEmrtd.nitro.ts +114 -0
- package/src/specs/MyazaFaceDetector.nitro.ts +10 -0
- package/src/specs/MyazaRectDetector.nitro.ts +50 -0
- package/src/specs/MyazaTextRecognizer.nitro.ts +48 -0
- package/src/store/derive.ts +225 -0
- package/src/store/kycStore.ts +250 -149
- package/src/store/state.ts +253 -0
- package/src/store/submit.ts +101 -0
- package/src/store/submitApplicant.ts +81 -0
- package/src/types/appearance.ts +113 -0
- package/src/types/business.ts +131 -0
- package/src/types/config.ts +150 -124
- package/src/types/id-types.ts +57 -0
- package/src/types/verification.ts +5 -0
- package/src/types/workflow.ts +141 -0
- package/src/utils/amountFormat.ts +73 -0
- package/src/utils/tokens.ts +4 -2
- package/src/__tests__/cardCrop.test.ts +0 -39
- package/src/__tests__/deviceMetadata.test.ts +0 -34
- package/src/__tests__/errors.test.ts +0 -34
- package/src/__tests__/flow.test.ts +0 -61
- package/src/__tests__/gestureDetector.test.ts +0 -37
- package/src/__tests__/liveness.test.ts +0 -112
- package/src/__tests__/resolveUrl.test.ts +0 -64
- package/src/__tests__/validators.test.ts +0 -38
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import React, { useCallback, useState } from 'react';
|
|
2
|
+
import { ActivityIndicator, Image, Pressable, View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { radius, 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 { MediaSourceSheet } from '../components/MediaSourceSheet';
|
|
10
|
+
import { MyazaSelect } from '../components/MyazaSelect';
|
|
11
|
+
import { withRetry } from '../services/retry';
|
|
12
|
+
import { compressDocumentImage } from '../services/mediaCompress';
|
|
13
|
+
import { poaDocumentTypes, poaMaxAgeDays, poaTypeLabel } from '../config/proofOfAddress';
|
|
14
|
+
import { usePoaAttach, type PoaPick } from './usePoaAttach';
|
|
15
|
+
import type { PoaDocumentType } from '../types/workflow';
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Proof of Address — pick the kind of document, then supply it.
|
|
19
|
+
//
|
|
20
|
+
// Mirrors the web SDK: a DASHED drop zone that NAMES the document being asked
|
|
21
|
+
// for ("Upload your utility bill") plus what's accepted, replaced after upload
|
|
22
|
+
// by a row showing the file, its kind, and an X to remove it. A generic "choose
|
|
23
|
+
// a file" hid which of the offered document kinds the user had to supply.
|
|
24
|
+
//
|
|
25
|
+
// The check itself is soft — the server reads the document and reports a
|
|
26
|
+
// verdict, but never fails the verification over it — so nothing here blocks
|
|
27
|
+
// beyond making sure a file was actually attached.
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
export function proofOfAddressMeta(maxAgeDays: number): { title: string; description: string } {
|
|
31
|
+
return {
|
|
32
|
+
title: 'Proof of address',
|
|
33
|
+
description: `Upload a document that shows your name and home address, issued within the last ${maxAgeDays} days.`,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function ProofOfAddressStep(): React.ReactElement {
|
|
38
|
+
const config = useKycConfig();
|
|
39
|
+
const store = useKycStore();
|
|
40
|
+
const { colors } = useTheme();
|
|
41
|
+
const mediaId = useKyc((s) => s.mediaIds.proofOfAddress);
|
|
42
|
+
const fileName = useKyc((s) => s.poaFileName);
|
|
43
|
+
const storedType = useKyc((s) => s.poaDocumentType);
|
|
44
|
+
|
|
45
|
+
const types = poaDocumentTypes(config.proofOfAddress);
|
|
46
|
+
const [selectedType, setSelectedType] = useState<PoaDocumentType>(storedType ?? types[0]!);
|
|
47
|
+
const [busy, setBusy] = useState(false);
|
|
48
|
+
const [error, setError] = useState<string | null>(null);
|
|
49
|
+
// The picked file, kept for the thumbnail — the only way a user catches
|
|
50
|
+
// "wrong photo from the camera roll" before submitting.
|
|
51
|
+
const [preview, setPreview] = useState<{ uri: string; isPdf: boolean } | null>(null);
|
|
52
|
+
|
|
53
|
+
const typeLabel = poaTypeLabel(selectedType, config.proofOfAddress);
|
|
54
|
+
|
|
55
|
+
const upload = useCallback(
|
|
56
|
+
async ({ uri, mimeType, name }: PoaPick) => {
|
|
57
|
+
setBusy(true);
|
|
58
|
+
setError(null);
|
|
59
|
+
const isPdf = (mimeType ?? '').toLowerCase().startsWith('application/pdf');
|
|
60
|
+
setPreview({ uri, isPdf });
|
|
61
|
+
try {
|
|
62
|
+
// Photos are compressed like any other capture; a PDF is passed through
|
|
63
|
+
// untouched — re-encoding it would destroy the text the server reads.
|
|
64
|
+
const finalUri = isPdf ? uri : await compressDocumentImage(uri).catch(() => uri);
|
|
65
|
+
const id = await withRetry(() =>
|
|
66
|
+
store.getState().api.upload({ uri: finalUri, type: mimeType, name }, 'proof_of_address'),
|
|
67
|
+
);
|
|
68
|
+
store.getState().setProofOfAddress(id, selectedType, name);
|
|
69
|
+
} catch {
|
|
70
|
+
setPreview(null);
|
|
71
|
+
setError('We could not upload that document. Please try again.');
|
|
72
|
+
} finally {
|
|
73
|
+
setBusy(false);
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
[selectedType, store],
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const { pick, sheetOpen, closeSheet, choosePhoto, takePhoto, chooseFile } = usePoaAttach(
|
|
80
|
+
upload,
|
|
81
|
+
setError,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const remove = useCallback(() => {
|
|
85
|
+
setPreview(null);
|
|
86
|
+
setError(null);
|
|
87
|
+
store.getState().clearProofOfAddress();
|
|
88
|
+
}, [store]);
|
|
89
|
+
|
|
90
|
+
const uploaded = Boolean(mediaId) && !busy;
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<View>
|
|
94
|
+
<MediaSourceSheet
|
|
95
|
+
open={sheetOpen}
|
|
96
|
+
onClose={closeSheet}
|
|
97
|
+
title="Upload your document"
|
|
98
|
+
options={[
|
|
99
|
+
{
|
|
100
|
+
icon: 'image',
|
|
101
|
+
label: 'Photo library',
|
|
102
|
+
caption: 'Pick a photo you already have',
|
|
103
|
+
onPress: () => void choosePhoto(),
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
icon: 'camera',
|
|
107
|
+
label: 'Take a photo',
|
|
108
|
+
caption: 'Photograph the document now',
|
|
109
|
+
onPress: () => void takePhoto(),
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
icon: 'file-text',
|
|
113
|
+
label: 'Choose a file',
|
|
114
|
+
caption: 'A PDF or image from your files',
|
|
115
|
+
onPress: () => void chooseFile(),
|
|
116
|
+
},
|
|
117
|
+
]}
|
|
118
|
+
/>
|
|
119
|
+
{types.length > 1 ? (
|
|
120
|
+
<>
|
|
121
|
+
<MyazaText variant="bodySmall" style={{ fontWeight: '600', marginBottom: spacing.xs }}>
|
|
122
|
+
Document type
|
|
123
|
+
</MyazaText>
|
|
124
|
+
<MyazaSelect<PoaDocumentType>
|
|
125
|
+
value={selectedType}
|
|
126
|
+
sheetTitle="Document type"
|
|
127
|
+
// Locked once a file is attached: switching the kind afterwards
|
|
128
|
+
// would mislabel the document already uploaded.
|
|
129
|
+
enabled={!uploaded && !busy}
|
|
130
|
+
options={types.map((type) => ({
|
|
131
|
+
value: type,
|
|
132
|
+
label: poaTypeLabel(type, config.proofOfAddress),
|
|
133
|
+
}))}
|
|
134
|
+
onChange={setSelectedType}
|
|
135
|
+
/>
|
|
136
|
+
<View style={{ height: spacing.lg }} />
|
|
137
|
+
</>
|
|
138
|
+
) : null}
|
|
139
|
+
|
|
140
|
+
{uploaded ? (
|
|
141
|
+
<View
|
|
142
|
+
style={{
|
|
143
|
+
flexDirection: 'row',
|
|
144
|
+
alignItems: 'center',
|
|
145
|
+
padding: spacing.md,
|
|
146
|
+
borderRadius: radius.md,
|
|
147
|
+
borderWidth: 1,
|
|
148
|
+
borderColor: colors.border,
|
|
149
|
+
backgroundColor: colors.backgroundSecondary,
|
|
150
|
+
}}
|
|
151
|
+
>
|
|
152
|
+
{preview && !preview.isPdf ? (
|
|
153
|
+
<Image
|
|
154
|
+
source={{ uri: preview.uri }}
|
|
155
|
+
style={{ width: 48, height: 48, borderRadius: radius.sm }}
|
|
156
|
+
resizeMode="cover"
|
|
157
|
+
/>
|
|
158
|
+
) : (
|
|
159
|
+
<View
|
|
160
|
+
style={{
|
|
161
|
+
width: 48,
|
|
162
|
+
height: 48,
|
|
163
|
+
borderRadius: radius.sm,
|
|
164
|
+
borderWidth: 1,
|
|
165
|
+
borderColor: colors.border,
|
|
166
|
+
alignItems: 'center',
|
|
167
|
+
justifyContent: 'center',
|
|
168
|
+
}}
|
|
169
|
+
>
|
|
170
|
+
<Icon name="file-text" size={22} color={colors.primary} />
|
|
171
|
+
</View>
|
|
172
|
+
)}
|
|
173
|
+
<View style={{ width: spacing.md, flexShrink: 0 }} />
|
|
174
|
+
<View style={{ flex: 1, minWidth: 0 }}>
|
|
175
|
+
<MyazaText variant="bodySmall" style={{ fontWeight: '600' }} numberOfLines={1}>
|
|
176
|
+
{fileName ?? 'Document uploaded'}
|
|
177
|
+
</MyazaText>
|
|
178
|
+
<MyazaText variant="bodySmall" color={colors.textSecondary} numberOfLines={1}>
|
|
179
|
+
{typeLabel}
|
|
180
|
+
</MyazaText>
|
|
181
|
+
</View>
|
|
182
|
+
<Pressable
|
|
183
|
+
onPress={remove}
|
|
184
|
+
accessibilityRole="button"
|
|
185
|
+
accessibilityLabel="Remove document"
|
|
186
|
+
hitSlop={8}
|
|
187
|
+
>
|
|
188
|
+
<Icon name="x" size={18} color={colors.textSecondary} />
|
|
189
|
+
</Pressable>
|
|
190
|
+
</View>
|
|
191
|
+
) : (
|
|
192
|
+
<Pressable
|
|
193
|
+
onPress={() => {
|
|
194
|
+
if (!busy) void pick();
|
|
195
|
+
}}
|
|
196
|
+
accessibilityRole="button"
|
|
197
|
+
accessibilityLabel={`Upload your ${typeLabel.toLowerCase()}`}
|
|
198
|
+
style={{
|
|
199
|
+
alignItems: 'center',
|
|
200
|
+
justifyContent: 'center',
|
|
201
|
+
paddingVertical: spacing.xl,
|
|
202
|
+
paddingHorizontal: spacing.lg,
|
|
203
|
+
borderRadius: radius.md,
|
|
204
|
+
borderWidth: 1.5,
|
|
205
|
+
borderStyle: 'dashed',
|
|
206
|
+
borderColor: colors.border,
|
|
207
|
+
}}
|
|
208
|
+
>
|
|
209
|
+
{busy ? (
|
|
210
|
+
<ActivityIndicator size="small" color={colors.primary} />
|
|
211
|
+
) : (
|
|
212
|
+
<Icon name="upload" size={30} color={colors.textSecondary} />
|
|
213
|
+
)}
|
|
214
|
+
<View style={{ height: spacing.sm }} />
|
|
215
|
+
<MyazaText variant="bodySmall" style={{ fontWeight: '600' }}>
|
|
216
|
+
{busy ? 'Uploading…' : `Upload your ${typeLabel.toLowerCase()}`}
|
|
217
|
+
</MyazaText>
|
|
218
|
+
<MyazaText variant="bodySmall" color={colors.textSecondary}>
|
|
219
|
+
Photo or PDF, up to 20MB
|
|
220
|
+
</MyazaText>
|
|
221
|
+
</Pressable>
|
|
222
|
+
)}
|
|
223
|
+
|
|
224
|
+
{error ? (
|
|
225
|
+
<MyazaText variant="bodySmall" color={colors.error} style={{ marginTop: spacing.sm }}>
|
|
226
|
+
{error}
|
|
227
|
+
</MyazaText>
|
|
228
|
+
) : null}
|
|
229
|
+
|
|
230
|
+
<View style={{ height: spacing.lg }} />
|
|
231
|
+
<MyazaButton
|
|
232
|
+
label="Continue"
|
|
233
|
+
disabled={!mediaId || busy}
|
|
234
|
+
onPress={() => store.getState().nextStep()}
|
|
235
|
+
/>
|
|
236
|
+
</View>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Pressable, View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { radius, spacing } from '../config/theme';
|
|
5
|
+
import { useTheme } from '../components/runtime';
|
|
6
|
+
import { MyazaText } from '../components/Typography';
|
|
7
|
+
import { MyazaDateField } from '../components/MyazaDateField';
|
|
8
|
+
import { MyazaInput } from '../components/MyazaInput';
|
|
9
|
+
import { MyazaSelect } from '../components/MyazaSelect';
|
|
10
|
+
import { OptionRow } from '../components/OptionRow';
|
|
11
|
+
import { MoneyField as QuestionnaireMoneyField } from './QuestionnaireMoneyField';
|
|
12
|
+
import { currencyKeyFor } from '../config/questionnaire';
|
|
13
|
+
import type { QuestionnaireAnswerValue, QuestionnaireField as FieldDef } from '../types/workflow';
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// One questionnaire question.
|
|
17
|
+
//
|
|
18
|
+
// Mirrors the Flutter SDK field-for-field, because a questionnaire is authored
|
|
19
|
+
// once and rendered on every platform — a question that looks like a different
|
|
20
|
+
// control per platform reads as a different question:
|
|
21
|
+
//
|
|
22
|
+
// label the question, with a RED * when required (not "(optional)" on
|
|
23
|
+
// the ones that aren't — most questions are required, so marking
|
|
24
|
+
// the exception is quieter than marking the rule)
|
|
25
|
+
// select a select field that opens a sheet
|
|
26
|
+
// multiselect tappable cards with square checks (the choices are the point)
|
|
27
|
+
// boolean two centred Yes / No cards
|
|
28
|
+
// money amount + currency on ONE row
|
|
29
|
+
// text/number a plain input
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
export function QuestionnaireFieldView({
|
|
33
|
+
field,
|
|
34
|
+
value,
|
|
35
|
+
currencyValue,
|
|
36
|
+
error,
|
|
37
|
+
onChange,
|
|
38
|
+
onCurrencyChange,
|
|
39
|
+
}: {
|
|
40
|
+
field: FieldDef;
|
|
41
|
+
value: QuestionnaireAnswerValue | undefined;
|
|
42
|
+
/** money only: the `<key>_currency` companion answer. */
|
|
43
|
+
currencyValue?: string;
|
|
44
|
+
error?: string;
|
|
45
|
+
onChange: (value: QuestionnaireAnswerValue | undefined) => void;
|
|
46
|
+
onCurrencyChange: (currency: string) => void;
|
|
47
|
+
}): React.ReactElement {
|
|
48
|
+
const { colors } = useTheme();
|
|
49
|
+
|
|
50
|
+
const isPlainInput = field.type === 'text' || field.type === 'number';
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<View style={{ marginBottom: spacing.lg }}>
|
|
54
|
+
<FieldLabel
|
|
55
|
+
text={field.label}
|
|
56
|
+
required={field.required === true}
|
|
57
|
+
helpText={
|
|
58
|
+
field.helpText ??
|
|
59
|
+
(field.type === 'multiselect' ? 'Choose all that apply.' : undefined)
|
|
60
|
+
}
|
|
61
|
+
/>
|
|
62
|
+
|
|
63
|
+
{isPlainInput ? (
|
|
64
|
+
<MyazaInput
|
|
65
|
+
value={value === undefined ? '' : String(value)}
|
|
66
|
+
onChangeText={(text) => onChange(text === '' ? undefined : text)}
|
|
67
|
+
placeholder={field.placeholder}
|
|
68
|
+
error={error ?? null}
|
|
69
|
+
keyboardType={field.type === 'number' ? 'numeric' : 'default'}
|
|
70
|
+
autoCapitalize="none"
|
|
71
|
+
/>
|
|
72
|
+
) : null}
|
|
73
|
+
|
|
74
|
+
{field.type === 'date' ? (
|
|
75
|
+
// A real calendar, not a "type YYYY-MM-DD" text box — same picker
|
|
76
|
+
// contract as the Flutter SDK's _DateField / showMyazaDatePicker.
|
|
77
|
+
<MyazaDateField
|
|
78
|
+
value={typeof value === 'string' ? value : undefined}
|
|
79
|
+
onChange={(iso) => onChange(iso)}
|
|
80
|
+
placeholder={field.placeholder}
|
|
81
|
+
error={error ?? null}
|
|
82
|
+
/>
|
|
83
|
+
) : null}
|
|
84
|
+
|
|
85
|
+
{field.type === 'money' ? (
|
|
86
|
+
<QuestionnaireMoneyField
|
|
87
|
+
field={field}
|
|
88
|
+
value={value}
|
|
89
|
+
currencyValue={currencyValue}
|
|
90
|
+
error={error}
|
|
91
|
+
onChange={onChange}
|
|
92
|
+
onCurrencyChange={onCurrencyChange}
|
|
93
|
+
/>
|
|
94
|
+
) : null}
|
|
95
|
+
|
|
96
|
+
{field.type === 'boolean' ? (
|
|
97
|
+
<View style={{ flexDirection: 'row', gap: spacing.sm }}>
|
|
98
|
+
{[true, false].map((option) => (
|
|
99
|
+
<View key={String(option)} style={{ flex: 1 }}>
|
|
100
|
+
<BooleanChoice
|
|
101
|
+
label={option ? 'Yes' : 'No'}
|
|
102
|
+
selected={value === option}
|
|
103
|
+
// Re-tapping the chosen answer clears it — the only way to
|
|
104
|
+
// un-answer an optional yes/no.
|
|
105
|
+
onPress={() => onChange(value === option ? undefined : option)}
|
|
106
|
+
/>
|
|
107
|
+
</View>
|
|
108
|
+
))}
|
|
109
|
+
</View>
|
|
110
|
+
) : null}
|
|
111
|
+
|
|
112
|
+
{field.type === 'select' ? (
|
|
113
|
+
<MyazaSelect<string>
|
|
114
|
+
value={typeof value === 'string' ? value : null}
|
|
115
|
+
hint={field.placeholder ?? 'Select an option'}
|
|
116
|
+
sheetTitle={field.label}
|
|
117
|
+
options={(field.options ?? []).map((option) => ({
|
|
118
|
+
value: option.value,
|
|
119
|
+
label: option.label,
|
|
120
|
+
}))}
|
|
121
|
+
onChange={(next) => onChange(next)}
|
|
122
|
+
/>
|
|
123
|
+
) : null}
|
|
124
|
+
|
|
125
|
+
{field.type === 'multiselect'
|
|
126
|
+
? (field.options ?? []).map((option) => {
|
|
127
|
+
const selected = Array.isArray(value) && value.includes(option.value);
|
|
128
|
+
return (
|
|
129
|
+
<OptionRow
|
|
130
|
+
key={option.value}
|
|
131
|
+
label={option.label}
|
|
132
|
+
selected={selected}
|
|
133
|
+
multi
|
|
134
|
+
onPress={() => {
|
|
135
|
+
const current = Array.isArray(value) ? value : [];
|
|
136
|
+
const next = selected
|
|
137
|
+
? current.filter((v) => v !== option.value)
|
|
138
|
+
: [...current, option.value];
|
|
139
|
+
onChange(next.length > 0 ? next : undefined);
|
|
140
|
+
}}
|
|
141
|
+
/>
|
|
142
|
+
);
|
|
143
|
+
})
|
|
144
|
+
: null}
|
|
145
|
+
|
|
146
|
+
{/* Inputs draw their own error; the rest need one underneath. */}
|
|
147
|
+
{error && !isPlainInput && field.type !== 'money' ? (
|
|
148
|
+
<MyazaText variant="bodySmall" color={colors.error} style={{ marginTop: spacing.xs }}>
|
|
149
|
+
{error}
|
|
150
|
+
</MyazaText>
|
|
151
|
+
) : null}
|
|
152
|
+
</View>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** A centred Yes / No card — no radio mark; the label IS the choice. */
|
|
157
|
+
function BooleanChoice({
|
|
158
|
+
label,
|
|
159
|
+
selected,
|
|
160
|
+
onPress,
|
|
161
|
+
}: {
|
|
162
|
+
label: string;
|
|
163
|
+
selected: boolean;
|
|
164
|
+
onPress: () => void;
|
|
165
|
+
}): React.ReactElement {
|
|
166
|
+
const { colors } = useTheme();
|
|
167
|
+
return (
|
|
168
|
+
<Pressable
|
|
169
|
+
onPress={onPress}
|
|
170
|
+
accessibilityRole="radio"
|
|
171
|
+
accessibilityState={{ checked: selected }}
|
|
172
|
+
accessibilityLabel={label}
|
|
173
|
+
style={{
|
|
174
|
+
height: 48,
|
|
175
|
+
alignItems: 'center',
|
|
176
|
+
justifyContent: 'center',
|
|
177
|
+
borderRadius: radius.md,
|
|
178
|
+
borderWidth: selected ? 1.5 : 1,
|
|
179
|
+
borderColor: selected ? colors.primary : colors.border,
|
|
180
|
+
backgroundColor: selected ? colors.primary50 : 'transparent',
|
|
181
|
+
}}
|
|
182
|
+
>
|
|
183
|
+
<MyazaText
|
|
184
|
+
variant="body"
|
|
185
|
+
color={selected ? colors.primary : undefined}
|
|
186
|
+
style={{ fontWeight: '600' }}
|
|
187
|
+
>
|
|
188
|
+
{label}
|
|
189
|
+
</MyazaText>
|
|
190
|
+
</Pressable>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function FieldLabel({
|
|
195
|
+
text,
|
|
196
|
+
required,
|
|
197
|
+
helpText,
|
|
198
|
+
}: {
|
|
199
|
+
text: string;
|
|
200
|
+
required: boolean;
|
|
201
|
+
helpText?: string;
|
|
202
|
+
}): React.ReactElement {
|
|
203
|
+
const { colors } = useTheme();
|
|
204
|
+
return (
|
|
205
|
+
<View style={{ marginBottom: spacing.xs }}>
|
|
206
|
+
<MyazaText variant="body" style={{ fontWeight: '600' }}>
|
|
207
|
+
{text}
|
|
208
|
+
{required ? <MyazaText color={colors.error}> *</MyazaText> : null}
|
|
209
|
+
</MyazaText>
|
|
210
|
+
{helpText ? (
|
|
211
|
+
<MyazaText variant="bodySmall" color={colors.textSecondary}>
|
|
212
|
+
{helpText}
|
|
213
|
+
</MyazaText>
|
|
214
|
+
) : null}
|
|
215
|
+
</View>
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export { currencyKeyFor };
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { radius, spacing } from '../config/theme';
|
|
5
|
+
import { useTheme } from '../components/runtime';
|
|
6
|
+
import { MyazaText } from '../components/Typography';
|
|
7
|
+
import { MyazaInput } from '../components/MyazaInput';
|
|
8
|
+
import { MyazaSelect } from '../components/MyazaSelect';
|
|
9
|
+
import { CountryFlag } from '../components/CountryFlag';
|
|
10
|
+
import { currencyFlagCountry, currencyName } from '../config/currencyFlags';
|
|
11
|
+
import { formatGroupedAmount, parseGroupedAmount } from '../utils/amountFormat';
|
|
12
|
+
import type { QuestionnaireAnswerValue, QuestionnaireField as FieldDef } from '../types/workflow';
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// A money question: an amount plus the currency it is in.
|
|
16
|
+
//
|
|
17
|
+
// Two answer keys, not one — `<key>` and `<key>_currency`. Keeping them as
|
|
18
|
+
// separate scalars is what lets a decision graph compare the amount with `gt`
|
|
19
|
+
// and `lt`; a `{amount, currency}` object would not compare at all.
|
|
20
|
+
//
|
|
21
|
+
// Laid out as ONE row — amount, then the currency beside it — mirroring the
|
|
22
|
+
// Flutter SDK. Currencies stacked below the field read as a second question
|
|
23
|
+
// rather than a unit attached to the number above them.
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
/** Matches MyazaInput's single-line height so the two controls line up. */
|
|
27
|
+
const CONTROL_HEIGHT = 48;
|
|
28
|
+
|
|
29
|
+
/** Flag for a currency code — blank for supranational codes (XOF, XAU…). */
|
|
30
|
+
function CurrencyFlag({ code }: { code: string }): React.ReactElement | null {
|
|
31
|
+
const country = currencyFlagCountry(code);
|
|
32
|
+
if (!country) return null;
|
|
33
|
+
return <CountryFlag country={country} size={18} />;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function MoneyField({
|
|
37
|
+
field,
|
|
38
|
+
value,
|
|
39
|
+
currencyValue,
|
|
40
|
+
error,
|
|
41
|
+
onChange,
|
|
42
|
+
onCurrencyChange,
|
|
43
|
+
}: {
|
|
44
|
+
field: FieldDef;
|
|
45
|
+
value: QuestionnaireAnswerValue | undefined;
|
|
46
|
+
currencyValue?: string;
|
|
47
|
+
error?: string;
|
|
48
|
+
onChange: (value: QuestionnaireAnswerValue | undefined) => void;
|
|
49
|
+
onCurrencyChange: (currency: string) => void;
|
|
50
|
+
}): React.ReactElement {
|
|
51
|
+
const { colors } = useTheme();
|
|
52
|
+
const currencies = field.currencies ?? [];
|
|
53
|
+
// The showing currency is the answer when the user never opens the picker,
|
|
54
|
+
// which is why the payload defaults to the definition's first.
|
|
55
|
+
const currency = currencyValue ?? currencies[0];
|
|
56
|
+
|
|
57
|
+
// The field shows a GROUPED string while the answer stays a number. Seeded
|
|
58
|
+
// once from any previously entered answer (returning to the step via back),
|
|
59
|
+
// then owned by the input — same as Flutter's controller.
|
|
60
|
+
const [display, setDisplay] = useState(() =>
|
|
61
|
+
value === undefined ? '' : formatGroupedAmount(String(value)),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const onAmountText = (text: string): void => {
|
|
65
|
+
const grouped = formatGroupedAmount(text);
|
|
66
|
+
setDisplay(grouped);
|
|
67
|
+
const amount = parseGroupedAmount(grouped);
|
|
68
|
+
onChange(amount === null ? undefined : amount);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
// Currency FIRST, then the amount — it reads as a unit prefix
|
|
73
|
+
// ("NGN 250,000"), the way a currency is actually written.
|
|
74
|
+
// TOP-aligned, not centered: the amount column grows taller when its
|
|
75
|
+
// validation error renders under it, and centering would drag the
|
|
76
|
+
// currency select down out of line with the input box. Both controls are
|
|
77
|
+
// CONTROL_HEIGHT tall, so top alignment keeps them level always.
|
|
78
|
+
<View style={{ flexDirection: 'row', alignItems: 'flex-start' }}>
|
|
79
|
+
{currencies.length > 1 ? (
|
|
80
|
+
<>
|
|
81
|
+
{/* Height is pinned so the select matches MyazaInput's single-line
|
|
82
|
+
box exactly; without it the select renders short and the two
|
|
83
|
+
controls sit at different heights. */}
|
|
84
|
+
<View style={{ height: CONTROL_HEIGHT }}>
|
|
85
|
+
<MyazaSelect<string>
|
|
86
|
+
value={currency}
|
|
87
|
+
sheetTitle="Currency"
|
|
88
|
+
height={CONTROL_HEIGHT}
|
|
89
|
+
compact
|
|
90
|
+
options={currencies.map((code) => ({
|
|
91
|
+
value: code,
|
|
92
|
+
label: code,
|
|
93
|
+
description: currencyName(code) ?? undefined,
|
|
94
|
+
leading: <CurrencyFlag code={code} />,
|
|
95
|
+
}))}
|
|
96
|
+
onChange={onCurrencyChange}
|
|
97
|
+
/>
|
|
98
|
+
</View>
|
|
99
|
+
<View style={{ width: spacing.sm }} />
|
|
100
|
+
</>
|
|
101
|
+
) : currency ? (
|
|
102
|
+
<>
|
|
103
|
+
<View
|
|
104
|
+
style={{
|
|
105
|
+
height: CONTROL_HEIGHT,
|
|
106
|
+
flexDirection: 'row',
|
|
107
|
+
alignItems: 'center',
|
|
108
|
+
paddingHorizontal: spacing.md,
|
|
109
|
+
borderRadius: radius.sm,
|
|
110
|
+
backgroundColor: colors.primary50,
|
|
111
|
+
}}
|
|
112
|
+
>
|
|
113
|
+
<CurrencyFlag code={currency} />
|
|
114
|
+
<View style={{ width: spacing.xs }} />
|
|
115
|
+
<MyazaText variant="body" style={{ fontWeight: '600' }}>
|
|
116
|
+
{currency}
|
|
117
|
+
</MyazaText>
|
|
118
|
+
</View>
|
|
119
|
+
<View style={{ width: spacing.sm }} />
|
|
120
|
+
</>
|
|
121
|
+
) : null}
|
|
122
|
+
|
|
123
|
+
<View style={{ flex: 1 }}>
|
|
124
|
+
<MyazaInput
|
|
125
|
+
value={display}
|
|
126
|
+
onChangeText={onAmountText}
|
|
127
|
+
placeholder={field.placeholder ?? '0.00'}
|
|
128
|
+
error={error ?? null}
|
|
129
|
+
keyboardType="decimal-pad"
|
|
130
|
+
/>
|
|
131
|
+
</View>
|
|
132
|
+
</View>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { spacing } from '../config/theme';
|
|
5
|
+
import { useKyc, useKycConfig, useKycStore } from '../components/runtime';
|
|
6
|
+
import { MyazaButton } from '../components/MyazaButton';
|
|
7
|
+
import { currencyKeyFor, validateQuestionnaire } from '../config/questionnaire';
|
|
8
|
+
import { QuestionnaireFieldView } from './QuestionnaireField';
|
|
9
|
+
import type { QuestionnaireAnswerValue } from '../types/workflow';
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Extra-info questionnaire — compliance declarations asked after capture and
|
|
13
|
+
// just before submission.
|
|
14
|
+
//
|
|
15
|
+
// Questions come from the workflow; answers ride the /verify submission and are
|
|
16
|
+
// re-validated server-side against the PUBLISHED definition, so what happens
|
|
17
|
+
// here is purely about telling the user before they spend a round trip.
|
|
18
|
+
//
|
|
19
|
+
// The step's title/description live in the sheet header (KycFlow reads them),
|
|
20
|
+
// matching every other step.
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
/** Header title/description for the questionnaire step. Used by KycFlow. */
|
|
24
|
+
export function questionnaireMeta(
|
|
25
|
+
title: string | undefined,
|
|
26
|
+
description: string | undefined,
|
|
27
|
+
): { title: string; description: string } {
|
|
28
|
+
return {
|
|
29
|
+
title: title ?? 'A few more questions',
|
|
30
|
+
description:
|
|
31
|
+
description ??
|
|
32
|
+
'This information is required for compliance and helps keep your account safe.',
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function QuestionnaireStep(): React.ReactElement {
|
|
37
|
+
const config = useKycConfig();
|
|
38
|
+
const store = useKycStore();
|
|
39
|
+
const answers = useKyc((s) => s.questionnaireAnswers);
|
|
40
|
+
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
41
|
+
|
|
42
|
+
const fields = config.questionnaire?.fields ?? [];
|
|
43
|
+
|
|
44
|
+
const setAnswer = (key: string, value: QuestionnaireAnswerValue | undefined): void => {
|
|
45
|
+
store.getState().setQuestionnaireAnswer(key, value);
|
|
46
|
+
// Clear this field's error as soon as it is touched — leaving it up while
|
|
47
|
+
// the user is fixing it reads as though the fix didn't take.
|
|
48
|
+
setErrors((prev) => (prev[key] ? { ...prev, [key]: '' } : prev));
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const handleContinue = (): void => {
|
|
52
|
+
const nextErrors = validateQuestionnaire(fields, answers);
|
|
53
|
+
setErrors(nextErrors);
|
|
54
|
+
if (Object.keys(nextErrors).length > 0) return;
|
|
55
|
+
store.getState().nextStep();
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<View>
|
|
60
|
+
{fields.map((field) => (
|
|
61
|
+
<QuestionnaireFieldView
|
|
62
|
+
key={field.key}
|
|
63
|
+
field={field}
|
|
64
|
+
value={answers[field.key]}
|
|
65
|
+
currencyValue={answers[currencyKeyFor(field)] as string | undefined}
|
|
66
|
+
error={errors[field.key] || undefined}
|
|
67
|
+
onChange={(value) => setAnswer(field.key, value)}
|
|
68
|
+
onCurrencyChange={(currency) => setAnswer(currencyKeyFor(field), currency)}
|
|
69
|
+
/>
|
|
70
|
+
))}
|
|
71
|
+
|
|
72
|
+
<View style={{ height: spacing.sm }} />
|
|
73
|
+
<MyazaButton label="Continue" onPress={handleContinue} />
|
|
74
|
+
</View>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -11,6 +11,7 @@ import { MyazaAlert } from '../components/MyazaAlert';
|
|
|
11
11
|
import { MyazaPulseLoader } from '../components/MyazaPulseLoader';
|
|
12
12
|
import { Icon } from '../components/Icon';
|
|
13
13
|
import { fillTokens } from '../utils/tokens';
|
|
14
|
+
import { KeyPeopleInviteLinks } from './KeyPeopleInviteLinks';
|
|
14
15
|
|
|
15
16
|
// Terminal screen — 1:1 with the Flutter SDK's SubmittedScreen. Calls
|
|
16
17
|
// submitAsync on mount; renders submitting / success / error views with the same
|
|
@@ -135,6 +136,9 @@ export function SubmittedStep({ onClose }: { onClose: () => void }): React.React
|
|
|
135
136
|
const description = config.success?.description
|
|
136
137
|
? fillTokens(config.success.description, config.userData)
|
|
137
138
|
: DEFAULT_SUCCESS_DESCRIPTION;
|
|
139
|
+
// KYB: per-person verification links for full-KYC key people — rendered so
|
|
140
|
+
// the applicant can send each one immediately.
|
|
141
|
+
const invites = store.getState().keyPeopleInvites; // set before phase flips to success
|
|
138
142
|
|
|
139
143
|
return (
|
|
140
144
|
<View style={{ flex: 1, justifyContent: 'space-between' }}>
|
|
@@ -151,6 +155,7 @@ export function SubmittedStep({ onClose }: { onClose: () => void }): React.React
|
|
|
151
155
|
<MyazaText variant="bodyMedium" style={{ textAlign: 'center' }}>
|
|
152
156
|
{description}
|
|
153
157
|
</MyazaText>
|
|
158
|
+
<KeyPeopleInviteLinks invites={invites} />
|
|
154
159
|
</View>
|
|
155
160
|
<MyazaButton label="Done" onPress={onClose} />
|
|
156
161
|
</View>
|