@myazahq/kyc-sdk-react-native 2.0.1 → 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 (283) 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/CameraPermissionView.tsx +39 -3
  99. package/src/components/CameraViewfinder.tsx +226 -70
  100. package/src/components/ContactCodeEntry.tsx +139 -0
  101. package/src/components/CountryField.tsx +87 -0
  102. package/src/components/CountryFlag.tsx +15 -15
  103. package/src/components/DialCodePicker.tsx +186 -0
  104. package/src/components/DocumentCropper.tsx +1 -1
  105. package/src/components/DocumentReview.tsx +142 -0
  106. package/src/components/DocumentReviewSide.tsx +135 -0
  107. package/src/components/DocumentReviewZoom.tsx +119 -0
  108. package/src/components/ExpiryCountdown.tsx +52 -0
  109. package/src/components/FlashOverlay.tsx +156 -0
  110. package/src/components/Icon.tsx +67 -1
  111. package/src/components/KycFlow.tsx +66 -94
  112. package/src/components/KycSheet.tsx +148 -18
  113. package/src/components/MediaSourceSheet.tsx +163 -0
  114. package/src/components/MyazaButton.tsx +14 -2
  115. package/src/components/MyazaDateField.tsx +240 -0
  116. package/src/components/MyazaInput.tsx +73 -26
  117. package/src/components/MyazaSelect.tsx +283 -0
  118. package/src/components/MyazaWordmark.tsx +33 -0
  119. package/src/components/OptionRow.tsx +132 -0
  120. package/src/components/PhoneNumberInput.tsx +149 -0
  121. package/src/components/PoweredBy.tsx +89 -0
  122. package/src/components/ReadyPrimer.tsx +149 -0
  123. package/src/components/StepView.tsx +91 -0
  124. package/src/components/Typography.tsx +18 -3
  125. package/src/components/VerifiedNotice.tsx +38 -0
  126. package/src/components/WhatsAppIcon.tsx +29 -0
  127. package/src/components/WorkflowGate.tsx +128 -0
  128. package/src/components/brand-font.ts +113 -0
  129. package/src/components/flashHoleGeometry.ts +35 -0
  130. package/src/components/flow/FatalConfigError.tsx +56 -0
  131. package/src/components/fonts.ts +2 -19
  132. package/src/components/readyPrimerContent.ts +52 -0
  133. package/src/components/runtime.tsx +38 -43
  134. package/src/components/stepHeaderMeta.tsx +87 -0
  135. package/src/components/theme-provider.tsx +89 -0
  136. package/src/components/useWorkflowMount.ts +116 -0
  137. package/src/components/viewfinder/CardGuide.tsx +60 -0
  138. package/src/components/viewfinder/DocumentGhost.tsx +149 -0
  139. package/src/components/viewfinder/ImmersiveBottomBar.tsx +172 -0
  140. package/src/components/viewfinder/ImmersiveControls.tsx +127 -0
  141. package/src/components/viewfinder/ImmersiveGuide.tsx +174 -0
  142. package/src/components/viewfinder/ImmersiveOverlay.tsx +79 -0
  143. package/src/components/viewfinder/ViewfinderControls.tsx +154 -0
  144. package/src/config/brand.ts +97 -0
  145. package/src/config/business.ts +179 -0
  146. package/src/config/businessSteps.ts +105 -0
  147. package/src/config/contact.ts +159 -0
  148. package/src/config/countryNames.g.ts +254 -0
  149. package/src/config/currencyFlags.ts +144 -0
  150. package/src/config/font-resolve.ts +78 -0
  151. package/src/config/idTypes.ts +98 -1
  152. package/src/config/keyPeople.ts +152 -0
  153. package/src/config/phone.ts +39 -0
  154. package/src/config/proofOfAddress.ts +75 -0
  155. package/src/config/questionnaire.ts +153 -0
  156. package/src/config/regions.ts +118 -0
  157. package/src/config/registrationHint.ts +87 -0
  158. package/src/config/stepOrder.ts +118 -0
  159. package/src/config/theme.ts +38 -2
  160. package/src/config/workflowMerge.ts +124 -0
  161. package/src/emrtd/bac.ts +128 -0
  162. package/src/emrtd/bytes.ts +127 -0
  163. package/src/emrtd/crypto.ts +163 -0
  164. package/src/emrtd/der.ts +192 -0
  165. package/src/emrtd/dg2.ts +111 -0
  166. package/src/emrtd/files.ts +215 -0
  167. package/src/emrtd/index.ts +30 -0
  168. package/src/emrtd/mrzKey.ts +54 -0
  169. package/src/emrtd/native.ts +79 -0
  170. package/src/emrtd/optionalRead.ts +42 -0
  171. package/src/emrtd/read.ts +224 -0
  172. package/src/emrtd/retry.ts +98 -0
  173. package/src/emrtd/secureMessaging.ts +178 -0
  174. package/src/emrtd/session.ts +200 -0
  175. package/src/emrtd/stages.ts +122 -0
  176. package/src/index.ts +110 -0
  177. package/src/lib/calendar.ts +71 -0
  178. package/src/lib/use-keyboard-inset.ts +34 -0
  179. package/src/liveness/challengeManager.ts +27 -1
  180. package/src/liveness/faceContinuity.ts +153 -0
  181. package/src/liveness/flashDetector.ts +145 -0
  182. package/src/liveness/flashReadyGate.ts +106 -0
  183. package/src/liveness/flashRunner.ts +118 -0
  184. package/src/liveness/integritySignals.ts +60 -0
  185. package/src/liveness/livenessState.ts +139 -0
  186. package/src/liveness/speech.ts +19 -1
  187. package/src/liveness/types.ts +48 -1
  188. package/src/liveness/useFlashPhase.ts +78 -0
  189. package/src/liveness/useLiveness.ts +260 -75
  190. package/src/liveness/visionCameraFaceDetector.ts +14 -0
  191. package/src/mrz/extract.ts +101 -0
  192. package/src/mrz/parse.ts +178 -0
  193. package/src/mrz/textRecognizer.ts +149 -0
  194. package/src/screens/ApplicantRoleStep.tsx +331 -0
  195. package/src/screens/BusinessDetailsStep.tsx +212 -0
  196. package/src/screens/BusinessDocumentSlot.tsx +167 -0
  197. package/src/screens/BusinessDocumentsStep.tsx +213 -0
  198. package/src/screens/BusinessKeyPeopleStep.tsx +151 -0
  199. package/src/screens/BusinessKeyPersonRow.tsx +170 -0
  200. package/src/screens/CompanyInfoFields.tsx +83 -0
  201. package/src/screens/ConsentStep.tsx +68 -68
  202. package/src/screens/ContactChannelChoice.tsx +133 -0
  203. package/src/screens/ContactCodeStep.tsx +130 -0
  204. package/src/screens/ContactDestinationField.tsx +70 -0
  205. package/src/screens/ContactFooterNote.tsx +34 -0
  206. package/src/screens/ContactVerificationStep.tsx +199 -0
  207. package/src/screens/ContactVerifiedPanel.tsx +36 -0
  208. package/src/screens/CountrySelectStep.tsx +210 -0
  209. package/src/screens/DocumentCaptureStep.tsx +190 -145
  210. package/src/screens/IdInputStep.tsx +19 -9
  211. package/src/screens/IdTypeStep.tsx +74 -33
  212. package/src/screens/KeyPeopleInviteLinks.tsx +239 -0
  213. package/src/screens/LivenessStep.tsx +181 -267
  214. package/src/screens/MrzScanView.tsx +173 -0
  215. package/src/screens/NfcStep.tsx +290 -0
  216. package/src/screens/ProofOfAddressStep.tsx +238 -0
  217. package/src/screens/QuestionnaireField.tsx +219 -0
  218. package/src/screens/QuestionnaireMoneyField.tsx +134 -0
  219. package/src/screens/QuestionnaireStep.tsx +76 -0
  220. package/src/screens/SubmittedStep.tsx +5 -0
  221. package/src/screens/consent/model.ts +109 -0
  222. package/src/screens/document/CameraPhase.tsx +150 -0
  223. package/src/screens/document/RequiredPill.tsx +60 -0
  224. package/src/screens/liveness/LightingBanner.tsx +50 -0
  225. package/src/screens/liveness/LivenessOutcome.tsx +109 -0
  226. package/src/screens/liveness/ProgressDots.tsx +67 -0
  227. package/src/screens/liveness/SelfiePreview.tsx +39 -0
  228. package/src/screens/liveness/constants.ts +31 -0
  229. package/src/screens/liveness/index.ts +8 -0
  230. package/src/screens/liveness/resolveGuidance.ts +27 -0
  231. package/src/screens/liveness/useSelfieUpload.ts +107 -0
  232. package/src/screens/nfc/NfcMrzPrompt.tsx +40 -0
  233. package/src/screens/nfc/NfcReadBody.tsx +63 -0
  234. package/src/screens/nfc/NfcReadProgress.tsx +96 -0
  235. package/src/screens/nfc/NfcScanIllustration.tsx +244 -0
  236. package/src/screens/nfc/NfcScannedSummary.tsx +74 -0
  237. package/src/screens/nfc/NfcSuccessPanel.tsx +162 -0
  238. package/src/screens/nfc/availability.ts +73 -0
  239. package/src/screens/nfc/readErrorMessage.ts +93 -0
  240. package/src/screens/nfc/useChipPortrait.ts +58 -0
  241. package/src/screens/nfc/useNfcAvailability.ts +82 -0
  242. package/src/screens/useFlashHole.ts +105 -0
  243. package/src/screens/useFlashSequence.ts +96 -0
  244. package/src/screens/usePoaAttach.ts +121 -0
  245. package/src/services/api-types.ts +164 -0
  246. package/src/services/api-verify-types.ts +99 -0
  247. package/src/services/api.ts +72 -102
  248. package/src/services/cardCrop.ts +25 -10
  249. package/src/services/contactErrors.ts +43 -0
  250. package/src/services/deviceMetadata.ts +1 -1
  251. package/src/services/documentPicker.ts +20 -0
  252. package/src/services/fingerprint-sources.ts +129 -0
  253. package/src/services/fingerprint.ts +101 -0
  254. package/src/services/haptics.ts +38 -0
  255. package/src/services/mediaCompress.ts +7 -2
  256. package/src/services/resolveUrl.ts +3 -2
  257. package/src/services/validators.ts +3 -0
  258. package/src/services/workflowGate.ts +138 -0
  259. package/src/specs/MyazaEmrtd.nitro.ts +114 -0
  260. package/src/specs/MyazaFaceDetector.nitro.ts +10 -0
  261. package/src/specs/MyazaRectDetector.nitro.ts +50 -0
  262. package/src/specs/MyazaTextRecognizer.nitro.ts +48 -0
  263. package/src/store/derive.ts +225 -0
  264. package/src/store/kycStore.ts +250 -149
  265. package/src/store/state.ts +253 -0
  266. package/src/store/submit.ts +101 -0
  267. package/src/store/submitApplicant.ts +81 -0
  268. package/src/types/appearance.ts +113 -0
  269. package/src/types/business.ts +131 -0
  270. package/src/types/config.ts +150 -119
  271. package/src/types/id-types.ts +57 -0
  272. package/src/types/verification.ts +5 -0
  273. package/src/types/workflow.ts +141 -0
  274. package/src/utils/amountFormat.ts +73 -0
  275. package/src/utils/tokens.ts +4 -2
  276. package/src/__tests__/cardCrop.test.ts +0 -39
  277. package/src/__tests__/deviceMetadata.test.ts +0 -34
  278. package/src/__tests__/errors.test.ts +0 -34
  279. package/src/__tests__/flow.test.ts +0 -61
  280. package/src/__tests__/gestureDetector.test.ts +0 -37
  281. package/src/__tests__/liveness.test.ts +0 -112
  282. package/src/__tests__/resolveUrl.test.ts +0 -64
  283. package/src/__tests__/validators.test.ts +0 -38
