@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.
Files changed (283) hide show
  1. package/README.md +152 -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 +70 -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 +162 -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 +322 -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 +207 -0
  198. package/src/screens/CompanyInfoFields.tsx +83 -0
  199. package/src/screens/ConsentStep.tsx +68 -68
  200. package/src/screens/ContactChannelChoice.tsx +133 -0
  201. package/src/screens/ContactCodeStep.tsx +130 -0
  202. package/src/screens/ContactDestinationField.tsx +70 -0
  203. package/src/screens/ContactFooterNote.tsx +34 -0
  204. package/src/screens/ContactVerificationStep.tsx +199 -0
  205. package/src/screens/ContactVerifiedPanel.tsx +36 -0
  206. package/src/screens/CountrySelectStep.tsx +210 -0
  207. package/src/screens/DocumentCaptureStep.tsx +156 -132
  208. package/src/screens/IdInputStep.tsx +13 -5
  209. package/src/screens/IdTypeStep.tsx +74 -33
  210. package/src/screens/KeyPeopleInviteLinks.tsx +239 -0
  211. package/src/screens/KeyPersonCard.tsx +119 -0
  212. package/src/screens/KeyPersonForm.tsx +148 -0
  213. package/src/screens/KeyPersonSheet.tsx +184 -0
  214. package/src/screens/LivenessStep.tsx +155 -247
  215. package/src/screens/MrzScanView.tsx +173 -0
  216. package/src/screens/NfcStep.tsx +290 -0
  217. package/src/screens/ProofOfAddressStep.tsx +238 -0
  218. package/src/screens/QuestionnaireField.tsx +219 -0
  219. package/src/screens/QuestionnaireMoneyField.tsx +134 -0
  220. package/src/screens/QuestionnaireStep.tsx +76 -0
  221. package/src/screens/SubmittedStep.tsx +5 -0
  222. package/src/screens/consent/model.ts +109 -0
  223. package/src/screens/document/CameraPhase.tsx +150 -0
  224. package/src/screens/document/RequiredPill.tsx +60 -0
  225. package/src/screens/liveness/LightingBanner.tsx +50 -0
  226. package/src/screens/liveness/LivenessOutcome.tsx +109 -0
  227. package/src/screens/liveness/ProgressDots.tsx +67 -0
  228. package/src/screens/liveness/SelfiePreview.tsx +39 -0
  229. package/src/screens/liveness/constants.ts +31 -0
  230. package/src/screens/liveness/index.ts +8 -0
  231. package/src/screens/liveness/resolveGuidance.ts +27 -0
  232. package/src/screens/liveness/useSelfieUpload.ts +107 -0
  233. package/src/screens/nfc/NfcMrzPrompt.tsx +40 -0
  234. package/src/screens/nfc/NfcReadBody.tsx +63 -0
  235. package/src/screens/nfc/NfcReadProgress.tsx +96 -0
  236. package/src/screens/nfc/NfcScanIllustration.tsx +244 -0
  237. package/src/screens/nfc/NfcScannedSummary.tsx +74 -0
  238. package/src/screens/nfc/NfcSuccessPanel.tsx +162 -0
  239. package/src/screens/nfc/availability.ts +73 -0
  240. package/src/screens/nfc/readErrorMessage.ts +93 -0
  241. package/src/screens/nfc/useChipPortrait.ts +58 -0
  242. package/src/screens/nfc/useNfcAvailability.ts +82 -0
  243. package/src/screens/useFlashHole.ts +105 -0
  244. package/src/screens/useFlashSequence.ts +96 -0
  245. package/src/screens/usePoaAttach.ts +121 -0
  246. package/src/services/api-types.ts +164 -0
  247. package/src/services/api-verify-types.ts +99 -0
  248. package/src/services/api.ts +72 -102
  249. package/src/services/cardCrop.ts +25 -10
  250. package/src/services/contactErrors.ts +43 -0
  251. package/src/services/deviceMetadata.ts +1 -1
  252. package/src/services/documentPicker.ts +20 -0
  253. package/src/services/fingerprint-sources.ts +129 -0
  254. package/src/services/fingerprint.ts +101 -0
  255. package/src/services/haptics.ts +38 -0
  256. package/src/services/mediaCompress.ts +7 -2
  257. package/src/services/resolveUrl.ts +2 -2
  258. package/src/services/workflowGate.ts +138 -0
  259. package/src/specs/MyazaEmrtd.nitro.ts +114 -0
  260. package/src/specs/MyazaFaceDetector.nitro.ts +10 -0
  261. package/src/specs/MyazaRectDetector.nitro.ts +50 -0
  262. package/src/specs/MyazaTextRecognizer.nitro.ts +48 -0
  263. package/src/store/derive.ts +225 -0
  264. package/src/store/kycStore.ts +250 -149
  265. package/src/store/state.ts +253 -0
  266. package/src/store/submit.ts +101 -0
  267. package/src/store/submitApplicant.ts +81 -0
  268. package/src/types/appearance.ts +113 -0
  269. package/src/types/business.ts +131 -0
  270. package/src/types/config.ts +150 -124
  271. package/src/types/id-types.ts +57 -0
  272. package/src/types/verification.ts +5 -0
  273. package/src/types/workflow.ts +141 -0
  274. package/src/utils/amountFormat.ts +73 -0
  275. package/src/utils/tokens.ts +4 -2
  276. package/src/__tests__/cardCrop.test.ts +0 -39
  277. package/src/__tests__/deviceMetadata.test.ts +0 -34
  278. package/src/__tests__/errors.test.ts +0 -34
  279. package/src/__tests__/flow.test.ts +0 -61
  280. package/src/__tests__/gestureDetector.test.ts +0 -37
  281. package/src/__tests__/liveness.test.ts +0 -112
  282. package/src/__tests__/resolveUrl.test.ts +0 -64
  283. package/src/__tests__/validators.test.ts +0 -38
