@myazahq/kyc-sdk-react-native 2.0.2 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (281) hide show
  1. package/README.md +111 -8
  2. package/android/build.gradle +6 -0
  3. package/android/src/main/AndroidManifest.xml +14 -4
  4. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaEmrtd.kt +358 -0
  5. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +88 -1
  6. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaRectDetector.kt +34 -0
  7. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizer.kt +118 -0
  8. package/android/src/main/java/com/margelo/nitro/myazakyc/Jp2Decoder.kt +117 -0
  9. package/app.plugin.js +102 -13
  10. package/ios/HybridMyazaEmrtd.swift +447 -0
  11. package/ios/HybridMyazaFaceDetector.swift +104 -2
  12. package/ios/HybridMyazaRectDetector.swift +103 -0
  13. package/ios/HybridMyazaTextRecognizer.swift +111 -0
  14. package/nitrogen/generated/.gitattributes +1 -0
  15. package/nitrogen/generated/android/KycSdkReactNative+autolinking.cmake +87 -0
  16. package/nitrogen/generated/android/KycSdkReactNative+autolinking.gradle +27 -0
  17. package/nitrogen/generated/android/KycSdkReactNativeOnLoad.cpp +102 -0
  18. package/nitrogen/generated/android/KycSdkReactNativeOnLoad.hpp +34 -0
  19. package/nitrogen/generated/android/c++/JDetectedRect.hpp +81 -0
  20. package/nitrogen/generated/android/c++/JEmrtdApduResponse.hpp +61 -0
  21. package/nitrogen/generated/android/c++/JEmrtdTagInfo.hpp +61 -0
  22. package/nitrogen/generated/android/c++/JFaceResult.hpp +113 -0
  23. package/nitrogen/generated/android/c++/JHybridMyazaEmrtdSpec.cpp +176 -0
  24. package/nitrogen/generated/android/c++/JHybridMyazaEmrtdSpec.hpp +75 -0
  25. package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.cpp +60 -0
  26. package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.hpp +63 -0
  27. package/nitrogen/generated/android/c++/JHybridMyazaRectDetectorSpec.cpp +60 -0
  28. package/nitrogen/generated/android/c++/JHybridMyazaRectDetectorSpec.hpp +63 -0
  29. package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.cpp +80 -0
  30. package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.hpp +64 -0
  31. package/nitrogen/generated/android/c++/JTextResult.hpp +76 -0
  32. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/DetectedRect.kt +81 -0
  33. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/EmrtdApduResponse.kt +56 -0
  34. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/EmrtdTagInfo.kt +56 -0
  35. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/FaceResult.kt +121 -0
  36. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaEmrtdSpec.kt +103 -0
  37. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaFaceDetectorSpec.kt +55 -0
  38. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaRectDetectorSpec.kt +55 -0
  39. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizerSpec.kt +60 -0
  40. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/KycSdkReactNativeOnLoad.kt +35 -0
  41. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/TextResult.kt +51 -0
  42. package/nitrogen/generated/ios/KycSdkReactNative+autolinking.rb +62 -0
  43. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.cpp +143 -0
  44. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.hpp +423 -0
  45. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Umbrella.hpp +81 -0
  46. package/nitrogen/generated/ios/KycSdkReactNativeAutolinking.mm +57 -0
  47. package/nitrogen/generated/ios/KycSdkReactNativeAutolinking.swift +62 -0
  48. package/nitrogen/generated/ios/c++/HybridMyazaEmrtdSpecSwift.cpp +11 -0
  49. package/nitrogen/generated/ios/c++/HybridMyazaEmrtdSpecSwift.hpp +184 -0
  50. package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.cpp +11 -0
  51. package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.hpp +87 -0
  52. package/nitrogen/generated/ios/c++/HybridMyazaRectDetectorSpecSwift.cpp +11 -0
  53. package/nitrogen/generated/ios/c++/HybridMyazaRectDetectorSpecSwift.hpp +87 -0
  54. package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.cpp +11 -0
  55. package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.hpp +98 -0
  56. package/nitrogen/generated/ios/swift/DetectedRect.swift +59 -0
  57. package/nitrogen/generated/ios/swift/EmrtdApduResponse.swift +34 -0
  58. package/nitrogen/generated/ios/swift/EmrtdTagInfo.swift +34 -0
  59. package/nitrogen/generated/ios/swift/FaceResult.swift +99 -0
  60. package/nitrogen/generated/ios/swift/Func_void.swift +46 -0
  61. package/nitrogen/generated/ios/swift/Func_void_EmrtdApduResponse.swift +46 -0
  62. package/nitrogen/generated/ios/swift/Func_void_EmrtdTagInfo.swift +46 -0
  63. package/nitrogen/generated/ios/swift/Func_void_TextResult.swift +46 -0
  64. package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +46 -0
  65. package/nitrogen/generated/ios/swift/Func_void_std__string.swift +46 -0
  66. package/nitrogen/generated/ios/swift/HybridMyazaEmrtdSpec.swift +67 -0
  67. package/nitrogen/generated/ios/swift/HybridMyazaEmrtdSpec_cxx.swift +317 -0
  68. package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec.swift +56 -0
  69. package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec_cxx.swift +143 -0
  70. package/nitrogen/generated/ios/swift/HybridMyazaRectDetectorSpec.swift +56 -0
  71. package/nitrogen/generated/ios/swift/HybridMyazaRectDetectorSpec_cxx.swift +143 -0
  72. package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec.swift +57 -0
  73. package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec_cxx.swift +162 -0
  74. package/nitrogen/generated/ios/swift/TextResult.swift +35 -0
  75. package/nitrogen/generated/shared/c++/DetectedRect.hpp +107 -0
  76. package/nitrogen/generated/shared/c++/EmrtdApduResponse.hpp +87 -0
  77. package/nitrogen/generated/shared/c++/EmrtdTagInfo.hpp +87 -0
  78. package/nitrogen/generated/shared/c++/FaceResult.hpp +139 -0
  79. package/nitrogen/generated/shared/c++/HybridMyazaEmrtdSpec.cpp +33 -0
  80. package/nitrogen/generated/shared/c++/HybridMyazaEmrtdSpec.hpp +80 -0
  81. package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.cpp +21 -0
  82. package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.hpp +67 -0
  83. package/nitrogen/generated/shared/c++/HybridMyazaRectDetectorSpec.cpp +21 -0
  84. package/nitrogen/generated/shared/c++/HybridMyazaRectDetectorSpec.hpp +67 -0
  85. package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.cpp +22 -0
  86. package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.hpp +70 -0
  87. package/nitrogen/generated/shared/c++/TextResult.hpp +84 -0
  88. package/package.json +16 -4
  89. package/src/MyazaKYC.tsx +115 -22
  90. package/src/capture/documentIdentity.ts +153 -0
  91. package/src/capture/documentSignals.ts +177 -0
  92. package/src/capture/documentTextGate.ts +171 -0
  93. package/src/capture/hints.ts +57 -0
  94. package/src/capture/index.ts +24 -0
  95. package/src/capture/rectDetector.ts +84 -0
  96. package/src/capture/rectGate.ts +91 -0
  97. package/src/capture/useAutoCapture.ts +198 -0
  98. package/src/components/CameraViewfinder.tsx +226 -70
  99. package/src/components/ContactCodeEntry.tsx +139 -0
  100. package/src/components/CountryField.tsx +87 -0
  101. package/src/components/CountryFlag.tsx +15 -15
  102. package/src/components/DialCodePicker.tsx +186 -0
  103. package/src/components/DocumentCropper.tsx +1 -1
  104. package/src/components/DocumentReview.tsx +142 -0
  105. package/src/components/DocumentReviewSide.tsx +135 -0
  106. package/src/components/DocumentReviewZoom.tsx +119 -0
  107. package/src/components/ExpiryCountdown.tsx +52 -0
  108. package/src/components/FlashOverlay.tsx +156 -0
  109. package/src/components/Icon.tsx +67 -1
  110. package/src/components/KycFlow.tsx +66 -94
  111. package/src/components/KycSheet.tsx +148 -18
  112. package/src/components/MediaSourceSheet.tsx +163 -0
  113. package/src/components/MyazaButton.tsx +14 -2
  114. package/src/components/MyazaDateField.tsx +240 -0
  115. package/src/components/MyazaInput.tsx +73 -26
  116. package/src/components/MyazaSelect.tsx +283 -0
  117. package/src/components/MyazaWordmark.tsx +33 -0
  118. package/src/components/OptionRow.tsx +132 -0
  119. package/src/components/PhoneNumberInput.tsx +149 -0
  120. package/src/components/PoweredBy.tsx +89 -0
  121. package/src/components/ReadyPrimer.tsx +149 -0
  122. package/src/components/StepView.tsx +91 -0
  123. package/src/components/Typography.tsx +18 -3
  124. package/src/components/VerifiedNotice.tsx +38 -0
  125. package/src/components/WhatsAppIcon.tsx +29 -0
  126. package/src/components/WorkflowGate.tsx +128 -0
  127. package/src/components/brand-font.ts +113 -0
  128. package/src/components/flashHoleGeometry.ts +35 -0
  129. package/src/components/flow/FatalConfigError.tsx +56 -0
  130. package/src/components/fonts.ts +2 -19
  131. package/src/components/readyPrimerContent.ts +52 -0
  132. package/src/components/runtime.tsx +38 -43
  133. package/src/components/stepHeaderMeta.tsx +87 -0
  134. package/src/components/theme-provider.tsx +89 -0
  135. package/src/components/useWorkflowMount.ts +116 -0
  136. package/src/components/viewfinder/CardGuide.tsx +60 -0
  137. package/src/components/viewfinder/DocumentGhost.tsx +149 -0
  138. package/src/components/viewfinder/ImmersiveBottomBar.tsx +172 -0
  139. package/src/components/viewfinder/ImmersiveControls.tsx +127 -0
  140. package/src/components/viewfinder/ImmersiveGuide.tsx +174 -0
  141. package/src/components/viewfinder/ImmersiveOverlay.tsx +79 -0
  142. package/src/components/viewfinder/ViewfinderControls.tsx +154 -0
  143. package/src/config/brand.ts +97 -0
  144. package/src/config/business.ts +179 -0
  145. package/src/config/businessSteps.ts +105 -0
  146. package/src/config/contact.ts +159 -0
  147. package/src/config/countryNames.g.ts +254 -0
  148. package/src/config/currencyFlags.ts +144 -0
  149. package/src/config/font-resolve.ts +78 -0
  150. package/src/config/idTypes.ts +96 -1
  151. package/src/config/keyPeople.ts +152 -0
  152. package/src/config/phone.ts +39 -0
  153. package/src/config/proofOfAddress.ts +75 -0
  154. package/src/config/questionnaire.ts +153 -0
  155. package/src/config/regions.ts +118 -0
  156. package/src/config/registrationHint.ts +87 -0
  157. package/src/config/stepOrder.ts +118 -0
  158. package/src/config/theme.ts +38 -2
  159. package/src/config/workflowMerge.ts +124 -0
  160. package/src/emrtd/bac.ts +128 -0
  161. package/src/emrtd/bytes.ts +127 -0
  162. package/src/emrtd/crypto.ts +163 -0
  163. package/src/emrtd/der.ts +192 -0
  164. package/src/emrtd/dg2.ts +111 -0
  165. package/src/emrtd/files.ts +215 -0
  166. package/src/emrtd/index.ts +30 -0
  167. package/src/emrtd/mrzKey.ts +54 -0
  168. package/src/emrtd/native.ts +79 -0
  169. package/src/emrtd/optionalRead.ts +42 -0
  170. package/src/emrtd/read.ts +224 -0
  171. package/src/emrtd/retry.ts +98 -0
  172. package/src/emrtd/secureMessaging.ts +178 -0
  173. package/src/emrtd/session.ts +200 -0
  174. package/src/emrtd/stages.ts +122 -0
  175. package/src/index.ts +110 -0
  176. package/src/lib/calendar.ts +71 -0
  177. package/src/lib/use-keyboard-inset.ts +34 -0
  178. package/src/liveness/challengeManager.ts +27 -1
  179. package/src/liveness/faceContinuity.ts +153 -0
  180. package/src/liveness/flashDetector.ts +145 -0
  181. package/src/liveness/flashReadyGate.ts +106 -0
  182. package/src/liveness/flashRunner.ts +118 -0
  183. package/src/liveness/integritySignals.ts +60 -0
  184. package/src/liveness/livenessState.ts +139 -0
  185. package/src/liveness/speech.ts +19 -1
  186. package/src/liveness/types.ts +48 -1
  187. package/src/liveness/useFlashPhase.ts +78 -0
  188. package/src/liveness/useLiveness.ts +260 -75
  189. package/src/liveness/visionCameraFaceDetector.ts +14 -0
  190. package/src/mrz/extract.ts +101 -0
  191. package/src/mrz/parse.ts +178 -0
  192. package/src/mrz/textRecognizer.ts +149 -0
  193. package/src/screens/ApplicantRoleStep.tsx +331 -0
  194. package/src/screens/BusinessDetailsStep.tsx +212 -0
  195. package/src/screens/BusinessDocumentSlot.tsx +167 -0
  196. package/src/screens/BusinessDocumentsStep.tsx +213 -0
  197. package/src/screens/BusinessKeyPeopleStep.tsx +151 -0
  198. package/src/screens/BusinessKeyPersonRow.tsx +170 -0
  199. package/src/screens/CompanyInfoFields.tsx +83 -0
  200. package/src/screens/ConsentStep.tsx +68 -68
  201. package/src/screens/ContactChannelChoice.tsx +133 -0
  202. package/src/screens/ContactCodeStep.tsx +130 -0
  203. package/src/screens/ContactDestinationField.tsx +70 -0
  204. package/src/screens/ContactFooterNote.tsx +34 -0
  205. package/src/screens/ContactVerificationStep.tsx +199 -0
  206. package/src/screens/ContactVerifiedPanel.tsx +36 -0
  207. package/src/screens/CountrySelectStep.tsx +210 -0
  208. package/src/screens/DocumentCaptureStep.tsx +156 -132
  209. package/src/screens/IdInputStep.tsx +13 -5
  210. package/src/screens/IdTypeStep.tsx +74 -33
  211. package/src/screens/KeyPeopleInviteLinks.tsx +239 -0
  212. package/src/screens/LivenessStep.tsx +155 -247
  213. package/src/screens/MrzScanView.tsx +173 -0
  214. package/src/screens/NfcStep.tsx +290 -0
  215. package/src/screens/ProofOfAddressStep.tsx +238 -0
  216. package/src/screens/QuestionnaireField.tsx +219 -0
  217. package/src/screens/QuestionnaireMoneyField.tsx +134 -0
  218. package/src/screens/QuestionnaireStep.tsx +76 -0
  219. package/src/screens/SubmittedStep.tsx +5 -0
  220. package/src/screens/consent/model.ts +109 -0
  221. package/src/screens/document/CameraPhase.tsx +150 -0
  222. package/src/screens/document/RequiredPill.tsx +60 -0
  223. package/src/screens/liveness/LightingBanner.tsx +50 -0
  224. package/src/screens/liveness/LivenessOutcome.tsx +109 -0
  225. package/src/screens/liveness/ProgressDots.tsx +67 -0
  226. package/src/screens/liveness/SelfiePreview.tsx +39 -0
  227. package/src/screens/liveness/constants.ts +31 -0
  228. package/src/screens/liveness/index.ts +8 -0
  229. package/src/screens/liveness/resolveGuidance.ts +27 -0
  230. package/src/screens/liveness/useSelfieUpload.ts +107 -0
  231. package/src/screens/nfc/NfcMrzPrompt.tsx +40 -0
  232. package/src/screens/nfc/NfcReadBody.tsx +63 -0
  233. package/src/screens/nfc/NfcReadProgress.tsx +96 -0
  234. package/src/screens/nfc/NfcScanIllustration.tsx +244 -0
  235. package/src/screens/nfc/NfcScannedSummary.tsx +74 -0
  236. package/src/screens/nfc/NfcSuccessPanel.tsx +162 -0
  237. package/src/screens/nfc/availability.ts +73 -0
  238. package/src/screens/nfc/readErrorMessage.ts +93 -0
  239. package/src/screens/nfc/useChipPortrait.ts +58 -0
  240. package/src/screens/nfc/useNfcAvailability.ts +82 -0
  241. package/src/screens/useFlashHole.ts +105 -0
  242. package/src/screens/useFlashSequence.ts +96 -0
  243. package/src/screens/usePoaAttach.ts +121 -0
  244. package/src/services/api-types.ts +164 -0
  245. package/src/services/api-verify-types.ts +99 -0
  246. package/src/services/api.ts +72 -102
  247. package/src/services/cardCrop.ts +25 -10
  248. package/src/services/contactErrors.ts +43 -0
  249. package/src/services/deviceMetadata.ts +1 -1
  250. package/src/services/documentPicker.ts +20 -0
  251. package/src/services/fingerprint-sources.ts +129 -0
  252. package/src/services/fingerprint.ts +101 -0
  253. package/src/services/haptics.ts +38 -0
  254. package/src/services/mediaCompress.ts +7 -2
  255. package/src/services/resolveUrl.ts +2 -2
  256. package/src/services/workflowGate.ts +138 -0
  257. package/src/specs/MyazaEmrtd.nitro.ts +114 -0
  258. package/src/specs/MyazaFaceDetector.nitro.ts +10 -0
  259. package/src/specs/MyazaRectDetector.nitro.ts +50 -0
  260. package/src/specs/MyazaTextRecognizer.nitro.ts +48 -0
  261. package/src/store/derive.ts +225 -0
  262. package/src/store/kycStore.ts +250 -149
  263. package/src/store/state.ts +253 -0
  264. package/src/store/submit.ts +101 -0
  265. package/src/store/submitApplicant.ts +81 -0
  266. package/src/types/appearance.ts +113 -0
  267. package/src/types/business.ts +131 -0
  268. package/src/types/config.ts +150 -124
  269. package/src/types/id-types.ts +57 -0
  270. package/src/types/verification.ts +5 -0
  271. package/src/types/workflow.ts +141 -0
  272. package/src/utils/amountFormat.ts +73 -0
  273. package/src/utils/tokens.ts +4 -2
  274. package/src/__tests__/cardCrop.test.ts +0 -39
  275. package/src/__tests__/deviceMetadata.test.ts +0 -34
  276. package/src/__tests__/errors.test.ts +0 -34
  277. package/src/__tests__/flow.test.ts +0 -61
  278. package/src/__tests__/gestureDetector.test.ts +0 -37
  279. package/src/__tests__/liveness.test.ts +0 -112
  280. package/src/__tests__/resolveUrl.test.ts +0 -64
  281. package/src/__tests__/validators.test.ts +0 -38
