@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,130 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Pressable, 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 { ContactCodeEntry } from '../components/ContactCodeEntry';
|
|
8
|
+
import { ExpiryCountdown } from '../components/ExpiryCountdown';
|
|
9
|
+
import { CHANNEL_LABELS, type PhoneOtpChannel } from '../config/contact';
|
|
10
|
+
import type { OtpInputStyle } from '../types/workflow';
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// The second half of a contact check: the code has been sent, now enter it.
|
|
14
|
+
//
|
|
15
|
+
// Split from ContactVerificationStep (200-line rule). Auto-submits on the last
|
|
16
|
+
// digit — the user has already typed the whole code by then, and making them
|
|
17
|
+
// press a button as well is a step for nothing.
|
|
18
|
+
//
|
|
19
|
+
// Layout mirrors the web SDK: the code field, then a single row carrying the
|
|
20
|
+
// live expiry on the left and Resend on the right, then the switch-channel
|
|
21
|
+
// escape hatch. A code that never arrives is usually a channel problem rather
|
|
22
|
+
// than a typo, so resending down the same channel is rarely the fix.
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
export function ContactCodeStep({
|
|
26
|
+
codeLength,
|
|
27
|
+
inputStyle,
|
|
28
|
+
code,
|
|
29
|
+
busy,
|
|
30
|
+
error,
|
|
31
|
+
attemptsLeft,
|
|
32
|
+
expiresAt,
|
|
33
|
+
otherChannel,
|
|
34
|
+
onChange,
|
|
35
|
+
onCheck,
|
|
36
|
+
onResend,
|
|
37
|
+
}: {
|
|
38
|
+
codeLength: number;
|
|
39
|
+
inputStyle: OtpInputStyle;
|
|
40
|
+
code: string;
|
|
41
|
+
busy: boolean;
|
|
42
|
+
error: string | null;
|
|
43
|
+
attemptsLeft?: number;
|
|
44
|
+
/** ISO expiry from the send response; null falls back to static copy. */
|
|
45
|
+
expiresAt: string | null;
|
|
46
|
+
/** The channel NOT in use, when the workflow offers a second one. */
|
|
47
|
+
otherChannel: PhoneOtpChannel | null;
|
|
48
|
+
onChange: (code: string) => void;
|
|
49
|
+
onCheck: (code: string) => void;
|
|
50
|
+
/** Resends; with a channel, switches to it first. */
|
|
51
|
+
onResend: (switchTo?: PhoneOtpChannel) => void;
|
|
52
|
+
}): React.ReactElement {
|
|
53
|
+
const { colors } = useTheme();
|
|
54
|
+
// No "enter the code we sent to …" line here: the sheet header carries it
|
|
55
|
+
// (see contactMeta), matching the web SDK. Two copies drifted apart.
|
|
56
|
+
return (
|
|
57
|
+
<View>
|
|
58
|
+
<ContactCodeEntry
|
|
59
|
+
code={code}
|
|
60
|
+
onChange={onChange}
|
|
61
|
+
codeLength={codeLength}
|
|
62
|
+
style={inputStyle}
|
|
63
|
+
disabled={busy}
|
|
64
|
+
onComplete={onCheck}
|
|
65
|
+
/>
|
|
66
|
+
|
|
67
|
+
<View style={{ height: spacing.sm }} />
|
|
68
|
+
<View
|
|
69
|
+
style={{
|
|
70
|
+
flexDirection: 'row',
|
|
71
|
+
alignItems: 'center',
|
|
72
|
+
justifyContent: 'space-between',
|
|
73
|
+
}}
|
|
74
|
+
>
|
|
75
|
+
{expiresAt ? (
|
|
76
|
+
<ExpiryCountdown expiresAt={expiresAt} />
|
|
77
|
+
) : (
|
|
78
|
+
<MyazaText variant="bodySmall" color={colors.textMuted}>
|
|
79
|
+
The code expires in 5 minutes.
|
|
80
|
+
</MyazaText>
|
|
81
|
+
)}
|
|
82
|
+
<Pressable
|
|
83
|
+
onPress={() => onResend()}
|
|
84
|
+
disabled={busy}
|
|
85
|
+
accessibilityRole="button"
|
|
86
|
+
hitSlop={8}
|
|
87
|
+
>
|
|
88
|
+
<MyazaText
|
|
89
|
+
variant="bodySmall"
|
|
90
|
+
color={busy ? colors.textMuted : colors.primary}
|
|
91
|
+
style={{ fontWeight: '600' }}
|
|
92
|
+
>
|
|
93
|
+
Resend code
|
|
94
|
+
</MyazaText>
|
|
95
|
+
</Pressable>
|
|
96
|
+
</View>
|
|
97
|
+
|
|
98
|
+
{otherChannel ? (
|
|
99
|
+
<>
|
|
100
|
+
<View style={{ height: spacing.sm }} />
|
|
101
|
+
<Pressable
|
|
102
|
+
onPress={() => onResend(otherChannel)}
|
|
103
|
+
disabled={busy}
|
|
104
|
+
accessibilityRole="button"
|
|
105
|
+
hitSlop={8}
|
|
106
|
+
>
|
|
107
|
+
<MyazaText
|
|
108
|
+
variant="bodySmall"
|
|
109
|
+
color={colors.textMuted}
|
|
110
|
+
style={{ textAlign: 'center' }}
|
|
111
|
+
>
|
|
112
|
+
{`Didn't get it? Send by ${CHANNEL_LABELS[otherChannel]} instead`}
|
|
113
|
+
</MyazaText>
|
|
114
|
+
</Pressable>
|
|
115
|
+
</>
|
|
116
|
+
) : null}
|
|
117
|
+
|
|
118
|
+
{error ? (
|
|
119
|
+
<MyazaText variant="bodySmall" color={colors.error} style={{ marginTop: spacing.sm }}>
|
|
120
|
+
{/* The remaining count is what stops someone burning the whole
|
|
121
|
+
budget guessing — without it there is no signal the code is
|
|
122
|
+
about to die. */}
|
|
123
|
+
{attemptsLeft !== undefined
|
|
124
|
+
? `${error} ${attemptsLeft} ${attemptsLeft === 1 ? 'attempt' : 'attempts'} left.`
|
|
125
|
+
: error}
|
|
126
|
+
</MyazaText>
|
|
127
|
+
) : null}
|
|
128
|
+
</View>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
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 { MyazaInput } from '../components/MyazaInput';
|
|
8
|
+
import { PhoneNumberInput } from '../components/PhoneNumberInput';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* "Where should we send it": an email field, or the dial-code phone input that
|
|
12
|
+
* formats nationally and emits E.164. Mirrors the web SDK's component of the
|
|
13
|
+
* same name and Flutter's ContactDestinationField.
|
|
14
|
+
*/
|
|
15
|
+
export function ContactDestinationField({
|
|
16
|
+
isEmail,
|
|
17
|
+
email,
|
|
18
|
+
onEmailChange,
|
|
19
|
+
onPhoneChange,
|
|
20
|
+
defaultCountry,
|
|
21
|
+
error,
|
|
22
|
+
disabled,
|
|
23
|
+
}: {
|
|
24
|
+
isEmail: boolean;
|
|
25
|
+
email: string;
|
|
26
|
+
onEmailChange: (value: string) => void;
|
|
27
|
+
onPhoneChange: (value: { e164: string; isValid: boolean }) => void;
|
|
28
|
+
defaultCountry?: string;
|
|
29
|
+
error: string | null;
|
|
30
|
+
disabled?: boolean;
|
|
31
|
+
}): React.ReactElement {
|
|
32
|
+
if (isEmail) {
|
|
33
|
+
return (
|
|
34
|
+
<MyazaInput
|
|
35
|
+
label="Email address"
|
|
36
|
+
value={email}
|
|
37
|
+
onChangeText={onEmailChange}
|
|
38
|
+
placeholder="you@example.com"
|
|
39
|
+
keyboardType="email-address"
|
|
40
|
+
autoCapitalize="none"
|
|
41
|
+
error={error}
|
|
42
|
+
editable={!disabled}
|
|
43
|
+
autoFocus
|
|
44
|
+
/>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<View>
|
|
50
|
+
<MyazaText variant="label" style={{ marginBottom: spacing.xs }}>
|
|
51
|
+
Phone number
|
|
52
|
+
</MyazaText>
|
|
53
|
+
<PhoneNumberInput
|
|
54
|
+
defaultCountry={defaultCountry}
|
|
55
|
+
disabled={disabled}
|
|
56
|
+
onChange={onPhoneChange}
|
|
57
|
+
/>
|
|
58
|
+
{error ? <ErrorLine text={error} /> : null}
|
|
59
|
+
</View>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function ErrorLine({ text }: { text: string }): React.ReactElement {
|
|
64
|
+
const { colors } = useTheme();
|
|
65
|
+
return (
|
|
66
|
+
<MyazaText variant="bodySmall" color={colors.error} style={{ marginTop: spacing.xs }}>
|
|
67
|
+
{text}
|
|
68
|
+
</MyazaText>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
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 { Icon } from '../components/Icon';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The reassurance line under the contact step's actions, matching the web SDK.
|
|
11
|
+
*
|
|
12
|
+
* Small, but it answers the question the step provokes: an email step is asked
|
|
13
|
+
* "what will you do with my address", a phone step "will this cost me money".
|
|
14
|
+
*/
|
|
15
|
+
export function ContactFooterNote({ isEmail }: { isEmail: boolean }): React.ReactElement {
|
|
16
|
+
const { colors } = useTheme();
|
|
17
|
+
return (
|
|
18
|
+
<View
|
|
19
|
+
style={{
|
|
20
|
+
flexDirection: 'row',
|
|
21
|
+
alignItems: 'center',
|
|
22
|
+
justifyContent: 'center',
|
|
23
|
+
gap: spacing.xs,
|
|
24
|
+
}}
|
|
25
|
+
>
|
|
26
|
+
<Icon name={isEmail ? 'mail' : 'smartphone'} size={12} color={colors.textMuted} />
|
|
27
|
+
<MyazaText variant="bodySmall" color={colors.textMuted}>
|
|
28
|
+
{isEmail
|
|
29
|
+
? 'We only use this to verify your identity.'
|
|
30
|
+
: 'Standard message rates may apply.'}
|
|
31
|
+
</MyazaText>
|
|
32
|
+
</View>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { spacing } from '../config/theme';
|
|
5
|
+
import { useEffectiveCountry, useKyc, useKycConfig, useKycStore } from '../components/runtime';
|
|
6
|
+
import { MyazaButton } from '../components/MyazaButton';
|
|
7
|
+
import { ContactDestinationField } from './ContactDestinationField';
|
|
8
|
+
import { ContactVerifiedPanel } from './ContactVerifiedPanel';
|
|
9
|
+
import { ContactCodeStep } from './ContactCodeStep';
|
|
10
|
+
import { ContactChannelChoice } from './ContactChannelChoice';
|
|
11
|
+
import { ContactFooterNote } from './ContactFooterNote';
|
|
12
|
+
import {
|
|
13
|
+
contactCodeLength,
|
|
14
|
+
contactIsRequired,
|
|
15
|
+
isValidContactEmail,
|
|
16
|
+
offeredPhoneChannels,
|
|
17
|
+
type PhoneOtpChannel,
|
|
18
|
+
} from '../config/contact';
|
|
19
|
+
import { attemptsRemaining, describeCheckError, describeSendError } from '../services/contactErrors';
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Contact-verification OTP step — email or phone, one component, two mounts.
|
|
23
|
+
//
|
|
24
|
+
// enter destination → send → code entry → verified → continue
|
|
25
|
+
//
|
|
26
|
+
// The proof token is single-use and rides the /verify submission. The server
|
|
27
|
+
// enforces whether it was required; the `required: false` skip here is UX, and
|
|
28
|
+
// a skipped optional check simply submits without the proof.
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
export function ContactVerificationStep({
|
|
32
|
+
channel,
|
|
33
|
+
}: {
|
|
34
|
+
channel: 'email' | 'phone';
|
|
35
|
+
}): React.ReactElement {
|
|
36
|
+
const config = useKycConfig();
|
|
37
|
+
const country = useEffectiveCountry();
|
|
38
|
+
const store = useKycStore();
|
|
39
|
+
const contact = useKyc((s) => s.contact);
|
|
40
|
+
|
|
41
|
+
const isEmail = channel === 'email';
|
|
42
|
+
const stepConfig = isEmail ? config.emailVerification : config.phoneVerification;
|
|
43
|
+
const required = contactIsRequired(stepConfig);
|
|
44
|
+
const codeLength = contactCodeLength(stepConfig);
|
|
45
|
+
const inputStyle = stepConfig?.inputStyle ?? 'segmented';
|
|
46
|
+
// The org decides which channels are on offer; the user picks between them.
|
|
47
|
+
const offeredChannels: PhoneOtpChannel[] = isEmail
|
|
48
|
+
? []
|
|
49
|
+
: offeredPhoneChannels(config.phoneVerification?.channels);
|
|
50
|
+
const [via, setVia] = useState<PhoneOtpChannel>(offeredChannels[0] ?? 'sms');
|
|
51
|
+
|
|
52
|
+
const alreadyVerified = isEmail ? contact.emailToken != null : contact.phoneToken != null;
|
|
53
|
+
|
|
54
|
+
const [destination, setDestination] = useState(contact.emailAddress ?? '');
|
|
55
|
+
// The phone field emits E.164 + validity from libphonenumber, so the step no
|
|
56
|
+
// longer guesses either from raw text.
|
|
57
|
+
const [phone, setPhone] = useState<{ e164: string; isValid: boolean }>({
|
|
58
|
+
e164: contact.phoneNumber ?? '',
|
|
59
|
+
isValid: false,
|
|
60
|
+
});
|
|
61
|
+
const [challengeId, setChallengeId] = useState<string | null>(null);
|
|
62
|
+
const [expiresAt, setExpiresAt] = useState<string | null>(null);
|
|
63
|
+
const [code, setCode] = useState('');
|
|
64
|
+
const [busy, setBusy] = useState(false);
|
|
65
|
+
const [error, setError] = useState<string | null>(null);
|
|
66
|
+
const [attemptsLeft, setAttemptsLeft] = useState<number | undefined>(undefined);
|
|
67
|
+
const otherChannel = offeredChannels.find((c) => c !== via) ?? null;
|
|
68
|
+
|
|
69
|
+
const value = isEmail ? destination.trim() : phone.e164;
|
|
70
|
+
const canSend = isEmail ? isValidContactEmail(value) : phone.isValid;
|
|
71
|
+
|
|
72
|
+
const advance = useCallback(() => store.getState().nextStep(), [store]);
|
|
73
|
+
|
|
74
|
+
// The sheet header is rendered by the shell and cannot see this step's state,
|
|
75
|
+
// so publish what it needs to caption: the channel in play now, and the
|
|
76
|
+
// destination once a code is actually out. Re-published when the user picks a
|
|
77
|
+
// different delivery channel so the header's "by WhatsApp" follows the choice.
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
store.getState().setContactChallenge({
|
|
80
|
+
channel,
|
|
81
|
+
...(isEmail ? {} : { via }),
|
|
82
|
+
...(challengeId ? { destination: value } : {}),
|
|
83
|
+
});
|
|
84
|
+
}, [store, channel, isEmail, via, challengeId, value]);
|
|
85
|
+
|
|
86
|
+
/** `switchTo` re-sends over the other channel when the first one didn't land. */
|
|
87
|
+
const send = async (switchTo?: PhoneOtpChannel): Promise<void> => {
|
|
88
|
+
const sendVia = switchTo ?? via;
|
|
89
|
+
if (switchTo) setVia(switchTo);
|
|
90
|
+
setBusy(true);
|
|
91
|
+
setError(null);
|
|
92
|
+
setAttemptsLeft(undefined);
|
|
93
|
+
try {
|
|
94
|
+
const res = await store.getState().api.contactSend({
|
|
95
|
+
channel,
|
|
96
|
+
destination: value,
|
|
97
|
+
codeLength,
|
|
98
|
+
...(stepConfig?.maxAttempts != null ? { maxAttempts: stepConfig.maxAttempts } : {}),
|
|
99
|
+
...(!isEmail && country ? { country } : {}),
|
|
100
|
+
...(isEmail ? {} : { via: sendVia }),
|
|
101
|
+
});
|
|
102
|
+
// Remember what was typed BEFORE the code lands: a user who backs out
|
|
103
|
+
// here and returns should not have to retype it.
|
|
104
|
+
store.getState().setContactDestination(channel, value);
|
|
105
|
+
setChallengeId(res.challengeId);
|
|
106
|
+
setExpiresAt(res.expiresAt ?? null);
|
|
107
|
+
setCode('');
|
|
108
|
+
} catch (err) {
|
|
109
|
+
setError(describeSendError(err));
|
|
110
|
+
} finally {
|
|
111
|
+
setBusy(false);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const check = async (submitted: string): Promise<void> => {
|
|
116
|
+
if (!challengeId || submitted.length !== codeLength) return;
|
|
117
|
+
setBusy(true);
|
|
118
|
+
setError(null);
|
|
119
|
+
try {
|
|
120
|
+
const res = await store.getState().api.contactCheck({ challengeId, code: submitted });
|
|
121
|
+
store.getState().setContactVerified(channel, value, res.token);
|
|
122
|
+
advance();
|
|
123
|
+
} catch (err) {
|
|
124
|
+
setError(describeCheckError(err));
|
|
125
|
+
setAttemptsLeft(attemptsRemaining(err));
|
|
126
|
+
setCode('');
|
|
127
|
+
} finally {
|
|
128
|
+
setBusy(false);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// Returning to a step already passed — nothing left to do here.
|
|
133
|
+
if (alreadyVerified) {
|
|
134
|
+
return (
|
|
135
|
+
<ContactVerifiedPanel
|
|
136
|
+
isEmail={isEmail}
|
|
137
|
+
destination={isEmail ? contact.emailAddress : contact.phoneNumber}
|
|
138
|
+
onContinue={advance}
|
|
139
|
+
/>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// One frame for both halves, like web: the panel swaps, the actions stay put.
|
|
144
|
+
return (
|
|
145
|
+
<View>
|
|
146
|
+
{challengeId ? (
|
|
147
|
+
<ContactCodeStep
|
|
148
|
+
codeLength={codeLength}
|
|
149
|
+
inputStyle={inputStyle}
|
|
150
|
+
code={code}
|
|
151
|
+
busy={busy}
|
|
152
|
+
error={error}
|
|
153
|
+
attemptsLeft={attemptsLeft}
|
|
154
|
+
expiresAt={expiresAt}
|
|
155
|
+
otherChannel={otherChannel}
|
|
156
|
+
onChange={setCode}
|
|
157
|
+
onCheck={(full) => void check(full)}
|
|
158
|
+
onResend={(switchTo) => void send(switchTo)}
|
|
159
|
+
/>
|
|
160
|
+
) : (
|
|
161
|
+
<>
|
|
162
|
+
<ContactDestinationField
|
|
163
|
+
isEmail={isEmail}
|
|
164
|
+
email={destination}
|
|
165
|
+
onEmailChange={setDestination}
|
|
166
|
+
onPhoneChange={setPhone}
|
|
167
|
+
defaultCountry={config.phoneVerification?.defaultCountry ?? country ?? undefined}
|
|
168
|
+
error={error}
|
|
169
|
+
disabled={busy}
|
|
170
|
+
/>
|
|
171
|
+
<ContactChannelChoice
|
|
172
|
+
offered={offeredChannels}
|
|
173
|
+
picked={via}
|
|
174
|
+
disabled={busy}
|
|
175
|
+
onPick={setVia}
|
|
176
|
+
/>
|
|
177
|
+
</>
|
|
178
|
+
)}
|
|
179
|
+
|
|
180
|
+
<View style={{ height: spacing.md }} />
|
|
181
|
+
<MyazaButton
|
|
182
|
+
label={challengeId ? 'Verify code' : 'Send code'}
|
|
183
|
+
loading={busy}
|
|
184
|
+
disabled={challengeId ? code.length !== codeLength : !canSend}
|
|
185
|
+
onPress={() => (challengeId ? void check(code) : void send())}
|
|
186
|
+
/>
|
|
187
|
+
|
|
188
|
+
{!required ? (
|
|
189
|
+
<>
|
|
190
|
+
<View style={{ height: spacing.sm }} />
|
|
191
|
+
<MyazaButton label="Skip for now" variant="ghost" disabled={busy} onPress={advance} />
|
|
192
|
+
</>
|
|
193
|
+
) : null}
|
|
194
|
+
|
|
195
|
+
<View style={{ height: spacing.md }} />
|
|
196
|
+
<ContactFooterNote isEmail={isEmail} />
|
|
197
|
+
</View>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { spacing } from '../config/theme';
|
|
5
|
+
import { MyazaButton } from '../components/MyazaButton';
|
|
6
|
+
import { VerifiedNotice } from '../components/VerifiedNotice';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Shown when the user returns to a contact step they already passed: the
|
|
10
|
+
* confirmation card plus Continue. Mirrors the web SDK's verified branch and
|
|
11
|
+
* Flutter's ContactVerifiedView.
|
|
12
|
+
*/
|
|
13
|
+
export function ContactVerifiedPanel({
|
|
14
|
+
isEmail,
|
|
15
|
+
destination,
|
|
16
|
+
onContinue,
|
|
17
|
+
}: {
|
|
18
|
+
isEmail: boolean;
|
|
19
|
+
/** The verified address/number, when known — shown verbatim, like web. */
|
|
20
|
+
destination?: string;
|
|
21
|
+
onContinue: () => void;
|
|
22
|
+
}): React.ReactElement {
|
|
23
|
+
return (
|
|
24
|
+
<View>
|
|
25
|
+
<VerifiedNotice
|
|
26
|
+
label={
|
|
27
|
+
destination
|
|
28
|
+
? `${destination} is verified.`
|
|
29
|
+
: `Your ${isEmail ? 'email' : 'phone number'} is verified.`
|
|
30
|
+
}
|
|
31
|
+
/>
|
|
32
|
+
<View style={{ height: spacing.md }} />
|
|
33
|
+
<MyazaButton label="Continue" onPress={onContinue} />
|
|
34
|
+
</View>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import React, { useMemo, useState } from 'react';
|
|
2
|
+
import { Pressable, ScrollView, 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 { MyazaInput } from '../components/MyazaInput';
|
|
8
|
+
import { CountryFlag } from '../components/CountryFlag';
|
|
9
|
+
import { Icon } from '../components/Icon';
|
|
10
|
+
import { groupCountriesByRegion, regionCountryName } from '../config/regions';
|
|
11
|
+
import { countrySelectOptions } from '../store/derive';
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Country selection for multi-region flows (`countries` has more than one).
|
|
15
|
+
//
|
|
16
|
+
// The pick sets the session's EFFECTIVE country, which every later step reads —
|
|
17
|
+
// the ID types on offer, the validators, the endpoints and chip capability all
|
|
18
|
+
// follow from it.
|
|
19
|
+
//
|
|
20
|
+
// Above a handful of countries a flat list stops being a chooser and becomes a
|
|
21
|
+
// scroll, so past SEARCH_THRESHOLD this switches to a searchable, region-grouped
|
|
22
|
+
// picker. Global Documents can offer ~200 countries.
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
/** Above this many offered countries the flat list becomes a searchable,
|
|
26
|
+
* region-grouped picker. Exported because the sheet has to know too: the
|
|
27
|
+
* picker pins its search box and therefore needs a non-scrolling body. */
|
|
28
|
+
export const COUNTRY_SEARCH_THRESHOLD = 5;
|
|
29
|
+
|
|
30
|
+
export const countrySelectMeta = {
|
|
31
|
+
title: 'Where was your ID issued?',
|
|
32
|
+
description: 'Choose the country that issued your identity document.',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function CountrySelectStep(): React.ReactElement {
|
|
36
|
+
const config = useKycConfig();
|
|
37
|
+
const store = useKycStore();
|
|
38
|
+
// Only an actual PICK counts as selected. `useEffectiveCountry` falls back to
|
|
39
|
+
// the workflow's primary country, which on this screen would render a
|
|
40
|
+
// highlighted row for a choice the user has not made yet.
|
|
41
|
+
const selected = useKyc((s) => s.selectedCountry);
|
|
42
|
+
|
|
43
|
+
// Multi-region flows carry `countries`; the KYB applicant leg does not, so
|
|
44
|
+
// it offers the org's GRANTED countries instead — see countrySelectOptions.
|
|
45
|
+
const serverConfig = useKyc((s) => s.serverConfig);
|
|
46
|
+
const options = useMemo(
|
|
47
|
+
() => countrySelectOptions({ config, serverConfig }),
|
|
48
|
+
[config, serverConfig],
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const pick = (country: string): void => {
|
|
52
|
+
store.getState().setCountry(country);
|
|
53
|
+
store.getState().nextStep();
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
if (options.length > COUNTRY_SEARCH_THRESHOLD) {
|
|
57
|
+
return <CountryRegionPicker countries={options} selected={selected} onPick={pick} />;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<View>
|
|
62
|
+
{options.map((country) => (
|
|
63
|
+
<CountryRow
|
|
64
|
+
key={country}
|
|
65
|
+
code={country}
|
|
66
|
+
name={regionCountryName(country)}
|
|
67
|
+
selected={country === selected}
|
|
68
|
+
onPress={() => pick(country)}
|
|
69
|
+
/>
|
|
70
|
+
))}
|
|
71
|
+
</View>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function CountryRegionPicker({
|
|
76
|
+
countries,
|
|
77
|
+
selected,
|
|
78
|
+
onPick,
|
|
79
|
+
}: {
|
|
80
|
+
countries: string[];
|
|
81
|
+
selected: string | null;
|
|
82
|
+
onPick: (country: string) => void;
|
|
83
|
+
}): React.ReactElement {
|
|
84
|
+
const { colors } = useTheme();
|
|
85
|
+
const [query, setQuery] = useState('');
|
|
86
|
+
|
|
87
|
+
const groups = useMemo(() => {
|
|
88
|
+
const needle = query.trim().toLowerCase();
|
|
89
|
+
const filtered = needle
|
|
90
|
+
? countries.filter(
|
|
91
|
+
(code) =>
|
|
92
|
+
code.toLowerCase().includes(needle) ||
|
|
93
|
+
regionCountryName(code).toLowerCase().includes(needle),
|
|
94
|
+
)
|
|
95
|
+
: countries;
|
|
96
|
+
return groupCountriesByRegion(filtered);
|
|
97
|
+
}, [countries, query]);
|
|
98
|
+
|
|
99
|
+
const empty = groups.length === 0;
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
// The search box stays put and the list owns its own scroll, so a
|
|
103
|
+
// ~240-country flow does not scroll the search field off the top. That
|
|
104
|
+
// relies on the SHEET rendering this step with `fillsViewport` — inside a
|
|
105
|
+
// scroll view the nested list below would take an unbounded height, outgrow
|
|
106
|
+
// the viewport, and drag the search box off the top with it.
|
|
107
|
+
<View style={{ flex: 1 }}>
|
|
108
|
+
<MyazaInput
|
|
109
|
+
value={query}
|
|
110
|
+
onChangeText={setQuery}
|
|
111
|
+
placeholder="Search countries…"
|
|
112
|
+
autoCapitalize="none"
|
|
113
|
+
prefix={<Icon name="search" size={18} color={colors.textSecondary} />}
|
|
114
|
+
/>
|
|
115
|
+
<View style={{ height: spacing.md }} />
|
|
116
|
+
|
|
117
|
+
{empty ? (
|
|
118
|
+
<MyazaText variant="bodyMedium" style={{ textAlign: 'center', marginTop: spacing.lg }}>
|
|
119
|
+
No countries match your search.
|
|
120
|
+
</MyazaText>
|
|
121
|
+
) : (
|
|
122
|
+
// `nestedScrollEnabled` stays: Android refuses to scroll a nested
|
|
123
|
+
// same-direction list without it, and this is still nested on the
|
|
124
|
+
// flat-list path and inside any future scrolling parent.
|
|
125
|
+
<ScrollView
|
|
126
|
+
nestedScrollEnabled
|
|
127
|
+
keyboardShouldPersistTaps="handled"
|
|
128
|
+
showsVerticalScrollIndicator={false}
|
|
129
|
+
>
|
|
130
|
+
{groups.map((group) => (
|
|
131
|
+
<View key={group.region}>
|
|
132
|
+
{/* Uppercase, tracked-out section label — Flutter's region
|
|
133
|
+
header. Lowercase text at the default weight read as another
|
|
134
|
+
list row rather than a divider. */}
|
|
135
|
+
<MyazaText
|
|
136
|
+
variant="bodySmall"
|
|
137
|
+
color={colors.textSecondary}
|
|
138
|
+
style={{
|
|
139
|
+
fontWeight: '700',
|
|
140
|
+
letterSpacing: 0.6,
|
|
141
|
+
paddingHorizontal: spacing.xs,
|
|
142
|
+
paddingVertical: spacing.sm,
|
|
143
|
+
}}
|
|
144
|
+
>
|
|
145
|
+
{group.region.toUpperCase()}
|
|
146
|
+
</MyazaText>
|
|
147
|
+
{group.countries.map((entry) => (
|
|
148
|
+
<CountryRow
|
|
149
|
+
key={entry.code}
|
|
150
|
+
code={entry.code}
|
|
151
|
+
name={entry.name}
|
|
152
|
+
selected={entry.code === selected}
|
|
153
|
+
onPress={() => onPick(entry.code)}
|
|
154
|
+
/>
|
|
155
|
+
))}
|
|
156
|
+
</View>
|
|
157
|
+
))}
|
|
158
|
+
</ScrollView>
|
|
159
|
+
)}
|
|
160
|
+
</View>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function CountryRow({
|
|
165
|
+
code,
|
|
166
|
+
name,
|
|
167
|
+
selected,
|
|
168
|
+
onPress,
|
|
169
|
+
}: {
|
|
170
|
+
code: string;
|
|
171
|
+
name: string;
|
|
172
|
+
selected: boolean;
|
|
173
|
+
onPress: () => void;
|
|
174
|
+
}): React.ReactElement {
|
|
175
|
+
const { colors } = useTheme();
|
|
176
|
+
return (
|
|
177
|
+
<Pressable
|
|
178
|
+
onPress={onPress}
|
|
179
|
+
accessibilityRole="radio"
|
|
180
|
+
accessibilityState={{ checked: selected }}
|
|
181
|
+
accessibilityLabel={name}
|
|
182
|
+
// Matches the Flutter SDK's CountryOptionTile exactly — the two had
|
|
183
|
+
// drifted (24px flag / 12px padding here vs 32 / 16 there), which is why
|
|
184
|
+
// the same screen looked like a different component on each platform. The
|
|
185
|
+
// spacing and radius scales are identical across the SDKs, so there is
|
|
186
|
+
// nothing platform-specific to reconcile: `md` means 16 on both.
|
|
187
|
+
style={{
|
|
188
|
+
flexDirection: 'row',
|
|
189
|
+
alignItems: 'center',
|
|
190
|
+
padding: spacing.md,
|
|
191
|
+
borderRadius: radius.md,
|
|
192
|
+
borderWidth: selected ? 1.5 : 1,
|
|
193
|
+
borderColor: selected ? colors.primary : colors.border,
|
|
194
|
+
backgroundColor: selected ? colors.primary50 : colors.background,
|
|
195
|
+
marginBottom: spacing.sm,
|
|
196
|
+
}}
|
|
197
|
+
>
|
|
198
|
+
<CountryFlag country={code} size={32} />
|
|
199
|
+
<View style={{ width: 12 }} />
|
|
200
|
+
<MyazaText variant="label" style={{ flex: 1, fontWeight: '500' }}>
|
|
201
|
+
{name}
|
|
202
|
+
</MyazaText>
|
|
203
|
+
<Icon
|
|
204
|
+
name="chevron-right"
|
|
205
|
+
size={18}
|
|
206
|
+
color={selected ? colors.primary : colors.textSecondary}
|
|
207
|
+
/>
|
|
208
|
+
</Pressable>
|
|
209
|
+
);
|
|
210
|
+
}
|