@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.
Files changed (281) hide show
  1. package/README.md +111 -8
  2. package/android/build.gradle +6 -0
  3. package/android/src/main/AndroidManifest.xml +14 -4
  4. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaEmrtd.kt +358 -0
  5. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +88 -1
  6. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaRectDetector.kt +34 -0
  7. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizer.kt +118 -0
  8. package/android/src/main/java/com/margelo/nitro/myazakyc/Jp2Decoder.kt +117 -0
  9. package/app.plugin.js +102 -13
  10. package/ios/HybridMyazaEmrtd.swift +447 -0
  11. package/ios/HybridMyazaFaceDetector.swift +104 -2
  12. package/ios/HybridMyazaRectDetector.swift +103 -0
  13. package/ios/HybridMyazaTextRecognizer.swift +111 -0
  14. package/nitrogen/generated/.gitattributes +1 -0
  15. package/nitrogen/generated/android/KycSdkReactNative+autolinking.cmake +87 -0
  16. package/nitrogen/generated/android/KycSdkReactNative+autolinking.gradle +27 -0
  17. package/nitrogen/generated/android/KycSdkReactNativeOnLoad.cpp +102 -0
  18. package/nitrogen/generated/android/KycSdkReactNativeOnLoad.hpp +34 -0
  19. package/nitrogen/generated/android/c++/JDetectedRect.hpp +81 -0
  20. package/nitrogen/generated/android/c++/JEmrtdApduResponse.hpp +61 -0
  21. package/nitrogen/generated/android/c++/JEmrtdTagInfo.hpp +61 -0
  22. package/nitrogen/generated/android/c++/JFaceResult.hpp +113 -0
  23. package/nitrogen/generated/android/c++/JHybridMyazaEmrtdSpec.cpp +176 -0
  24. package/nitrogen/generated/android/c++/JHybridMyazaEmrtdSpec.hpp +75 -0
  25. package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.cpp +60 -0
  26. package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.hpp +63 -0
  27. package/nitrogen/generated/android/c++/JHybridMyazaRectDetectorSpec.cpp +60 -0
  28. package/nitrogen/generated/android/c++/JHybridMyazaRectDetectorSpec.hpp +63 -0
  29. package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.cpp +80 -0
  30. package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.hpp +64 -0
  31. package/nitrogen/generated/android/c++/JTextResult.hpp +76 -0
  32. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/DetectedRect.kt +81 -0
  33. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/EmrtdApduResponse.kt +56 -0
  34. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/EmrtdTagInfo.kt +56 -0
  35. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/FaceResult.kt +121 -0
  36. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaEmrtdSpec.kt +103 -0
  37. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaFaceDetectorSpec.kt +55 -0
  38. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaRectDetectorSpec.kt +55 -0
  39. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizerSpec.kt +60 -0
  40. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/KycSdkReactNativeOnLoad.kt +35 -0
  41. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/TextResult.kt +51 -0
  42. package/nitrogen/generated/ios/KycSdkReactNative+autolinking.rb +62 -0
  43. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.cpp +143 -0
  44. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.hpp +423 -0
  45. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Umbrella.hpp +81 -0
  46. package/nitrogen/generated/ios/KycSdkReactNativeAutolinking.mm +57 -0
  47. package/nitrogen/generated/ios/KycSdkReactNativeAutolinking.swift +62 -0
  48. package/nitrogen/generated/ios/c++/HybridMyazaEmrtdSpecSwift.cpp +11 -0
  49. package/nitrogen/generated/ios/c++/HybridMyazaEmrtdSpecSwift.hpp +184 -0
  50. package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.cpp +11 -0
  51. package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.hpp +87 -0
  52. package/nitrogen/generated/ios/c++/HybridMyazaRectDetectorSpecSwift.cpp +11 -0
  53. package/nitrogen/generated/ios/c++/HybridMyazaRectDetectorSpecSwift.hpp +87 -0
  54. package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.cpp +11 -0
  55. package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.hpp +98 -0
  56. package/nitrogen/generated/ios/swift/DetectedRect.swift +59 -0
  57. package/nitrogen/generated/ios/swift/EmrtdApduResponse.swift +34 -0
  58. package/nitrogen/generated/ios/swift/EmrtdTagInfo.swift +34 -0
  59. package/nitrogen/generated/ios/swift/FaceResult.swift +99 -0
  60. package/nitrogen/generated/ios/swift/Func_void.swift +46 -0
  61. package/nitrogen/generated/ios/swift/Func_void_EmrtdApduResponse.swift +46 -0
  62. package/nitrogen/generated/ios/swift/Func_void_EmrtdTagInfo.swift +46 -0
  63. package/nitrogen/generated/ios/swift/Func_void_TextResult.swift +46 -0
  64. package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +46 -0
  65. package/nitrogen/generated/ios/swift/Func_void_std__string.swift +46 -0
  66. package/nitrogen/generated/ios/swift/HybridMyazaEmrtdSpec.swift +67 -0
  67. package/nitrogen/generated/ios/swift/HybridMyazaEmrtdSpec_cxx.swift +317 -0
  68. package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec.swift +56 -0
  69. package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec_cxx.swift +143 -0
  70. package/nitrogen/generated/ios/swift/HybridMyazaRectDetectorSpec.swift +56 -0
  71. package/nitrogen/generated/ios/swift/HybridMyazaRectDetectorSpec_cxx.swift +143 -0
  72. package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec.swift +57 -0
  73. package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec_cxx.swift +162 -0
  74. package/nitrogen/generated/ios/swift/TextResult.swift +35 -0
  75. package/nitrogen/generated/shared/c++/DetectedRect.hpp +107 -0
  76. package/nitrogen/generated/shared/c++/EmrtdApduResponse.hpp +87 -0
  77. package/nitrogen/generated/shared/c++/EmrtdTagInfo.hpp +87 -0
  78. package/nitrogen/generated/shared/c++/FaceResult.hpp +139 -0
  79. package/nitrogen/generated/shared/c++/HybridMyazaEmrtdSpec.cpp +33 -0
  80. package/nitrogen/generated/shared/c++/HybridMyazaEmrtdSpec.hpp +80 -0
  81. package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.cpp +21 -0
  82. package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.hpp +67 -0
  83. package/nitrogen/generated/shared/c++/HybridMyazaRectDetectorSpec.cpp +21 -0
  84. package/nitrogen/generated/shared/c++/HybridMyazaRectDetectorSpec.hpp +67 -0
  85. package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.cpp +22 -0
  86. package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.hpp +70 -0
  87. package/nitrogen/generated/shared/c++/TextResult.hpp +84 -0
  88. package/package.json +16 -4
  89. package/src/MyazaKYC.tsx +115 -22
  90. package/src/capture/documentIdentity.ts +153 -0
  91. package/src/capture/documentSignals.ts +177 -0
  92. package/src/capture/documentTextGate.ts +171 -0
  93. package/src/capture/hints.ts +57 -0
  94. package/src/capture/index.ts +24 -0
  95. package/src/capture/rectDetector.ts +84 -0
  96. package/src/capture/rectGate.ts +91 -0
  97. package/src/capture/useAutoCapture.ts +198 -0
  98. package/src/components/CameraViewfinder.tsx +226 -70
  99. package/src/components/ContactCodeEntry.tsx +139 -0
  100. package/src/components/CountryField.tsx +87 -0
  101. package/src/components/CountryFlag.tsx +15 -15
  102. package/src/components/DialCodePicker.tsx +186 -0
  103. package/src/components/DocumentCropper.tsx +1 -1
  104. package/src/components/DocumentReview.tsx +142 -0
  105. package/src/components/DocumentReviewSide.tsx +135 -0
  106. package/src/components/DocumentReviewZoom.tsx +119 -0
  107. package/src/components/ExpiryCountdown.tsx +52 -0
  108. package/src/components/FlashOverlay.tsx +156 -0
  109. package/src/components/Icon.tsx +67 -1
  110. package/src/components/KycFlow.tsx +66 -94
  111. package/src/components/KycSheet.tsx +148 -18
  112. package/src/components/MediaSourceSheet.tsx +163 -0
  113. package/src/components/MyazaButton.tsx +14 -2
  114. package/src/components/MyazaDateField.tsx +240 -0
  115. package/src/components/MyazaInput.tsx +73 -26
  116. package/src/components/MyazaSelect.tsx +283 -0
  117. package/src/components/MyazaWordmark.tsx +33 -0
  118. package/src/components/OptionRow.tsx +132 -0
  119. package/src/components/PhoneNumberInput.tsx +149 -0
  120. package/src/components/PoweredBy.tsx +89 -0
  121. package/src/components/ReadyPrimer.tsx +149 -0
  122. package/src/components/StepView.tsx +91 -0
  123. package/src/components/Typography.tsx +18 -3
  124. package/src/components/VerifiedNotice.tsx +38 -0
  125. package/src/components/WhatsAppIcon.tsx +29 -0
  126. package/src/components/WorkflowGate.tsx +128 -0
  127. package/src/components/brand-font.ts +113 -0
  128. package/src/components/flashHoleGeometry.ts +35 -0
  129. package/src/components/flow/FatalConfigError.tsx +56 -0
  130. package/src/components/fonts.ts +2 -19
  131. package/src/components/readyPrimerContent.ts +52 -0
  132. package/src/components/runtime.tsx +38 -43
  133. package/src/components/stepHeaderMeta.tsx +87 -0
  134. package/src/components/theme-provider.tsx +89 -0
  135. package/src/components/useWorkflowMount.ts +116 -0
  136. package/src/components/viewfinder/CardGuide.tsx +60 -0
  137. package/src/components/viewfinder/DocumentGhost.tsx +149 -0
  138. package/src/components/viewfinder/ImmersiveBottomBar.tsx +172 -0
  139. package/src/components/viewfinder/ImmersiveControls.tsx +127 -0
  140. package/src/components/viewfinder/ImmersiveGuide.tsx +174 -0
  141. package/src/components/viewfinder/ImmersiveOverlay.tsx +79 -0
  142. package/src/components/viewfinder/ViewfinderControls.tsx +154 -0
  143. package/src/config/brand.ts +97 -0
  144. package/src/config/business.ts +179 -0
  145. package/src/config/businessSteps.ts +105 -0
  146. package/src/config/contact.ts +159 -0
  147. package/src/config/countryNames.g.ts +254 -0
  148. package/src/config/currencyFlags.ts +144 -0
  149. package/src/config/font-resolve.ts +78 -0
  150. package/src/config/idTypes.ts +96 -1
  151. package/src/config/keyPeople.ts +152 -0
  152. package/src/config/phone.ts +39 -0
  153. package/src/config/proofOfAddress.ts +75 -0
  154. package/src/config/questionnaire.ts +153 -0
  155. package/src/config/regions.ts +118 -0
  156. package/src/config/registrationHint.ts +87 -0
  157. package/src/config/stepOrder.ts +118 -0
  158. package/src/config/theme.ts +38 -2
  159. package/src/config/workflowMerge.ts +124 -0
  160. package/src/emrtd/bac.ts +128 -0
  161. package/src/emrtd/bytes.ts +127 -0
  162. package/src/emrtd/crypto.ts +163 -0
  163. package/src/emrtd/der.ts +192 -0
  164. package/src/emrtd/dg2.ts +111 -0
  165. package/src/emrtd/files.ts +215 -0
  166. package/src/emrtd/index.ts +30 -0
  167. package/src/emrtd/mrzKey.ts +54 -0
  168. package/src/emrtd/native.ts +79 -0
  169. package/src/emrtd/optionalRead.ts +42 -0
  170. package/src/emrtd/read.ts +224 -0
  171. package/src/emrtd/retry.ts +98 -0
  172. package/src/emrtd/secureMessaging.ts +178 -0
  173. package/src/emrtd/session.ts +200 -0
  174. package/src/emrtd/stages.ts +122 -0
  175. package/src/index.ts +110 -0
  176. package/src/lib/calendar.ts +71 -0
  177. package/src/lib/use-keyboard-inset.ts +34 -0
  178. package/src/liveness/challengeManager.ts +27 -1
  179. package/src/liveness/faceContinuity.ts +153 -0
  180. package/src/liveness/flashDetector.ts +145 -0
  181. package/src/liveness/flashReadyGate.ts +106 -0
  182. package/src/liveness/flashRunner.ts +118 -0
  183. package/src/liveness/integritySignals.ts +60 -0
  184. package/src/liveness/livenessState.ts +139 -0
  185. package/src/liveness/speech.ts +19 -1
  186. package/src/liveness/types.ts +48 -1
  187. package/src/liveness/useFlashPhase.ts +78 -0
  188. package/src/liveness/useLiveness.ts +260 -75
  189. package/src/liveness/visionCameraFaceDetector.ts +14 -0
  190. package/src/mrz/extract.ts +101 -0
  191. package/src/mrz/parse.ts +178 -0
  192. package/src/mrz/textRecognizer.ts +149 -0
  193. package/src/screens/ApplicantRoleStep.tsx +331 -0
  194. package/src/screens/BusinessDetailsStep.tsx +212 -0
  195. package/src/screens/BusinessDocumentSlot.tsx +167 -0
  196. package/src/screens/BusinessDocumentsStep.tsx +213 -0
  197. package/src/screens/BusinessKeyPeopleStep.tsx +151 -0
  198. package/src/screens/BusinessKeyPersonRow.tsx +170 -0
  199. package/src/screens/CompanyInfoFields.tsx +83 -0
  200. package/src/screens/ConsentStep.tsx +68 -68
  201. package/src/screens/ContactChannelChoice.tsx +133 -0
  202. package/src/screens/ContactCodeStep.tsx +130 -0
  203. package/src/screens/ContactDestinationField.tsx +70 -0
  204. package/src/screens/ContactFooterNote.tsx +34 -0
  205. package/src/screens/ContactVerificationStep.tsx +199 -0
  206. package/src/screens/ContactVerifiedPanel.tsx +36 -0
  207. package/src/screens/CountrySelectStep.tsx +210 -0
  208. package/src/screens/DocumentCaptureStep.tsx +156 -132
  209. package/src/screens/IdInputStep.tsx +13 -5
  210. package/src/screens/IdTypeStep.tsx +74 -33
  211. package/src/screens/KeyPeopleInviteLinks.tsx +239 -0
  212. package/src/screens/LivenessStep.tsx +155 -247
  213. package/src/screens/MrzScanView.tsx +173 -0
  214. package/src/screens/NfcStep.tsx +290 -0
  215. package/src/screens/ProofOfAddressStep.tsx +238 -0
  216. package/src/screens/QuestionnaireField.tsx +219 -0
  217. package/src/screens/QuestionnaireMoneyField.tsx +134 -0
  218. package/src/screens/QuestionnaireStep.tsx +76 -0
  219. package/src/screens/SubmittedStep.tsx +5 -0
  220. package/src/screens/consent/model.ts +109 -0
  221. package/src/screens/document/CameraPhase.tsx +150 -0
  222. package/src/screens/document/RequiredPill.tsx +60 -0
  223. package/src/screens/liveness/LightingBanner.tsx +50 -0
  224. package/src/screens/liveness/LivenessOutcome.tsx +109 -0
  225. package/src/screens/liveness/ProgressDots.tsx +67 -0
  226. package/src/screens/liveness/SelfiePreview.tsx +39 -0
  227. package/src/screens/liveness/constants.ts +31 -0
  228. package/src/screens/liveness/index.ts +8 -0
  229. package/src/screens/liveness/resolveGuidance.ts +27 -0
  230. package/src/screens/liveness/useSelfieUpload.ts +107 -0
  231. package/src/screens/nfc/NfcMrzPrompt.tsx +40 -0
  232. package/src/screens/nfc/NfcReadBody.tsx +63 -0
  233. package/src/screens/nfc/NfcReadProgress.tsx +96 -0
  234. package/src/screens/nfc/NfcScanIllustration.tsx +244 -0
  235. package/src/screens/nfc/NfcScannedSummary.tsx +74 -0
  236. package/src/screens/nfc/NfcSuccessPanel.tsx +162 -0
  237. package/src/screens/nfc/availability.ts +73 -0
  238. package/src/screens/nfc/readErrorMessage.ts +93 -0
  239. package/src/screens/nfc/useChipPortrait.ts +58 -0
  240. package/src/screens/nfc/useNfcAvailability.ts +82 -0
  241. package/src/screens/useFlashHole.ts +105 -0
  242. package/src/screens/useFlashSequence.ts +96 -0
  243. package/src/screens/usePoaAttach.ts +121 -0
  244. package/src/services/api-types.ts +164 -0
  245. package/src/services/api-verify-types.ts +99 -0
  246. package/src/services/api.ts +72 -102
  247. package/src/services/cardCrop.ts +25 -10
  248. package/src/services/contactErrors.ts +43 -0
  249. package/src/services/deviceMetadata.ts +1 -1
  250. package/src/services/documentPicker.ts +20 -0
  251. package/src/services/fingerprint-sources.ts +129 -0
  252. package/src/services/fingerprint.ts +101 -0
  253. package/src/services/haptics.ts +38 -0
  254. package/src/services/mediaCompress.ts +7 -2
  255. package/src/services/resolveUrl.ts +2 -2
  256. package/src/services/workflowGate.ts +138 -0
  257. package/src/specs/MyazaEmrtd.nitro.ts +114 -0
  258. package/src/specs/MyazaFaceDetector.nitro.ts +10 -0
  259. package/src/specs/MyazaRectDetector.nitro.ts +50 -0
  260. package/src/specs/MyazaTextRecognizer.nitro.ts +48 -0
  261. package/src/store/derive.ts +225 -0
  262. package/src/store/kycStore.ts +250 -149
  263. package/src/store/state.ts +253 -0
  264. package/src/store/submit.ts +101 -0
  265. package/src/store/submitApplicant.ts +81 -0
  266. package/src/types/appearance.ts +113 -0
  267. package/src/types/business.ts +131 -0
  268. package/src/types/config.ts +150 -124
  269. package/src/types/id-types.ts +57 -0
  270. package/src/types/verification.ts +5 -0
  271. package/src/types/workflow.ts +141 -0
  272. package/src/utils/amountFormat.ts +73 -0
  273. package/src/utils/tokens.ts +4 -2
  274. package/src/__tests__/cardCrop.test.ts +0 -39
  275. package/src/__tests__/deviceMetadata.test.ts +0 -34
  276. package/src/__tests__/errors.test.ts +0 -34
  277. package/src/__tests__/flow.test.ts +0 -61
  278. package/src/__tests__/gestureDetector.test.ts +0 -37
  279. package/src/__tests__/liveness.test.ts +0 -112
  280. package/src/__tests__/resolveUrl.test.ts +0 -64
  281. package/src/__tests__/validators.test.ts +0 -38
@@ -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
+ }