@myazahq/kyc-sdk-react-native 2.0.2 → 2.1.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 +111 -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 +67 -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 +152 -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 +331 -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 +151 -0
- package/src/screens/BusinessKeyPersonRow.tsx +170 -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/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
package/src/mrz/parse.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// MRZ parsing (ICAO 9303).
|
|
3
|
+
//
|
|
4
|
+
// The Machine Readable Zone off a document's photo page: TD3 (passports, 2×44)
|
|
5
|
+
// and TD1 (ID cards, 3×30), validated with the 7-3-1 check digits.
|
|
6
|
+
//
|
|
7
|
+
// Unlike the server — which reads EXACT bytes off the chip's DG1 — this parses
|
|
8
|
+
// OCR output, which misreads characters. Two defences:
|
|
9
|
+
//
|
|
10
|
+
// 1. Numeric-only fields (dates, check digits) get an OCR-confusion pass
|
|
11
|
+
// (O→0, I→1, S→5, …) before validation. It is applied ONLY where the spec
|
|
12
|
+
// says the field is numeric — running it over a name or a document number
|
|
13
|
+
// would corrupt letters that are genuinely there.
|
|
14
|
+
// 2. Nothing is accepted unless EVERY check digit passes. That makes a bad
|
|
15
|
+
// frame self-rejecting: the scanner simply keeps looking, and a misread
|
|
16
|
+
// never becomes a wrong document number.
|
|
17
|
+
//
|
|
18
|
+
// The second point is what makes this safe to trust. A wrong document number
|
|
19
|
+
// would be used as a BAC key (the chip refuses, harmlessly) or, worse, sent to
|
|
20
|
+
// a paid government lookup that would return a confident "not found".
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
const WEIGHTS = [7, 3, 1];
|
|
24
|
+
|
|
25
|
+
/** 7-3-1 weighted modulus-10 check digit over an MRZ field. */
|
|
26
|
+
export function mrzCheckDigit(field: string): number {
|
|
27
|
+
let sum = 0;
|
|
28
|
+
for (let i = 0; i < field.length; i++) {
|
|
29
|
+
const c = field.charCodeAt(i);
|
|
30
|
+
// '0'–'9' → 0–9, 'A'–'Z' → 10–35, '<' (and anything else) → 0.
|
|
31
|
+
const v = c >= 48 && c <= 57 ? c - 48 : c >= 65 && c <= 90 ? c - 55 : 0;
|
|
32
|
+
sum += v * WEIGHTS[i % 3]!;
|
|
33
|
+
}
|
|
34
|
+
return sum % 10;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Letters OCR commonly returns in place of digits.
|
|
39
|
+
*
|
|
40
|
+
* Only ever applied to fields the spec defines as numeric. Applying it to a
|
|
41
|
+
* name or document number would turn a real 'O' into a '0'.
|
|
42
|
+
*/
|
|
43
|
+
function toDigits(s: string): string {
|
|
44
|
+
return s.replace(/[OQDU]/g, '0').replace(/[IL]/g, '1').replace(/Z/g, '2')
|
|
45
|
+
.replace(/S/g, '5').replace(/G/g, '6').replace(/T/g, '7').replace(/B/g, '8');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function digitOk(field: string, checkDigit: string): boolean {
|
|
49
|
+
const fixed = toDigits(checkDigit);
|
|
50
|
+
if (fixed.length !== 1) return false;
|
|
51
|
+
const code = fixed.charCodeAt(0);
|
|
52
|
+
if (code < 48 || code > 57) return false;
|
|
53
|
+
return mrzCheckDigit(field) === code - 48;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A YYMMDD field as a date.
|
|
58
|
+
*
|
|
59
|
+
* The century is inferred, because the MRZ does not carry it. Birth dates pivot
|
|
60
|
+
* on the current year (a two-digit year later than today must be last century);
|
|
61
|
+
* expiry pivots at 70, since eMRTD validity is at most ten years, so anything
|
|
62
|
+
* that high cannot be a future date.
|
|
63
|
+
*/
|
|
64
|
+
function mrzDate(raw: string, opts: { expiry: boolean; now: Date }): Date | null {
|
|
65
|
+
const yymmdd = toDigits(raw);
|
|
66
|
+
if (yymmdd.length !== 6 || !/^\d{6}$/.test(yymmdd)) return null;
|
|
67
|
+
const yy = Number(yymmdd.slice(0, 2));
|
|
68
|
+
const mm = Number(yymmdd.slice(2, 4));
|
|
69
|
+
const dd = Number(yymmdd.slice(4, 6));
|
|
70
|
+
if (mm < 1 || mm > 12 || dd < 1 || dd > 31) return null;
|
|
71
|
+
const nowYY = opts.now.getFullYear() % 100;
|
|
72
|
+
const century = opts.expiry ? (yy >= 70 ? 1900 : 2000) : yy > nowYY ? 1900 : 2000;
|
|
73
|
+
return new Date(Date.UTC(century + yy, mm - 1, dd));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Filler-stripped text, or null when the field was empty. */
|
|
77
|
+
function clean(s: string): string | null {
|
|
78
|
+
const v = s.replace(/</g, ' ').replace(/\s+/g, ' ').trim();
|
|
79
|
+
return v === '' ? null : v;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The fields the BAC key needs, plus the biodata worth showing back. */
|
|
83
|
+
export interface MrzScan {
|
|
84
|
+
format: 'TD1' | 'TD3';
|
|
85
|
+
documentNumber: string;
|
|
86
|
+
/** ISO date (YYYY-MM-DD) — the shape the rest of the SDK passes around. */
|
|
87
|
+
dateOfBirth: string;
|
|
88
|
+
dateOfExpiry: string;
|
|
89
|
+
firstName?: string;
|
|
90
|
+
lastName?: string;
|
|
91
|
+
nationality?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const iso = (d: Date): string => d.toISOString().slice(0, 10);
|
|
95
|
+
|
|
96
|
+
function parseTd3(l1: string, l2: string, now: Date): MrzScan | null {
|
|
97
|
+
const docNumber = l2.slice(0, 9);
|
|
98
|
+
const dob = l2.slice(13, 19);
|
|
99
|
+
const expiry = l2.slice(21, 27);
|
|
100
|
+
const personal = l2.slice(28, 42);
|
|
101
|
+
const personalCd = l2[42]!;
|
|
102
|
+
const composite = l2.slice(0, 10) + l2.slice(13, 20) + l2.slice(21, 43);
|
|
103
|
+
|
|
104
|
+
const valid =
|
|
105
|
+
digitOk(docNumber, l2[9]!) &&
|
|
106
|
+
digitOk(dob, l2[19]!) &&
|
|
107
|
+
digitOk(expiry, l2[27]!) &&
|
|
108
|
+
// An unused personal-number field carries either filler or '0' as its
|
|
109
|
+
// check digit, depending on the issuer.
|
|
110
|
+
(clean(personal) === null
|
|
111
|
+
? personalCd === '<' || personalCd === '0'
|
|
112
|
+
: digitOk(personal, personalCd)) &&
|
|
113
|
+
digitOk(composite, l2[43]!);
|
|
114
|
+
if (!valid) return null;
|
|
115
|
+
|
|
116
|
+
const birth = mrzDate(dob, { expiry: false, now });
|
|
117
|
+
const expires = mrzDate(expiry, { expiry: true, now });
|
|
118
|
+
const number = clean(docNumber)?.replace(/ /g, '');
|
|
119
|
+
if (!birth || !expires || !number) return null;
|
|
120
|
+
|
|
121
|
+
const names = l1.slice(5).split('<<');
|
|
122
|
+
return {
|
|
123
|
+
format: 'TD3',
|
|
124
|
+
documentNumber: number,
|
|
125
|
+
dateOfBirth: iso(birth),
|
|
126
|
+
dateOfExpiry: iso(expires),
|
|
127
|
+
lastName: clean(names[0] ?? '') ?? undefined,
|
|
128
|
+
firstName: clean(names.slice(1).join(' ')) ?? undefined,
|
|
129
|
+
nationality: clean(l2.slice(10, 13)) ?? undefined,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function parseTd1(l1: string, l2: string, l3: string, now: Date): MrzScan | null {
|
|
134
|
+
const docNumber = l1.slice(5, 14);
|
|
135
|
+
const dob = l2.slice(0, 6);
|
|
136
|
+
const expiry = l2.slice(8, 14);
|
|
137
|
+
const composite =
|
|
138
|
+
l1.slice(5, 30) + l2.slice(0, 7) + l2.slice(8, 15) + l2.slice(18, 29);
|
|
139
|
+
|
|
140
|
+
const valid =
|
|
141
|
+
digitOk(docNumber, l1[14]!) &&
|
|
142
|
+
digitOk(dob, l2[6]!) &&
|
|
143
|
+
digitOk(expiry, l2[14]!) &&
|
|
144
|
+
digitOk(composite, l2[29]!);
|
|
145
|
+
if (!valid) return null;
|
|
146
|
+
|
|
147
|
+
const birth = mrzDate(dob, { expiry: false, now });
|
|
148
|
+
const expires = mrzDate(expiry, { expiry: true, now });
|
|
149
|
+
const number = clean(docNumber)?.replace(/ /g, '');
|
|
150
|
+
if (!birth || !expires || !number) return null;
|
|
151
|
+
|
|
152
|
+
const names = l3.split('<<');
|
|
153
|
+
return {
|
|
154
|
+
format: 'TD1',
|
|
155
|
+
documentNumber: number,
|
|
156
|
+
dateOfBirth: iso(birth),
|
|
157
|
+
dateOfExpiry: iso(expires),
|
|
158
|
+
lastName: clean(names[0] ?? '') ?? undefined,
|
|
159
|
+
firstName: clean(names.slice(1).join(' ')) ?? undefined,
|
|
160
|
+
nationality: clean(l2.slice(15, 18)) ?? undefined,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Parse a continuous MRZ string (88 chars = TD3, 90 = TD1).
|
|
166
|
+
*
|
|
167
|
+
* Returns null when it is not syntactically valid or ANY check digit fails —
|
|
168
|
+
* there is no partial success, because a partially-correct MRZ is a wrong
|
|
169
|
+
* document number wearing a plausible disguise.
|
|
170
|
+
*/
|
|
171
|
+
export function parseMrz(text: string, now: Date = new Date()): MrzScan | null {
|
|
172
|
+
const flat = text.toUpperCase().replace(/[^A-Z0-9<]/g, '');
|
|
173
|
+
if (flat.length === 88) return parseTd3(flat.slice(0, 44), flat.slice(44), now);
|
|
174
|
+
if (flat.length === 90) {
|
|
175
|
+
return parseTd1(flat.slice(0, 30), flat.slice(30, 60), flat.slice(60), now);
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { NitroModules } from 'react-native-nitro-modules';
|
|
2
|
+
import type { BoxedHybridObject } from 'react-native-nitro-modules';
|
|
3
|
+
import type { Frame } from 'react-native-vision-camera';
|
|
4
|
+
|
|
5
|
+
import type { MyazaTextRecognizer } from '../specs/MyazaTextRecognizer.nitro';
|
|
6
|
+
import { extractMrz } from './extract';
|
|
7
|
+
import type { MrzScan } from './parse';
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// The native text recogniser, reached from the camera-thread worklet.
|
|
11
|
+
//
|
|
12
|
+
// Same boxing dance as the face detector: the HybridObject is created on the JS
|
|
13
|
+
// thread, boxed, and unboxed inside the worklet, so the frame never crosses the
|
|
14
|
+
// bridge — only the recognised lines come back.
|
|
15
|
+
//
|
|
16
|
+
// A build without the native code (Expo Go, or a host app that has not rebuilt)
|
|
17
|
+
// leaves `hasHybridObject` false and every call returns no lines. The MRZ step
|
|
18
|
+
// then simply never finds one, which is the same as pointing the camera at a
|
|
19
|
+
// blank wall — a capability that is absent rather than an app that crashes.
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resolved LAZILY and re-tried until it appears.
|
|
24
|
+
*
|
|
25
|
+
* Creating it at module load is fragile in a way that fails SILENTLY: if this
|
|
26
|
+
* module evaluates before Nitro's registry is populated — Fast Refresh does
|
|
27
|
+
* this — the reference stays null for the session and every scan returns "no
|
|
28
|
+
* text", which looks exactly like a document the camera cannot read.
|
|
29
|
+
*
|
|
30
|
+
* The BOXED handle is created alongside on the JS thread; a worklet cannot
|
|
31
|
+
* construct one itself, so it must exist before the first frame arrives.
|
|
32
|
+
*/
|
|
33
|
+
function create(): MyazaTextRecognizer | null {
|
|
34
|
+
try {
|
|
35
|
+
return NitroModules.hasHybridObject('MyazaTextRecognizer')
|
|
36
|
+
? NitroModules.createHybridObject<MyazaTextRecognizer>('MyazaTextRecognizer')
|
|
37
|
+
: null;
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// EAGER, and it has to be. The worklet below captures `boxed` when the worklet
|
|
44
|
+
// is CREATED, so a value that is null at that moment stays null on the camera
|
|
45
|
+
// thread forever — every frame then returns no text, which looks exactly like a
|
|
46
|
+
// camera that cannot read the document. (A lazy version of this silently killed
|
|
47
|
+
// auto-capture.) The JS-side path re-tries via `resolve()` for the cold-start
|
|
48
|
+
// case where the registry was not ready at import.
|
|
49
|
+
let recognizer: MyazaTextRecognizer | null = create();
|
|
50
|
+
// `let`, not `const`: if the eager create above ran before Nitro's registry was
|
|
51
|
+
// populated, `resolve()` re-creates AND re-boxes. Leaving this const meant a
|
|
52
|
+
// late resolution produced a working JS-side module with a null worklet handle
|
|
53
|
+
// — the camera thread stayed blind while everything looked fine from JS.
|
|
54
|
+
let boxed: BoxedHybridObject<MyazaTextRecognizer> | null = recognizer ? NitroModules.box(recognizer) : null;
|
|
55
|
+
|
|
56
|
+
function resolve(): MyazaTextRecognizer | null {
|
|
57
|
+
if (!recognizer) {
|
|
58
|
+
recognizer = create();
|
|
59
|
+
if (recognizer) boxed = NitroModules.box(recognizer);
|
|
60
|
+
}
|
|
61
|
+
return recognizer;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Whether this build can recognise text at all.
|
|
66
|
+
*
|
|
67
|
+
* Also the WARM-UP: calling it resolves the module on the JS thread so the
|
|
68
|
+
* boxed handle exists by the time frames start arriving in the worklet.
|
|
69
|
+
*/
|
|
70
|
+
export function hasTextRecognizer(): boolean {
|
|
71
|
+
return resolve() !== null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The bottom slice of the frame the MRZ occupies.
|
|
76
|
+
*
|
|
77
|
+
* Generous on purpose: the band tolerates FRAMING, not the document. A tighter
|
|
78
|
+
* crop sliced the MRZ off real captures in the server's own scanner, because
|
|
79
|
+
* the document does not fill the frame — the same mistake is easy to repeat
|
|
80
|
+
* here, and the cost is a scanner that never reads anything.
|
|
81
|
+
*/
|
|
82
|
+
export const MRZ_BAND_FRACTION = 0.55;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Read the MRZ off a captured photo.
|
|
86
|
+
*
|
|
87
|
+
* This is the path the document step uses. Reading it from the picture the user
|
|
88
|
+
* just took is what spares them scanning the same passport a second time for
|
|
89
|
+
* the chip step — the MRZ IS the key that unlocks the chip.
|
|
90
|
+
*
|
|
91
|
+
* Returns null when nothing valid was found, which includes every document that
|
|
92
|
+
* has no MRZ at all. That is the ordinary case, not an error.
|
|
93
|
+
*/
|
|
94
|
+
export async function scanMrzFromImage(uri: string): Promise<MrzScan | null> {
|
|
95
|
+
const r = resolve();
|
|
96
|
+
if (r == null) return null;
|
|
97
|
+
try {
|
|
98
|
+
const { lines } = await r.recognizeTextInImage(uri, MRZ_BAND_FRACTION);
|
|
99
|
+
const banded = extractMrz(lines);
|
|
100
|
+
if (banded) return banded;
|
|
101
|
+
// Second pass over the WHOLE image. The band is an optimisation, not a
|
|
102
|
+
// truth: Android post-filters by line position rather than true ROI, and a
|
|
103
|
+
// capture whose document sits high (or whose EXIF orientation confused
|
|
104
|
+
// "bottom") can leave the real MRZ just outside the band. One extra pass
|
|
105
|
+
// on an already-captured still is cheap; sending the user to rescan the
|
|
106
|
+
// same passport at the chip step is not.
|
|
107
|
+
const { lines: allLines } = await r.recognizeTextInImage(uri, 1);
|
|
108
|
+
return extractMrz(allLines);
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Recognise the MRZ band in a frame. A WORKLET — call it from `onFrame`.
|
|
116
|
+
*
|
|
117
|
+
* Returns an empty array when the recogniser is unavailable or found nothing,
|
|
118
|
+
* which is the ordinary case for most frames while the user is still aiming.
|
|
119
|
+
*/
|
|
120
|
+
export function recognizeMrzLines(frame: Frame): string[] {
|
|
121
|
+
'worklet';
|
|
122
|
+
if (boxed == null) return [];
|
|
123
|
+
try {
|
|
124
|
+
return boxed.unbox().recognizeText(frame, MRZ_BAND_FRACTION).lines;
|
|
125
|
+
} catch {
|
|
126
|
+
// A recogniser that throws on one frame must not take the scan down; the
|
|
127
|
+
// next frame gets its own attempt.
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Recognise text across the WHOLE frame. A WORKLET — call it from `onFrame`.
|
|
134
|
+
*
|
|
135
|
+
* This is what auto-capture reads: identity comes from the printed words
|
|
136
|
+
* ("PASSPORT", "FRSC", "INEC"), which sit above the MRZ band and are cropped
|
|
137
|
+
* out by `recognizeMrzLines`. The MRZ is still recoverable from these lines —
|
|
138
|
+
* it is simply included rather than isolated — so one pass per frame answers
|
|
139
|
+
* both "which document is this" and "is its strip readable".
|
|
140
|
+
*/
|
|
141
|
+
export function recognizeAllLines(frame: Frame): string[] {
|
|
142
|
+
'worklet';
|
|
143
|
+
if (boxed == null) return [];
|
|
144
|
+
try {
|
|
145
|
+
return boxed.unbox().recognizeText(frame, 1).lines;
|
|
146
|
+
} catch {
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { 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 { MyazaInput } from '../components/MyazaInput';
|
|
9
|
+
import { MyazaSelect } from '../components/MyazaSelect';
|
|
10
|
+
import { Pressable } from 'react-native';
|
|
11
|
+
import { Icon } from '../components/Icon';
|
|
12
|
+
import { CountryFlag } from '../components/CountryFlag';
|
|
13
|
+
import {
|
|
14
|
+
KEY_PERSON_ROLE_LABELS,
|
|
15
|
+
KEY_PERSON_ROLES,
|
|
16
|
+
isKeyPersonRowValid,
|
|
17
|
+
namesLooselyMatch,
|
|
18
|
+
} from '../config/keyPeople';
|
|
19
|
+
import type { ApplicantRole } from '../types/business';
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// The applicant declares who they are to the business, then verifies their own
|
|
23
|
+
// identity in the ordinary individual capture steps that follow.
|
|
24
|
+
//
|
|
25
|
+
// When key people were entered earlier, the applicant may BE one of them — so
|
|
26
|
+
// the step first asks "which of these is you?" (pre-selected when the
|
|
27
|
+
// consumer's userData name matches). Picking themselves flags that entry on
|
|
28
|
+
// the submission and the server merges the two records: one person, one KYC,
|
|
29
|
+
// one screening, no duplicate invite. "I'm not one of these" falls through to
|
|
30
|
+
// the plain role + name form. Mirrors the web SDK's ApplicantRoleStep.
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
export const applicantRoleMeta = {
|
|
34
|
+
title: 'Now verify your own identity',
|
|
35
|
+
description:
|
|
36
|
+
'Tell us your role at the business, then verify your identity with a government-issued ID.',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const APPLICANT_ROLES: ApplicantRole[] = [...KEY_PERSON_ROLES, 'authorized_representative'];
|
|
40
|
+
|
|
41
|
+
const APPLICANT_ROLE_LABELS: Record<ApplicantRole, string> = {
|
|
42
|
+
...KEY_PERSON_ROLE_LABELS,
|
|
43
|
+
authorized_representative: 'Authorized representative',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** "Richard Ingwe" → "RI" — the avatar monogram. */
|
|
47
|
+
function initialsOf(name: string): string {
|
|
48
|
+
return name
|
|
49
|
+
.trim()
|
|
50
|
+
.split(/\s+/)
|
|
51
|
+
.slice(0, 2)
|
|
52
|
+
.map((t) => t[0]?.toUpperCase() ?? '')
|
|
53
|
+
.join('');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function ApplicantRoleStep(): React.ReactElement {
|
|
57
|
+
const store = useKycStore();
|
|
58
|
+
const config = useKycConfig();
|
|
59
|
+
const { colors } = useTheme();
|
|
60
|
+
const stored = useKyc((s) => s.businessApplication);
|
|
61
|
+
|
|
62
|
+
// The valid entered people, keeping their ORIGINAL index — the payload flag
|
|
63
|
+
// is index-based, so the list and the submission can never disagree.
|
|
64
|
+
const people = stored.keyPeople
|
|
65
|
+
.map((row, index) => ({ row, index }))
|
|
66
|
+
.filter(({ row }) => isKeyPersonRowValid(row));
|
|
67
|
+
const hasPeople = people.length > 0;
|
|
68
|
+
|
|
69
|
+
const propName = [config.userData?.firstName, config.userData?.lastName]
|
|
70
|
+
.filter(Boolean)
|
|
71
|
+
.join(' ');
|
|
72
|
+
|
|
73
|
+
// Tri-state: a person's index, 'other', or nothing chosen yet. First arrival
|
|
74
|
+
// pre-selects the applicant's own entry when the userData name matches
|
|
75
|
+
// (they still confirm) — a stored choice always wins.
|
|
76
|
+
const [selection, setSelection] = useState<number | 'other' | null>(() => {
|
|
77
|
+
if (stored.applicantKeyPersonIndex !== null) return stored.applicantKeyPersonIndex;
|
|
78
|
+
if (stored.applicantRole) return 'other';
|
|
79
|
+
if (propName) {
|
|
80
|
+
const match = people.find(({ row }) => namesLooselyMatch(propName, row.name));
|
|
81
|
+
if (match) return match.index;
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
});
|
|
85
|
+
const [role, setRole] = useState<ApplicantRole | null>(stored.applicantRole);
|
|
86
|
+
// Pre-filled from the consumer's userData when nothing was typed yet — the
|
|
87
|
+
// applicant is usually the person the integrator already knows about.
|
|
88
|
+
const [name, setName] = useState(() => stored.applicantName || propName);
|
|
89
|
+
|
|
90
|
+
const handleContinue = (): void => {
|
|
91
|
+
if (typeof selection === 'number') {
|
|
92
|
+
const person = stored.keyPeople[selection];
|
|
93
|
+
if (!person) return;
|
|
94
|
+
store.getState().setApplicant(person.role, person.name.trim(), selection);
|
|
95
|
+
// Their entry already answered "where was your ID issued?" — the leg
|
|
96
|
+
// uses that country and the country-select step is skipped (see
|
|
97
|
+
// derive.hasCountrySelect).
|
|
98
|
+
if (person.country.trim() !== '') {
|
|
99
|
+
store.getState().setCountry(person.country.trim().toUpperCase());
|
|
100
|
+
}
|
|
101
|
+
} else {
|
|
102
|
+
if (!role) return;
|
|
103
|
+
store.getState().setApplicant(role, name, null);
|
|
104
|
+
}
|
|
105
|
+
store.getState().nextStep();
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const canContinue = hasPeople
|
|
109
|
+
? typeof selection === 'number' || (selection === 'other' && role !== null)
|
|
110
|
+
: role !== null;
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<View>
|
|
114
|
+
{/* Regulatory note card — the web SDK's ScanFace callout (rounded-xl,
|
|
115
|
+
p-4, 36px icon tile). */}
|
|
116
|
+
<View
|
|
117
|
+
style={{
|
|
118
|
+
flexDirection: 'row',
|
|
119
|
+
alignItems: 'center',
|
|
120
|
+
backgroundColor: colors.backgroundSecondary,
|
|
121
|
+
borderRadius: radius.sm,
|
|
122
|
+
padding: spacing.md,
|
|
123
|
+
}}
|
|
124
|
+
>
|
|
125
|
+
<View
|
|
126
|
+
style={{
|
|
127
|
+
width: 36,
|
|
128
|
+
height: 36,
|
|
129
|
+
borderRadius: radius.xs,
|
|
130
|
+
backgroundColor: colors.primary100,
|
|
131
|
+
alignItems: 'center',
|
|
132
|
+
justifyContent: 'center',
|
|
133
|
+
marginRight: spacing.sm + 4,
|
|
134
|
+
}}
|
|
135
|
+
>
|
|
136
|
+
<Icon name="scan-face" size={18} color={colors.primary} />
|
|
137
|
+
</View>
|
|
138
|
+
<MyazaText variant="bodySmall" style={{ flex: 1 }}>
|
|
139
|
+
Regulations require the person submitting a business application to verify their own
|
|
140
|
+
identity. This only takes a minute.
|
|
141
|
+
</MyazaText>
|
|
142
|
+
</View>
|
|
143
|
+
|
|
144
|
+
{hasPeople ? (
|
|
145
|
+
<>
|
|
146
|
+
<View style={{ height: spacing.lg }} />
|
|
147
|
+
<MyazaText variant="bodySmall" style={{ fontWeight: '600', marginBottom: spacing.sm }}>
|
|
148
|
+
Are you one of the people you listed?
|
|
149
|
+
</MyazaText>
|
|
150
|
+
{people.map(({ row, index }) => (
|
|
151
|
+
<SelfCard
|
|
152
|
+
key={index}
|
|
153
|
+
name={row.name.trim()}
|
|
154
|
+
subtitle={KEY_PERSON_ROLE_LABELS[row.role]}
|
|
155
|
+
ownershipPct={row.ownershipPct.trim() || null}
|
|
156
|
+
country={row.country.trim() ? row.country.trim().toUpperCase() : null}
|
|
157
|
+
selected={selection === index}
|
|
158
|
+
onPress={() => setSelection(index)}
|
|
159
|
+
/>
|
|
160
|
+
))}
|
|
161
|
+
<SelfCard
|
|
162
|
+
name="I'm not one of these people"
|
|
163
|
+
other
|
|
164
|
+
selected={selection === 'other'}
|
|
165
|
+
onPress={() => setSelection('other')}
|
|
166
|
+
/>
|
|
167
|
+
{typeof selection === 'number' ? (
|
|
168
|
+
<View
|
|
169
|
+
style={{
|
|
170
|
+
flexDirection: 'row',
|
|
171
|
+
alignItems: 'flex-start',
|
|
172
|
+
backgroundColor: colors.primary100,
|
|
173
|
+
borderRadius: radius.xs,
|
|
174
|
+
paddingHorizontal: spacing.sm + 4,
|
|
175
|
+
paddingVertical: spacing.sm + 2,
|
|
176
|
+
marginTop: spacing.xs,
|
|
177
|
+
}}
|
|
178
|
+
>
|
|
179
|
+
<Icon name="check" size={14} color={colors.primary} />
|
|
180
|
+
<MyazaText variant="bodySmall" style={{ flex: 1, marginLeft: spacing.xs + 2 }}>
|
|
181
|
+
You'll verify your identity at the end of this form — no separate invite link is
|
|
182
|
+
needed for you.
|
|
183
|
+
</MyazaText>
|
|
184
|
+
</View>
|
|
185
|
+
) : null}
|
|
186
|
+
</>
|
|
187
|
+
) : null}
|
|
188
|
+
|
|
189
|
+
{!hasPeople || selection === 'other' ? (
|
|
190
|
+
<>
|
|
191
|
+
<View style={{ height: spacing.lg }} />
|
|
192
|
+
<MyazaText variant="bodySmall" style={{ fontWeight: '600', marginBottom: spacing.xs }}>
|
|
193
|
+
Your role at the business
|
|
194
|
+
</MyazaText>
|
|
195
|
+
<MyazaSelect
|
|
196
|
+
value={role}
|
|
197
|
+
hint="Select your role"
|
|
198
|
+
sheetTitle="Your role"
|
|
199
|
+
options={APPLICANT_ROLES.map((option) => ({
|
|
200
|
+
value: option,
|
|
201
|
+
label: APPLICANT_ROLE_LABELS[option],
|
|
202
|
+
}))}
|
|
203
|
+
onChange={setRole}
|
|
204
|
+
/>
|
|
205
|
+
|
|
206
|
+
<View style={{ height: spacing.md }} />
|
|
207
|
+
<MyazaInput
|
|
208
|
+
label="Full name (optional)"
|
|
209
|
+
value={name}
|
|
210
|
+
onChangeText={setName}
|
|
211
|
+
placeholder="Enter your full name"
|
|
212
|
+
autoCapitalize="words"
|
|
213
|
+
/>
|
|
214
|
+
</>
|
|
215
|
+
) : null}
|
|
216
|
+
|
|
217
|
+
<View style={{ height: spacing.xl }} />
|
|
218
|
+
<MyazaButton label="Continue" onPress={handleContinue} disabled={!canContinue} />
|
|
219
|
+
</View>
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
// One selectable "this is me" card — monogram avatar with the person's
|
|
225
|
+
// ID-issuing-country flag badged on its corner, name + role/ownership, and a
|
|
226
|
+
// radio dot. The "someone else" variant swaps the avatar for an add-person
|
|
227
|
+
// icon on a dashed border. Mirrors the web SDK's ApplicantRoleStep cards 1:1.
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
function SelfCard({
|
|
231
|
+
name,
|
|
232
|
+
subtitle,
|
|
233
|
+
ownershipPct,
|
|
234
|
+
country,
|
|
235
|
+
other = false,
|
|
236
|
+
selected,
|
|
237
|
+
onPress,
|
|
238
|
+
}: {
|
|
239
|
+
name: string;
|
|
240
|
+
subtitle?: string;
|
|
241
|
+
ownershipPct?: string | null;
|
|
242
|
+
country?: string | null;
|
|
243
|
+
/** The dashed "I'm not one of these people" variant. */
|
|
244
|
+
other?: boolean;
|
|
245
|
+
selected: boolean;
|
|
246
|
+
onPress: () => void;
|
|
247
|
+
}): React.ReactElement {
|
|
248
|
+
const { colors } = useTheme();
|
|
249
|
+
return (
|
|
250
|
+
<Pressable
|
|
251
|
+
onPress={onPress}
|
|
252
|
+
accessibilityRole="radio"
|
|
253
|
+
accessibilityState={{ checked: selected }}
|
|
254
|
+
accessibilityLabel={name}
|
|
255
|
+
style={{
|
|
256
|
+
flexDirection: 'row',
|
|
257
|
+
alignItems: 'center',
|
|
258
|
+
borderWidth: selected ? 1.5 : 1,
|
|
259
|
+
borderStyle: other && !selected ? 'dashed' : 'solid',
|
|
260
|
+
borderColor: selected ? colors.primary : colors.border,
|
|
261
|
+
borderRadius: radius.sm,
|
|
262
|
+
backgroundColor: selected ? colors.primary50 : colors.background,
|
|
263
|
+
padding: spacing.md - 2,
|
|
264
|
+
marginBottom: spacing.sm,
|
|
265
|
+
}}
|
|
266
|
+
>
|
|
267
|
+
<View
|
|
268
|
+
style={{
|
|
269
|
+
width: 40,
|
|
270
|
+
height: 40,
|
|
271
|
+
borderRadius: 20,
|
|
272
|
+
backgroundColor: other && !selected ? colors.backgroundSecondary : colors.primary100,
|
|
273
|
+
alignItems: 'center',
|
|
274
|
+
justifyContent: 'center',
|
|
275
|
+
}}
|
|
276
|
+
>
|
|
277
|
+
{other ? (
|
|
278
|
+
<Icon name="user-plus" size={18} color={selected ? colors.primary : colors.textMuted} />
|
|
279
|
+
) : (
|
|
280
|
+
<MyazaText variant="bodySmall" color={colors.primary} style={{ fontWeight: '700' }}>
|
|
281
|
+
{initialsOf(name)}
|
|
282
|
+
</MyazaText>
|
|
283
|
+
)}
|
|
284
|
+
{country ? (
|
|
285
|
+
<View
|
|
286
|
+
style={{
|
|
287
|
+
position: 'absolute',
|
|
288
|
+
bottom: -2,
|
|
289
|
+
right: -4,
|
|
290
|
+
borderRadius: 10,
|
|
291
|
+
borderWidth: 2,
|
|
292
|
+
borderColor: selected ? colors.primary50 : colors.background,
|
|
293
|
+
overflow: 'hidden',
|
|
294
|
+
}}
|
|
295
|
+
>
|
|
296
|
+
<CountryFlag country={country} size={16} />
|
|
297
|
+
</View>
|
|
298
|
+
) : null}
|
|
299
|
+
</View>
|
|
300
|
+
|
|
301
|
+
<View style={{ flex: 1, minWidth: 0, marginLeft: spacing.sm + 4 }}>
|
|
302
|
+
<MyazaText variant="bodyMedium" numberOfLines={1} style={{ fontWeight: '600' }}>
|
|
303
|
+
{name}
|
|
304
|
+
</MyazaText>
|
|
305
|
+
{subtitle ? (
|
|
306
|
+
<MyazaText variant="bodySmall" color={colors.textMuted} numberOfLines={1} style={{ marginTop: 1 }}>
|
|
307
|
+
{subtitle}
|
|
308
|
+
{ownershipPct ? ` · ${ownershipPct}% ownership` : ''}
|
|
309
|
+
</MyazaText>
|
|
310
|
+
) : null}
|
|
311
|
+
</View>
|
|
312
|
+
|
|
313
|
+
{/* Radio dot — filled with a check when selected. */}
|
|
314
|
+
<View
|
|
315
|
+
style={{
|
|
316
|
+
width: 20,
|
|
317
|
+
height: 20,
|
|
318
|
+
borderRadius: 10,
|
|
319
|
+
borderWidth: 2,
|
|
320
|
+
borderColor: selected ? colors.primary : colors.border,
|
|
321
|
+
backgroundColor: selected ? colors.primary : 'transparent',
|
|
322
|
+
alignItems: 'center',
|
|
323
|
+
justifyContent: 'center',
|
|
324
|
+
marginLeft: spacing.sm,
|
|
325
|
+
}}
|
|
326
|
+
>
|
|
327
|
+
{selected ? <Icon name="check" size={12} color={colors.background} /> : null}
|
|
328
|
+
</View>
|
|
329
|
+
</Pressable>
|
|
330
|
+
);
|
|
331
|
+
}
|