@@ -0,0 +1,239 @@
1
+ import React, { useState } from 'react';
2
+ import { Pressable, Share, View } from 'react-native';
3
+ import * as Clipboard from 'expo-clipboard';
4
+
5
+ import { radius, spacing } from '../config/theme';
6
+ import { useKyc, useTheme } from '../components/runtime';
7
+ import { MyazaText } from '../components/Typography';
8
+ import { Icon } from '../components/Icon';
9
+ import { CountryFlag } from '../components/CountryFlag';
10
+ import { regionCountryName } from '../config/regions';
11
+ import { KEY_PERSON_ROLE_LABELS } from '../config/keyPeople';
12
+ import type { KeyPersonInvite } from '../services/api-types';
13
+ import type { ApplicantRole, KeyPersonRole } from '../types/business';
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // "Awaiting users" — the KYB success screen's people section. Every person the
17
+ // review is waiting on, grouped by role, each with a status pill; pending
18
+ // people carry a shareable verification link, and the APPLICANT (who verified
19
+ // in-flow) appears with a green Submitted pill so the picture is complete.
20
+ // Mirrors the web SDK's KeyPeopleInviteLinks 1:1 — mobile shares via the
21
+ // native sheet (reaches WhatsApp/SMS, still offers Copy inside).
22
+ // ---------------------------------------------------------------------------
23
+
24
+ type AwaitRole = ApplicantRole;
25
+
26
+ interface AwaitRow {
27
+ name: string;
28
+ role: AwaitRole;
29
+ pct: string | null;
30
+ country: string | null;
31
+ status: 'submitted' | 'pending';
32
+ inviteUrl?: string;
33
+ isApplicant?: boolean;
34
+ }
35
+
36
+ const ROLE_LABELS: Record<AwaitRole, string> = {
37
+ ...KEY_PERSON_ROLE_LABELS,
38
+ authorized_representative: 'Authorized representative',
39
+ };
40
+
41
+ const SECTIONS: Array<{ role: AwaitRole; label: string }> = [
42
+ { role: 'beneficial_owner', label: 'UBOS' },
43
+ { role: 'director', label: 'DIRECTORS' },
44
+ { role: 'signatory', label: 'SIGNATORIES' },
45
+ { role: 'shareholder', label: 'SHAREHOLDERS' },
46
+ { role: 'authorized_representative', label: 'REPRESENTATIVES' },
47
+ ];
48
+
49
+ export function KeyPeopleInviteLinks({
50
+ invites,
51
+ }: {
52
+ invites: KeyPersonInvite[];
53
+ }): React.ReactElement | null {
54
+ const { colors } = useTheme();
55
+ const app = useKyc((s) => s.businessApplication);
56
+ if (invites.length === 0) return null;
57
+
58
+ const rows: AwaitRow[] = [];
59
+
60
+ // The applicant themselves — verified in-flow, nothing more to do.
61
+ if (app.applicantRole) {
62
+ const self =
63
+ app.applicantKeyPersonIndex !== null ? app.keyPeople[app.applicantKeyPersonIndex] : null;
64
+ rows.push({
65
+ name: (self?.name ?? app.applicantName).trim() || 'You',
66
+ role: (self?.role ?? app.applicantRole) as AwaitRole,
67
+ pct: self?.ownershipPct.trim() || null,
68
+ country: self?.country.trim() ? self.country.trim().toUpperCase() : null,
69
+ status: 'submitted',
70
+ isApplicant: true,
71
+ });
72
+ }
73
+
74
+ // Everyone the server minted a link for — enriched from the entered rows.
75
+ for (const invite of invites) {
76
+ const entered = app.keyPeople.find((p) => p.name.trim() === invite.name.trim());
77
+ rows.push({
78
+ name: invite.name,
79
+ role: (entered?.role ?? 'director') as AwaitRole,
80
+ pct: entered?.ownershipPct.trim() || null,
81
+ country: entered?.country.trim() ? entered.country.trim().toUpperCase() : null,
82
+ status: 'pending',
83
+ inviteUrl: invite.inviteUrl,
84
+ });
85
+ }
86
+
87
+ return (
88
+ <View style={{ width: '100%', marginTop: spacing.lg }}>
89
+ <MyazaText variant="bodySmall" color={colors.textMuted} style={{ textAlign: 'center', marginBottom: spacing.md }}>
90
+ To complete the review, the people below must verify their identity with a KYC check.
91
+ Anyone with an email on file has already been sent their link.
92
+ </MyazaText>
93
+
94
+ {SECTIONS.map(({ role, label }) => {
95
+ const group = rows.filter((r) => r.role === role);
96
+ if (group.length === 0) return null;
97
+ return (
98
+ <View key={role} style={{ marginBottom: spacing.sm }}>
99
+ <MyazaText
100
+ variant="bodySmall"
101
+ color={colors.textMuted}
102
+ style={{ fontWeight: '600', letterSpacing: 1.2, marginBottom: spacing.xs + 2 }}
103
+ >
104
+ {label}
105
+ </MyazaText>
106
+ {group.map((row, i) => (
107
+ <AwaitCard key={`${role}-${i}`} row={row} />
108
+ ))}
109
+ </View>
110
+ );
111
+ })}
112
+
113
+ <MyazaText variant="bodySmall" color={colors.textMuted} style={{ textAlign: 'center' }}>
114
+ Links are valid for 14 days.
115
+ </MyazaText>
116
+ </View>
117
+ );
118
+ }
119
+
120
+ function AwaitCard({ row }: { row: AwaitRow }): React.ReactElement {
121
+ const { colors } = useTheme();
122
+ const submitted = row.status === 'submitted';
123
+ const [copied, setCopied] = useState(false);
124
+
125
+ const share = (): void => {
126
+ if (!row.inviteUrl) return;
127
+ Share.share({ message: row.inviteUrl }).catch(() => undefined);
128
+ };
129
+ const copy = (): void => {
130
+ if (!row.inviteUrl) return;
131
+ Clipboard.setStringAsync(row.inviteUrl)
132
+ .then(() => {
133
+ setCopied(true);
134
+ setTimeout(() => setCopied(false), 2000);
135
+ })
136
+ .catch(() => undefined);
137
+ };
138
+
139
+ return (
140
+ <View
141
+ style={{
142
+ backgroundColor: colors.backgroundSecondary,
143
+ borderRadius: radius.md,
144
+ padding: spacing.md,
145
+ marginBottom: spacing.sm,
146
+ }}
147
+ >
148
+ <View style={{ flexDirection: 'row', alignItems: 'flex-start' }}>
149
+ <View style={{ flex: 1, minWidth: 0, marginRight: spacing.sm }}>
150
+ <MyazaText variant="bodyMedium" numberOfLines={1} style={{ fontWeight: '700' }}>
151
+ {row.name}
152
+ {row.isApplicant ? (
153
+ <MyazaText variant="bodySmall" color={colors.textMuted}>
154
+ {' (you)'}
155
+ </MyazaText>
156
+ ) : null}
157
+ </MyazaText>
158
+ <View style={{ flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', marginTop: 2 }}>
159
+ <MyazaText variant="bodySmall" color={colors.textMuted}>
160
+ {ROLE_LABELS[row.role]}
161
+ {row.pct ? ` · ${row.pct}%` : ''}
162
+ {row.country ? ' · ' : ''}
163
+ </MyazaText>
164
+ {row.country ? (
165
+ <>
166
+ <CountryFlag country={row.country} size={14} />
167
+ <MyazaText variant="bodySmall" color={colors.textMuted}>
168
+ {` ${regionCountryName(row.country)}`}
169
+ </MyazaText>
170
+ </>
171
+ ) : null}
172
+ </View>
173
+ </View>
174
+ <View
175
+ style={{
176
+ borderRadius: radius.full,
177
+ paddingHorizontal: spacing.sm + 4,
178
+ paddingVertical: spacing.xs,
179
+ backgroundColor: submitted ? colors.successBg : colors.primary100,
180
+ }}
181
+ >
182
+ <MyazaText
183
+ variant="bodySmall"
184
+ color={submitted ? colors.success : colors.primary}
185
+ style={{ fontWeight: '700', fontSize: 11, letterSpacing: 0.6 }}
186
+ >
187
+ {submitted ? 'SUBMITTED' : 'KYC PENDING'}
188
+ </MyazaText>
189
+ </View>
190
+ </View>
191
+
192
+ {row.status === 'pending' && row.inviteUrl ? (
193
+ // Both actions, since it's mobile: Copy for pasting anywhere, Share
194
+ // for the native sheet (WhatsApp/SMS directly).
195
+ <View style={{ flexDirection: 'row', marginTop: spacing.sm + 4 }}>
196
+ <Pressable
197
+ onPress={copy}
198
+ accessibilityRole="button"
199
+ accessibilityLabel={`Copy ${row.name}'s verification link`}
200
+ style={{
201
+ flex: 1,
202
+ flexDirection: 'row',
203
+ alignItems: 'center',
204
+ justifyContent: 'center',
205
+ backgroundColor: colors.primary100,
206
+ borderRadius: radius.full,
207
+ paddingVertical: spacing.sm + 2,
208
+ marginRight: spacing.sm,
209
+ }}
210
+ >
211
+ <Icon name={copied ? 'check' : 'copy'} size={15} color={colors.primary} />
212
+ <MyazaText variant="bodySmall" color={colors.primary} style={{ fontWeight: '700', marginLeft: spacing.xs + 2 }}>
213
+ {copied ? 'Copied' : 'Copy link'}
214
+ </MyazaText>
215
+ </Pressable>
216
+ <Pressable
217
+ onPress={share}
218
+ accessibilityRole="button"
219
+ accessibilityLabel={`Share ${row.name}'s verification link`}
220
+ style={{
221
+ flex: 1,
222
+ flexDirection: 'row',
223
+ alignItems: 'center',
224
+ justifyContent: 'center',
225
+ backgroundColor: colors.primary100,
226
+ borderRadius: radius.full,
227
+ paddingVertical: spacing.sm + 2,
228
+ }}
229
+ >
230
+ <Icon name="share" size={15} color={colors.primary} />
231
+ <MyazaText variant="bodySmall" color={colors.primary} style={{ fontWeight: '700', marginLeft: spacing.xs + 2 }}>
232
+ Share
233
+ </MyazaText>
234
+ </Pressable>
235
+ </View>
236
+ ) : null}
237
+ </View>
238
+ );
239
+ }
@@ -0,0 +1,119 @@
1
+ import React from 'react';
2
+ import { Pressable, View } from 'react-native';
3
+
4
+ import { radius, spacing } from '../config/theme';
5
+ import { useTheme } from '../components/runtime';
6
+ import { MyazaText } from '../components/Typography';
7
+ import { Icon } from '../components/Icon';
8
+ import { CountryFlag } from '../components/CountryFlag';
9
+ import {
10
+ KEY_PERSON_ROLE_LABELS,
11
+ initialsOf,
12
+ isKeyPersonRowValid,
13
+ type KeyPersonEntry,
14
+ } from '../config/keyPeople';
15
+ import { regionCountryName } from '../config/regions';
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // One saved key person, summarised — monogram avatar with their ID-issuing
19
+ // country flag badged on its corner, name, role · ownership meta, and the
20
+ // email their invite will go to. The whole card opens the edit sheet (the
21
+ // chevron is the affordance); removal lives INSIDE that sheet, so a stray tap
22
+ // can never delete a person.
23
+ //
24
+ // Same visual language as the applicant step's "this is me" cards, so the two
25
+ // screens read as one system.
26
+ // ---------------------------------------------------------------------------
27
+
28
+ export function KeyPersonCard({
29
+ entry,
30
+ onPress,
31
+ }: {
32
+ entry: KeyPersonEntry;
33
+ onPress: () => void;
34
+ }): React.ReactElement {
35
+ const { colors } = useTheme();
36
+ const name = entry.name.trim() || 'Unnamed person';
37
+ const country = entry.country.trim() ? entry.country.trim().toUpperCase() : null;
38
+ const pct = entry.ownershipPct.trim();
39
+ // A row persisted by the old inline UI (or interrupted mid-edit) may be
40
+ // incomplete — the card says so instead of silently blocking Continue.
41
+ const incomplete = !isKeyPersonRowValid(entry);
42
+
43
+ const meta = [KEY_PERSON_ROLE_LABELS[entry.role], pct ? `${pct}% ownership` : null]
44
+ .filter(Boolean)
45
+ .join(' · ');
46
+ // The flag alone doesn't say WHICH country — spell it out, alongside the
47
+ // email their invite goes to.
48
+ const detail = [country ? regionCountryName(country) : null, entry.email.trim() || null]
49
+ .filter(Boolean)
50
+ .join(' · ');
51
+
52
+ return (
53
+ <Pressable
54
+ onPress={onPress}
55
+ accessibilityRole="button"
56
+ accessibilityLabel={`Edit ${name}`}
57
+ style={({ pressed }) => ({
58
+ flexDirection: 'row',
59
+ alignItems: 'center',
60
+ borderWidth: 1,
61
+ borderColor: incomplete ? colors.error : colors.border,
62
+ borderRadius: radius.sm,
63
+ backgroundColor: pressed ? colors.backgroundSecondary : colors.background,
64
+ padding: spacing.md - 2,
65
+ marginBottom: spacing.sm,
66
+ })}
67
+ >
68
+ <View
69
+ style={{
70
+ width: 40,
71
+ height: 40,
72
+ borderRadius: 20,
73
+ backgroundColor: colors.primary100,
74
+ alignItems: 'center',
75
+ justifyContent: 'center',
76
+ }}
77
+ >
78
+ <MyazaText variant="bodySmall" color={colors.primary} style={{ fontWeight: '700' }}>
79
+ {initialsOf(name)}
80
+ </MyazaText>
81
+ {country ? (
82
+ <View
83
+ style={{
84
+ position: 'absolute',
85
+ bottom: -2,
86
+ right: -4,
87
+ borderRadius: 10,
88
+ borderWidth: 2,
89
+ borderColor: colors.background,
90
+ overflow: 'hidden',
91
+ }}
92
+ >
93
+ <CountryFlag country={country} size={16} />
94
+ </View>
95
+ ) : null}
96
+ </View>
97
+
98
+ <View style={{ flex: 1, minWidth: 0, marginLeft: spacing.sm + 4 }}>
99
+ <MyazaText variant="bodyMedium" numberOfLines={1} style={{ fontWeight: '600' }}>
100
+ {name}
101
+ </MyazaText>
102
+ <MyazaText variant="bodySmall" color={colors.textMuted} numberOfLines={1} style={{ marginTop: 1 }}>
103
+ {meta}
104
+ </MyazaText>
105
+ {incomplete ? (
106
+ <MyazaText variant="bodySmall" color={colors.error} numberOfLines={1} style={{ marginTop: 1 }}>
107
+ Incomplete — tap to finish
108
+ </MyazaText>
109
+ ) : detail !== '' ? (
110
+ <MyazaText variant="bodySmall" color={colors.textMuted} numberOfLines={1} style={{ marginTop: 1 }}>
111
+ {detail}
112
+ </MyazaText>
113
+ ) : null}
114
+ </View>
115
+
116
+ <Icon name="pencil" size={16} color={colors.textMuted} />
117
+ </Pressable>
118
+ );
119
+ }
@@ -0,0 +1,148 @@
1
+ import React, { useMemo } 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 { MyazaSelect } from '../components/MyazaSelect';
9
+ import { CountryField } from '../components/CountryField';
10
+ import type { DialCodeOption } from '../components/DialCodePicker';
11
+ import { ALL_REGION_CODES, regionCountryName } from '../config/regions';
12
+ import { isValidContactEmail } from '../config/contact';
13
+ import {
14
+ KEY_PERSON_ROLES,
15
+ KEY_PERSON_ROLE_LABELS,
16
+ type KeyPersonEntry,
17
+ } from '../config/keyPeople';
18
+ import type { KeyPersonRole } from '../types/business';
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // The key-person FIELDS — full name → role → ownership % → country → email —
22
+ // with the same live per-field validation the old inline row had. No card
23
+ // chrome, no header: this is the body of the add/edit sheet (KeyPersonSheet),
24
+ // which owns the draft state and the save/remove actions.
25
+ // ---------------------------------------------------------------------------
26
+
27
+ /** Whether the role is an ownership one (% is only meaningful then). */
28
+ function isOwnerRole(role: KeyPersonRole): boolean {
29
+ return role === 'beneficial_owner' || role === 'shareholder';
30
+ }
31
+
32
+ /** Every ISO-2 we can name, alphabetical — built once, shared by all mounts. */
33
+ let countryOptions: DialCodeOption[] | null = null;
34
+ function allCountryOptions(): DialCodeOption[] {
35
+ countryOptions ??= [...ALL_REGION_CODES]
36
+ .map((code) => ({ code, name: regionCountryName(code) }))
37
+ .sort((a, b) => a.name.localeCompare(b.name));
38
+ return countryOptions;
39
+ }
40
+
41
+ export function KeyPersonForm({
42
+ entry,
43
+ onChange,
44
+ uboThreshold = 25,
45
+ combinedPctError = null,
46
+ }: {
47
+ entry: KeyPersonEntry;
48
+ onChange: (patch: Partial<KeyPersonEntry>) => void;
49
+ /** Ownership % at/above which the server treats a person as a beneficial
50
+ * owner (the workflow's `keyPeople.ownershipThreshold`, default 25). */
51
+ uboThreshold?: number;
52
+ /**
53
+ * Set when this draft's % would push the COMBINED ownership across all
54
+ * people past 100% — shown on the % field as a warning. It never blocks
55
+ * saving (the fix may live on a different person); the list's summary and
56
+ * the disabled Continue enforce the total.
57
+ */
58
+ combinedPctError?: string | null;
59
+ }): React.ReactElement {
60
+ const { colors } = useTheme();
61
+ const options = useMemo(allCountryOptions, []);
62
+
63
+ const nameInvalid = entry.name !== '' && entry.name.trim().length < 2;
64
+ const emailInvalid = entry.email.trim() !== '' && !isValidContactEmail(entry.email.trim());
65
+ const pct = entry.ownershipPct.trim();
66
+ const pctNum = Number(pct);
67
+ const pctInvalid = pct !== '' && (!Number.isFinite(pctNum) || pctNum < 0 || pctNum > 100);
68
+ // Surface the regulatory consequence as feedback: at/above the threshold the
69
+ // server escalates this person to a beneficial owner regardless of the role
70
+ // picked. Quiet when they already chose UBO — nothing new to say.
71
+ const uboHint =
72
+ !pctInvalid && pct !== '' && pctNum >= uboThreshold && entry.role !== 'beneficial_owner';
73
+
74
+ return (
75
+ <View>
76
+ <MyazaInput
77
+ label="Full name"
78
+ value={entry.name}
79
+ onChangeText={(name) => onChange({ name })}
80
+ placeholder="e.g. Bola Owner"
81
+ // It's a person's name — start every word capitalized.
82
+ autoCapitalize="words"
83
+ error={nameInvalid ? 'Enter the person’s full name.' : null}
84
+ />
85
+
86
+ <View style={{ height: spacing.sm }} />
87
+ <MyazaText variant="bodySmall" style={{ fontWeight: '600', marginBottom: spacing.xs }}>
88
+ Role
89
+ </MyazaText>
90
+ <MyazaSelect
91
+ value={entry.role}
92
+ sheetTitle="Role"
93
+ options={KEY_PERSON_ROLES.map((role) => ({
94
+ value: role,
95
+ label: KEY_PERSON_ROLE_LABELS[role],
96
+ }))}
97
+ onChange={(role) => onChange({ role })}
98
+ />
99
+
100
+ <View style={{ height: spacing.sm }} />
101
+ <MyazaInput
102
+ label="Ownership % (optional)"
103
+ value={entry.ownershipPct}
104
+ onChangeText={(ownershipPct) => onChange({ ownershipPct })}
105
+ placeholder={isOwnerRole(entry.role) ? 'e.g. 60' : '—'}
106
+ keyboardType="decimal-pad"
107
+ suffix={
108
+ <MyazaText variant="bodySmall" color={colors.textMuted}>
109
+ %
110
+ </MyazaText>
111
+ }
112
+ error={pctInvalid ? 'Enter a value between 0 and 100.' : combinedPctError}
113
+ helper={
114
+ uboHint
115
+ ? `At ${uboThreshold}% or more, this person counts as a beneficial owner.`
116
+ : undefined
117
+ }
118
+ />
119
+
120
+ <View style={{ height: spacing.sm }} />
121
+ <MyazaText variant="bodySmall" style={{ fontWeight: '600', marginBottom: spacing.xs }}>
122
+ Country{' '}
123
+ <MyazaText variant="bodySmall" color={colors.textSecondary}>
124
+ (where their ID was issued)
125
+ </MyazaText>
126
+ </MyazaText>
127
+ {/* The SAME sheet as the phone field's dial-code picker (keyboard-aware,
128
+ autofocused search, results pinned above the keys) — minus the dial
129
+ codes. Two country pickers that feel different would read as a bug. */}
130
+ <CountryField
131
+ value={entry.country || null}
132
+ options={options}
133
+ onChange={(country) => onChange({ country })}
134
+ />
135
+
136
+ <View style={{ height: spacing.sm }} />
137
+ <MyazaInput
138
+ label="Email (optional — used to send their verification link)"
139
+ value={entry.email}
140
+ onChangeText={(email) => onChange({ email })}
141
+ placeholder="name@company.com"
142
+ keyboardType="email-address"
143
+ autoCapitalize="none"
144
+ error={emailInvalid ? 'Enter a valid email address.' : null}
145
+ />
146
+ </View>
147
+ );
148
+ }
@@ -0,0 +1,184 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import {
3
+ Keyboard,
4
+ Modal,
5
+ Platform,
6
+ Pressable,
7
+ ScrollView,
8
+ useWindowDimensions,
9
+ View,
10
+ } from 'react-native';
11
+
12
+ import { radius, spacing } from '../config/theme';
13
+ import { useTheme } from '../components/runtime';
14
+ import { MyazaText } from '../components/Typography';
15
+ import { MyazaButton } from '../components/MyazaButton';
16
+ import { Icon } from '../components/Icon';
17
+ import { KeyPersonForm } from './KeyPersonForm';
18
+ import { isKeyPersonRowValid, type KeyPersonEntry } from '../config/keyPeople';
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // The add/edit key-person sheet. The list step stays a clean stack of summary
22
+ // cards; the FORM lives here — slide-up sheet, grab handle, the five fields,
23
+ // and a pinned primary action. Editing adds a visually separated destructive
24
+ // "Remove this person" beneath the save (never beside it — HIG destructive
25
+ // separation).
26
+ //
27
+ // Keyboard handling mirrors DialCodePicker: the sheet is lifted above the keys
28
+ // and sized against the space that remains, so the focused field is never
29
+ // underneath the keyboard.
30
+ //
31
+ // Save is enabled once the draft is valid. A combined-ownership overshoot
32
+ // WARNS here but never blocks saving — the fix may live on a different
33
+ // person's %, and trapping the user inside this sheet would force them to
34
+ // discard their work to go adjust it.
35
+ // ---------------------------------------------------------------------------
36
+
37
+ export function KeyPersonSheet({
38
+ mode,
39
+ initial,
40
+ uboThreshold,
41
+ otherPctTotal,
42
+ onSave,
43
+ onRemove,
44
+ onClose,
45
+ }: {
46
+ mode: 'add' | 'edit';
47
+ initial: KeyPersonEntry;
48
+ uboThreshold?: number;
49
+ /** Sum of every OTHER person's ownership % — for the combined warning. */
50
+ otherPctTotal: number;
51
+ onSave: (entry: KeyPersonEntry) => void;
52
+ /** Edit mode only — removes the person and closes. */
53
+ onRemove?: () => void;
54
+ onClose: () => void;
55
+ }): React.ReactElement {
56
+ const { colors } = useTheme();
57
+ const { height: screenHeight } = useWindowDimensions();
58
+ const [draft, setDraft] = useState<KeyPersonEntry>(initial);
59
+ const [keyboard, setKeyboard] = useState(0);
60
+
61
+ useEffect(() => {
62
+ // iOS reports 'will' events early enough to resize before the keys land;
63
+ // Android only emits 'did'.
64
+ const show = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
65
+ const hide = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
66
+ const shown = Keyboard.addListener(show, (e) => setKeyboard(e.endCoordinates.height));
67
+ const hidden = Keyboard.addListener(hide, () => setKeyboard(0));
68
+ return () => {
69
+ shown.remove();
70
+ hidden.remove();
71
+ };
72
+ }, []);
73
+
74
+ const available = screenHeight - keyboard;
75
+ const maxHeight = Math.min(available * 0.92, screenHeight * 0.85);
76
+
77
+ const draftPct = Number(draft.ownershipPct);
78
+ const combinedTotal =
79
+ draft.ownershipPct.trim() !== '' && Number.isFinite(draftPct)
80
+ ? otherPctTotal + draftPct
81
+ : otherPctTotal;
82
+ const fmtPct = (n: number): string => (Number.isInteger(n) ? String(n) : n.toFixed(1));
83
+ const combinedPctError =
84
+ combinedTotal > 100
85
+ ? `Combined ownership would be ${fmtPct(combinedTotal)}% — over by ${fmtPct(combinedTotal - 100)}%.`
86
+ : null;
87
+
88
+ const canSave = isKeyPersonRowValid(draft) && draft.name.trim().length >= 2;
89
+
90
+ return (
91
+ <Modal visible transparent animationType="slide" onRequestClose={onClose}>
92
+ {/* Same wrapper as MyazaSelect's option sheet: a flex-end backdrop with
93
+ the sheet inside it, so the top corners round identically. Tapping
94
+ the backdrop dismisses; a tap inside the sheet does not. */}
95
+ <Pressable
96
+ onPress={onClose}
97
+ accessibilityLabel="Close"
98
+ style={{
99
+ flex: 1,
100
+ backgroundColor: 'rgba(0,0,0,0.45)',
101
+ justifyContent: 'flex-end',
102
+ }}
103
+ >
104
+ <Pressable
105
+ onPress={() => {}}
106
+ style={{
107
+ maxHeight,
108
+ marginBottom: keyboard,
109
+ backgroundColor: colors.background,
110
+ borderTopLeftRadius: radius.lg,
111
+ borderTopRightRadius: radius.lg,
112
+ overflow: 'hidden',
113
+ }}
114
+ >
115
+ <View
116
+ style={{
117
+ alignSelf: 'center',
118
+ width: 36,
119
+ height: 4,
120
+ borderRadius: radius.full,
121
+ backgroundColor: colors.border,
122
+ marginTop: spacing.sm,
123
+ }}
124
+ />
125
+ <View
126
+ style={{
127
+ flexDirection: 'row',
128
+ alignItems: 'center',
129
+ paddingHorizontal: spacing.md,
130
+ paddingTop: spacing.sm + 4,
131
+ paddingBottom: spacing.sm,
132
+ }}
133
+ >
134
+ <MyazaText variant="body" style={{ flex: 1, fontWeight: '700' }}>
135
+ {mode === 'add' ? 'Add a person' : 'Edit person'}
136
+ </MyazaText>
137
+ <Pressable onPress={onClose} hitSlop={12} accessibilityRole="button" accessibilityLabel="Close">
138
+ <Icon name="x" size={20} color={colors.textSecondary} />
139
+ </Pressable>
140
+ </View>
141
+
142
+ <ScrollView
143
+ bounces={false}
144
+ keyboardShouldPersistTaps="handled"
145
+ contentContainerStyle={{ paddingHorizontal: spacing.md, paddingBottom: spacing.sm }}
146
+ >
147
+ <KeyPersonForm
148
+ entry={draft}
149
+ onChange={(patch) => setDraft((d) => ({ ...d, ...patch }))}
150
+ uboThreshold={uboThreshold}
151
+ combinedPctError={combinedPctError}
152
+ />
153
+ </ScrollView>
154
+
155
+ <View style={{ paddingHorizontal: spacing.md, paddingTop: spacing.sm, paddingBottom: spacing.lg }}>
156
+ <MyazaButton
157
+ label={mode === 'add' ? 'Add person' : 'Save changes'}
158
+ onPress={canSave ? () => onSave(draft) : undefined}
159
+ disabled={!canSave}
160
+ />
161
+ {mode === 'edit' && onRemove ? (
162
+ <Pressable
163
+ onPress={onRemove}
164
+ accessibilityRole="button"
165
+ accessibilityLabel="Remove this person"
166
+ style={({ pressed }) => ({
167
+ height: 44,
168
+ alignItems: 'center',
169
+ justifyContent: 'center',
170
+ marginTop: spacing.xs,
171
+ opacity: pressed ? 0.7 : 1,
172
+ })}
173
+ >
174
+ <MyazaText variant="bodySmall" color={colors.error} style={{ fontWeight: '600' }}>
175
+ Remove this person
176
+ </MyazaText>
177
+ </Pressable>
178
+ ) : null}
179
+ </View>
180
+ </Pressable>
181
+ </Pressable>
182
+ </Modal>
183
+ );
184
+ }