package/src/MyazaKYC.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import React, { useCallback, useMemo, useRef, useState } from 'react';
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { Modal, Platform } from 'react-native';
3
3
 
4
4
  // iOS presents the flow as a swipe-down card sheet (the RN equivalent of
@@ -12,7 +12,10 @@ const MODAL_PRESENTATION = Platform.OS === 'ios' ? 'pageSheet' : 'fullScreen';
12
12
  const MODAL_ANIMATION = 'slide';
13
13
 
14
14
  import type { KYCStep, MyazaKYCConfig, SupportedCountry } from './types/config';
15
- import { KycRuntimeProvider, useKycStore } from './components/runtime';
15
+ import { KycRuntimeProvider, MyazaThemeProvider } from './components/runtime';
16
+ import { WorkflowGate } from './components/WorkflowGate';
17
+ import { useWorkflowMount } from './components/useWorkflowMount';
18
+ import { safeReportError } from './services/errors';
16
19
  import { KycFlow, type BackResult } from './components/KycFlow';
17
20
  import { MyazaButton } from './components/MyazaButton';
18
21
 
@@ -62,10 +65,13 @@ export function MyazaKYC<C extends SupportedCountry = SupportedCountry>(
62
65
  // The provider wraps BOTH the trigger and the modal so the built-in trigger
63
66
  // button can read the theme. The store is created once and `reset()` on each
64
67
  // open for a fresh run.
68
+ // The theme provider wraps the trigger so the built-in button is branded
69
+ // before anything is resolved; the flow's own provider (with the resolved
70
+ // workflow's appearance) is mounted inside the modal by the gate.
65
71
  return (
66
- <KycRuntimeProvider config={config}>
72
+ <MyazaThemeProvider appearance={config.appearance}>
67
73
  <MyazaKYCTrigger config={config} label={children} disabled={disabled} />
68
- </KycRuntimeProvider>
74
+ </MyazaThemeProvider>
69
75
  );
70
76
  }
