@myazahq/kyc-sdk-react-native 2.4.0 → 2.6.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 (246) hide show
  1. package/README.md +151 -5
  2. package/android/build.gradle +35 -3
  3. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +77 -0
  4. package/app.plugin.js +89 -8
  5. package/ios/HybridMyazaFaceDetector.swift +16 -3
  6. package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.cpp +23 -0
  7. package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.hpp +2 -0
  8. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaFaceDetectorSpec.kt +9 -0
  9. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.cpp +8 -0
  10. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.hpp +43 -0
  11. package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.hpp +17 -0
  12. package/nitrogen/generated/ios/swift/Func_void_bool.swift +46 -0
  13. package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec.swift +2 -0
  14. package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec_cxx.swift +31 -0
  15. package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.cpp +2 -0
  16. package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.hpp +3 -0
  17. package/package.json +14 -2
  18. package/src/MyazaBiometricAuth.tsx +119 -0
  19. package/src/MyazaKYC.tsx +12 -0
  20. package/src/components/CountryField.tsx +8 -0
  21. package/src/components/CountryRegionPicker.tsx +184 -0
  22. package/src/components/DashedBorder.tsx +55 -0
  23. package/src/components/DialCodePicker.tsx +53 -67
  24. package/src/components/DialCodeRow.tsx +102 -0
  25. package/src/components/FramedMapPicker.tsx +122 -0
  26. package/src/components/GeoBadge.tsx +34 -0
  27. package/src/components/Icon.tsx +2 -176
  28. package/src/components/KycFlow.tsx +60 -32
  29. package/src/components/LineSkeleton.tsx +100 -0
  30. package/src/components/MapChrome.tsx +67 -0
  31. package/src/components/MapPinMarker.tsx +54 -0
  32. package/src/components/MapPinPicker.tsx +179 -0
  33. package/src/components/MilestoneTrack.tsx +155 -0
  34. package/src/components/MultiIdProgress.tsx +107 -0
  35. package/src/components/MyazaInput.tsx +39 -4
  36. package/src/components/MyazaSelect.tsx +2 -1
  37. package/src/components/OptionRow.tsx +22 -3
  38. package/src/components/OwnershipSlider.tsx +102 -0
  39. package/src/components/PhoneNumberInput.tsx +45 -4
  40. package/src/components/PoweredBy.tsx +5 -5
  41. package/src/components/PresenceBlocks.tsx +153 -0
  42. package/src/components/StaggerIn.tsx +63 -0
  43. package/src/components/StepView.tsx +43 -0
  44. package/src/components/StickyActions.tsx +56 -0
  45. package/src/components/dialCodeRows.ts +64 -0
  46. package/src/components/glass/FloatingSheet.tsx +73 -21
  47. package/src/components/glass/GlassSheet.tsx +14 -2
  48. package/src/components/icon-map.ts +176 -0
  49. package/src/components/stepHeaderMeta.tsx +45 -5
  50. package/src/config/addressCollection.ts +125 -0
  51. package/src/config/biometricOptions.ts +100 -0
  52. package/src/config/business.ts +26 -7
  53. package/src/config/businessDetailsValidity.ts +52 -0
  54. package/src/config/businessPrefill.ts +72 -0
  55. package/src/config/businessSteps.ts +20 -3
  56. package/src/config/consentStep.ts +19 -0
  57. package/src/config/keyPeople.ts +111 -7
  58. package/src/config/keyPeopleCorporate.ts +19 -0
  59. package/src/config/keyPeopleOwnerTypes.ts +12 -0
  60. package/src/config/keyPeopleOwners.ts +26 -0
  61. package/src/config/keyPeoplePrefill.ts +138 -0
  62. package/src/config/keyPeopleSectionDefs.ts +65 -0
  63. package/src/config/keyPeopleSections.ts +167 -0
  64. package/src/config/proofOfAddress.ts +54 -11
  65. package/src/config/regions.ts +24 -0
  66. package/src/config/registrationHint.ts +1 -1
  67. package/src/config/stepOrder.ts +100 -6
  68. package/src/config/theme.ts +8 -2
  69. package/src/config/uploadLimits.ts +35 -0
  70. package/src/config/website.ts +36 -0
  71. package/src/config/workflowMerge.ts +16 -5
  72. package/src/emrtd/activeAuth.ts +100 -0
  73. package/src/emrtd/extras.ts +76 -0
  74. package/src/emrtd/files.ts +5 -0
  75. package/src/emrtd/index.ts +1 -0
  76. package/src/emrtd/read.ts +8 -1
  77. package/src/emrtd/session.ts +41 -0
  78. package/src/emrtd/stages.ts +11 -0
  79. package/src/index.ts +54 -1
  80. package/src/lib/address-current-location.ts +171 -0
  81. package/src/lib/address-field-modes.ts +119 -0
  82. package/src/lib/address-flow.ts +172 -0
  83. package/src/lib/address-helpers.ts +48 -0
  84. package/src/lib/address-line.ts +76 -0
  85. package/src/lib/address-pin-move.ts +97 -0
  86. package/src/lib/address-step-recovery.ts +63 -0
  87. package/src/lib/authed-image.ts +78 -0
  88. package/src/lib/biometric-auth.ts +52 -0
  89. package/src/lib/biometric-copy.ts +51 -0
  90. package/src/lib/captureRing.ts +83 -0
  91. package/src/lib/contact-recovery.ts +44 -0
  92. package/src/lib/country-adoption.ts +89 -0
  93. package/src/lib/inferred-country.ts +65 -0
  94. package/src/lib/livenessLayout.ts +49 -0
  95. package/src/lib/map-frame.ts +159 -0
  96. package/src/lib/map-tiles.ts +143 -0
  97. package/src/lib/multi-id.ts +210 -0
  98. package/src/lib/poa-country-gate.ts +27 -0
  99. package/src/lib/resubmit.ts +130 -0
  100. package/src/lib/result-copy.ts +116 -0
  101. package/src/lib/result-wait.ts +53 -0
  102. package/src/lib/review-map-surface.ts +26 -0
  103. package/src/lib/scope.ts +31 -0
  104. package/src/lib/screen-corners.ts +50 -0
  105. package/src/lib/selfie-upload-wait.ts +79 -0
  106. package/src/lib/step-log.ts +24 -3
  107. package/src/lib/street-view-fov.ts +42 -0
  108. package/src/lib/use-multi-id-plan.ts +29 -0
  109. package/src/lib/webview-available.ts +47 -0
  110. package/src/liveness/useLiveness.ts +6 -1
  111. package/src/liveness/useModelReady.ts +65 -0
  112. package/src/liveness/visionCameraFaceDetector.ts +35 -0
  113. package/src/presence/background-math.ts +100 -0
  114. package/src/presence/background-store.ts +82 -0
  115. package/src/presence/background.ts +165 -0
  116. package/src/presence/foreground-service.ts +193 -0
  117. package/src/presence/fs.ts +51 -0
  118. package/src/presence/geofence.ts +41 -0
  119. package/src/presence/math.ts +44 -0
  120. package/src/presence/post.ts +40 -0
  121. package/src/presence/report.ts +87 -0
  122. package/src/presence/sampler.ts +110 -0
  123. package/src/presence/status.ts +92 -0
  124. package/src/presence/store.ts +94 -0
  125. package/src/presence/tier.ts +36 -0
  126. package/src/presence/watch-wait.ts +81 -0
  127. package/src/screens/AddressCountryControl.tsx +115 -0
  128. package/src/screens/ApplicantRoleStep.tsx +9 -5
  129. package/src/screens/BusinessCheckPanel.tsx +89 -0
  130. package/src/screens/BusinessDetailsFields.tsx +112 -0
  131. package/src/screens/BusinessDetailsStep.tsx +133 -145
  132. package/src/screens/BusinessDocumentSlot.tsx +5 -5
  133. package/src/screens/BusinessDocumentsStep.tsx +5 -56
  134. package/src/screens/BusinessKeyPeopleStep.tsx +103 -71
  135. package/src/screens/BusinessPickedCard.tsx +86 -0
  136. package/src/screens/BusinessPickedSection.tsx +51 -0
  137. package/src/screens/BusinessRegistryPickers.tsx +67 -0
  138. package/src/screens/BusinessSandboxToggle.tsx +114 -0
  139. package/src/screens/BusinessSearch.tsx +199 -0
  140. package/src/screens/BusinessSearchResults.tsx +115 -0
  141. package/src/screens/CompanyInfoFields.tsx +91 -51
  142. package/src/screens/ConsentStep.tsx +37 -17
  143. package/src/screens/ContactActions.tsx +54 -0
  144. package/src/screens/ContactDestinationField.tsx +5 -0
  145. package/src/screens/ContactEntryPanel.tsx +75 -0
  146. package/src/screens/ContactVerificationStep.tsx +43 -42
  147. package/src/screens/CountrySelectStep.tsx +13 -147
  148. package/src/screens/IdTypeStep.tsx +12 -5
  149. package/src/screens/KeepLinksSheet.tsx +88 -0
  150. package/src/screens/KeyPeopleAwaitCard.tsx +204 -0
  151. package/src/screens/KeyPeopleAwaitList.tsx +126 -0
  152. package/src/screens/KeyPeoplePending.tsx +154 -0
  153. package/src/screens/KeyPeopleSection.tsx +162 -0
  154. package/src/screens/KeyPeopleSectionsList.tsx +103 -0
  155. package/src/screens/KeyPeopleUboExemption.tsx +72 -0
  156. package/src/screens/KeyPersonCard.tsx +36 -4
  157. package/src/screens/KeyPersonForm.tsx +195 -36
  158. package/src/screens/KeyPersonKindToggle.tsx +68 -0
  159. package/src/screens/KeyPersonOwners.tsx +109 -0
  160. package/src/screens/KeyPersonRoleChips.tsx +68 -0
  161. package/src/screens/KeyPersonSheet.tsx +92 -108
  162. package/src/screens/LivenessAvatar.tsx +15 -3
  163. package/src/screens/LivenessStep.tsx +172 -52
  164. package/src/screens/NfcStep.tsx +14 -0
  165. package/src/screens/PoaDocumentTypeList.tsx +66 -0
  166. package/src/screens/ProofOfAddressParts.tsx +151 -0
  167. package/src/screens/ProofOfAddressStep.tsx +58 -96
  168. package/src/screens/SubmittedBadge.tsx +25 -0
  169. package/src/screens/SubmittedError.tsx +64 -0
  170. package/src/screens/SubmittedResult.tsx +117 -0
  171. package/src/screens/SubmittedStep.tsx +82 -119
  172. package/src/screens/SubmittedSuccess.tsx +127 -0
  173. package/src/screens/SubmittedWaiting.tsx +45 -0
  174. package/src/screens/address/AddressEntranceStep.tsx +161 -0
  175. package/src/screens/address/AddressIntroGate.tsx +148 -0
  176. package/src/screens/address/AddressMapStub.tsx +50 -0
  177. package/src/screens/address/AddressPinStep.tsx +178 -0
  178. package/src/screens/address/AddressReviewStep.tsx +162 -0
  179. package/src/screens/address/AddressSandboxTabs.tsx +158 -0
  180. package/src/screens/address/AddressSearchField.tsx +115 -0
  181. package/src/screens/address/AddressSearchStep.tsx +94 -0
  182. package/src/screens/address/CurrentLocationRow.tsx +135 -0
  183. package/src/screens/address/DetailsSheet.tsx +175 -0
  184. package/src/screens/address/DetailsSheetFields.tsx +184 -0
  185. package/src/screens/address/EntranceDropzone.tsx +178 -0
  186. package/src/screens/address/EntranceFraming.tsx +66 -0
  187. package/src/screens/address/EntrancePills.tsx +60 -0
  188. package/src/screens/address/FramedStreetView.tsx +155 -0
  189. package/src/screens/address/IntroDisclosures.tsx +210 -0
  190. package/src/screens/address/LabelDecisionRow.tsx +113 -0
  191. package/src/screens/address/PinSummaryRow.tsx +105 -0
  192. package/src/screens/address/ReviewAddressBand.tsx +111 -0
  193. package/src/screens/address/ReviewEntranceThumbs.tsx +64 -0
  194. package/src/screens/address/ReviewMapPicture.tsx +106 -0
  195. package/src/screens/address/SearchResults.tsx +105 -0
  196. package/src/screens/address/SearchScreen.tsx +176 -0
  197. package/src/screens/address/SkipForNow.tsx +43 -0
  198. package/src/screens/address/StreetViewChrome.tsx +81 -0
  199. package/src/screens/address/detail-values.ts +22 -0
  200. package/src/screens/address/fix-source.ts +27 -0
  201. package/src/screens/address/index.ts +8 -0
  202. package/src/screens/address/meta.ts +44 -0
  203. package/src/screens/address/use-address-flow.ts +200 -0
  204. package/src/screens/address/use-label-pin.ts +80 -0
  205. package/src/screens/address/use-pin-actions.ts +192 -0
  206. package/src/screens/biometric/BiometricAuthFlow.tsx +170 -0
  207. package/src/screens/consent/model.ts +79 -10
  208. package/src/screens/liveness/CaptureRing.tsx +91 -0
  209. package/src/screens/liveness/LivenessCamera.tsx +63 -0
  210. package/src/screens/liveness/LivenessHandover.tsx +43 -0
  211. package/src/screens/liveness/LivenessOutcome.tsx +4 -3
  212. package/src/screens/liveness/SelfiePreview.tsx +13 -4
  213. package/src/screens/liveness/ShutterFlash.tsx +25 -0
  214. package/src/screens/liveness/index.ts +4 -0
  215. package/src/screens/liveness/useSelfieUpload.ts +23 -3
  216. package/src/screens/nfc/NfcReadProgress.tsx +1 -0
  217. package/src/screens/nfc/NfcScanIllustration.tsx +7 -18
  218. package/src/screens/useAddressPhotoAttach.ts +77 -0
  219. package/src/screens/useAwaitingPeople.ts +90 -0
  220. package/src/screens/useBusinessDocumentAttach.ts +93 -0
  221. package/src/screens/usePoaAttach.ts +12 -15
  222. package/src/services/api-biometric.ts +37 -0
  223. package/src/services/api-types-biometric.ts +41 -0
  224. package/src/services/api-types.ts +251 -3
  225. package/src/services/api-verify-types.ts +90 -1
  226. package/src/services/api.ts +237 -3
  227. package/src/services/deviceMetadata.ts +1 -1
  228. package/src/services/errors.ts +10 -2
  229. package/src/services/location.ts +234 -0
  230. package/src/services/uploadErrors.ts +30 -0
  231. package/src/services/workflowGate.ts +8 -0
  232. package/src/specs/MyazaFaceDetector.nitro.ts +29 -0
  233. package/src/store/address.ts +97 -0
  234. package/src/store/businessCheck.ts +113 -0
  235. package/src/store/derive.ts +44 -4
  236. package/src/store/kycStore.ts +352 -16
  237. package/src/store/serverConfig.ts +20 -0
  238. package/src/store/session.ts +227 -0
  239. package/src/store/state.ts +289 -2
  240. package/src/store/submit.ts +80 -13
  241. package/src/store/submitApplicant.ts +5 -11
  242. package/src/types/business.ts +23 -2
  243. package/src/types/config.ts +70 -2
  244. package/src/types/verification.ts +13 -1
  245. package/src/types/workflow.ts +112 -1
  246. package/src/screens/KeyPeopleInviteLinks.tsx +0 -239
