@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
@@ -0,0 +1,153 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Extra-info questionnaire — the pure half.
3
+ //
4
+ // Compliance declarations (source of funds, expected volume, PEP status …) the
5
+ // org authors in the workflow builder and the SDK asks just before submission.
6
+ // Answer keys are a published contract: they land on the verification, ride the
7
+ // webhook, and decision graphs branch on `questionnaire.<key>` — so a key must
8
+ // never be repurposed once a workflow is live.
9
+ //
10
+ // Validation lives here rather than in the screen so it can be tested without a
11
+ // renderer, and so the screen and the submission cannot disagree about what
12
+ // counts as answered. The server re-validates against the published definition
13
+ // regardless; this is for telling the user before they spend a round trip.
14
+ // ---------------------------------------------------------------------------
15
+
16
+ import type {
17
+ QuestionnaireAnswerValue,
18
+ QuestionnaireConfig,
19
+ QuestionnaireField,
20
+ } from '../types/workflow';
21
+
22
+ export type QuestionnaireAnswers = Record<string, QuestionnaireAnswerValue | undefined>;
23
+
24
+ /**
25
+ * Whether the step appears in the flow.
26
+ *
27
+ * `enabled: false` turns the step off while KEEPING the questions configured —
28
+ * that is the builder's toggle, so a org can park a questionnaire without
29
+ * losing it. Single source of truth for the gate; the step order reads it too.
30
+ */
31
+ export function hasActiveQuestionnaire(
32
+ questionnaire: QuestionnaireConfig | undefined | null,
33
+ ): boolean {
34
+ return questionnaire?.enabled !== false && (questionnaire?.fields?.length ?? 0) > 0;
35
+ }
36
+
37
+ /** The companion key a money answer stores its ISO currency under. */
38
+ export function currencyKeyFor(field: QuestionnaireField): string {
39
+ return `${field.key}_currency`;
40
+ }
41
+
42
+ /**
43
+ * Every answer key a definition can produce — money questions contribute two.
44
+ * The server uses the same expansion to cross-check decision-graph references,
45
+ * so anything reasoning about "the keys this questionnaire yields" must use it
46
+ * rather than mapping over `fields`.
47
+ */
48
+ export function questionnaireAnswerKeys(questionnaire: QuestionnaireConfig | undefined): string[] {
49
+ const keys: string[] = [];
50
+ for (const field of questionnaire?.fields ?? []) {
51
+ keys.push(field.key);
52
+ if (field.type === 'money') keys.push(currencyKeyFor(field));
53
+ }
54
+ return keys;
55
+ }
56
+
57
+ function isEmpty(value: QuestionnaireAnswerValue | undefined): boolean {
58
+ return value === undefined || value === '' || (Array.isArray(value) && value.length === 0);
59
+ }
60
+
61
+ /**
62
+ * Per-field error messages, keyed by field key. Empty means good to submit.
63
+ *
64
+ * A field that is not required and left blank is valid and simply not sent —
65
+ * an unanswered optional question must not become an empty string on the
66
+ * verification record.
67
+ */
68
+ export function validateQuestionnaire(
69
+ fields: QuestionnaireField[],
70
+ answers: QuestionnaireAnswers,
71
+ ): Record<string, string> {
72
+ const errors: Record<string, string> = {};
73
+
74
+ for (const field of fields) {
75
+ const value = answers[field.key];
76
+ if (isEmpty(value)) {
77
+ if (field.required) errors[field.key] = 'This field is required.';
78
+ continue;
79
+ }
80
+
81
+ if (field.type === 'number' || field.type === 'money') {
82
+ const num = Number(value);
83
+ if (!Number.isFinite(num) || (field.type === 'money' && num < 0)) {
84
+ errors[field.key] =
85
+ field.type === 'money' ? 'Enter a valid amount.' : 'Enter a valid number.';
86
+ } else if (field.min !== undefined && num < field.min) {
87
+ errors[field.key] = `Must be at least ${field.min.toLocaleString()}.`;
88
+ } else if (field.max !== undefined && num > field.max) {
89
+ errors[field.key] = `Must be at most ${field.max.toLocaleString()}.`;
90
+ }
91
+ continue;
92
+ }
93
+
94
+ if (field.type === 'select' && field.options && field.options.length > 0) {
95
+ if (!field.options.some((o) => o.value === value)) {
96
+ errors[field.key] = 'Choose one of the listed options.';
97
+ }
98
+ continue;
99
+ }
100
+
101
+ if (field.type === 'multiselect' && field.options && field.options.length > 0) {
102
+ const chosen = Array.isArray(value) ? value : [];
103
+ if (chosen.some((v) => !field.options!.some((o) => o.value === v))) {
104
+ errors[field.key] = 'Choose from the listed options.';
105
+ }
106
+ }
107
+ }
108
+
109
+ return errors;
110
+ }
111
+
112
+ /**
113
+ * The answers to submit.
114
+ *
115
+ * Blank optional answers are dropped, numbers are sent as numbers (a numeric
116
+ * field typed as text would break a `gt`/`lt` branch in a decision graph), and
117
+ * money amounts are rounded to 2dp and always paired with a currency — the
118
+ * definition's first is the default, because a user who never opened the picker
119
+ * still meant the one that was showing.
120
+ */
121
+ export function questionnairePayload(
122
+ fields: QuestionnaireField[],
123
+ answers: QuestionnaireAnswers,
124
+ ): Record<string, QuestionnaireAnswerValue> {
125
+ const payload: Record<string, QuestionnaireAnswerValue> = {};
126
+
127
+ for (const field of fields) {
128
+ const value = answers[field.key];
129
+ if (isEmpty(value)) continue;
130
+
131
+ if (field.type === 'number') {
132
+ const num = Number(value);
133
+ if (!Number.isFinite(num)) continue;
134
+ payload[field.key] = num;
135
+ continue;
136
+ }
137
+
138
+ if (field.type === 'money') {
139
+ const num = Number(value);
140
+ if (!Number.isFinite(num)) continue;
141
+ payload[field.key] = Math.round(num * 100) / 100;
142
+ const currency = answers[currencyKeyFor(field)] ?? field.currencies?.[0];
143
+ if (typeof currency === 'string' && currency !== '') {
144
+ payload[currencyKeyFor(field)] = currency;
145
+ }
146
+ continue;
147
+ }
148
+
149
+ payload[field.key] = value as QuestionnaireAnswerValue;
150
+ }
151
+
152
+ return payload;
153
+ }
@@ -0,0 +1,118 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Full ISO region grouping for the country-select step.
3
+ //
4
+ // Global Documents let a workflow offer any of ~200 countries, and a flat list
5
+ // that long is not a chooser — it is a scroll. Grouping by region (with search)
6
+ // is what makes it usable. Identical to the web SDK's `lib/regions.ts` and the
7
+ // dashboard's `lib/country-regions.ts`; keep the three in step.
8
+ //
9
+ // `Intl.DisplayNames` supplies the names. Hermes ships full-icu, so this works
10
+ // on device — but it is guarded anyway, and falls back to the ISO code, because
11
+ // a picker showing "NG" is worse than one showing "Nigeria" and much better
12
+ // than one that throws.
13
+ // ---------------------------------------------------------------------------
14
+
15
+ import { COUNTRY_NAMES } from './countryNames.g';
16
+
17
+ let cachedDn: Intl.DisplayNames | null | undefined;
18
+ function displayNames(): Intl.DisplayNames | null {
19
+ if (cachedDn === undefined) {
20
+ try {
21
+ // `fallback: 'code'` returns the ISO code for a region CLDR does not
22
+ // know, instead of throwing. A row reading "QQ" is a poor label; a crash
23
+ // in the middle of a picker is a dead flow.
24
+ cachedDn = new Intl.DisplayNames(['en'], { type: 'region', fallback: 'code' });
25
+ } catch {
26
+ cachedDn = null;
27
+ }
28
+ }
29
+ return cachedDn ?? null;
30
+ }
31
+
32
+ /** English name for an ISO-2 code (falls back to the code itself). */
33
+ export function regionCountryName(code: string): string {
34
+ const up = code.toUpperCase();
35
+ // The generated table FIRST. `Intl.DisplayNames` is not dependable on this
36
+ // runtime — Hermes may ship without region data, in which case every row read
37
+ // as a bare ISO code, which is what this fixes. The table is also shared with
38
+ // the Flutter SDK, so both label a country identically.
39
+ const known = COUNTRY_NAMES[up];
40
+ if (known) return known;
41
+ try {
42
+ const name = displayNames()?.of(up);
43
+ // CLDR maps the reserved code ZZ to the literal "Unknown Region", which as
44
+ // a picker row tells the user nothing at all — the code is more use.
45
+ if (!name || name === 'Unknown Region') return up;
46
+ return name;
47
+ } catch {
48
+ return up;
49
+ }
50
+ }
51
+
52
+ export type Region = 'Africa' | 'Europe' | 'Americas' | 'Middle East' | 'Asia & Pacific' | 'Other';
53
+
54
+ const REGION_ORDER: Region[] = ['Africa', 'Europe', 'Americas', 'Middle East', 'Asia & Pacific', 'Other'];
55
+
56
+ const REGION_SETS: Record<Exclude<Region, 'Other'>, string[]> = {
57
+ Africa: [
58
+ 'DZ', 'AO', 'BJ', 'BW', 'BF', 'BI', 'CV', 'CM', 'CF', 'TD', 'KM', 'CG',
59
+ 'CD', 'CI', 'DJ', 'EG', 'GQ', 'ER', 'SZ', 'ET', 'GA', 'GM', 'GH', 'GN',
60
+ 'GW', 'KE', 'LS', 'LR', 'LY', 'MG', 'MW', 'ML', 'MR', 'MU', 'YT', 'MA',
61
+ 'MZ', 'NA', 'NE', 'NG', 'RE', 'RW', 'SH', 'ST', 'SN', 'SC', 'SL', 'SO',
62
+ 'ZA', 'SS', 'SD', 'TZ', 'TG', 'TN', 'UG', 'EH', 'ZM', 'ZW',
63
+ ],
64
+ 'Middle East': [
65
+ 'AE', 'BH', 'IL', 'IQ', 'IR', 'JO', 'KW', 'LB', 'OM', 'PS', 'QA', 'SA',
66
+ 'SY', 'TR', 'YE',
67
+ ],
68
+ Europe: [
69
+ 'AD', 'AL', 'AT', 'AX', 'BA', 'BE', 'BG', 'BY', 'CH', 'CY', 'CZ', 'DE',
70
+ 'DK', 'EE', 'ES', 'FI', 'FO', 'FR', 'GB', 'GG', 'GI', 'GR', 'HR', 'HU',
71
+ 'IE', 'IM', 'IS', 'IT', 'JE', 'LI', 'LT', 'LU', 'LV', 'MC', 'MD', 'ME',
72
+ 'MK', 'MT', 'NL', 'NO', 'PL', 'PT', 'RO', 'RS', 'RU', 'SE', 'SI', 'SJ',
73
+ 'SK', 'SM', 'UA', 'VA', 'XK',
74
+ ],
75
+ Americas: [
76
+ 'AG', 'AI', 'AR', 'AW', 'BB', 'BL', 'BM', 'BO', 'BQ', 'BR', 'BS', 'BZ',
77
+ 'CA', 'CL', 'CO', 'CR', 'CU', 'CW', 'DM', 'DO', 'EC', 'FK', 'GD', 'GF',
78
+ 'GL', 'GP', 'GT', 'GY', 'HN', 'HT', 'JM', 'KN', 'KY', 'LC', 'MF', 'MQ',
79
+ 'MS', 'MX', 'NI', 'PA', 'PE', 'PM', 'PR', 'PY', 'SR', 'SV', 'SX', 'TC',
80
+ 'TT', 'US', 'UY', 'VC', 'VE', 'VG', 'VI',
81
+ ],
82
+ 'Asia & Pacific': [
83
+ 'AF', 'AM', 'AS', 'AU', 'AZ', 'BD', 'BN', 'BT', 'CK', 'CN', 'FJ', 'FM',
84
+ 'GE', 'GU', 'HK', 'ID', 'IN', 'JP', 'KG', 'KH', 'KI', 'KP', 'KR', 'KZ',
85
+ 'LA', 'LK', 'MH', 'MM', 'MN', 'MO', 'MP', 'MV', 'MY', 'NC', 'NF', 'NP',
86
+ 'NR', 'NU', 'NZ', 'PF', 'PG', 'PH', 'PK', 'PN', 'PW', 'SB', 'SG', 'TH',
87
+ 'TJ', 'TK', 'TL', 'TM', 'TO', 'TV', 'TW', 'UZ', 'VN', 'VU', 'WF', 'WS',
88
+ ],
89
+ };
90
+
91
+ const BY_CODE = new Map<string, Region>();
92
+ for (const [region, codes] of Object.entries(REGION_SETS) as Array<[Region, string[]]>) {
93
+ for (const code of codes) BY_CODE.set(code, region);
94
+ }
95
+
96
+ /** Every ISO code in the region map — the "all countries" picker source. */
97
+ export const ALL_REGION_CODES: string[] = Object.values(REGION_SETS).flat();
98
+
99
+ export interface RegionGroup {
100
+ region: Region;
101
+ countries: Array<{ code: string; name: string }>;
102
+ }
103
+
104
+ /** Group ISO codes by region (Africa first, 'Other' last), names A→Z within. */
105
+ export function groupCountriesByRegion(codes: string[]): RegionGroup[] {
106
+ const buckets = new Map<Region, Array<{ code: string; name: string }>>();
107
+ for (const raw of codes) {
108
+ const code = raw.toUpperCase();
109
+ const region = BY_CODE.get(code) ?? 'Other';
110
+ const list = buckets.get(region) ?? [];
111
+ list.push({ code, name: regionCountryName(code) });
112
+ buckets.set(region, list);
113
+ }
114
+ return REGION_ORDER.filter((r) => buckets.has(r)).map((region) => ({
115
+ region,
116
+ countries: buckets.get(region)!.sort((a, b) => a.name.localeCompare(b.name)),
117
+ }));
118
+ }
@@ -0,0 +1,87 @@
1
+ import { businessCountryName, type BusinessProductDef } from './business';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Country-aware guidance for the registration-number input.
5
+ //
6
+ // Nigeria's registry (CAC) prefixes every number by entity type — the provider
7
+ // rejects a number whose prefix is missing or separated from the digits — so
8
+ // NG gets explicit prefix guidance AND format validation. Other registries get
9
+ // a placeholder + tip only; no format is enforced outside Nigeria.
10
+ //
11
+ // Ported from the web SDK's `lib/registration-hint.ts` — keep the two in step
12
+ // (country examples/tips are registry-verified there).
13
+ // ---------------------------------------------------------------------------
14
+
15
+ export interface RegistrationHint {
16
+ placeholder: string;
17
+ /** Guidance rendered beneath the input (null = nothing to show). */
18
+ tip: string | null;
19
+ /** Format check for the typed value (null = only non-empty is required). */
20
+ isValidFormat: ((value: string) => boolean) | null;
21
+ /** Inline error when isValidFormat fails. */
22
+ formatError: string | null;
23
+ }
24
+
25
+ const NG_PREFIX_RE = /^(RC|BN|IT|LP|LLP)\d+$/i;
26
+
27
+ const NG_TIP =
28
+ 'Prefix your registration number with RC for private companies limited by shares, ' +
29
+ 'BN for business names, IT for incorporated trustees, LP for limited partnerships or ' +
30
+ 'LLP for limited liability partnerships — with no space or character between the ' +
31
+ 'prefix and the number, e.g. RC0000000.';
32
+
33
+ // Countries whose English name takes a definite article ("the United States").
34
+ const THE_COUNTRIES = new Set([
35
+ 'US', 'GB', 'AE', 'NL', 'PH', 'CZ', 'GM', 'BS', 'MV', 'CD', 'CF', 'DO', 'KM', 'MH', 'SB', 'CG',
36
+ ]);
37
+
38
+ function countryName(code: string): string {
39
+ const name = businessCountryName(code);
40
+ return THE_COUNTRIES.has(code.toUpperCase()) ? `the ${name}` : name;
41
+ }
42
+
43
+ const COUNTRY_EXAMPLES: Record<string, string> = {
44
+ KE: 'PVT-JZUA6Z663',
45
+ ZA: '201133333323',
46
+ };
47
+
48
+ const COUNTRY_TIPS: Record<string, string> = {
49
+ KE: 'Your registration number as it appears on your certificate of incorporation, e.g. PVT-JZUA6Z663.',
50
+ ZA: 'Your CIPC registration number — printed as 2011/333333/23 on your documents; enter it without the slashes, e.g. 201133333323.',
51
+ };
52
+
53
+ export function registrationNumberHint(
54
+ country: string,
55
+ productDef: BusinessProductDef,
56
+ ): RegistrationHint {
57
+ // TIN-keyed products keep their own placeholder/format (not a registry number).
58
+ if (productDef.inputLabel === 'TIN') {
59
+ return {
60
+ placeholder: productDef.placeholder,
61
+ tip: 'Your tax identification number as issued by the tax authority, e.g. 01234567-0001.',
62
+ isValidFormat: null,
63
+ formatError: null,
64
+ };
65
+ }
66
+
67
+ if (country === 'NG') {
68
+ return {
69
+ placeholder: 'e.g. RC0000000',
70
+ tip: NG_TIP,
71
+ isValidFormat: (value) => NG_PREFIX_RE.test(value.trim()),
72
+ formatError:
73
+ 'Start with RC, BN, IT, LP or LLP followed by the number — no spaces, e.g. RC0000000.',
74
+ };
75
+ }
76
+
77
+ const cc = country.toUpperCase();
78
+ const example = COUNTRY_EXAMPLES[cc];
79
+ return {
80
+ placeholder: example ? `e.g. ${example}` : 'Enter your registration number',
81
+ tip:
82
+ COUNTRY_TIPS[cc] ??
83
+ `Your official company registration number, exactly as issued by the business registry in ${countryName(country)}.`,
84
+ isValidFormat: null,
85
+ formatError: null,
86
+ };
87
+ }
@@ -0,0 +1,118 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Flow step ordering + progress accounting.
3
+ //
4
+ // ONE definition of the sequence, which navigation, the back button and the
5
+ // progress bar all read. The store used to carry a hand-written `switch` per
6
+ // direction; every optional step added there was two more cases to keep in
7
+ // agreement, and forward and backward could disagree. An explicit ordered list
8
+ // with index ±1 cannot.
9
+ //
10
+ // individual: consent → (email) → (phone) → (country-select) → id-type →
11
+ // capture → (nfc) → (liveness) → (poa) → (questionnaire) →
12
+ // submitted
13
+ // business: consent → (email) → (phone) → business-details →
14
+ // (key-people) → (documents) → (applicant-role → capture leg) →
15
+ // (questionnaire) → submitted
16
+ //
17
+ // Mirrors the web SDK's `lib/step-order.ts`, plus the `nfc` step, which is a
18
+ // real step here — the web SDK can't do ISO-DEP so it only previews the screen.
19
+ // ---------------------------------------------------------------------------
20
+
21
+ import type { WorkflowBusinessConfig } from '../types/business';
22
+ import type { KYCStep } from '../types/config';
23
+ import { businessSectionSteps, hasApplicantVerification } from './businessSteps';
24
+
25
+ export interface StepOrderOptions {
26
+ isBusiness: boolean;
27
+ /** Business (KYB) configuration — drives the application-section steps. */
28
+ business?: WorkflowBusinessConfig;
29
+ hasDocCapture: boolean;
30
+ hasNfc: boolean;
31
+ hasLiveness: boolean;
32
+ hasCountrySelect: boolean;
33
+ hasEmailVerification: boolean;
34
+ hasPhoneVerification: boolean;
35
+ hasPoa: boolean;
36
+ hasQuestionnaire: boolean;
37
+ }
38
+
39
+ // Contact-verification OTP steps sit right after consent (both flows) — a cheap
40
+ // pre-filter before capture/registry spend; email before phone.
41
+ function contactSteps(o: StepOrderOptions): KYCStep[] {
42
+ return [
43
+ ...(o.hasEmailVerification ? (['email-verification'] as KYCStep[]) : []),
44
+ ...(o.hasPhoneVerification ? (['phone-verification'] as KYCStep[]) : []),
45
+ ];
46
+ }
47
+
48
+ /** The individual capture leg — shared by the KYC flow and the KYB applicant. */
49
+ function captureLeg(o: StepOrderOptions): KYCStep[] {
50
+ const steps: KYCStep[] = [o.hasDocCapture ? 'document-capture' : 'id-input'];
51
+ // The chip read follows the photo: the MRZ from that capture is what unlocks
52
+ // the chip, so there is nothing to read before it.
53
+ if (o.hasNfc && o.hasDocCapture) steps.push('nfc');
54
+ if (o.hasLiveness) steps.push('liveness');
55
+ return steps;
56
+ }
57
+
58
+ export function buildStepOrder(o: StepOrderOptions): KYCStep[] {
59
+ // Business (KYB) flow — the application section, then (when the workflow
60
+ // requires applicant verification) the ordinary individual capture leg.
61
+ if (o.isBusiness) {
62
+ const steps: KYCStep[] = ['consent', ...contactSteps(o), ...businessSectionSteps(o.business)];
63
+ if (hasApplicantVerification(o.business)) {
64
+ // The applicant may hold an ID issued anywhere the org can verify —
65
+ // more than one granted country (hasCountrySelect, derived from the
66
+ // server config for business flows) means they pick theirs first,
67
+ // exactly like a multi-region individual flow.
68
+ if (o.hasCountrySelect) steps.push('country-select');
69
+ steps.push('id-type', ...captureLeg(o));
70
+ }
71
+ if (o.hasQuestionnaire) steps.push('questionnaire');
72
+ steps.push('submitted');
73
+ return steps;
74
+ }
75
+
76
+ const middle: KYCStep[] = [...captureLeg(o)];
77
+ if (o.hasPoa) middle.push('proof-of-address');
78
+ if (o.hasQuestionnaire) middle.push('questionnaire');
79
+ return [
80
+ 'consent',
81
+ ...contactSteps(o),
82
+ ...(o.hasCountrySelect ? (['country-select'] as KYCStep[]) : []),
83
+ 'id-type',
84
+ ...middle,
85
+ 'submitted',
86
+ ];
87
+ }
88
+
89
+ /** Percentage complete for the progress bar. */
90
+ export function getStepProgress(step: KYCStep, o: StepOrderOptions): number {
91
+ const order = buildStepOrder(o);
92
+ const index = order.indexOf(step);
93
+ if (index === -1) return 0;
94
+ return Math.round(((index + 1) / order.length) * 100);
95
+ }
96
+
97
+ /**
98
+ * The step that follows `step`.
99
+ *
100
+ * A step not in the order (a workflow toggled it off after the user reached it,
101
+ * or a preview jumped straight to it) is left where it is rather than guessed
102
+ * at — advancing from a step the flow does not contain has no correct answer,
103
+ * and moving somewhere arbitrary is worse than standing still.
104
+ */
105
+ export function nextStepInOrder(step: KYCStep, o: StepOrderOptions): KYCStep {
106
+ const order = buildStepOrder(o);
107
+ const index = order.indexOf(step);
108
+ if (index === -1) return step;
109
+ return order[index + 1] ?? order[order.length - 1]!;
110
+ }
111
+
112
+ /** The step before `step` — what the back button goes to. */
113
+ export function previousStepInOrder(step: KYCStep, o: StepOrderOptions): KYCStep {
114
+ const order = buildStepOrder(o);
115
+ const index = order.indexOf(step);
116
+ if (index <= 0) return order[0]!;
117
+ return order[index - 1]!;
118
+ }
@@ -82,7 +82,38 @@ export const DARK_COLORS: MyazaColorScheme = {
82
82
 
83
83
  export type ThemeMode = 'light' | 'dark';
84
84
 
85
- export const radius = { xs: 8, sm: 12, md: 16, lg: 20, xl: 24, full: 999 } as const;
85
+ // The shipped scale, and the basis every override derives from.
86
+ const DEFAULT_RADIUS = { xs: 8, sm: 12, md: 16, lg: 20, xl: 24, full: 999 };
87
+
88
+ /**
89
+ * MUTABLE, so `appearance.borderRadius` can scale it.
90
+ *
91
+ * A mutable module object is a real trade-off, taken deliberately: 43 call sites
92
+ * read `radius.md` directly, and threading it through context buys nothing here
93
+ * — exactly one KYC flow is on screen at a time, and the scale is set once from
94
+ * the resolved config before that flow renders. The alternative is a 43-site
95
+ * refactor carrying far more risk.
96
+ */
97
+ export const radius = { ...DEFAULT_RADIUS };
98
+
99
+ /**
100
+ * `sm` (12) is the base — what buttons, inputs and cards use — so it is the rung
101
+ * a consumer is choosing when they say "our corners are 4px". The scale moves
102
+ * proportionally, keeping a card rounder than an input at every setting.
103
+ *
104
+ * `full` is deliberately untouched: it renders avatars and the camera circle,
105
+ * and scaling it turns circles into squircles.
106
+ */
107
+ export function applyRadiusScale(base?: number): void {
108
+ const b = base == null || !Number.isFinite(base) ? DEFAULT_RADIUS.sm : Math.min(Math.max(base, 0), 32);
109
+ const ratio = b / DEFAULT_RADIUS.sm;
110
+ radius.xs = DEFAULT_RADIUS.xs * ratio;
111
+ radius.sm = DEFAULT_RADIUS.sm * ratio;
112
+ radius.md = DEFAULT_RADIUS.md * ratio;
113
+ radius.lg = DEFAULT_RADIUS.lg * ratio;
114
+ radius.xl = DEFAULT_RADIUS.xl * ratio;
115
+ radius.full = DEFAULT_RADIUS.full;
116
+ }
86
117
  export const spacing = { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 } as const;
87
118
  export const sizing = {
88
119
  buttonHeight: 48,
@@ -173,7 +204,12 @@ export function applyAppearance(
173
204
 
174
205
  /** Resolves the active color scheme for a mode + appearance override. */
175
206
  export function resolveColors(mode: ThemeMode, appearance?: KYCAppearance): MyazaColorScheme {
176
- return applyAppearance(mode === 'dark' ? DARK_COLORS : LIGHT_COLORS, appearance);
207
+ // The appearance is applied ON TOP of the active base scheme, so without the
208
+ // dark overrides an org's LIGHT background simply overwrote the dark one and
209
+ // the theme toggle stopped working for any branded flow.
210
+ const forMode =
211
+ mode === 'dark' && appearance?.dark ? { ...appearance, ...appearance.dark } : appearance;
212
+ return applyAppearance(mode === 'dark' ? DARK_COLORS : LIGHT_COLORS, forMode);
177
213
  }
178
214
 
179
215
  /**
@@ -0,0 +1,124 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Merging a resolved workflow over the consumer's props.
3
+ //
4
+ // A published flow is the org's authored template; the props are the
5
+ // developer's code. The flow WINS on every key it defines, and props fill the
6
+ // gaps — so mounting with `workflowId` gives the org control over the flow's
7
+ // shape without the developer having to mirror every builder change.
8
+ //
9
+ // Mirrors the web SDK's `lib/workflow-merge.ts`.
10
+ // ---------------------------------------------------------------------------
11
+
12
+ /**
13
+ * The prop keys a published flow may override. Exactly the template surface —
14
+ * runtime data (apiKey, devUrl, userId, userData, metadata, callbacks) is never
15
+ * flow-controlled and always comes from the consumer's code.
16
+ */
17
+ export const WORKFLOW_KEYS = [
18
+ 'subjectType',
19
+ 'business',
20
+ 'country',
21
+ 'countries',
22
+ 'idTypes',
23
+ 'enableSelfie',
24
+ 'enableDocumentCapture',
25
+ 'allowDocumentUpload',
26
+ 'enableLiveness',
27
+ 'livenessMode',
28
+ 'flashSequenceLength',
29
+ 'deviceIntelligence',
30
+ 'requireMobileDevice',
31
+ 'voiceGuidance',
32
+ 'showThemeToggle',
33
+ 'disableClose',
34
+ 'appearance',
35
+ 'consent',
36
+ 'success',
37
+ 'emailVerification',
38
+ 'phoneVerification',
39
+ 'questionnaire',
40
+ 'proofOfAddress',
41
+ 'nfc',
42
+ ] as const;
43
+
44
+ export type WorkflowKey = (typeof WORKFLOW_KEYS)[number];
45
+
46
+ /**
47
+ * Merge a resolved flow config over the consumer's props — flow wins on every
48
+ * key it DEFINES; an absent key leaves the prop alone (which is how a flow that
49
+ * only sets, say, `livenessMode` doesn't wipe a prop-supplied `userId`).
50
+ *
51
+ * `appearance` merges shallowly with flow keys winning per-field, so a flow
52
+ * that only sets `primaryColor` doesn't wipe a prop-supplied `logo`.
53
+ *
54
+ * Pure and side-effect free.
55
+ */
56
+ export function mergeWorkflowConfig<P extends Record<string, unknown>>(
57
+ flowConfig: Record<string, unknown>,
58
+ props: P,
59
+ ): P {
60
+ const merged: Record<string, unknown> = { ...props };
61
+
62
+ for (const key of WORKFLOW_KEYS) {
63
+ const value = flowConfig[key];
64
+ if (value === undefined) continue;
65
+ if (key === 'appearance') {
66
+ const propAppearance = props['appearance'];
67
+ merged[key] = {
68
+ ...(typeof propAppearance === 'object' && propAppearance !== null ? propAppearance : {}),
69
+ ...(value as Record<string, unknown>),
70
+ };
71
+ } else {
72
+ merged[key] = value;
73
+ }
74
+ }
75
+
76
+ // Business (KYB) workflows carry no top-level country — fall back to the
77
+ // registry country so downstream code that expects one never sees undefined.
78
+ // The business submission reads business.country anyway.
79
+ if (merged['country'] === undefined && flowConfig['subjectType'] === 'business') {
80
+ const business = flowConfig['business'] as { country?: string } | undefined;
81
+ if (business?.country) merged['country'] = business.country;
82
+ }
83
+
84
+ return merged as P;
85
+ }
86
+
87
+ /**
88
+ * The template keys a mapped APPLICANT workflow (business.applicant.workflowId)
89
+ * overlays onto a KYB mount — the individual capture-leg surface only. KYB
90
+ * publish REJECTS these keys on the business config itself, so the overlay is
91
+ * collision-free by construction. Contact OTPs, the questionnaire, branding and
92
+ * device policy stay the KYB workflow's own. Mirrors the web SDK.
93
+ */
94
+ const APPLICANT_LEG_KEYS = [
95
+ 'country',
96
+ 'countries',
97
+ 'idTypes',
98
+ 'enableSelfie',
99
+ 'enableDocumentCapture',
100
+ 'allowDocumentUpload',
101
+ 'enableLiveness',
102
+ 'livenessMode',
103
+ 'flashSequenceLength',
104
+ 'nfc',
105
+ ] as const;
106
+
107
+ /**
108
+ * Overlay a resolved applicant workflow's capture template over an (already
109
+ * workflow-merged) KYB config, recording its id as `applicantWorkflowId` so
110
+ * the applicant's own submission is stamped with it. No-op when nothing was
111
+ * mapped/resolved.
112
+ */
113
+ export function overlayApplicantWorkflow<P extends Record<string, unknown>>(
114
+ applicantWorkflow: { id: string; config: Record<string, unknown> } | null | undefined,
115
+ merged: P,
116
+ ): P {
117
+ if (!applicantWorkflow) return merged;
118
+ const out: Record<string, unknown> = { ...merged, applicantWorkflowId: applicantWorkflow.id };
119
+ for (const key of APPLICANT_LEG_KEYS) {
120
+ const value = applicantWorkflow.config[key];
121
+ if (value !== undefined) out[key] = value;
122
+ }
123
+ return out as P;
124
+ }