71
77
 
@@ -78,23 +84,49 @@ function MyazaKYCTrigger({
78
84
  label?: string;
79
85
  disabled?: boolean;
80
86
  }): React.ReactElement {
81
- const store = useKycStore();
82
- const [open, setOpen] = useState(false);
87
+ const [wantOpen, setWantOpen] = useState(false);
83
88
  // Idempotent close — the X button, Android back, and iOS swipe-down dismiss can
84
89
  // each fire; `onClose` must run at most once per open.
85
90
  const closedRef = useRef(true);
86
91
  const backRef = useRef<(() => BackResult) | null>(null);
87
92
 
93
+ // Resolution happens HERE, outside the modal, and starts on mount rather than
94
+ // on press. The modal is only presented once it has settled, so its very
95
+ // first frame carries the workflow's own appearance instead of coming up in
96
+ // the default brand and recolouring underneath the user.
97
+ const { state, retry, refresh } = useWorkflowMount(config);
98
+ const settled = state.status !== 'resolving';
99
+ const open = wantOpen && settled;
100
+
101
+ // Reported only once the user has actually tried to start. Prefetching must
102
+ // not fire a consumer's error handler for a flow they never opened.
103
+ const reportedRef = useRef(false);
104
+ useEffect(() => {
105
+ if (!wantOpen || state.status !== 'error' || reportedRef.current) return;
106
+ reportedRef.current = true;
107
+ safeReportError(config.onError, state.error);
108
+ }, [wantOpen, state, config]);
109
+
110
+ const retryFlow = useCallback(() => {
111
+ reportedRef.current = false;
112
+ retry();
113
+ }, [retry]);
114
+
115
+ // Each open mounts a fresh gate → provider → store, so there is no state to
116
+ // reset; the previous run's store is discarded with its provider.
88
117
  const openFlow = useCallback(() => {
89
118
  closedRef.current = false;
90
- store.getState().reset();
91
- setOpen(true);
92
- }, [store]);
119
+ // Re-resolve on every open: the mount-time prefetch is a warm-up, not a
120
+ // cache, so a workflow published since the app started is still picked up.
121
+ reportedRef.current = false;
122
+ refresh();
123
+ setWantOpen(true);
124
+ }, [refresh]);
93
125
 