@@ -0,0 +1,103 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+
4
+ import { useTheme } from '../components/runtime';
5
+ import { spacing } from '../config/theme';
6
+ import {
7
+ grantRole,
8
+ quickAddCandidates,
9
+ sectionMembers,
10
+ withoutSection,
11
+ type KeyPeopleSection as SectionKey,
12
+ } from '../config/keyPeopleSections';
13
+ import type { KeyPeopleSectionDef } from '../config/keyPeopleSectionDefs';
14
+ import { KeyPeopleSection } from './KeyPeopleSection';
15
+ import { KeyPeopleUboExemption } from './KeyPeopleUboExemption';
16
+ import type { KeyPersonEntry } from '../config/keyPeople';
17
+ import type { KeyPersonRole } from '../types/business';
18
+
19
+ // The stacked sections with their shared-list plumbing: quick-add grants an
20
+ // existing person the section's hat, and the card's X takes them out of that
21
+ // section — dropping just the hat when membership rested on it, or the whole
22
+ // person when a declared stake (or a last remaining hat) is what holds them
23
+ // there. Split from BusinessKeyPeopleStep (200-line rule); mirrors the web
24
+ // SDK's KeyPeopleSectionsList 1:1.
25
+
26
+ export function KeyPeopleSectionsList({
27
+ sections,
28
+ rows,
29
+ threshold,
30
+ emailRequiredFor,
31
+ uboUnidentifiable,
32
+ canAdd,
33
+ onRows,
34
+ onSheet,
35
+ onExemption,
36
+ }: {
37
+ sections: KeyPeopleSectionDef[];
38
+ rows: KeyPersonEntry[];
39
+ threshold: number;
40
+ emailRequiredFor: ReadonlySet<KeyPersonRole>;
41
+ uboUnidentifiable: boolean;
42
+ canAdd: boolean;
43
+ onRows: (next: KeyPersonEntry[]) => void;
44
+ onSheet: (sheet: { mode: 'add' | 'edit'; section: SectionKey; index?: number }) => void;
45
+ onExemption: (next: boolean) => void;
46
+ }): React.ReactElement {
47
+ const { colors } = useTheme();
48
+ const members = sectionMembers(rows, threshold);
49
+
50
+ const removeFromSection = (section: SectionKey, index: number): void => {
51
+ const next = withoutSection(rows[index]!, section, threshold);
52
+ onRows(
53
+ next === null
54
+ ? rows.filter((_, i) => i !== index)
55
+ : rows.map((row, i) => (i === index ? next : row)),
56
+ );
57
+ };
58
+
59
+ return (
60
+ <View>
61
+ {sections.map(({ key, title, description, addLabel }, i) => (
62
+ <React.Fragment key={key}>
63
+ {i > 0 ? (
64
+ <View
65
+ style={{
66
+ borderTopWidth: 1,
67
+ borderTopColor: colors.border,
68
+ marginVertical: spacing.lg,
69
+ }}
70
+ />
71
+ ) : null}
72
+ <KeyPeopleSection
73
+ section={key}
74
+ title={title}
75
+ description={description}
76
+ rows={rows}
77
+ members={members[key]}
78
+ quickAdd={
79
+ key === 'ubos' && uboUnidentifiable ? [] : quickAddCandidates(rows, key, threshold)
80
+ }
81
+ emailRequiredFor={emailRequiredFor}
82
+ addLabel={addLabel}
83
+ canAdd={canAdd && !(key === 'ubos' && uboUnidentifiable)}
84
+ onAdd={() => onSheet({ mode: 'add', section: key })}
85
+ onEdit={(index) => onSheet({ mode: 'edit', section: key, index })}
86
+ onRemove={(index) => removeFromSection(key, index)}
87
+ onQuickAdd={(index) =>
88
+ onRows(rows.map((row, i2) => (i2 === index ? grantRole(row, key) : row)))
89
+ }
90
+ >
91
+ {key === 'ubos' ? (
92
+ <KeyPeopleUboExemption
93
+ checked={uboUnidentifiable}
94
+ hasUbos={members.ubos.length > 0}
95
+ onChange={onExemption}
96
+ />
97
+ ) : null}
98
+ </KeyPeopleSection>
99
+ </React.Fragment>
100
+ ))}
101
+ </View>
102
+ );
103
+ }
@@ -0,0 +1,72 @@
1
+ import React from 'react';
2
+ import { Pressable, View } from 'react-native';
3
+
4
+ import { Icon } from '../components/Icon';
5
+ import { MyazaText } from '../components/Typography';
6
+ import { useTheme } from '../components/runtime';
7
+ import { spacing } from '../config/theme';
8
+
9
+ // The FATF fallback, attested: some companies genuinely have no natural
10
+ // person who qualifies as a UBO (listed companies, complex trusts, nominee
11
+ // arrangements), and without this box the applicant's only moves were to
12
+ // stall or to invent one. It is an attestation the org can branch on, never a
13
+ // verdict — and it is disabled the moment a UBO is listed, because the two
14
+ // claims contradict. Mirrors the web SDK's KeyPeopleUboExemption.
15
+
16
+ export function KeyPeopleUboExemption({
17
+ checked,
18
+ hasUbos,
19
+ onChange,
20
+ }: {
21
+ checked: boolean;
22
+ hasUbos: boolean;
23
+ onChange: (checked: boolean) => void;
24
+ }): React.ReactElement {
25
+ const { colors } = useTheme();
26
+ return (
27
+ <View style={{ marginTop: spacing.sm + 4 }}>
28
+ <Pressable
29
+ accessibilityRole="checkbox"
30
+ accessibilityState={{ checked, disabled: hasUbos }}
31
+ disabled={hasUbos}
32
+ onPress={() => onChange(!checked)}
33
+ style={{
34
+ flexDirection: 'row',
35
+ alignItems: 'flex-start',
36
+ gap: 12,
37
+ opacity: hasUbos ? 0.5 : 1,
38
+ }}
39
+ >
40
+ <View
41
+ style={{
42
+ width: 20,
43
+ height: 20,
44
+ marginTop: 1,
45
+ borderRadius: 6,
46
+ borderWidth: 1.5,
47
+ borderColor: checked ? colors.primary : colors.border,
48
+ backgroundColor: checked ? colors.primary : 'transparent',
49
+ alignItems: 'center',
50
+ justifyContent: 'center',
51
+ }}
52
+ >
53
+ {checked ? <Icon name="check" size={13} color={colors.onPrimary} /> : null}
54
+ </View>
55
+ <MyazaText variant="bodyMedium" color={colors.textSecondary} style={{ flex: 1 }}>
56
+ UBOs cannot be identified due to public share structures, complex trusts or nominee
57
+ arrangements.
58
+ </MyazaText>
59
+ </Pressable>
60
+ {checked && !hasUbos ? (
61
+ <MyazaText
62
+ variant="bodySmall"
63
+ color={colors.textSecondary}
64
+ style={{ marginTop: 6, paddingLeft: 32 }}
65
+ >
66
+ We will record this with the application; a senior person is still identified through
67
+ the applicant's own verification.
68
+ </MyazaText>
69
+ ) : null}
70
+ </View>
71
+ );
72
+ }
@@ -7,12 +7,14 @@ import { MyazaText } from '../components/Typography';
7
7
  import { Icon } from '../components/Icon';
