@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
@@ -1,57 +1,87 @@
1
- import React, { useState } from 'react';
1
+ import React, { useEffect, useRef, useState, useMemo } from 'react';
2
2
  import { View } from 'react-native';
3
3
 
4
- import { radius, spacing } from '../config/theme';
4
+ import { spacing } from '../config/theme';
5
5
  import { useKyc, useKycConfig, useKycStore, useTheme } from '../components/runtime';
6
6
  import { MyazaText } from '../components/Typography';
7
7
  import { MyazaButton } from '../components/MyazaButton';
8
- import { KeyPersonCard } from './KeyPersonCard';
8
+ import { DashedBorder } from '../components/DashedBorder';
9
+ import { KeyPeopleSectionsList } from './KeyPeopleSectionsList';
9
10
  import { KeyPersonSheet } from './KeyPersonSheet';
10
11
  import { keyPeopleMinEntries } from '../config/businessSteps';
11
12
  import {
13
+ keyPeopleRequireEmail,
12
14
  emptyKeyPerson,
13
15
  invalidKeyPersonRows,
14
- isKeyPersonRowBlank,
15
16
  isKeyPersonRowValid,
16
17
  MAX_KEY_PEOPLE_ROWS,
17
18
  type KeyPersonEntry,
18
19
  } from '../config/keyPeople';
19
- import type { KeyPersonRole } from '../types/business';
20
+ import { SECTION_ROLE, type KeyPeopleSection as SectionKey } from '../config/keyPeopleSections';
21
+ import { defaultUboThreshold, keyPeopleSectionList } from '../config/keyPeopleSectionDefs';
22
+ import { prefillKeyPeople, shouldPrefill } from '../config/keyPeoplePrefill';
23
+ import { radius } from '../config/theme';
20
24
 
21
25
  // ---------------------------------------------------------------------------
22
- // Applicant-declared directors, owners and signatories.
26
+ // The key-people step, sectioned: Beneficial owners / Shareholders /
27
+ // Directors & representatives, each with a plain-language definition, its own
28
+ // add-tile, and quick-add chips that grant a person already entered another
29
+ // hat. The sections are VIEWS over one shared list (keyPeopleSections.ts):
30
+ // one human can hold several roles, exactly as the register files them, and
31
+ // a stake at the threshold moves them up on screen exactly as the server
32
+ // will escalate them at submit.
23
33
  //
24
- // The registry lookup discovers people too, and the server reconciles the two:
25
- // someone the registry names who the applicant did NOT list is flagged
26
- // `undisclosed`, which is a risk signal in its own right. So this screen is not
27
- // merely data entry — what the applicant chooses to omit is evidence.
28
- //
29
- // The list stays a clean stack of compact summary cards; the FORM lives in the
30
- // add/edit sheet (KeyPersonSheet). Tapping a card edits it; "Add a person"
31
- // opens a fresh sheet. Removal is inside the edit sheet, behind a deliberate
32
- // tap — never one stray touch on the list.
34
+ // The registry lookup discovers people too, and the server reconciles the
35
+ // two: someone the registry names who the applicant did NOT list is flagged
36
+ // `undisclosed` — what the applicant chooses to omit is evidence.
33
37
  // ---------------------------------------------------------------------------
34
38
 
35
39
  export const businessKeyPeopleMeta = {
36
- title: 'Directors & owners',
37
- description:
38
- "List the company's directors and owners of 25% or more. Each will receive a link to verify their identity.",
40
+ title: 'Key people',
41
+ description: "Add the company's directors, shareholders and beneficial owners.",
39
42
  };
40
43
 
41
- type SheetState = { mode: 'add' } | { mode: 'edit'; index: number } | null;
44
+ type SheetState = { mode: 'add' | 'edit'; section: SectionKey; index?: number } | null;
42
45
 