94
126
  const close = useCallback(() => {
95
127
  if (closedRef.current) return;
96
128
  closedRef.current = true;
97
- setOpen(false);
129
+ setWantOpen(false);
98
130
  config.onClose?.();
99
131
  }, [config]);
100
132
 
@@ -113,6 +145,10 @@ function MyazaKYCTrigger({
113
145
  <MyazaButton
114
146
  label={label ?? defaultTriggerLabel(config)}
115
147
  disabled={disabled}
148
+ // Pressed, but the workflow has not arrived yet. Without this the press
149
+ // would look ignored — gating the modal on a resolved config trades a
150
+ // wrong colour for a dead button unless the wait is shown.
151
+ loading={wantOpen && !settled}
116
152
  fullWidth={false}
117
153
  onPress={openFlow}
118
154
  />
@@ -125,7 +161,15 @@ function MyazaKYCTrigger({
125
161
  onRequestClose={onRequestClose}
126
162
  onDismiss={blockDismiss ? undefined : close}
127
163
  >
128
- {open ? <KycFlow onClose={close} backRef={backRef} /> : null}
164
+ {open ? (
165
+ <WorkflowGate config={config} state={state} onRetry={retryFlow} onClose={close}>
166
+ {(mount) => (
167
+ <KycRuntimeProvider config={mount.config} serverConfig={mount.serverConfig}>
168
+ <KycFlow onClose={close} backRef={backRef} />
169
+ </KycRuntimeProvider>
170
+ )}
171
+ </WorkflowGate>
172
+ ) : null}
129
173
  </Modal>
130
174
  </>
131
175
  );
@@ -142,6 +186,15 @@ export interface UseMyazaKYCReturn {
142
186
  open: () => void;
143
187
  close: () => void;
144
188
  isOpen: boolean;
189
+ /**
190
+ * Opening was requested but the workflow has not resolved yet.
191
+ *
192
+ * Show a spinner on your trigger while this is true. The modal is deliberately
193
+ * withheld until the config has settled so it opens in the right brand rather
194
+ * than recolouring underneath the user — which means the press has to be
195
+ * acknowledged somewhere, or it looks ignored.
196
+ */
197
+ isPreparing: boolean;
145
198
  currentStep: KYCStep | null;
146
199
  /** Render this once in your component tree (RN has no implicit portal). */
147
200
  MyazaKYCModal: React.FC;
@@ -150,7 +203,7 @@ export interface UseMyazaKYCReturn {
150
203
  export function useMyazaKYC<C extends SupportedCountry = SupportedCountry>(
151
204
  config: MyazaKYCConfig<C>,
152
205
  ): UseMyazaKYCReturn {
153
- const [isOpen, setIsOpen] = useState(false);
206
+ const [wantOpen, setWantOpen] = useState(false);
154
207
  const [currentStep, setCurrentStep] = useState<KYCStep | null>(null);
155
208
  const closedRef = useRef(true);
156
209
 
@@ -164,14 +217,23 @@ export function useMyazaKYC<C extends SupportedCountry = SupportedCountry>(
164
217
  configRef.current = config;
165
218
  const backRef = useRef<(() => BackResult) | null>(null);
166
219
 
220
+ // `refresh` is defined further down (it needs `wrappedConfig`), so it is
221
+ // reached through a ref rather than reordering the component around it.
222
+ const refreshRef = useRef<() => void>(() => undefined);
223
+ // Only reported once the consumer has actually tried to start — prefetching
224
+ // must not fire their error handler for a flow they never opened.
225
+ const reportedRef = useRef(false);
167
226
  const open = useCallback(() => {
168
227
  closedRef.current = false;
169
- setIsOpen(true);
228
+ // Re-resolve on every open — see the trigger above.
229
+ reportedRef.current = false;
230
+ refreshRef.current();
231
+ setWantOpen(true);
170
232
  }, []);
171
233
  const close = useCallback(() => {
172
234
  if (closedRef.current) return;
173
235
  closedRef.current = true;
174
- setIsOpen(false);
236
+ setWantOpen(false);
175
237
  setCurrentStep(null);
176
238
  configRef.current.onClose?.();
177
239
  }, []);
@@ -195,6 +257,24 @@ export function useMyazaKYC<C extends SupportedCountry = SupportedCountry>(
195
257
  [],
196
258
  );
197
259
 
260
+ // Resolution runs outside the modal and starts on mount, so the modal's first
261
+ // frame already carries the workflow's appearance.
262
+ const { state, retry, refresh } = useWorkflowMount(wrappedConfig);
263
+ refreshRef.current = refresh;
264
+ const settled = state.status !== 'resolving';
265
+ const presenting = wantOpen && settled;
266
+
267
+ useEffect(() => {
268
+ if (!wantOpen || state.status !== 'error' || reportedRef.current) return;
269
+ reportedRef.current = true;
270
+ safeReportError(configRef.current.onError, state.error);
271
+ }, [wantOpen, state]);
272
+
273
+ const retryFlow = useCallback(() => {
274
+ reportedRef.current = false;
275
+ retry();
276
+ }, [retry]);
277
+
198
278
  // disableClose blocks user-initiated dismissal — only the returned close()
199
279
  // can dismiss. Present full-screen (no iOS swipe-down). The Android hardware
200
280
  // back goes through onRequestClose, which walks the flow back a step (or
@@ -212,7 +292,7 @@ export function useMyazaKYC<C extends SupportedCountry = SupportedCountry>(
212
292
  const MyazaKYCModal = useCallback<React.FC>(
213
293
  () => (
214
294
  <Modal
215
- visible={isOpen}
295
+ visible={presenting}
216
296
  animationType={MODAL_ANIMATION}
217
297
  presentationStyle={blockDismiss ? 'fullScreen' : MODAL_PRESENTATION}
218
298
  statusBarTranslucent
@@ -220,16 +300,29 @@ export function useMyazaKYC<C extends SupportedCountry = SupportedCountry>(
220
300
  onRequestClose={onRequestClose}
221
301
  onDismiss={blockDismiss ? undefined : close}
222
302
  >
223
- {/* Fresh provider/store per open. */}
224
- {isOpen ? (
225
- <KycRuntimeProvider config={wrappedConfig}>
226
- <KycFlow onClose={close} backRef={backRef} />
227
- </KycRuntimeProvider>
303
+ {/* Fresh gate/provider/store per open. */}
304
+ {presenting ? (
305
+ <WorkflowGate config={wrappedConfig} state={state} onRetry={retryFlow} onClose={close}>
306
+ {(mount) => (
307
+ <KycRuntimeProvider config={mount.config} serverConfig={mount.serverConfig}>
308
+ <KycFlow onClose={close} backRef={backRef} />
309
+ </KycRuntimeProvider>
310
+ )}
311
+ </WorkflowGate>
228
312
  ) : null}
229
313
  </Modal>
230
314
  ),
231
- [wrappedConfig, isOpen, close, blockDismiss, onRequestClose],
315
+ [wrappedConfig, presenting, state, retryFlow, close, blockDismiss, onRequestClose],
232
316
  );
233
317
 
234
- return { open, close, isOpen, currentStep, MyazaKYCModal };
318
+ // `isOpen` stays the consumer's intent, not the presentation state — a
319
+ // trigger disabled on it must stay disabled through the preparing window.
320
+ return {
321
+ open,
322
+ close,
323
+ isOpen: wantOpen,
324
+ isPreparing: wantOpen && !settled,
325
+ currentStep,
326
+ MyazaKYCModal,
327
+ };
235
328
  }
@@ -0,0 +1,153 @@
1
+ import { detectDocumentType, hasDocumentSignals, hasMrzLines } from './documentSignals';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Document identity.
5
+ //
6
+ // "Is the thing in frame the document the user picked?" — answered from
7
+ // recognized text, so a capture clears the same bar on both platforms.
8
+ //
9
+ // Text ALONE cannot answer it either: the word "passport" appears in prose, in
10
+ // code, and on any screen showing KYC documentation — which is exactly how a
11
+ // laptop screen full of text once identified as a passport and fired.
12
+ //
13
+ // So identity is deliberately strict about the one document that can prove
14
+ // itself: an MRZ. Only travel documents carry one, it is always on the page a
15
+ // passport capture is meant to frame, and it validates by check digit — so for
16
+ // passports the machine-readable zone, not the printed word, is the proof.
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /**
20
+ * What to actually tell the user. The gate rejects for several distinct
21
+ * reasons, and collapsing them into one "align your ID" message left people
22
+ * stuck: the commonest failure is holding the document TOO CLOSE, where the
23
+ * instinct is to move nearer still.
24
+ */
25
+ export type DocumentHint =
26
+ | 'searching'
27
+ | 'moreLight'
28
+ | 'wrongDocument'
29
+ | 'moveCloser'
30
+ /** Overflowing — corners are cut. THIS is the one users cannot guess. */
31
+ | 'moveBack'
32
+ | 'centre'
33
+ /**
34
+ * Passport only: the page is readable but the machine-readable strip is not
35
+ * in frame. Auto-capture waits for it (it is the chip's key and the proof the
36
+ * page is a passport), so a generic "move closer" sends the user the wrong way.
37
+ */
38
+ | 'showMrz'
39
+ | 'holdStill'
40
+ | 'captured';
41
+
42
+ export type DocumentFraming =
43
+ | 'none'
44
+ | 'wrongShape'
45
+ | 'adjust'
46
+ | 'holding'
47
+ | 'ready';
48
+
49
+ export interface DocumentGuidance {
50
+ framing: DocumentFraming;
51
+ hint: DocumentHint;
52
+ }
53
+
54
+ export interface DocumentIdentityResult {
55
+ identified: boolean;
56
+ /** Meaningful only when `identified` is false. */
57
+ hint: DocumentHint;
58
+ }
59
+
60
+ /** ID types whose page always carries a machine-readable zone. */
61
+ export function documentCarriesMrz(idType: string): boolean {
62
+ return idType === 'passport';
63
+ }
64
+
65
+ export interface VerifyIdentityOptions {
66
+ country: string;
67
+ idType: string;
68
+ /**
69
+ * Which face of the document is being photographed. Identity KEYWORDS live
70
+ * on the FRONT — the branding, the document name, the issuer. The back of a
71
+ * card (a PVC's barcode-and-address side, a licence's magstripe side)
72
+ * carries none of them, so demanding them there means the back can never
73
+ * auto-capture. By the time the back is up, the user has captured and
74
+ * confirmed the front of the right document seconds ago — identity is
75
+ * established; the back only has to be framed, text-bearing and still.
76
+ */
77
+ side?: 'front' | 'back';
78
+ /** The chip step needs the MRZ as a key, so demand a check-digit-valid read. */
79
+ requireValidMrz?: boolean;
80
+ hasValidMrz?: boolean;
81
+ /** The key is already stored from an earlier frame. */
82
+ mrzAlreadyCaptured?: boolean;
83
+ minConfidence?: number;
84
+ wrongTypeMargin?: number;
85
+ }
86
+
87
+ /** Verifies that `lines` identify the document the user picked. */
88
+ export function verifyDocumentIdentity(
89
+ lines: string[],
90
+ {
91
+ country,
92
+ idType,
93
+ side = 'front',
94
+ requireValidMrz = false,
95
+ hasValidMrz = false,
96
+ mrzAlreadyCaptured = false,
97
+ minConfidence = 0.34,
98
+ wrongTypeMargin = 0.34,
99
+ }: VerifyIdentityOptions,
100
+ ): DocumentIdentityResult {
101
+ if (lines.length === 0) return { identified: false, hint: 'searching' };
102
+ const isBack = side === 'back';
103
+
104
+ // ── The MRZ rule ────────────────────────────────────────────────────────
105
+ // For an MRZ-bearing document the zone must actually be in frame. This is
106
+ // what rejects a screen, a printout, or a page that merely mentions the word.
107
+ // Front only: the strip lives on the photo page.
108
+ if (!isBack && documentCarriesMrz(idType) && !hasValidMrz && !hasMrzLines(lines)) {
109
+ // Say WHICH problem it is. Text that already reads as the expected document
110
+ // is most likely the real thing with the strip cropped off the bottom edge
111
+ // — the commonest passport framing mistake — and telling that user "wrong
112
+ // document" sends them hunting for a different one.
113
+ const looksRight = detectDocumentType(lines, country).type === idType;
114
+ return { identified: false, hint: looksRight ? 'showMrz' : 'wrongDocument' };
115
+ }
116
+
117
+ // ── Keyword identity ────────────────────────────────────────────────────
118
+ // Only where signals exist: most Global Documents countries have no curated
119
+ // list, and there we must not block on a check we cannot perform.
120
+ if (hasDocumentSignals(country, idType)) {
121
+ const match = detectDocumentType(lines, country);
122
+ const expected = match.type === idType ? match.confidence : 0;
123
+
124
+ // Reading clearly as a DIFFERENT document — the server would reject this
125
+ // after upload as `document_type_mismatch`, so say so now. This arm runs
126
+ // for the back too: its keywords are absent from card backs, so a hit
127
+ // means a different document's FRONT swapped into the frame.
128
+ if (match.type != null && match.type !== idType && match.confidence >= expected + wrongTypeMargin) {
129
+ return { identified: false, hint: 'wrongDocument' };
130
+ }
131
+
132
+ // Two independent keyword hits identify the document, whatever the length
133
+ // of its synonym list. Confidence alone is a ratio, so a type with a
134
+ // thorough list is HARDER to recognise than a sparse one. REQUIRED on the
135
+ // front only — the back carries no branding to match.
136
+ const identifiedByCount = match.type === idType && match.matched >= 2;
137
+ if (!isBack && !identifiedByCount && expected < minConfidence) {
138
+ return { identified: false, hint: lines.length < 5 ? 'searching' : 'moveCloser' };
139
+ }
140
+ }
141
+
142
+ // ── Chip key ────────────────────────────────────────────────────────────
143
+ // Firing before the MRZ validates means the chip step has to scan the same
144
+ // document a second time, which is the whole reason this gate exists.
145
+ // Front only: the strip cannot appear on the back.
146
+ if (!isBack && requireValidMrz && !hasValidMrz && !mrzAlreadyCaptured) {
147
+ // The document is identified; only the strip is missing. "Move closer"
148
+ // would push the user to crop it off entirely.
149
+ return { identified: false, hint: 'showMrz' };
150
+ }
151
+
152
+ return { identified: true, hint: 'holdStill' };
153
+ }
@@ -0,0 +1,177 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Document identity signals.
3
+ //
4
+ // "Is the thing in frame actually the document the user picked?" — answered
5
+ // from recognized text alone.
6
+ //
7
+ // Geometry cannot answer it: an aspect ratio cannot tell a passport from a
8
+ // driver's licence. Text can, and more specifically — a passport page says
9
+ // PASSPORT and carries an MRZ; a Nigerian licence says FRSC.
10
+ //
11
+ // Deliberately mirrors the SERVER's DOCUMENT_SIGNALS / detectDocumentType
12
+ // (kyc-core src/lib/ocr-parser.ts). The server rejects a mismatched document
13
+ // AFTER upload with `document_type_mismatch`, so matching its vocabulary here
14
+ // means the camera refuses to shoot exactly what the server would later throw
15
+ // away. Keep the lists in sync when either changes — and with the Flutter SDK's
16
+ // document_type_signals.dart, which is the direct counterpart.
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /** Keyword signals per country → ID type. Uppercase; matched as substrings. */
20
+ export const DOCUMENT_SIGNALS: Record<string, Record<string, string[]>> = {
21
+ NG: {
22
+ passport: ['PASSPORT', 'TRAVEL DOCUMENT', 'P<NGA'],
23
+ 'drivers-license': [
24
+ 'DRIVER', 'LICENSE', 'FRSC', 'FEDERAL ROAD SAFETY', 'DRIVING LICENCE',
25
+ ],
26
+ pvc: [
27
+ 'VOTER', "VOTER'S CARD", 'PVC', 'INEC', 'INDEPENDENT NATIONAL ELECTORAL',
28
+ 'PERMANENT VOTER', 'ELECTORAL COMMISSION', 'VIN',
29
+ ],
30
+ },
31
+ GH: {
32
+ passport: ['PASSPORT', 'REPUBLIC OF GHANA', 'P<GHA'],
33
+ 'ghana-card': ['GHANA CARD', 'NATIONAL IDENTIFICATION AUTHORITY', 'NIA', 'GHA-'],
34
+ voters: ['VOTER', 'ELECTORAL COMMISSION', 'EC OF GHANA'],
35
+ 'drivers-license': ['DRIVER', 'LICENSE', 'DVLA', 'DRIVING AND VEHICLE'],
36
+ ssnit: ['SSNIT', 'SOCIAL SECURITY', 'NATIONAL INSURANCE'],
37
+ },
38
+ KE: {
39
+ passport: ['PASSPORT', 'REPUBLIC OF KENYA', 'P<KEN'],
40
+ 'national-id': [
41
+ 'REPUBLIC OF KENYA', 'NATIONAL ID', 'JAMHURI YA KENYA', 'IDENTITY CARD',
42
+ ],
43
+ },
44
+ ZA: {
45
+ passport: ['PASSPORT', 'P<ZAF'],
46
+ 'national-id': [
47
+ 'REPUBLIC OF SOUTH AFRICA', 'IDENTITY', 'ID NUMBER', 'REPUBLIEK VAN SUID-AFRIKA',
48
+ ],
49
+ },
50
+ CI: {
51
+ cni: ['CARTE NATIONALE', 'IDENTITE', 'REPUBLIQUE DE COTE', 'CNI'],
52
+ 'residence-card': ['CARTE DE SEJOUR', 'RESIDENCE', 'TITRE DE SEJOUR'],
53
+ },
54
+ };
55
+
56
+ /**
57
+ * Words that identify a passport in ANY country — an MRZ, or the word itself in
58
+ * English or French. Global Documents means most countries have no curated
59
+ * list, and a passport is the one document that identifies itself everywhere.
60
+ */
61
+ const UNIVERSAL_PASSPORT_WORDS = ['PASSPORT', 'PASSEPORT'];
62
+
63
+ /**
64
+ * True when `lines` contain two or more machine-readable-zone lines. A cheap
65
+ * STRUCTURAL check — no check-digit validation, which `extractMrz` does.
66
+ */
67
+ export function hasMrzLines(lines: string[]): boolean {
68
+ let count = 0;
69
+ for (const line of lines) {
70
+ const stripped = line.toUpperCase().replace(/[^A-Z0-9<]/g, '');
71
+ if (stripped.length >= 40 && stripped.includes('<')) count += 1;
72
+ }
73
+ return count >= 2;
74
+ }
75
+
76
+ export interface DocumentTypeMatch {
77
+ /** The ID type the text matches best, or null when nothing matched. */
78
+ type: string | null;
79
+ /**
80
+ * 0..1 share of that type's keywords present.
81
+ *
82
+ * A RATIO, so it shrinks as a type's synonym list grows — which is why it
83
+ * cannot be the only bar for "is this the right document". See `matched`.
84
+ */
85
+ confidence: number;
86
+ /**
87
+ * How many keywords actually hit.
88
+ *
89
+ * The evidence that matters when deciding the document IS the expected one:
90
+ * two independent hits mean the same thing whether the type lists three
91
+ * synonyms or eight, where `confidence` would call the same evidence 0.67 or
92
+ * 0.25. Kept alongside rather than replacing it — the wrong-type comparison
93
+ * still wants a ratio, so two types are weighed on one scale.
94
+ */
95
+ matched: number;
96
+ }
97
+
98
+ const NO_MATCH: DocumentTypeMatch = { type: null, confidence: 0, matched: 0 };
99
+
100
+ /**
101
+ * True when we have signals to verify this (country, idType) at all. When
102
+ * false the caller CANNOT check identity and must not block on it — most
103
+ * Global Documents countries have no curated list.
104
+ */
105
+ export function hasDocumentSignals(country: string, idType: string): boolean {
106
+ if (DOCUMENT_SIGNALS[country]?.[idType]) return true;
107
+ // A passport identifies itself in any country.
108
+ return idType === 'passport';
109
+ }
110
+
111
+ function isShortAcronym(keyword: string): boolean {
112
+ return keyword.length <= 4 && /^[A-Z]+$/.test(keyword);
113
+ }
114
+
115
+ /**
116
+ * How many of `keywords` appear in `upper`.
117
+ *
118
+ * Short acronyms match on WORD BOUNDARIES, not as bare substrings. `VIN` sits
119
+ * inside "driVINg", so a Nigerian driver's licence would otherwise score a
120
+ * voter-card hit — harmless while every score is diluted by the keyword count,
121
+ * and a false identification the moment anything counts hits directly.
122
+ */
123
+ export function countSignalHits(upper: string, keywords: string[]): number {
124
+ let hits = 0;
125
+ for (const keyword of keywords) {
126
+ if (isShortAcronym(keyword)) {
127
+ if (new RegExp(`\\b${keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).test(upper)) {
128
+ hits += 1;
129
+ }
130
+ } else if (upper.includes(keyword)) {
131
+ hits += 1;
132
+ }
133
+ }
134
+ return hits;
135
+ }
136
+
137
+ /** Best-matching document type for the recognized `lines` within `country`. */
138
+ export function detectDocumentType(lines: string[], country: string): DocumentTypeMatch {
139
+ if (lines.length === 0) return NO_MATCH;
140
+ const upper = lines.join('\n').toUpperCase();
141
+ const signals = DOCUMENT_SIGNALS[country] ?? {};
142
+
143
+ let bestType: string | null = null;
144
+ let bestConfidence = 0;
145
+ let bestMatched = 0;
146
+
147
+ for (const [type, keywords] of Object.entries(signals)) {
148
+ if (keywords.length === 0) continue;
149
+ const matched = countSignalHits(upper, keywords);
150
+ const confidence = matched / keywords.length;
151
+ if (confidence > bestConfidence) {
152
+ bestConfidence = confidence;
153
+ bestMatched = matched;
154
+ bestType = type;
155
+ }
156
+ }
157
+
158
+ // Universal passport fallback — an MRZ is worth a lot on its own, since only
159
+ // travel documents carry one.
160
+ const mrz = hasMrzLines(lines);
161
+ const saysPassport = UNIVERSAL_PASSPORT_WORDS.some((w) => upper.includes(w));
162
+ if (mrz || saysPassport) {
163
+ const keywords = signals.passport ?? [];
164
+ const matched = countSignalHits(upper, keywords);
165
+ const base = keywords.length === 0 ? 0 : matched / keywords.length;
166
+ const confidence = Math.min(1, Math.max(0, base + (mrz ? 0.5 : 0.25)));
167
+ if (confidence > bestConfidence) {
168
+ bestConfidence = confidence;
169
+ bestMatched = matched + 1; // the MRZ / the word itself is evidence too
170
+ bestType = 'passport';
171
+ }
172
+ }
173
+
174
+ return bestType == null
175
+ ? NO_MATCH
176
+ : { type: bestType, confidence: bestConfidence, matched: bestMatched };
177
+ }