8
8
  import { CountryFlag } from '../components/CountryFlag';
9
9
  import {
10
+ rowNeedsEmail,
10
11
  KEY_PERSON_ROLE_LABELS,
11
12
  initialsOf,
12
13
  isKeyPersonRowValid,
13
14
  type KeyPersonEntry,
14
15
  } from '../config/keyPeople';
15
16
  import { regionCountryName } from '../config/regions';
17
+ import type { KeyPersonRole } from '../types/business';
16
18
 
17
19
  // ---------------------------------------------------------------------------
18
20
  // One saved key person, summarised — monogram avatar with their ID-issuing
@@ -27,20 +29,39 @@ import { regionCountryName } from '../config/regions';
27
29
 
28
30
  export function KeyPersonCard({
29
31
  entry,
32
+ emailRequiredFor,
30
33
  onPress,
34
+ roleLabel,
35
+ onRemove,
31
36
  }: {
32
37
  entry: KeyPersonEntry;
38
+ emailRequiredFor?: ReadonlySet<KeyPersonRole>;
33
39
  onPress: () => void;
40
+ /** Names the hat the HOSTING section is about (sectioned step). */
41
+ roleLabel?: string;
42
+ /** The sectioned step's X: take this person out of that section. */
43
+ onRemove?: () => void;
34
44
  }): React.ReactElement {
35
45
  const { colors } = useTheme();
36
46
  const name = entry.name.trim() || 'Unnamed person';
37
47
  const country = entry.country.trim() ? entry.country.trim().toUpperCase() : null;
38
48
  const pct = entry.ownershipPct.trim();
39
49
  // 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);
50
+ // incomplete — the card says so instead of silently blocking Continue. A
51
+ // missing REQUIRED email gets named specifically: "incomplete" on a row
52
+ // whose name, role and country are all filled reads as a bug.
53
+ const required = emailRequiredFor ?? new Set<KeyPersonRole>();
54
+ const incomplete = !isKeyPersonRowValid(entry, required);
55
+ const problem =
56
+ rowNeedsEmail(entry, required) && entry.email.trim() === '' && entry.name.trim().length >= 2
57
+ ? 'Email required, tap to add'
58
+ : 'Incomplete, tap to finish';
42
59
 
43
- const meta = [KEY_PERSON_ROLE_LABELS[entry.role], pct ? `${pct}% ownership` : null]
60
+ const meta = [
61
+ roleLabel ?? KEY_PERSON_ROLE_LABELS[entry.role],
62
+ entry.title.trim() || null,
63
+ pct ? `${pct}% ownership` : null,
64
+ ]
44
65
  .filter(Boolean)
45
66
  .join(' · ');
46
67
  // The flag alone doesn't say WHICH country — spell it out, alongside the
@@ -104,7 +125,7 @@ export function KeyPersonCard({
104
125
  </MyazaText>
105
126
  {incomplete ? (
106
127
  <MyazaText variant="bodySmall" color={colors.error} numberOfLines={1} style={{ marginTop: 1 }}>
107
- Incomplete — tap to finish
128
+ {problem}
108
129
  </MyazaText>
109
130
  ) : detail !== '' ? (
110
131
  <MyazaText variant="bodySmall" color={colors.textMuted} numberOfLines={1} style={{ marginTop: 1 }}>
@@ -114,6 +135,17 @@ export function KeyPersonCard({
114
135
  </View>
115
136
 
116
137
  <Icon name="pencil" size={16} color={colors.textMuted} />
138
+ {onRemove ? (
139
+ <Pressable
140
+ accessibilityRole="button"
141
+ accessibilityLabel={`Remove ${name} from this section`}
142
+ hitSlop={8}
143
+ onPress={onRemove}
144
+ style={{ marginLeft: 4, padding: 4 }}
145
+ >
146
+ <Icon name="close" size={16} color={colors.textMuted} />
147
+ </Pressable>
148
+ ) : null}
117
149
  </Pressable>
118
150
  );
119
151
  }
@@ -1,27 +1,34 @@
1
- import React, { useMemo } from 'react';
2
- import { View } from 'react-native';
1
+ import React, { useMemo, useState } from 'react';
2
+ import { Pressable, View } from 'react-native';
3
3
 
4
- import { spacing } from '../config/theme';
4
+ import { radius, spacing } from '../config/theme';
5
5
  import { useTheme } from '../components/runtime';
6
6
  import { MyazaText } from '../components/Typography';
7
7
  import { MyazaInput } from '../components/MyazaInput';
8
- import { MyazaSelect } from '../components/MyazaSelect';
8
+ import { Icon } from '../components/Icon';
9
+ import { OwnershipSlider } from '../components/OwnershipSlider';
9
10
  import { CountryField } from '../components/CountryField';
10
11
  import type { DialCodeOption } from '../components/DialCodePicker';
11
12
  import { ALL_REGION_CODES, regionCountryName } from '../config/regions';
12
13
  import { isValidContactEmail } from '../config/contact';
14
+ import { rowNeedsEmail, type KeyPersonEntry } from '../config/keyPeople';
13
15
  import {
14
- KEY_PERSON_ROLES,
15
- KEY_PERSON_ROLE_LABELS,
16
- type KeyPersonEntry,
17
- } from '../config/keyPeople';
16
+ primaryRole,
17
+ rolesOf,
18
+ type KeyPeopleSection as SectionKey,
19
+ } from '../config/keyPeopleSections';
18
20
  import type { KeyPersonRole } from '../types/business';
21
+ import { KeyPersonKindToggle } from './KeyPersonKindToggle';
22
+ import { KeyPersonOwners } from './KeyPersonOwners';
23
+ import { KeyPersonRoleChips } from './KeyPersonRoleChips';
19
24
 
20
25
  // ---------------------------------------------------------------------------
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.
26
+ // The key-person FIELDS, scoped by the SECTION that opened the sheet: the
27
+ // section already said what this person is, so no coarse role dropdown — the
28
+ // UBO form asks name/stake/country/email, the shareholder form adds the
29
+ // individual-or-company toggle, and the representative form picks between the
30
+ // real classifications (chips) with the human nuance ("CFO, Board Member")
31
+ // captured as a free-text title. Mirrors the web SDK's KeyPersonForm 1:1.
25
32
  // ---------------------------------------------------------------------------
26
33
 
27
34
  /** Whether the role is an ownership one (% is only meaningful then). */
@@ -40,12 +47,22 @@ function allCountryOptions(): DialCodeOption[] {
40
47
 
41
48
  export function KeyPersonForm({
42
49
  entry,
50
+ section,
51
+ corporateKyb = false,
43
52
  onChange,
44
53
  uboThreshold = 25,
45
54
  combinedPctError = null,
55
+ emailRequiredFor,
46
56
  }: {
47
57
  entry: KeyPersonEntry;
58
+ /** The section whose add-tile or card opened the sheet. */
59
+ section: SectionKey;
60
+ /** The workflow sends corporate shareholders their own KYB application -
61
+ * the callout must tell that truth instead of the screening-only one. */
62
+ corporateKyb?: boolean;
48
63
  onChange: (patch: Partial<KeyPersonEntry>) => void;
64
+ /** Roles whose email is mandatory (they are sent a verification link). */
65
+ emailRequiredFor?: ReadonlySet<KeyPersonRole>;
49
66
  /** Ownership % at/above which the server treats a person as a beneficial
50
67
  * owner (the workflow's `keyPeople.ownershipThreshold`, default 25). */
51
68
  uboThreshold?: number;
@@ -62,47 +79,97 @@ export function KeyPersonForm({
62
79
 
63
80
  const nameInvalid = entry.name !== '' && entry.name.trim().length < 2;
64
81
  const emailInvalid = entry.email.trim() !== '' && !isValidContactEmail(entry.email.trim());
82
+ const needsEmail = rowNeedsEmail(entry, emailRequiredFor ?? new Set());
83
+ // COLLAPSED by default so the company sheet stays five fields; open when
84
+ // owners already exist (a restored draft, a look-through find). The
85
+ // capability is one tap away, not one field more. Mirrors the web SDK.
86
+ const [showOwners, setShowOwners] = useState((entry.owners?.length ?? 0) > 0);
65
87
  const pct = entry.ownershipPct.trim();
66
88
  const pctNum = Number(pct);
67
89
  const pctInvalid = pct !== '' && (!Number.isFinite(pctNum) || pctNum < 0 || pctNum > 100);
68
90
  // Surface the regulatory consequence as feedback: at/above the threshold the
69
91
  // server escalates this person to a beneficial owner regardless of the role
70
92
  // picked. Quiet when they already chose UBO — nothing new to say.
93
+ const corp = entry.isCorporate;
94
+ const roles = rolesOf(entry);
71
95
  const uboHint =
72
- !pctInvalid && pct !== '' && pctNum >= uboThreshold && entry.role !== 'beneficial_owner';
96
+ !corp && !pctInvalid && pct !== '' && pctNum >= uboThreshold && !roles.includes('beneficial_owner');
97
+ const belowThresholdNote =
98
+ section === 'ubos' && !pctInvalid && pct !== '' && pctNum > 0 && pctNum < uboThreshold;
99
+
100
+ const setRoles = (next: KeyPersonRole[]): void =>
101
+ onChange({ roles: next, role: primaryRole(next) });
73
102
 
74
103
  return (
75
104
  <View>
105
+ {/* Only shareholders can be a company: a beneficial owner is a natural
106
+ person in every regime that defines one, and a representative form
107
+ is about the people who act. */}
108
+ {section === 'shareholders' ? (
109
+ <>
110
+ <KeyPersonKindToggle
111
+ isCorporate={corp}
112
+ onChange={(isCorporate) =>
113
+ // Beneficial ownership is a claim about a person, so switching
114
+ // to a company reads the role set down rather than leaving an
115
+ // impossible one.
116
+ onChange({
117
+ isCorporate,
118
+ ...(isCorporate
119
+ ? {
120
+ roles: roles.map((r) => (r === 'beneficial_owner' ? 'shareholder' : r)),
121
+ role: primaryRole(
122
+ roles.map((r) => (r === 'beneficial_owner' ? 'shareholder' : r)),
123
+ ),
124
+ }
125
+ : { registrationNumber: '', owners: [] }),
126
+ })
127
+ }
128
+ />
129
+ <View style={{ height: spacing.sm }} />
130
+ </>
131
+ ) : null}
76
132
  <MyazaInput
77
- label="Full name"
133
+ label={corp ? 'Company name' : 'Full name'}
78
134
  value={entry.name}
79
135
  onChangeText={(name) => onChange({ name })}
80
- placeholder="e.g. Bola Owner"
81
- // It's a person's name — start every word capitalized.
136
+ placeholder={corp ? 'e.g. Acme Holdings Ltd' : 'e.g. Bola Owner'}
137
+ // It's a name — start every word capitalized.
82
138
  autoCapitalize="words"
83
- error={nameInvalid ? 'Enter the person’s full name.' : null}
139
+ error={
140
+ nameInvalid
141
+ ? `Enter the ${corp ? 'registered company name' : 'person’s full name'}.`
142
+ : null
143
+ }
84
144
  />
85
145
 
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
- />
146
+ {section === 'representatives' ? (
147
+ <>
148
+ <View style={{ height: spacing.sm }} />
149
+ <KeyPersonRoleChips roles={roles} onRoles={setRoles} />
150
+ </>
151
+ ) : null}
152
+
153
+ {!corp ? (
154
+ <>
155
+ <View style={{ height: spacing.sm }} />
156
+ <MyazaInput
157
+ label="Position or title (optional)"
158
+ value={entry.title}
159
+ onChangeText={(title) => onChange({ title })}
160
+ placeholder="e.g. CFO, Board Member"
161
+ autoCapitalize="words"
162
+ autoCorrect={false}
163
+ />
164
+ </>
165
+ ) : null}
99
166
 
100
167
  <View style={{ height: spacing.sm }} />
101
168
  <MyazaInput
102
169
  label="Ownership % (optional)"
103
170
  value={entry.ownershipPct}
104
171
  onChangeText={(ownershipPct) => onChange({ ownershipPct })}
105
- placeholder={isOwnerRole(entry.role) ? 'e.g. 60' : '—'}
172
+ placeholder="0"
106
173
  keyboardType="decimal-pad"
107
174
  suffix={
108
175
  <MyazaText variant="bodySmall" color={colors.textMuted}>
@@ -113,15 +180,37 @@ export function KeyPersonForm({
113
180
  helper={
114
181
  uboHint
115
182
  ? `At ${uboThreshold}% or more, this person counts as a beneficial owner.`
116
- : undefined
183
+ : belowThresholdNote
184
+ ? `Below ${uboThreshold}% they will be recorded as a shareholder.`
185
+ : undefined
117
186
  }
118
187
  />
188
+ <View style={{ height: spacing.xs }} />
189
+ {/* The slider rests at 0 for an undeclared stake; only a drag writes a
190
+ value, so an untouched slider still submits "not declared". */}
191
+ <OwnershipSlider
192
+ value={pct !== '' && Number.isFinite(Number(pct)) ? Number(pct) : 0}
193
+ onChange={(next) => onChange({ ownershipPct: String(next) })}
194
+ />
195
+
196
+ {corp ? (
197
+ <>
198
+ <View style={{ height: spacing.sm }} />
199
+ <MyazaInput
200
+ label="Registration number (optional)"
201
+ value={entry.registrationNumber}
202
+ onChangeText={(registrationNumber) => onChange({ registrationNumber })}
203
+ placeholder="e.g. RC123456"
204
+ autoCapitalize="characters"
205
+ />
206
+ </>
207
+ ) : null}
119
208
 
120
209
  <View style={{ height: spacing.sm }} />
121
- <MyazaText variant="bodySmall" style={{ fontWeight: '600', marginBottom: spacing.xs }}>
210
+ <MyazaText variant="label" style={{ marginBottom: spacing.sm }}>
122
211
  Country{' '}
123
- <MyazaText variant="bodySmall" color={colors.textSecondary}>
124
- (where their ID was issued)
212
+ <MyazaText variant="bodyMedium" color={colors.textSecondary}>
213
+ {corp ? '(of incorporation)' : '(where their ID was issued)'}
125
214
  </MyazaText>
126
215
  </MyazaText>
127
216
  {/* The SAME sheet as the phone field's dial-code picker (keyboard-aware,
@@ -133,16 +222,86 @@ export function KeyPersonForm({
133
222
  onChange={(country) => onChange({ country })}
134
223
  />
135
224
 
225
+ {corp && !showOwners ? (
226
+ <>
227
+ <View style={{ height: spacing.sm }} />
228
+ <Pressable
229
+ accessibilityRole="button"
230
+ onPress={() => setShowOwners(true)}
231
+ hitSlop={8}
232
+ style={{ flexDirection: 'row', alignItems: 'center', gap: 6, alignSelf: 'flex-start' }}
233
+ >
234
+ <Icon name="chevron-right" size={16} color={colors.textSecondary} />
235
+ <MyazaText
236
+ variant="bodyMedium"
237
+ color={colors.textSecondary}
238
+ style={{ fontWeight: '500' }}
239
+ >
240
+ Add the people who own it (optional)
241
+ </MyazaText>
242
+ </Pressable>
243
+ </>
244
+ ) : null}
245
+ {corp && showOwners ? (
246
+ <>
247
+ <View style={{ height: spacing.sm }} />
248
+ <KeyPersonOwners
249
+ owners={entry.owners ?? []}
250
+ companyName={entry.name}
251
+ onChange={(owners) => onChange({ owners })}
252
+ />
253
+ </>
254
+ ) : null}
255
+
136
256
  <View style={{ height: spacing.sm }} />
137
257
  <MyazaInput
138
- label="Email (optional — used to send their verification link)"
258
+ label={
259
+ needsEmail
260
+ ? 'Email *'
261
+ : corp
262
+ ? 'Email (optional)'
263
+ : 'Email (optional, used to send their verification link)'
264
+ }
139
265
  value={entry.email}
140
266
  onChangeText={(email) => onChange({ email })}
141
267
  placeholder="name@company.com"
142
268
  keyboardType="email-address"
143
269
  autoCapitalize="none"
144
270
  error={emailInvalid ? 'Enter a valid email address.' : null}
271
+ helper={
272
+ needsEmail && entry.email.trim() === ''
273
+ ? 'Required: this is how they receive their own verification link.'
274
+ : undefined
275
+ }
145
276
  />
277
+
278
+ {/* What actually happens to a company on this list, stated as a callout
279
+ rather than buried in a field helper, because it answers the question
280
+ every applicant has at this exact point ("is the company going to be
281
+ asked for a selfie?"). Deliberately OUR facts, not a promise of a
282
+ nested KYB we do not run. Mirrors the web SDK's callout. */}
283
+ {corp ? (
284
+ <View
285
+ style={{
286
+ marginTop: spacing.md,
287
+ flexDirection: 'row',
288
+ alignItems: 'flex-start',
289
+ gap: 10,
290
+ borderRadius: radius.sm,
291
+ backgroundColor: colors.primary50,
292
+ padding: 12,
293
+ }}
294
+ >
295
+ <View style={{ marginTop: 2 }}>
296
+ <Icon name="info" size={16} color={colors.primary} />
297
+ </View>
298
+ <MyazaText variant="bodyMedium" color={colors.textSecondary} style={{ flex: 1 }}>
299
+ {corporateKyb
300
+ ? 'This company will need its own KYB verification: it receives a link to a business application of its own, where the people who own it are identified. We also screen it against sanctions lists.'
301
+ : 'A company is never asked to verify an identity. We check it against sanctions lists, and the people who own it are reviewed separately, so list its owners above if you know them.'}
302
+ </MyazaText>
303
+ </View>
304
+ ) : null}
146
305
  </View>
147
306
  );
148
307
  }
@@ -0,0 +1,68 @@
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
+
8
+ // Person or company.
9
+ //
10
+ // The first question on the form, because it changes what the rest of it asks:
11
+ // a company has a registration number rather than an ID country, and no
12
+ // identity of its own to verify. Left unasked, a limited company was collected
13
+ // as a person, escalated to beneficial owner (which by definition means a
14
+ // natural person), and sent a link to take a selfie.
15
+
16
+ export function KeyPersonKindToggle({
17
+ isCorporate,
18
+ onChange,
19
+ }: {
20
+ isCorporate: boolean;
21
+ onChange: (isCorporate: boolean) => void;
22
+ }): React.ReactElement {
23
+ const { colors } = useTheme();
24
+ const options: Array<{ value: boolean; label: string }> = [
25
+ { value: false, label: 'A person' },
26
+ { value: true, label: 'A company' },
27
+ ];
28
+
29
+ return (
30
+ <View>
31
+ <MyazaText variant="bodySmall" style={{ fontWeight: '600', marginBottom: spacing.xs }}>
32
+ Who is this?
33
+ </MyazaText>
34
+ <View style={{ flexDirection: 'row', gap: spacing.sm }}>
35
+ {options.map(({ value, label }) => {
36
+ const selected = isCorporate === value;
37
+ return (
38
+ <Pressable
39
+ key={label}
40
+ accessibilityRole="radio"
41
+ accessibilityState={{ selected }}
42
+ accessibilityLabel={label}
43
+ onPress={() => onChange(value)}
44
+ style={{
45
+ flex: 1,
46
+ height: 44,
47
+ alignItems: 'center',
48
+ justifyContent: 'center',
49
+ borderRadius: radius.lg,
50
+ borderWidth: 1,
51
+ borderColor: selected ? colors.primary : colors.border,
52
+ backgroundColor: selected ? `${colors.primary}1A` : 'transparent',
53
+ }}
54
+ >
55
+ <MyazaText
56
+ variant="bodySmall"
57
+ style={{ fontWeight: '600' }}
58
+ color={selected ? colors.primary : colors.textSecondary}
59
+ >
60
+ {label}
61
+ </MyazaText>
62
+ </Pressable>
63
+ );
64
+ })}
65
+ </View>
66
+ </View>
67
+ );
68
+ }