43
46
  export function BusinessKeyPeopleStep(): React.ReactElement {
44
47
  const config = useKycConfig();
45
48
  const store = useKycStore();
46
49
  const { colors } = useTheme();
47
50
  const rows = useKyc((s) => s.businessApplication.keyPeople);
51
+ const uboUnidentifiable = useKyc((s) => s.businessApplication.uboUnidentifiable);
48
52
  const registryCountry = useKyc((s) => s.business.country);
53
+ const officers = useKyc((s) => s.businessCheck.officers);
49
54
  const [sheet, setSheet] = useState<SheetState>(null);
50
55
 
56
+ // Start from the register's own officer list (fetched by the paid check at
57
+ // selection), so this is a confirmation rather than a memory test. Once
58
+ // only, and never over anything typed: an applicant who has already entered
59
+ // a name has told us something the register did not.
60
+ const prefilled = useRef(false);
61
+ useEffect(() => {
62
+ if (prefilled.current || officers.length === 0 || !shouldPrefill(rows)) return;
63
+ prefilled.current = true;
64
+ store.getState().setKeyPeople(prefillKeyPeople(officers, registryCountry ?? ''));
65
+ // rows is deliberately out of the dep list: this must run on the arrival
66
+ // of officers, not on every edit the applicant then makes.
67
+ // eslint-disable-next-line react-hooks/exhaustive-deps
68
+ }, [officers, registryCountry]);
69
+
51
70
  const minEntries = keyPeopleMinEntries(config.business);
52
- const validCount = rows.filter(isKeyPersonRowValid).length;
53
- const invalidRows = invalidKeyPersonRows(rows);
54
- const uboThreshold = config.business?.keyPeople?.ownershipThreshold ?? 25;
71
+ // Roles whose email is mandatory (the ones actually sent a verification
72
+ // link) — threaded into every validity read so the card, the sheet and the
73
+ // Continue gate agree on what "complete" means.
74
+ const emailRequiredFor = useMemo(
75
+ () => keyPeopleRequireEmail(config.business),
76
+ [config.business],
77
+ );
78
+ const validCount = rows.filter((r) => isKeyPersonRowValid(r, emailRequiredFor)).length;
79
+ const invalidRows = invalidKeyPersonRows(rows, emailRequiredFor);
80
+ // The same line the server draws: the workflow's own threshold, else the
81
+ // register's default (NG files significant control from a lower bar).
82
+ const threshold =
83
+ config.business?.keyPeople?.ownershipThreshold ??
84
+ defaultUboThreshold(registryCountry || config.business?.country);
55
85
 
56
86
  // Combined ownership above 100% is factually impossible — catch the typo
57
87
  // here rather than shipping it into the registry cross-check as a doomed
@@ -65,15 +95,17 @@ export function BusinessKeyPeopleStep(): React.ReactElement {
65
95
  const fmtPct = (n: number): string => (Number.isInteger(n) ? String(n) : n.toFixed(1));
66
96
 
67
97
  const canContinue = validCount >= minEntries && invalidRows.length === 0 && !overAllocated;
98
+ const canAdd = rows.length < MAX_KEY_PEOPLE_ROWS;
68
99
 
69
100
  // New people default to the business's registry country (picked on the
70
101
  // details step) — most directors are local, and a foreign one just switches
71
102
  // theirs. Mirrors the web and Flutter SDKs.
72
103
  const defaultCountry = registryCountry ?? config.business?.country ?? '';
104
+ const sections = keyPeopleSectionList(config.business, threshold);
73
105
 
74
106
  const commit = (next: KeyPersonEntry[]): void => store.getState().setKeyPeople(next);
75
107
  const handleSave = (entry: KeyPersonEntry): void => {
76
- if (sheet?.mode === 'edit') {
108
+ if (sheet?.mode === 'edit' && sheet.index != null) {
77
109
  commit(rows.map((row, i) => (i === sheet.index ? entry : row)));
78
110
  } else {
79
111
  commit([...rows, entry]);
@@ -81,26 +113,28 @@ export function BusinessKeyPeopleStep(): React.ReactElement {
81
113
  setSheet(null);
82
114
  };
83
115
  const handleRemove = (): void => {
84
- if (sheet?.mode === 'edit') commit(rows.filter((_, i) => i !== sheet.index));
116
+ if (sheet?.mode === 'edit' && sheet.index != null) {
117
+ commit(rows.filter((_, i) => i !== sheet.index));
118
+ }
85
119
  setSheet(null);
86
120
  };
87
121
 
88
- // Indexed access may be undefined under strict indexing — resolved once here
89
- // so the sheet only ever mounts with a real entry.
90
- const editEntry = sheet?.mode === 'edit' ? rows[sheet.index] : undefined;
122
+ // Indexed access may be undefined under strict indexing — resolved once
123
+ // here so the sheet only ever mounts with a real entry.
124
+ const editEntry = sheet?.mode === 'edit' && sheet.index != null ? rows[sheet.index] : undefined;
91
125
 
92
126
  const handleContinue = (): void => {
93
127
  if (!canContinue) return;
94
128
  // Half-typed rows are dropped rather than submitted: an entry the user
95
129
  // abandoned is not a person they disclosed.
96
- commit(rows.filter(isKeyPersonRowValid));
130
+ commit(rows.filter((r) => isKeyPersonRowValid(r, emailRequiredFor)));
97
131
  store.getState().nextStep();
98
132
  };
99
133
 
100
134
  // The dashed hint boxes and their copy mirror the web SDK's step 1:1.
101
135
  const hint =
102
136
  rows.length === 0 && minEntries === 0
103
- ? "You can skip this if you're unsure — we'll identify directors and owners from the official registry. Adding them here speeds up the review."
137
+ ? "You can skip this if you're unsure. We'll identify directors and owners from the official registry. Adding them here speeds up the review."
104
138
  : minEntries > 0 && validCount < minEntries
105
139
  ? `List at least ${minEntries} ${minEntries === 1 ? 'person' : 'people'} to continue${
106
140
  validCount > 0 ? ` (${validCount} of ${minEntries} added)` : ''
@@ -112,67 +146,62 @@ export function BusinessKeyPeopleStep(): React.ReactElement {
112
146
  {hint ? (
113
147
  <View
114
148
  style={{
115
- borderWidth: 1,
116
- borderStyle: 'dashed',
117
- borderColor: colors.border,
118
149
  borderRadius: radius.sm,
119
150
  backgroundColor: colors.backgroundSecondary,
120
151
  padding: spacing.md,
121
- marginBottom: spacing.md,
152
+ marginBottom: spacing.lg,
122
153
  }}
123
154
  >
124
- <MyazaText variant="bodySmall" color={colors.textSecondary}>
155
+ <DashedBorder color={colors.border} radius={radius.sm} strokeWidth={1} />
156
+ <MyazaText variant="bodyMedium" color={colors.textSecondary}>
125
157
  {hint}
126
158
  </MyazaText>
127
159
  </View>
128
160
  ) : null}
129
161
 
130
- {rows.map((row, index) =>
131
- isKeyPersonRowBlank(row) ? null : (
132
- <KeyPersonCard key={index} entry={row} onPress={() => setSheet({ mode: 'edit', index })} />
133
- ),
134
- )}
135
- {/* The cards and the add affordance are different things — give the
136
- boundary some air (cards already carry a small bottom margin). */}
137
- {rows.some((row) => !isKeyPersonRowBlank(row)) ? (
138
- <View style={{ height: spacing.sm }} />
139
- ) : null}
140
-
141
- {rows.length < MAX_KEY_PEOPLE_ROWS ? (
142
- <MyazaButton
143
- label="Add a person"
144
- variant="outline"
145
- leadingIcon="user-plus"
146
- onPress={() => setSheet({ mode: 'add' })}
147
- />
148
- ) : (
149
- <MyazaText variant="bodySmall" color={colors.textMuted} style={{ textAlign: 'center' }}>
162
+ <KeyPeopleSectionsList
163
+ sections={sections}
164
+ rows={rows}
165
+ threshold={threshold}
166
+ emailRequiredFor={emailRequiredFor}
167
+ uboUnidentifiable={uboUnidentifiable}
168
+ canAdd={canAdd}
169
+ onRows={commit}
170
+ onSheet={setSheet}
171
+ onExemption={(next) => store.getState().setUboUnidentifiable(next)}
172
+ />
173
+
174
+ {!canAdd ? (
175
+ <MyazaText
176
+ variant="bodyMedium"
177
+ color={colors.textMuted}
178
+ style={{ textAlign: 'center', marginTop: spacing.lg }}
179
+ >
150
180
  {`You can list up to ${MAX_KEY_PEOPLE_ROWS} people here.`}
151
181
  </MyazaText>
152
- )}
153
- {/* Breathing room between the add-person affordance and the summary bar —
154
- matches the Flutter screen's SizedBox after its outline button. */}
155
- <View style={{ height: spacing.md }} />
182
+ ) : null}
156
183
 
157
- {/* Total-ownership summary at the DECISION point — the disabled Continue
158
- button always explains itself, wherever the offending card is. */}
184
+ {/* Total-ownership summary at the DECISION point — the disabled
185
+ Continue button always explains itself, wherever the offending
186
+ card is. */}
159
187
  {totalPct > 0 ? (
160
188
  <View
161
189
  style={{
190
+ marginTop: spacing.lg,
162
191
  flexDirection: 'row',
163
- justifyContent: 'space-between',
164
192
  alignItems: 'center',
165
- backgroundColor: overAllocated ? colors.errorBg : colors.backgroundSecondary,
193
+ justifyContent: 'space-between',
166
194
  borderRadius: radius.sm,
195
+ backgroundColor: overAllocated ? colors.errorBg : colors.backgroundSecondary,
167
196
  paddingHorizontal: spacing.md,
168
197
  paddingVertical: spacing.sm + 4,
169
198
  }}
170
199
  >
171
- <MyazaText variant="bodySmall" color={overAllocated ? colors.error : colors.textMuted}>
200
+ <MyazaText variant="bodyMedium" color={overAllocated ? colors.error : colors.textMuted}>
172
201
  Total ownership listed
173
202
  </MyazaText>
174
203
  <MyazaText
175
- variant="bodySmall"
204
+ variant="bodyMedium"
176
205
  color={overAllocated ? colors.error : colors.textDark}
177
206
  style={{ fontWeight: '700' }}
178
207
  >
@@ -181,19 +210,24 @@ export function BusinessKeyPeopleStep(): React.ReactElement {
181
210
  </View>
182
211
  ) : null}
183
212
  {overAllocated ? (
184
- <MyazaText variant="bodySmall" color={colors.error} style={{ marginTop: spacing.xs }}>
185
- {`Together the percentages can't exceed 100% — reduce them by ${fmtPct(totalPct - 100)}%.`}
213
+ <MyazaText variant="bodyMedium" color={colors.error} style={{ marginTop: spacing.xs }}>
214
+ {`Together the percentages can't exceed 100%, so reduce them by ${fmtPct(totalPct - 100)}%.`}
186
215
  </MyazaText>
187
216
  ) : null}
188
217
 
189
- <View style={{ height: spacing.md }} />
218
+ <View style={{ height: spacing.lg }} />
190
219
  <MyazaButton label="Continue" onPress={handleContinue} disabled={!canContinue} />
191
220
 
192
221
  {sheet && (sheet.mode === 'add' || editEntry) ? (
193
222
  <KeyPersonSheet
223
+ emailRequiredFor={emailRequiredFor}
194
224
  mode={sheet.mode}
195
- initial={editEntry ?? { ...emptyKeyPerson(), country: defaultCountry }}
196
- uboThreshold={uboThreshold}
225
+ section={sheet.section}
226
+ corporateKyb={config.business?.keyPeople?.corporateKyb?.enabled === true}
227
+ initial={
228
+ editEntry ?? { ...emptyKeyPerson(SECTION_ROLE[sheet.section]), country: defaultCountry }
229
+ }
230
+ uboThreshold={threshold}
197
231
  otherPctTotal={editEntry ? totalPct - pctOf(editEntry) : totalPct}
198
232
  onSave={handleSave}
199
233
  onRemove={sheet.mode === 'edit' ? handleRemove : undefined}
@@ -203,5 +237,3 @@ export function BusinessKeyPeopleStep(): React.ReactElement {
203
237
  </View>
204
238
  );
205
239
  }
206
-
207
- export type { KeyPersonRole };
@@ -0,0 +1,86 @@
1
+ import React from 'react';
2
+ import { Pressable, View } from 'react-native';
3
+
4
+ import { CountryFlag } from '../components/CountryFlag';
5
+ import { Icon } from '../components/Icon';
6
+ import { MyazaText } from '../components/Typography';
7
+ import { useTheme } from '../components/runtime';
8
+ import { radius, spacing } from '../config/theme';
9
+
10
+ // The company the applicant picked, standing where the search box was.
11
+ //
12
+ // Choosing a row IS the choice: the card replaces the list, and Change swaps
13
+ // back. A separate confirm button meant two primary buttons on screen at once,
14
+ // and the card already shows what was picked — the confirmation is the screen
15
+ // after, not a button before it. Dimensions mirror the web SDK's picked card
16
+ // (40px badge, 12px padding and gaps).
17
+
18
+ export function BusinessPickedCard({
19
+ country,
20
+ name,
21
+ registrationNumber,
22
+ onChange,
23
+ }: {
24
+ country: string;
25
+ name: string;
26
+ registrationNumber: string;
27
+ onChange: () => void;
28
+ }): React.ReactElement {
29
+ const { colors } = useTheme();
30
+ return (
31
+ <View>
32
+ <MyazaText variant="label" style={{ marginBottom: spacing.sm }}>
33
+ Business
34
+ </MyazaText>
35
+ <View
36
+ style={{
37
+ flexDirection: 'row',
38
+ alignItems: 'center',
39
+ gap: 12,
40
+ borderWidth: 1,
41
+ borderColor: colors.primary,
42
+ backgroundColor: colors.primary50,
43
+ borderRadius: radius.sm,
44
+ padding: 12,
45
+ }}
46
+ >
47
+ {/* The badge: the company as a thing that has been chosen, in the same
48
+ filled-circle language the picked rows of every other list use. */}
49
+ <View
50
+ style={{
51
+ width: 40,
52
+ height: 40,
53
+ borderRadius: 20,
54
+ alignItems: 'center',
55
+ justifyContent: 'center',
56
+ backgroundColor: colors.primary,
57
+ }}
58
+ >
59
+ <Icon name="building-2" size={20} color={colors.onPrimary} />
60
+ </View>
61
+ <View style={{ flex: 1, minWidth: 0 }}>
62
+ <MyazaText variant="label" color={colors.textDark} numberOfLines={1}>
63
+ {name.trim() || 'We will confirm the name with the register'}
64
+ </MyazaText>
65
+ <View style={{ flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 2 }}>
66
+ <CountryFlag country={country} size={14} />
67
+ <MyazaText variant="bodySmall" color={colors.textSecondary}>
68
+ {registrationNumber}
69
+ </MyazaText>
70
+ </View>
71
+ </View>
72
+ <Pressable
73
+ accessibilityRole="button"
74
+ onPress={onChange}
75
+ hitSlop={8}
76
+ style={{ flexDirection: 'row', alignItems: 'center', gap: 6, paddingHorizontal: 4 }}
77
+ >
78
+ <Icon name="pencil" size={14} color={colors.textDark} />
79
+ <MyazaText variant="bodyMedium" style={{ fontWeight: '500' }} color={colors.textDark}>
80
+ Change
81
+ </MyazaText>
82
+ </Pressable>
83
+ </View>
84
+ </View>
85
+ );
86
+ }
@@ -0,0 +1,51 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+
4
+ import { MyazaText } from '../components/Typography';
5
+ import { useTheme } from '../components/runtime';
6
+ import { spacing } from '../config/theme';
7
+ import { BusinessPickedCard } from './BusinessPickedCard';
8
+ import { BusinessSandboxToggle } from './BusinessSandboxToggle';
9
+
10
+ // The picked-company section: the card, what Continue is about to do, and the
11
+ // dev-only test-result pin. Split from BusinessDetailsStep (200-line rule).
12
+
13
+ export function BusinessPickedSection({
14
+ country,
15
+ name,
16
+ registrationNumber,
17
+ isSandbox,
18
+ onChange,
19
+ }: {
20
+ country: string;
21
+ name: string;
22
+ registrationNumber: string;
23
+ isSandbox: boolean;
24
+ onChange: () => void;
25
+ }): React.ReactElement {
26
+ const { colors } = useTheme();
27
+ return (
28
+ <>
29
+ <BusinessPickedCard
30
+ country={country}
31
+ name={name}
32
+ registrationNumber={registrationNumber}
33
+ onChange={onChange}
34
+ />
35
+ <View style={{ height: spacing.lg }} />
36
+ {/* Says what the button is about to do: Continue runs a real lookup
37
+ against the register, which takes a moment and is charged to the
38
+ organisation. "Continue" alone made a paid outbound call look like
39
+ moving to the next page. */}
40
+ <MyazaText variant="bodySmall" color={colors.textSecondary}>
41
+ Continue checks this business against the official register and brings back its details.
42
+ </MyazaText>
43
+ {isSandbox ? (
44
+ <>
45
+ <View style={{ height: spacing.lg }} />
46
+ <BusinessSandboxToggle />
47
+ </>
48
+ ) : null}
49
+ </>
50
+ );
51
+ }
@@ -0,0 +1,67 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+
4
+ import { CountryField } from '../components/CountryField';
5
+ import { MyazaSelect } from '../components/MyazaSelect';
6
+ import { MyazaText } from '../components/Typography';
7
+ import { businessCountryName, getBusinessProductDef } from '../config/business';
8
+ import { spacing } from '../config/theme';
9
+
10
+ // The registry pickers: which country's register, and which product to run
11
+ // against it. Rendered only when there is a choice to make. Split from
12
+ // BusinessDetailsStep (200-line rule).
13
+
14
+ export function BusinessRegistryPickers({
15
+ country,
16
+ countries,
17
+ product,
18
+ products,
19
+ onCountry,
20
+ onProduct,
21
+ }: {
22
+ country: string;
23
+ countries: string[];
24
+ product: string;
25
+ products: string[];
26
+ onCountry: (code: string) => void;
27
+ onProduct: (key: string) => void;
28
+ }): React.ReactElement {
29
+ return (
30
+ <>
31
+ {countries.length > 1 ? (
32
+ <>
33
+ <MyazaText variant="label" style={{ marginBottom: spacing.sm }}>
34
+ Country of registration
35
+ </MyazaText>
36
+ {/* The SAME sheet as the phone field's dial-code picker — restricted
37
+ to the workflow's registry countries. */}
38
+ <CountryField
39
+ value={country}
40
+ options={countries.map((code) => ({ code, name: businessCountryName(code) }))}
41
+ onChange={onCountry}
42
+ />
43
+ <View style={{ height: spacing.lg }} />
44
+ </>
45
+ ) : null}
46
+
47
+ {products.length > 1 ? (
48
+ <>
49
+ <MyazaText variant="label" style={{ marginBottom: spacing.sm }}>
50
+ Verification type
51
+ </MyazaText>
52
+ <MyazaSelect
53
+ value={product}
54
+ sheetTitle="Verification type"
55
+ options={products.map((key) => ({
56
+ value: key,
57
+ label: getBusinessProductDef(key).label,
58
+ }))}
59
+ onChange={onProduct}
60
+ />
61
+ <View style={{ height: spacing.lg }} />
62
+ </>
63
+ ) : null}
64
+
65
+ </>
66
+ );
67
+ }
@@ -0,0 +1,114 @@
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import { Animated, Easing, Pressable, View } from 'react-native';
3
+
4
+ import { MyazaText } from '../components/Typography';
5
+ import { useKyc, useKycStore, useTheme } from '../components/runtime';
6
+ import { DashedBorder } from '../components/DashedBorder';
7
+ import { spacing } from '../config/theme';
8
+
9
+ // Dev/sandbox only: pick the canned outcome the register check will serve.
10
+ //
11
+ // Chosen BEFORE the lookup runs — repeating the control afterwards would offer
12
+ // to change an answer that has already come back. Production never shows it
13
+ // and never honours a pin if one arrives. Dimensions and motion mirror the web
14
+ // SDK's Test-result control: 40px options in a 2px track, and an indicator
15
+ // that SLIDES between the two — a block that vanishes here and reappears there
16
+ // reads as two separate things blinking; moving it says the selection
17
+ // travelled, which is what actually happened.
18
+
19
+ const OUTCOMES = [
20
+ { key: 'verified', label: 'Verified' },
21
+ { key: 'not_found', label: 'Not found' },
22
+ ] as const;
23
+
24
+ export function BusinessSandboxToggle(): React.ReactElement {
25
+ const store = useKycStore();
26
+ const { colors } = useTheme();
27
+ const outcome = useKyc((s) => s.business.sandboxOutcome) || 'verified';
28
+ const [trackWidth, setTrackWidth] = useState(0);
29
+ // Equal columns are what let the indicator translate by exactly its own
30
+ // width. onLayout reports the BORDER box, so both the 1px borders and the
31
+ // 2px padding come off before halving — sizing off the raw width made the
32
+ // slid indicator touch the track's right edge while the left kept its gap.
33
+ const indicatorWidth = Math.max(0, (trackWidth - 2 * 1 - 2 * 2) / 2);
34
+ const slide = useRef(new Animated.Value(outcome === 'verified' ? 0 : 1)).current;
35
+
36
+ useEffect(() => {
37
+ Animated.timing(slide, {
38
+ toValue: outcome === 'verified' ? 0 : 1,
39
+ duration: 200,
40
+ easing: Easing.out(Easing.quad),
41
+ useNativeDriver: true,
42
+ }).start();
43
+ }, [outcome, slide]);
44
+
45
+ return (
46
+ <View
47
+ style={{
48
+ borderRadius: 12,
49
+ padding: 12,
50
+ }}
51
+ >
52
+ <DashedBorder color={colors.border} radius={12} strokeWidth={1} />
53
+ <MyazaText variant="label">Test result</MyazaText>
54
+ <View style={{ height: spacing.sm }} />
55
+ <View
56
+ onLayout={(e) => setTrackWidth(e.nativeEvent.layout.width)}
57
+ style={{
58
+ flexDirection: 'row',
59
+ borderWidth: 1,
60
+ borderColor: colors.border,
61
+ borderRadius: 8,
62
+ padding: 2,
63
+ }}
64
+ >
65
+ {trackWidth > 0 ? (
66
+ <Animated.View
67
+ pointerEvents="none"
68
+ style={{
69
+ position: 'absolute',
70
+ top: 2,
71
+ bottom: 2,
72
+ left: 2,
73
+ width: indicatorWidth,
74
+ borderRadius: 6,
75
+ backgroundColor: colors.textDark,
76
+ transform: [
77
+ {
78
+ translateX: slide.interpolate({
79
+ inputRange: [0, 1],
80
+ outputRange: [0, indicatorWidth],
81
+ }),
82
+ },
83
+ ],
84
+ }}
85
+ />
86
+ ) : null}
87
+ {OUTCOMES.map(({ key, label }) => {
88
+ const active = outcome === key;
89
+ return (
90
+ <Pressable
91
+ key={key}
92
+ accessibilityRole="button"
93
+ accessibilityState={{ selected: active }}
94
+ onPress={() => store.getState().setBusinessField('sandboxOutcome', key)}
95
+ style={{ flex: 1, alignItems: 'center', justifyContent: 'center', height: 40 }}
96
+ >
97
+ {/* Above the indicator, or the label slides out from under it. */}
98
+ <MyazaText
99
+ variant="bodyMedium"
100
+ color={active ? colors.background : colors.textSecondary}
101
+ >
102
+ {label}
103
+ </MyazaText>
104
+ </Pressable>
105
+ );
106
+ })}
107
+ </View>
108
+ <View style={{ height: spacing.sm }} />
109
+ <MyazaText variant="bodySmall" color={colors.textSecondary}>
110
+ Returned instead of calling the register.
111
+ </MyazaText>
112
+ </View>
113
+ );
114
+ }