@@ -0,0 +1,212 @@
1
+ import React, { useMemo, useState } from 'react';
2
+ import { View } from 'react-native';
3
+
4
+ import { spacing } from '../config/theme';
5
+ import { useKyc, useKycConfig, useKycStore, useTheme } from '../components/runtime';
6
+ import { MyazaText } from '../components/Typography';
7
+ import { MyazaButton } from '../components/MyazaButton';
8
+ import { MyazaInput } from '../components/MyazaInput';
9
+ import { MyazaSelect } from '../components/MyazaSelect';
10
+ import { CountryField } from '../components/CountryField';
11
+ import { CompanyInfoFields } from './CompanyInfoFields';
12
+ import {
13
+ businessCountriesFor,
14
+ businessCountryName,
15
+ businessProductsForCountry,
16
+ companyInfoFieldModes,
17
+ getBusinessProductDef,
18
+ keyPeopleNeedsContactEmail,
19
+ missingRequiredCompanyInfo,
20
+ } from '../config/business';
21
+ import { registrationNumberHint } from '../config/registrationHint';
22
+ import { isValidContactEmail } from '../config/contact';
23
+ import type { CompanyInfoField } from '../types/business';
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Business (KYB) registry details.
27
+ //
28
+ // The registry lookup verifies that a business EXISTS. Registration numbers are
29
+ // public, so it proves nothing about who is filling this in — that is what the
30
+ // rest of the KYB application (key people, documents, the applicant's own KYC)
31
+ // is for. This step collects the number, the registry country and product, and
32
+ // the company profile the workflow asks for.
33
+ // ---------------------------------------------------------------------------
34
+
35
+ export const businessDetailsMeta = {
36
+ title: 'Business Details',
37
+ description:
38
+ 'Provide your business registration details for verification against the official registry.',
39
+ };
40
+
41
+ export function BusinessDetailsStep(): React.ReactElement {
42
+ const config = useKycConfig();
43
+ const store = useKycStore();
44
+ const { colors } = useTheme();
45
+ const business = useKyc((s) => s.business);
46
+ const [touched, setTouched] = useState(false);
47
+
48
+ const workflowBusiness = config.business;
49
+ const countries = useMemo(() => businessCountriesFor(workflowBusiness), [workflowBusiness]);
50
+ // Precedence mirrors the web SDK exactly: the visitor's pick, then the
51
+ // workflow's PRIMARY country, then the list head. The primary is always in
52
+ // the offered list but not necessarily FIRST — preferring `countries[0]`
53
+ // defaulted the picker to the wrong registry whenever the primary sat later.
54
+ const country = business.country ?? workflowBusiness?.country ?? countries[0] ?? '';
55
+ const products = useMemo(
56
+ () => businessProductsForCountry(workflowBusiness, country),
57
+ [workflowBusiness, country],
58
+ );
59
+ // Re-derived per country rather than remembered: a product the picked country
60
+ // does not offer would be rejected at submit.
61
+ const product = products.includes(business.product ?? '') ? business.product! : products[0]!;
62
+ const productDef = getBusinessProductDef(product);
63
+
64
+ const modes = companyInfoFieldModes(workflowBusiness);
65
+ const companyFields = (Object.keys(modes) as CompanyInfoField[]).filter(
66
+ (field) => modes[field] !== 'off',
67
+ );
68
+ const needsContactEmail = keyPeopleNeedsContactEmail(workflowBusiness);
69
+
70
+ const set = <K extends keyof typeof business>(key: K, value: (typeof business)[K]): void =>
71
+ store.getState().setBusinessField(key, value);
72
+
73
+ const missingCompanyInfo = missingRequiredCompanyInfo(workflowBusiness, {
74
+ address: business.address,
75
+ email: business.email,
76
+ phone: business.phone,
77
+ website: business.website,
78
+ });
79
+ const contactEmailInvalid =
80
+ needsContactEmail && business.contactEmail.trim() !== '' && !isValidContactEmail(business.contactEmail);
81
+ const nameMissing =
82
+ workflowBusiness?.requireRegistrationName === true && business.registrationName.trim() === '';
83
+
84
+ // Country-aware registration-number guidance (NG: CAC prefix rules + format
85
+ // validation; elsewhere: placeholder + registry tip). Mirrors the web SDK.
86
+ const regHint = registrationNumberHint(country, productDef);
87
+ const regNumber = business.registrationNumber;
88
+ const formatOk =
89
+ !regHint.isValidFormat || regNumber.trim() === '' || regHint.isValidFormat(regNumber);
90
+ const numberValid = regNumber.trim().length >= 2 && formatOk;
91
+ const canContinue =
92
+ numberValid && !nameMissing && missingCompanyInfo.length === 0 && !contactEmailInvalid;
93
+
94
+ const handleContinue = (): void => {
95
+ setTouched(true);
96
+ if (!canContinue) return;
97
+ // Persist the resolved country/product so the submission uses exactly what
98
+ // was on screen, not a re-derivation from a config that may have changed.
99
+ set('country', country);
100
+ set('product', product);
101
+ store.getState().nextStep();
102
+ };
103
+
104
+ return (
105
+ <View>
106
+ {countries.length > 1 ? (
107
+ <>
108
+ <MyazaText variant="bodySmall" style={{ fontWeight: '600', marginBottom: spacing.xs }}>
109
+ Country of registration
110
+ </MyazaText>
111
+ {/* The SAME sheet as the phone field's dial-code picker — restricted
112
+ to the workflow's registry countries. */}
113
+ <CountryField
114
+ value={country}
115
+ options={countries.map((code) => ({ code, name: businessCountryName(code) }))}
116
+ onChange={(code) => {
117
+ set('country', code);
118
+ // The product list narrows per country, so a stale pick is
119
+ // cleared rather than carried into a refused submission.
120
+ set('product', null);
121
+ }}
122
+ />
123
+ <View style={{ height: spacing.md }} />
124
+ </>
125
+ ) : null}
126
+
127
+ {products.length > 1 ? (
128
+ <>
129
+ <MyazaText variant="bodySmall" style={{ fontWeight: '600', marginBottom: spacing.xs }}>
130
+ Verification type
131
+ </MyazaText>
132
+ <MyazaSelect
133
+ value={product}
134
+ sheetTitle="Verification type"
135
+ options={products.map((key) => ({
136
+ value: key,
137
+ label: getBusinessProductDef(key).label,
138
+ }))}
139
+ onChange={(key) => set('product', key)}
140
+ />
141
+ <View style={{ height: spacing.md }} />
142
+ </>
143
+ ) : null}
144
+
145
+ <MyazaInput
146
+ label={productDef.inputLabel}
147
+ value={business.registrationNumber}
148
+ onChangeText={(text) => set('registrationNumber', text)}
149
+ placeholder={regHint.placeholder}
150
+ autoCapitalize="characters"
151
+ autoFocus
152
+ // Live like the web SDK: a wrong CAC prefix is corrected on the spot,
153
+ // not discovered on Continue. The registry tip fills the same slot
154
+ // until there is an error to show.
155
+ error={
156
+ regNumber !== '' && !numberValid
157
+ ? (!formatOk && regHint.formatError) ||
158
+ `Enter a valid ${productDef.inputLabel.toLowerCase()}.`
159
+ : null
160
+ }
161
+ helper={regHint.tip ?? undefined}
162
+ />
163
+
164
+ <View style={{ height: spacing.sm }} />
165
+ <MyazaInput
166
+ label={`Registered business name${workflowBusiness?.requireRegistrationName ? '' : ' (optional)'}`}
167
+ value={business.registrationName}
168
+ onChangeText={(text) => set('registrationName', text)}
169
+ placeholder="Enter the registered business name"
170
+ error={touched && nameMissing ? 'Enter the registered business name.' : null}
171
+ />
172
+
173
+ <CompanyInfoFields
174
+ fields={companyFields}
175
+ modes={modes}
176
+ values={{
177
+ address: business.address,
178
+ email: business.email,
179
+ phone: business.phone,
180
+ website: business.website,
181
+ }}
182
+ missing={missingCompanyInfo}
183
+ showErrors={touched}
184
+ onChange={(field, text) => set(field, text)}
185
+ />
186
+
187
+ {needsContactEmail ? (
188
+ <>
189
+ <View style={{ height: spacing.sm }} />
190
+ <MyazaInput
191
+ label="Contact email for owner verification (optional)"
192
+ value={business.contactEmail}
193
+ onChangeText={(text) => set('contactEmail', text)}
194
+ placeholder="admin@company.com"
195
+ keyboardType="email-address"
196
+ autoCapitalize="none"
197
+ helper="We'll email this address a link for your directors and owners to verify their identity."
198
+ error={touched && contactEmailInvalid ? 'Enter a valid email address.' : null}
199
+ />
200
+ </>
201
+ ) : null}
202
+
203
+ <View style={{ height: spacing.md }} />
204
+ <MyazaButton label="Continue" onPress={handleContinue} disabled={touched && !canContinue} />
205
+ {touched && !canContinue ? (
206
+ <MyazaText variant="bodySmall" color={colors.error} style={{ marginTop: spacing.xs, textAlign: 'center' }}>
207
+ Please complete the highlighted fields.
208
+ </MyazaText>
209
+ ) : null}
210
+ </View>
211
+ );
212
+ }
@@ -0,0 +1,167 @@
1
+ import React from 'react';
2
+ import { ActivityIndicator, Image, 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
+
9
+ // ---------------------------------------------------------------------------
10
+ // One business-document upload slot — mirrors the web SDK's
11
+ // BusinessDocumentSlot 1:1: empty, it is a dashed full-width tap target
12
+ // (upload glyph — a spinner while uploading — label with a red * when
13
+ // required, "Photo or PDF, up to 20MB"); filled, a solid card showing a 48px
14
+ // THUMBNAIL of what was actually picked (a muted file tile for PDFs, a check
15
+ // when the preview didn't survive a remount — web's exact fallback ladder),
16
+ // the file's name over the slot label, Replace, and remove. Showing the file
17
+ // back matters — it is the only way a user catches "wrong photo from the
18
+ // camera roll" before submitting.
19
+ // ---------------------------------------------------------------------------
20
+
21
+ /** Web parity: UploadedFileThumb is h-12 w-12 rounded-lg (48 / radius 8). */
22
+ const THUMB = 48;
23
+
24
+ function UploadedThumb({ previewUri, isPdf }: { previewUri: string | null; isPdf: boolean }): React.ReactElement {
25
+ const { colors } = useTheme();
26
+ if (previewUri && !isPdf) {
27
+ return (
28
+ <Image
29
+ source={{ uri: previewUri }}
30
+ resizeMode="cover"
31
+ style={{
32
+ width: THUMB,
33
+ height: THUMB,
34
+ borderRadius: radius.xs,
35
+ borderWidth: 1,
36
+ borderColor: colors.border,
37
+ }}
38
+ />
39
+ );
40
+ }
41
+ if (isPdf) {
42
+ return (
43
+ <View
44
+ style={{
45
+ width: THUMB,
46
+ height: THUMB,
47
+ borderRadius: radius.xs,
48
+ borderWidth: 1,
49
+ borderColor: colors.border,
50
+ backgroundColor: colors.backgroundSecondary,
51
+ alignItems: 'center',
52
+ justifyContent: 'center',
53
+ }}
54
+ >
55
+ <Icon name="file-text" size={20} color={colors.textMuted} />
56
+ </View>
57
+ );
58
+ }
59
+ // The preview didn't survive a remount — the check still says "uploaded".
60
+ return <Icon name="check" size={20} color={colors.success} />;
61
+ }
62
+
63
+ export function BusinessDocumentSlot({
64
+ label,
65
+ required,
66
+ fileName,
67
+ uploading,
68
+ previewUri = null,
69
+ isPdf = false,
70
+ onPick,
71
+ onRemove,
72
+ }: {
73
+ label: string;
74
+ required: boolean;
75
+ /** Uploaded file name, when this slot already has a mediaId. */
76
+ fileName: string | null;
77
+ uploading: boolean;
78
+ /** Local URI of the picked image, for the thumbnail (null once remounted). */
79
+ previewUri?: string | null;
80
+ isPdf?: boolean;
81
+ /** Open the photo/file source sheet (also serves Replace). */
82
+ onPick: () => void;
83
+ onRemove: () => void;
84
+ }): React.ReactElement {
85
+ const { colors } = useTheme();
86
+
87
+ if (fileName !== null && !uploading) {
88
+ return (
89
+ <View
90
+ style={{
91
+ flexDirection: 'row',
92
+ alignItems: 'center',
93
+ borderWidth: 1,
94
+ borderColor: colors.border,
95
+ borderRadius: radius.sm,
96
+ backgroundColor: colors.backgroundSecondary,
97
+ padding: spacing.md,
98
+ marginBottom: spacing.sm + 4,
99
+ }}
100
+ >
101
+ <UploadedThumb previewUri={previewUri} isPdf={isPdf} />
102
+ <View style={{ width: spacing.sm + 4 }} />
103
+ <View style={{ flex: 1, minWidth: 0 }}>
104
+ <MyazaText variant="label" style={{ fontWeight: '600' }} numberOfLines={1}>
105
+ {fileName}
106
+ </MyazaText>
107
+ <MyazaText variant="bodySmall" color={colors.textMuted}>
108
+ {label}
109
+ </MyazaText>
110
+ </View>
111
+ <Pressable onPress={onPick} hitSlop={8} accessibilityRole="button">
112
+ <MyazaText variant="bodySmall" color={colors.primary} style={{ fontWeight: '600' }}>
113
+ Replace
114
+ </MyazaText>
115
+ </Pressable>
116
+ <View style={{ width: spacing.sm + 4 }} />
117
+ <Pressable
118
+ onPress={onRemove}
119
+ hitSlop={8}
120
+ accessibilityRole="button"
121
+ accessibilityLabel={`Remove ${label}`}
122
+ >
123
+ <Icon name="close" size={16} color={colors.textMuted} />
124
+ </Pressable>
125
+ </View>
126
+ );
127
+ }
128
+
129
+ return (
130
+ <Pressable
131
+ onPress={uploading ? undefined : onPick}
132
+ accessibilityRole="button"
133
+ accessibilityState={{ disabled: uploading }}
134
+ style={{
135
+ flexDirection: 'row',
136
+ alignItems: 'center',
137
+ borderWidth: 2,
138
+ borderStyle: 'dashed',
139
+ borderColor: colors.border,
140
+ borderRadius: radius.sm,
141
+ padding: spacing.md,
142
+ marginBottom: spacing.sm + 4,
143
+ opacity: uploading ? 0.7 : 1,
144
+ }}
145
+ >
146
+ {uploading ? (
147
+ <ActivityIndicator size="small" color={colors.primary} />
148
+ ) : (
149
+ <Icon name="upload" size={20} color={colors.textMuted} />
150
+ )}
151
+ <View style={{ width: spacing.sm + 4 }} />
152
+ <View style={{ flex: 1, minWidth: 0 }}>
153
+ <MyazaText variant="label" style={{ fontWeight: '600' }} numberOfLines={1}>
154
+ {label}
155
+ {required ? (
156
+ <MyazaText variant="label" color={colors.error} style={{ fontWeight: '600' }}>
157
+ {' *'}
158
+ </MyazaText>
159
+ ) : null}
160
+ </MyazaText>
161
+ <MyazaText variant="bodySmall" color={colors.textMuted}>
162
+ {uploading ? 'Uploading…' : 'Photo or PDF, up to 20MB'}
163
+ </MyazaText>
164
+ </View>
165
+ </Pressable>
166
+ );
167
+ }
@@ -0,0 +1,213 @@
1
+ import React, { useCallback, useMemo, useState } from 'react';
2
+ import { View } from 'react-native';
3
+ import * as ImagePicker from 'expo-image-picker';
4
+
5
+ import { spacing } from '../config/theme';
6
+ import { useKyc, useKycConfig, useKycStore, useTheme } from '../components/runtime';
7
+ import { MyazaText } from '../components/Typography';
8
+ import { MyazaButton } from '../components/MyazaButton';
9
+ import { MediaSourceSheet } from '../components/MediaSourceSheet';
10
+ import { BusinessDocumentSlot } from './BusinessDocumentSlot';
11
+ import { withRetry } from '../services/retry';
12
+ import { compressDocumentImage } from '../services/mediaCompress';
13
+ import { resolveBusinessDocumentTypes, type ResolvedBusinessDocumentType } from '../config/businessSteps';
14
+ import { isAcceptedPoaMimeType, POA_ACCEPTED_MIME_TYPES } from '../config/proofOfAddress';
15
+ import { loadDocumentPicker } from '../services/documentPicker';
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Supporting company documents (certificate of incorporation, MEMART, …).
19
+ //
20
+ // The registry says a business exists; these are what say the applicant holds
21
+ // its paperwork. The server OCRs each one and cross-checks it against the
22
+ // registry record — a soft result that never fails the verification, so the
23
+ // only thing blocking here is a REQUIRED slot left empty, which the server
24
+ // would 422 anyway.
25
+ //
26
+ // Layout mirrors the web SDK 1:1: the header carries the description, each
27
+ // document is a dashed upload slot (see BusinessDocumentSlot), and Continue is
28
+ // disabled until every required slot is filled. Tapping a slot opens the
29
+ // photo/file source sheet — the mobile stand-in for web's file input.
30
+ // ---------------------------------------------------------------------------
31
+
32
+ export const businessDocumentsMeta = {
33
+ title: 'Business documents',
34
+ description:
35
+ 'Upload the supporting documents for your business. Required documents are marked with *.',
36
+ };
37
+
38
+ export function BusinessDocumentsStep(): React.ReactElement {
39
+ const config = useKycConfig();
40
+ const store = useKycStore();
41
+ const { colors } = useTheme();
42
+ const uploaded = useKyc((s) => s.businessApplication.documents);
43
+ const [busySlot, setBusySlot] = useState<string | null>(null);
44
+ const [pickingSlot, setPickingSlot] = useState<ResolvedBusinessDocumentType | null>(null);
45
+ const [error, setError] = useState<string | null>(null);
46
+
47
+ const slots = useMemo(
48
+ () => resolveBusinessDocumentTypes(config.business),
49
+ [config.business],
50
+ );
51
+ const missing = slots.filter((s) => s.required && !uploaded.some((d) => d.type === s.key));
52
+
53
+ const attach = useCallback(
54
+ async (slot: ResolvedBusinessDocumentType, uri: string, mimeType: string | undefined, name: string) => {
55
+ setBusySlot(slot.key);
56
+ setError(null);
57
+ try {
58
+ // A PDF is passed through untouched — re-encoding would destroy the
59
+ // text the server reads off it.
60
+ const isPdf = (mimeType ?? '').toLowerCase().startsWith('application/pdf');
61
+ const finalUri = isPdf ? uri : await compressDocumentImage(uri).catch(() => uri);
62
+ const mediaId = await withRetry(() =>
63
+ store.getState().api.upload({ uri: finalUri, type: mimeType, name }, 'business_document'),
64
+ );
65
+ // Preview details ride the RECORD so the thumbnail survives leaving
66
+ // the step — screen-local state dies with the component.
67
+ store.getState().setBusinessDocument({
68
+ type: slot.key,
69
+ mediaId,
70
+ fileName: name,
71
+ ...(isPdf ? { isPdf: true } : { previewUri: finalUri }),
72
+ });
73
+ } catch {
74
+ setError(`We could not upload ${slot.label.toLowerCase()}. Please try again.`);
75
+ } finally {
76
+ setBusySlot(null);
77
+ }
78
+ },
79
+ [store],
80
+ );
81
+
82
+ const takePhoto = useCallback(
83
+ async (slot: ResolvedBusinessDocumentType) => {
84
+ const permission = await ImagePicker.requestCameraPermissionsAsync();
85
+ if (!permission.granted) {
86
+ setError('Camera access is needed to photograph the document.');
87
+ return;
88
+ }
89
+ const result = await ImagePicker.launchCameraAsync({ mediaTypes: ['images'], quality: 1 });
90
+ const asset = result.canceled ? undefined : result.assets[0];
91
+ if (asset) {
92
+ // A FRIENDLY name, not the picker's temp junk — camera/library assets
93
+ // carry generated names; the slot key says what the file IS.
94
+ await attach(slot, asset.uri, asset.mimeType ?? 'image/jpeg', `${slot.key}.jpg`);
95
+ }
96
+ },
97
+ [attach],
98
+ );
99
+
100
+ const choosePhoto = useCallback(
101
+ async (slot: ResolvedBusinessDocumentType) => {
102
+ const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], quality: 1 });
103
+ const asset = result.canceled ? undefined : result.assets[0];
104
+ if (asset) {
105
+ await attach(slot, asset.uri, asset.mimeType ?? 'image/jpeg', `${slot.key}.jpg`);
106
+ }
107
+ },
108
+ [attach],
109
+ );
110
+
111
+ const chooseFile = useCallback(
112
+ async (slot: ResolvedBusinessDocumentType) => {
113
+ const picker = loadDocumentPicker();
114
+ if (!picker) {
115
+ setError('Choosing a file is not available in this app. Please photograph the document.');
116
+ return;
117
+ }
118
+ const result = await picker.getDocumentAsync({
119
+ type: [...POA_ACCEPTED_MIME_TYPES],
120
+ copyToCacheDirectory: true,
121
+ multiple: false,
122
+ });
123
+ const asset = result.canceled ? undefined : result.assets?.[0];
124
+ if (!asset) return;
125
+ if (!isAcceptedPoaMimeType(asset.mimeType)) {
126
+ setError('Please choose a photo (JPEG, PNG or WebP) or a PDF.');
127
+ return;
128
+ }
129
+ await attach(slot, asset.uri, asset.mimeType, asset.name ?? slot.key);
130
+ },
131
+ [attach],
132
+ );
133
+
134
+ const handleContinue = (): void => {
135
+ if (missing.length > 0 || busySlot !== null) return;
136
+ store.getState().nextStep();
137
+ };
138
+
139
+ return (
140
+ <View>
141
+ {slots.map((slot) => {
142
+ const doc = uploaded.find((d) => d.type === slot.key);
143
+ return (
144
+ <BusinessDocumentSlot
145
+ key={slot.key}
146
+ label={slot.label}
147
+ required={slot.required}
148
+ fileName={doc?.fileName ?? null}
149
+ uploading={busySlot === slot.key}
150
+ previewUri={doc?.previewUri ?? null}
151
+ isPdf={doc?.isPdf === true}
152
+ onPick={() => setPickingSlot(slot)}
153
+ onRemove={() => store.getState().removeBusinessDocument(slot.key)}
154
+ />
155
+ );
156
+ })}
157
+
158
+ {error ? (
159
+ <MyazaText variant="bodySmall" color={colors.error} style={{ marginBottom: spacing.sm }}>
160
+ {error}
161
+ </MyazaText>
162
+ ) : null}
163
+
164
+ <View style={{ height: spacing.xs }} />
165
+ <MyazaButton
166
+ label="Continue"
167
+ onPress={handleContinue}
168
+ disabled={missing.length > 0 || busySlot !== null}
169
+ />
170
+
171
+ {/* THE SAME sheet as the Proof of Address step — identical title,
172
+ options, icons and captions, so uploading a document feels the same
173
+ everywhere in the flow. */}
174
+ <MediaSourceSheet
175
+ open={pickingSlot !== null}
176
+ title="Upload your document"
177
+ onClose={() => setPickingSlot(null)}
178
+ options={[
179
+ {
180
+ icon: 'image',
181
+ label: 'Photo library',
182
+ caption: 'Pick a photo you already have',
183
+ onPress: () => {
184
+ const slot = pickingSlot;
185
+ setPickingSlot(null);
186
+ if (slot) void choosePhoto(slot);
187
+ },
188
+ },
189
+ {
190
+ icon: 'camera',
191
+ label: 'Take a photo',
192
+ caption: 'Photograph the document now',
193
+ onPress: () => {
194
+ const slot = pickingSlot;
195
+ setPickingSlot(null);
196
+ if (slot) void takePhoto(slot);
197
+ },
198
+ },
199
+ {
200
+ icon: 'file-text',
201
+ label: 'Choose a file',
202
+ caption: 'A PDF or image from your files',
203
+ onPress: () => {
204
+ const slot = pickingSlot;
205
+ setPickingSlot(null);
206
+ if (slot) void chooseFile(slot);
207
+ },
208
+ },
209
+ ]}
210
+ />
211
+ </View>
212
+ );
213
+ }