@myazahq/kyc-sdk-react-native 2.0.2 → 2.2.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 +152 -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 +70 -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 +162 -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 +322 -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 +207 -0
  198. package/src/screens/CompanyInfoFields.tsx +83 -0
  199. package/src/screens/ConsentStep.tsx +68 -68
  200. package/src/screens/ContactChannelChoice.tsx +133 -0
  201. package/src/screens/ContactCodeStep.tsx +130 -0
  202. package/src/screens/ContactDestinationField.tsx +70 -0
  203. package/src/screens/ContactFooterNote.tsx +34 -0
  204. package/src/screens/ContactVerificationStep.tsx +199 -0
  205. package/src/screens/ContactVerifiedPanel.tsx +36 -0
  206. package/src/screens/CountrySelectStep.tsx +210 -0
  207. package/src/screens/DocumentCaptureStep.tsx +156 -132
  208. package/src/screens/IdInputStep.tsx +13 -5
  209. package/src/screens/IdTypeStep.tsx +74 -33
  210. package/src/screens/KeyPeopleInviteLinks.tsx +239 -0
  211. package/src/screens/KeyPersonCard.tsx +119 -0
  212. package/src/screens/KeyPersonForm.tsx +148 -0
  213. package/src/screens/KeyPersonSheet.tsx +184 -0
  214. package/src/screens/LivenessStep.tsx +155 -247
  215. package/src/screens/MrzScanView.tsx +173 -0
  216. package/src/screens/NfcStep.tsx +290 -0
  217. package/src/screens/ProofOfAddressStep.tsx +238 -0
  218. package/src/screens/QuestionnaireField.tsx +219 -0
  219. package/src/screens/QuestionnaireMoneyField.tsx +134 -0
  220. package/src/screens/QuestionnaireStep.tsx +76 -0
  221. package/src/screens/SubmittedStep.tsx +5 -0
  222. package/src/screens/consent/model.ts +109 -0
  223. package/src/screens/document/CameraPhase.tsx +150 -0
  224. package/src/screens/document/RequiredPill.tsx +60 -0
  225. package/src/screens/liveness/LightingBanner.tsx +50 -0
  226. package/src/screens/liveness/LivenessOutcome.tsx +109 -0
  227. package/src/screens/liveness/ProgressDots.tsx +67 -0
  228. package/src/screens/liveness/SelfiePreview.tsx +39 -0
  229. package/src/screens/liveness/constants.ts +31 -0
  230. package/src/screens/liveness/index.ts +8 -0
  231. package/src/screens/liveness/resolveGuidance.ts +27 -0
  232. package/src/screens/liveness/useSelfieUpload.ts +107 -0
  233. package/src/screens/nfc/NfcMrzPrompt.tsx +40 -0
  234. package/src/screens/nfc/NfcReadBody.tsx +63 -0
  235. package/src/screens/nfc/NfcReadProgress.tsx +96 -0
  236. package/src/screens/nfc/NfcScanIllustration.tsx +244 -0
  237. package/src/screens/nfc/NfcScannedSummary.tsx +74 -0
  238. package/src/screens/nfc/NfcSuccessPanel.tsx +162 -0
  239. package/src/screens/nfc/availability.ts +73 -0
  240. package/src/screens/nfc/readErrorMessage.ts +93 -0
  241. package/src/screens/nfc/useChipPortrait.ts +58 -0
  242. package/src/screens/nfc/useNfcAvailability.ts +82 -0
  243. package/src/screens/useFlashHole.ts +105 -0
  244. package/src/screens/useFlashSequence.ts +96 -0
  245. package/src/screens/usePoaAttach.ts +121 -0
  246. package/src/services/api-types.ts +164 -0
  247. package/src/services/api-verify-types.ts +99 -0
  248. package/src/services/api.ts +72 -102
  249. package/src/services/cardCrop.ts +25 -10
  250. package/src/services/contactErrors.ts +43 -0
  251. package/src/services/deviceMetadata.ts +1 -1
  252. package/src/services/documentPicker.ts +20 -0
  253. package/src/services/fingerprint-sources.ts +129 -0
  254. package/src/services/fingerprint.ts +101 -0
  255. package/src/services/haptics.ts +38 -0
  256. package/src/services/mediaCompress.ts +7 -2
  257. package/src/services/resolveUrl.ts +2 -2
  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 -124
  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,144 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Currency → flag country
3
+ //
4
+ // ISO 4217 currency codes are built from the ISO 3166 country code plus a
5
+ // letter for the currency itself (NG + N = NGN, US + D = USD), so the first two
6
+ // characters give the right flag for almost every real currency.
7
+ //
8
+ // Two exceptions matter and are handled explicitly:
9
+ // • EUR — no country prefix; use the EU flag.
10
+ // • X-prefixed codes — supranational/commodity (XOF, XAF, XCD, XAU…). These
11
+ // have no single country, so they get NO flag rather than a wrong one
12
+ // (XO isn't a country; rendering something arbitrary is worse than blank).
13
+ //
14
+ // Ported from the Flutter SDK's `config/currency_flags.dart` — keep the two in
15
+ // step, since a currency that flags differently per platform is a visible bug.
16
+ // ---------------------------------------------------------------------------
17
+
18
+ const EXPLICIT: Record<string, string> = {
19
+ EUR: 'EU',
20
+ };
21
+
22
+ /**
23
+ * ISO-3166 alpha-2 code whose flag represents `currency`, or null when no
24
+ * single country does.
25
+ */
26
+ export function currencyFlagCountry(currency: string): string | null {
27
+ const code = currency.trim().toUpperCase();
28
+ if (code.length < 3) return null;
29
+
30
+ const explicit = EXPLICIT[code];
31
+ if (explicit) return explicit;
32
+
33
+ // Supranational / metal codes carry no country.
34
+ if (code.startsWith('X')) return null;
35
+
36
+ return code.slice(0, 2);
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Currency → full name
41
+ //
42
+ // The picker shows the name beside the code, because "GHS" and "GMD" are one
43
+ // letter apart and mean different money. Covers Myaza's operating region in
44
+ // full plus the majors and the currencies a workflow is realistically offered;
45
+ // an unlisted code falls back to the code alone rather than a guess.
46
+ //
47
+ // Keep in step with the Flutter SDK's `config/currency_flags.dart`.
48
+ // ---------------------------------------------------------------------------
49
+
50
+ const CURRENCY_NAMES: Record<string, string> = {
51
+ // Africa
52
+ NGN: 'Nigerian Naira',
53
+ GHS: 'Ghanaian Cedi',
54
+ KES: 'Kenyan Shilling',
55
+ ZAR: 'South African Rand',
56
+ XOF: 'West African CFA Franc',
57
+ XAF: 'Central African CFA Franc',
58
+ EGP: 'Egyptian Pound',
59
+ MAD: 'Moroccan Dirham',
60
+ TND: 'Tunisian Dinar',
61
+ DZD: 'Algerian Dinar',
62
+ ETB: 'Ethiopian Birr',
63
+ UGX: 'Ugandan Shilling',
64
+ TZS: 'Tanzanian Shilling',
65
+ RWF: 'Rwandan Franc',
66
+ ZMW: 'Zambian Kwacha',
67
+ BWP: 'Botswana Pula',
68
+ NAD: 'Namibian Dollar',
69
+ MUR: 'Mauritian Rupee',
70
+ MWK: 'Malawian Kwacha',
71
+ MZN: 'Mozambican Metical',
72
+ AOA: 'Angolan Kwanza',
73
+ SLL: 'Sierra Leonean Leone',
74
+ SLE: 'Sierra Leonean Leone',
75
+ LRD: 'Liberian Dollar',
76
+ GMD: 'Gambian Dalasi',
77
+ GNF: 'Guinean Franc',
78
+ CDF: 'Congolese Franc',
79
+ BIF: 'Burundian Franc',
80
+ SOS: 'Somali Shilling',
81
+ SDG: 'Sudanese Pound',
82
+ LYD: 'Libyan Dinar',
83
+ ZWL: 'Zimbabwean Dollar',
84
+ SZL: 'Swazi Lilangeni',
85
+ LSL: 'Lesotho Loti',
86
+ CVE: 'Cape Verdean Escudo',
87
+ STN: 'São Tomé & Príncipe Dobra',
88
+ SCR: 'Seychellois Rupee',
89
+ DJF: 'Djiboutian Franc',
90
+ ERN: 'Eritrean Nakfa',
91
+ MGA: 'Malagasy Ariary',
92
+ MRU: 'Mauritanian Ouguiya',
93
+ KMF: 'Comorian Franc',
94
+ SSP: 'South Sudanese Pound',
95
+
96
+ // Majors / widely held
97
+ USD: 'US Dollar',
98
+ EUR: 'Euro',
99
+ GBP: 'British Pound',
100
+ CAD: 'Canadian Dollar',
101
+ AUD: 'Australian Dollar',
102
+ NZD: 'New Zealand Dollar',
103
+ CHF: 'Swiss Franc',
104
+ JPY: 'Japanese Yen',
105
+ CNY: 'Chinese Yuan',
106
+ HKD: 'Hong Kong Dollar',
107
+ SGD: 'Singapore Dollar',
108
+ INR: 'Indian Rupee',
109
+ AED: 'UAE Dirham',
110
+ SAR: 'Saudi Riyal',
111
+ QAR: 'Qatari Riyal',
112
+ KWD: 'Kuwaiti Dinar',
113
+ TRY: 'Turkish Lira',
114
+ ILS: 'Israeli Shekel',
115
+ SEK: 'Swedish Krona',
116
+ NOK: 'Norwegian Krone',
117
+ DKK: 'Danish Krone',
118
+ PLN: 'Polish Zloty',
119
+ CZK: 'Czech Koruna',
120
+ RON: 'Romanian Leu',
121
+ BRL: 'Brazilian Real',
122
+ MXN: 'Mexican Peso',
123
+ ARS: 'Argentine Peso',
124
+ CLP: 'Chilean Peso',
125
+ COP: 'Colombian Peso',
126
+ ZMK: 'Zambian Kwacha',
127
+ PKR: 'Pakistani Rupee',
128
+ BDT: 'Bangladeshi Taka',
129
+ LKR: 'Sri Lankan Rupee',
130
+ PHP: 'Philippine Peso',
131
+ THB: 'Thai Baht',
132
+ MYR: 'Malaysian Ringgit',
133
+ IDR: 'Indonesian Rupiah',
134
+ VND: 'Vietnamese Dong',
135
+ KRW: 'South Korean Won',
136
+ };
137
+
138
+ /**
139
+ * Full name for a currency code, or null when we don't have one — callers show
140
+ * the bare code rather than inventing a name.
141
+ */
142
+ export function currencyName(currency: string): string | null {
143
+ return CURRENCY_NAMES[currency.trim().toUpperCase()] ?? null;
144
+ }
@@ -0,0 +1,78 @@
1
+ import type { TextStyle } from 'react-native';
2
+
3
+ /**
4
+ * PURE font-name resolution — no expo/RN runtime imports, so it is unit
5
+ * testable. `components/fonts.ts` owns the loading; this owns the naming.
6
+ *
7
+ * The invariant both sides depend on: a weight is resolved INTO the family
8
+ * name, never left for the platform to synthesise. Synthetic bold smears
9
+ * glyphs wider than they were measured, which clips text.
10
+ */
11
+
12
+ /** The weights the SDK actually renders (see VARIANTS in Typography). */
13
+ export const BRAND_WEIGHTS = [400, 500, 600, 700] as const;
14
+
15
+ /** Registered family name for one weight — 400 keeps the bare name. */
16
+ export function brandFamilyName(family: string, weight: number): string {
17
+ return weight === 400 ? family : `${family}__${weight}`;
18
+ }
19
+
20
+ /**
21
+ * The family for the Myaza MARK itself — always the bundled Karla, never the
22
+ * org's font.
23
+ *
24
+ * "TRUST" is part of a wordmark, not body copy. Rendering it in whatever
25
+ * typeface the workflow chose redraws someone else's logo in the customer's
26
+ * font, which is both wrong as attribution and inconsistent with the same
27
+ * lockup on the web and in the dashboard sidebar. So this deliberately ignores
28
+ * the brand-font override that `fontFamilyFor` honours.
29
+ *
30
+ * The web equivalent is `BRAND_FONT_STACK` ('"Karla", system-ui, sans-serif').
31
+ * RN has no stack — `fontFamily` matches one registered family or nothing — so
32
+ * an unloaded font returns undefined and the platform face stands in, which is
33
+ * exactly what the web's `system-ui` fallback does.
34
+ */
35
+ export function markFamilyName(
36
+ weight: TextStyle['fontWeight'],
37
+ loaded: boolean,
38
+ ): string | undefined {
39
+ if (!loaded) return undefined;
40
+ const w = String(weight ?? '400');
41
+ if (w === '700' || w === '800' || w === '900' || w === 'bold') return 'Karla_700Bold';
42
+ if (w === '600') return 'Karla_600SemiBold';
43
+ if (w === '500') return 'Karla_500Medium';
44
+ return 'Karla_400Regular';
45
+ }
46
+
47
+ export function fontFamilyFor(
48
+ isHeading: boolean,
49
+ weight: TextStyle['fontWeight'],
50
+ loaded: boolean,
51
+ override?: { body?: string; heading?: string },
52
+ ): string | undefined {
53
+ const w = String(weight ?? '400');
54
+ const bold = w === '700' || w === '800' || w === '900' || w === 'bold';
55
+
56
+ // An org font wins. Its weights are registered as SEPARATE families (see
57
+ // brand-font.ts), exactly like the bundled ones — so the weight is resolved
58
+ // into the family name here and never left for the platform to synthesise.
59
+ // Synthetic bold smears glyphs wider than they were measured, which clips
60
+ // text; picking a real weight cannot.
61
+ const brand = isHeading ? (override?.heading ?? override?.body) : override?.body;
62
+ if (brand && brand.trim()) {
63
+ const family = brand.trim();
64
+ const target = bold ? 700 : w === '600' ? 600 : w === '500' ? 500 : 400;
65
+ return brandFamilyName(family, target);
66
+ }
67
+
68
+ if (!loaded) return undefined;
69
+ if (isHeading) {
70
+ if (bold) return 'SpaceGrotesk_700Bold';
71
+ if (w === '500') return 'SpaceGrotesk_500Medium';
72
+ return 'SpaceGrotesk_600SemiBold'; // 600 default for headings
73
+ }
74
+ if (bold) return 'Karla_700Bold';
75
+ if (w === '600') return 'Karla_600SemiBold';
76
+ if (w === '500') return 'Karla_500Medium';
77
+ return 'Karla_400Regular';
78
+ }
@@ -1,4 +1,4 @@
1
- import type { IdTypesByCountry } from '../types/config';
1
+ import type { IdTypeDefinition, IdTypesByCountry } from '../types/config';
2
2
 
3
3
  /**
4
4
  * Source of truth for the supported `(country, idType)` matrix — identical to
@@ -47,6 +47,33 @@ export function isNumberOnlyIdType(idType: string): boolean {
47
47
  return def ? !def.requiresDocumentCapture : false;
48
48
  }
49
49
 
50
+ /**
51
+ * ID keys whose physical document carries an ICAO 9303 eMRTD chip.
52
+ *
53
+ * A FALLBACK only. Chip capability is catalogue-driven SERVER-side and arrives
54
+ * per row on `/api/kyc/config` as `supportsNfc`; that answer is authoritative
55
+ * and covers documents this local list has never heard of (every country's
56
+ * passport, for one). This list is what the flow uses before config lands.
57
+ *
58
+ * Matches the Flutter SDK's `IdTypeConfig.supportsNfc`.
59
+ */
60
+ const CHIP_CAPABLE_KEYS = new Set(['passport', 'ghana-card', 'cni']);
61
+
62
+ /**
63
+ * Whether the document behind this ID has a readable chip.
64
+ *
65
+ * `serverSupportsNfc` is the row from `/api/kyc/config` when it has loaded —
66
+ * pass it and it wins outright, including its `false`.
67
+ */
68
+ export function supportsNfcChip(
69
+ _country: string,
70
+ idType: string,
71
+ serverSupportsNfc?: boolean,
72
+ ): boolean {
73
+ if (serverSupportsNfc !== undefined) return serverSupportsNfc;
74
+ return CHIP_CAPABLE_KEYS.has(idType);
75
+ }
76
+
50
77
  /** Returns true when the selected ID type requires a physical document scan. */
51
78
  export function requiresDocumentCapture(idType: string): boolean {
52
79
  const def = ALL_ID_TYPES.find((t) => t.key === idType);
@@ -62,6 +89,74 @@ export function getScanSides(idType: string): 'front_only' | 'front_and_back' {
62
89
  return def?.scanSides ?? 'front_only';
63
90
  }
64
91
 
92
+ /**
93
+ * The document's name, short enough to sit in a pill beside a flag.
94
+ *
95
+ * The catalogue label is written for a PICKER, where "International Passport"
96
+ * disambiguates. Beside a flag it is redundant twice over — the country is
97
+ * already shown, and nobody is choosing anything at the camera. Keyed off the
98
+ * ID type rather than trimmed from the string, so it holds for every country's
99
+ * passport whatever its catalogue label says.
100
+ */
101
+ export function shortDocumentLabel(idType: string | null, label: string): string {
102
+ return idType === 'passport' ? 'Passport' : label;
103
+ }
104
+
105
+ /** `drivers-license` → `Drivers License`. Last-resort label for an unknown key. */
106
+ export function humanizeIdType(key: string): string {
107
+ return key
108
+ .replace(/[-_]+/g, ' ')
109
+ .trim()
110
+ .split(' ')
111
+ .filter(Boolean)
112
+ .map((w) => w[0]!.toUpperCase() + w.slice(1))
113
+ .join(' ');
114
+ }
115
+
116
+ /** The server-config metadata a row carries for one (country, idType) pair. */
117
+ export interface IdTypeRowMeta {
118
+ label?: string;
119
+ requiresDocumentCapture?: boolean;
120
+ scanSides?: string;
121
+ supportsNfc?: boolean;
122
+ }
123
+
124
+ /**
125
+ * The definition for a (country, idType) pair.
126
+ *
127
+ * A curated entry wins; otherwise one is SYNTHESIZED from the server row. That
128
+ * fallback is the whole point: the curated table only covers the five gov-DB
129
+ * countries, while Global Documents lets a workflow offer any of ~240. Without
130
+ * it, every country outside that table renders an empty ID list — the flow
131
+ * looks like the org has no access when it simply has no curated entry.
132
+ *
133
+ * Mirrors the Flutter SDK's `resolveIdTypeDefinition`.
134
+ */
135
+ export function resolveIdTypeDefinition(
136
+ country: string,
137
+ key: string,
138
+ meta: IdTypeRowMeta = {},
139
+ ): IdTypeDefinition {
140
+ // Indexed loosely on purpose: `IdTypesByCountry` is keyed by the five curated
141
+ // countries, and the whole point here is being asked about the other ~235.
142
+ const table = ID_TYPES as Record<string, readonly IdTypeDefinition[] | undefined>;
143
+ const curated = (table[country.toUpperCase()] ?? []).find((t) => t.key === key);
144
+ if (curated) return curated;
145
+
146
+ // Default to needing a document: an unknown ID is far more likely to be a
147
+ // physical card than a number the user can type, and asking for a photo that
148
+ // wasn't needed is recoverable in a way that skipping capture is not.
149
+ const needsCapture = meta.requiresDocumentCapture ?? true;
150
+ return {
151
+ key: key as IdTypeDefinition['key'],
152
+ label: meta.label && meta.label.length > 0 ? meta.label : humanizeIdType(key),
153
+ requiresDocumentCapture: needsCapture,
154
+ ...(needsCapture
155
+ ? { scanSides: meta.scanSides === 'front_and_back' ? 'front_and_back' : 'front_only' }
156
+ : {}),
157
+ } as IdTypeDefinition;
158
+ }
159
+
65
160
  // Card-guide / crop aspect (width ÷ height) for the live camera. Mirrors the
66
161
  // Flutter SDK's documentGuideAspect: ID-1 cards use 1.586; passports use a taller
67
162
  // 1.42 so the data page's bottom MRZ band isn't cropped off.
@@ -0,0 +1,162 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Applicant-declared key people (directors, owners, signatories) — the pure
3
+ // half.
4
+ //
5
+ // Regulators require identifying the natural persons who own or control a
6
+ // business. The registry lookup discovers some of them; this is what the
7
+ // APPLICANT declares, and a person the applicant omits who the registry then
8
+ // names is a risk signal the server flags as `undisclosed`.
9
+ // ---------------------------------------------------------------------------
10
+
11
+ import type { KeyPersonRole } from '../types/business';
12
+ import { isValidContactEmail } from './contact';
13
+
14
+ /** UI cap on applicant-entered rows (the server accepts ≤20). */
15
+ export const MAX_KEY_PEOPLE_ROWS = 10;
16
+
17
+ export const KEY_PERSON_ROLE_LABELS: Record<KeyPersonRole, string> = {
18
+ director: 'Director',
19
+ beneficial_owner: 'Beneficial owner (UBO)',
20
+ signatory: 'Signatory',
21
+ shareholder: 'Shareholder',
22
+ };
23
+
24
+ export const KEY_PERSON_ROLES: KeyPersonRole[] = [
25
+ 'director',
26
+ 'beneficial_owner',
27
+ 'signatory',
28
+ 'shareholder',
29
+ ];
30
+
31
+ /** One row as typed. Everything is a string so a half-filled row is editable. */
32
+ export interface KeyPersonEntry {
33
+ name: string;
34
+ role: KeyPersonRole;
35
+ email: string;
36
+ /** The person's own ISO-2 country — a foreign director cannot verify with a
37
+ * local ID, so this drives their invite's country. */
38
+ country: string;
39
+ ownershipPct: string;
40
+ }
41
+
42
+ export function emptyKeyPerson(role: KeyPersonRole = 'director'): KeyPersonEntry {
43
+ return { name: '', role, email: '', country: '', ownershipPct: '' };
44
+ }
45
+
46
+ /** Whether a row is complete enough to submit. */
47
+ export function isKeyPersonRowValid(row: KeyPersonEntry): boolean {
48
+ if (row.name.trim().length < 2) return false;
49
+ if (!KEY_PERSON_ROLES.includes(row.role)) return false;
50
+ if (row.email.trim() !== '' && !isValidContactEmail(row.email.trim())) return false;
51
+ if (row.ownershipPct.trim() !== '') {
52
+ const pct = Number(row.ownershipPct);
53
+ if (!Number.isFinite(pct) || pct < 0 || pct > 100) return false;
54
+ }
55
+ return true;
56
+ }
57
+
58
+ /**
59
+ * A blank row is not an error — it is a row the user started and abandoned, and
60
+ * it is simply dropped. Only a row with SOMETHING in it can be invalid.
61
+ */
62
+ export function isKeyPersonRowBlank(row: KeyPersonEntry): boolean {
63
+ return (
64
+ row.name.trim() === '' &&
65
+ row.email.trim() === '' &&
66
+ row.country.trim() === '' &&
67
+ row.ownershipPct.trim() === ''
68
+ );
69
+ }
70
+
71
+ /** Rows the user has begun but not made valid — what blocks Continue. */
72
+ export function invalidKeyPersonRows(rows: KeyPersonEntry[]): number[] {
73
+ return rows
74
+ .map((row, i) => (!isKeyPersonRowBlank(row) && !isKeyPersonRowValid(row) ? i : -1))
75
+ .filter((i) => i >= 0);
76
+ }
77
+
78
+ /**
79
+ * The valid rows, in the shape the verify payload wants. `applicantIndex`
80
+ * (into the UNfiltered `rows`) flags the entry the applicant picked as
81
+ * themselves — the server merges it with the applicant row so one human
82
+ * never becomes two KeyPerson records (one KYC, one screening, no invite).
83
+ */
84
+ export function keyPeoplePayload(
85
+ rows: KeyPersonEntry[],
86
+ applicantIndex: number | null = null,
87
+ ): Array<{
88
+ name: string;
89
+ role: KeyPersonRole;
90
+ email?: string;
91
+ country?: string;
92
+ ownershipPct?: number;
93
+ isApplicant?: boolean;
94
+ }> {
95
+ return rows
96
+ .map((row, index) => ({ row, index }))
97
+ .filter(({ row }) => isKeyPersonRowValid(row))
98
+ .slice(0, 20)
99
+ .map(({ row, index }) => ({
100
+ name: row.name.trim(),
101
+ role: row.role,
102
+ ...(row.email.trim() !== '' ? { email: row.email.trim() } : {}),
103
+ ...(row.country.trim() !== '' ? { country: row.country.trim().toUpperCase() } : {}),
104
+ ...(row.ownershipPct.trim() !== '' ? { ownershipPct: Number(row.ownershipPct) } : {}),
105
+ ...(index === applicantIndex ? { isApplicant: true } : {}),
106
+ }));
107
+ }
108
+
109
+ /** "Richard Ingwe" → "RI" — the avatar monogram used on person cards. */
110
+ export function initialsOf(name: string): string {
111
+ return name
112
+ .trim()
113
+ .split(/\s+/)
114
+ .slice(0, 2)
115
+ .map((t) => t[0]?.toUpperCase() ?? '')
116
+ .join('');
117
+ }
118
+
119
+ /** Split a typed full name into first/last (best-effort). */
120
+ export function splitFullName(name: string): { firstName?: string; lastName?: string } | undefined {
121
+ const trimmed = name.trim();
122
+ if (!trimmed) return undefined;
123
+ const [firstName, ...rest] = trimmed.split(/\s+/);
124
+ return { firstName, ...(rest.length > 0 ? { lastName: rest.join(' ') } : {}) };
125
+ }
126
+
127
+ /**
128
+ * Loose "is this the same person?" match used ONLY to PRE-SELECT the
129
+ * applicant's own entry on the applicant-role step (from the consumer's
130
+ * userData). Every token of the shorter name must appear in the longer one,
131
+ * so "Richard Ingwe" matches "Richard A. Ingwe" but never "Jane Ingwe". The
132
+ * user still confirms explicitly — this never merges anything by itself.
133
+ */
134
+ export function namesLooselyMatch(a: string, b: string): boolean {
135
+ const tokens = (s: string): string[] =>
136
+ s
137
+ .toLowerCase()
138
+ .normalize('NFKD')
139
+ .replace(/[^\p{L}\p{N}\s]/gu, '')
140
+ .split(/\s+/)
141
+ .filter((t) => t.length > 1);
142
+ const ta = tokens(a);
143
+ const tb = tokens(b);
144
+ if (ta.length === 0 || tb.length === 0) return false;
145
+ const [shorter, longer] = ta.length <= tb.length ? [ta, tb] : [tb, ta];
146
+ return shorter.every((t) => longer.includes(t));
147
+ }
148
+
149
+ /**
150
+ * The self-selected key person's ID-issuing country (uppercase ISO-2), when
151
+ * the applicant picked themselves AND that entry carries one. The applicant
152
+ * leg then SKIPS the country-select step — they already answered "where was
153
+ * your ID issued?" on the key-people step.
154
+ */
155
+ export function applicantSelfCountry(app: {
156
+ keyPeople: KeyPersonEntry[];
157
+ applicantKeyPersonIndex: number | null;
158
+ }): string | null {
159
+ if (app.applicantKeyPersonIndex === null) return null;
160
+ const country = app.keyPeople[app.applicantKeyPersonIndex]?.country.trim();
161
+ return country ? country.toUpperCase() : null;
162
+ }
@@ -0,0 +1,39 @@
1
+ import { AsYouType, getCountryCallingCode, type CountryCode } from 'libphonenumber-js/min';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // National-format a phone number as it is typed.
5
+ //
6
+ // libphonenumber's national formats are keyed to the country's TRUNK PREFIX —
7
+ // the leading 0 in "0803 123 4567". Feed it a Nigerian number the way a picker
8
+ // UI collects it (dial code chosen separately, so "8031234567"), and no format
9
+ // matches: it hands back the raw digits and the field looks broken.
10
+ //
11
+ // So: try the country formatter first, because it is the only thing that knows
12
+ // per-country rules — including the countries where the leading 0 is genuinely
13
+ // part of the number (Italy: "06 1234 5678"). If it grouped nothing, re-run it
14
+ // on the international form and drop the "+CC " prefix, which yields the same
15
+ // grouping Flutter's formatNsn() produces.
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /** True once the formatter has actually grouped something. */
19
+ const isGrouped = (value: string): boolean => /[\s\-()./]/.test(value);
20
+
21
+ export function formatNationalNumber(typed: string, country: CountryCode): string {
22
+ const digits = typed.replace(/\D+/g, '');
23
+ if (!digits) return '';
24
+
25
+ // AsYouType is stateful per instance — never reuse one across keystrokes.
26
+ const direct = new AsYouType(country).input(digits);
27
+ if (isGrouped(direct)) return direct;
28
+
29
+ try {
30
+ const prefix = `+${getCountryCallingCode(country)}`;
31
+ const international = new AsYouType().input(`${prefix}${digits}`);
32
+ return international.startsWith(prefix)
33
+ ? international.slice(prefix.length).trim()
34
+ : direct;
35
+ } catch {
36
+ // Unknown country code — leave the digits alone rather than mangling them.
37
+ return direct;
38
+ }
39
+ }
@@ -0,0 +1,75 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Proof of Address — the pure half.
3
+ //
4
+ // A recent utility bill / bank statement / tenancy document, read server-side
5
+ // and cross-checked against the subject's name and a recency window. The result
6
+ // is a SOFT sub-result: it never changes the verification's own status, it
7
+ // feeds decisioning. So nothing here should block a user over a judgement the
8
+ // server is going to make anyway.
9
+ // ---------------------------------------------------------------------------
10
+
11
+ import type { PoaDocumentType, ProofOfAddressConfig } from '../types/workflow';
12
+
13
+ export const POA_TYPE_LABELS: Record<PoaDocumentType, string> = {
14
+ utility_bill: 'Utility bill',
15
+ bank_statement: 'Bank statement',
16
+ tenancy_agreement: 'Tenancy agreement',
17
+ other: 'Other document',
18
+ };
19
+
20
+ const ALL_POA_TYPES: PoaDocumentType[] = [
21
+ 'utility_bill',
22
+ 'bank_statement',
23
+ 'tenancy_agreement',
24
+ 'other',
25
+ ];
26
+
27
+ /** Default recency window the server checks the document date against. */
28
+ export const DEFAULT_POA_MAX_AGE_DAYS = 90;
29
+
30
+ /** Whether the step is part of the flow. */
31
+ export function hasProofOfAddressStep(poa: ProofOfAddressConfig | undefined | null): boolean {
32
+ return poa?.enabled === true;
33
+ }
34
+
35
+ /** The document kinds on offer. An absent or empty list means all of them. */
36
+ export function poaDocumentTypes(poa: ProofOfAddressConfig | undefined): PoaDocumentType[] {
37
+ const configured = poa?.documentTypes;
38
+ return configured && configured.length > 0 ? configured : ALL_POA_TYPES;
39
+ }
40
+
41
+ /**
42
+ * The label for a kind.
43
+ *
44
+ * `otherLabel` lets an org name what "other" means for them ("Council tax
45
+ * letter"), which is the difference between a user knowing what to upload and
46
+ * guessing.
47
+ */
48
+ export function poaTypeLabel(type: PoaDocumentType, poa: ProofOfAddressConfig | undefined): string {
49
+ const custom = poa?.otherLabel?.trim();
50
+ if (type === 'other' && custom) return custom;
51
+ return POA_TYPE_LABELS[type];
52
+ }
53
+
54
+ export function poaMaxAgeDays(poa: ProofOfAddressConfig | undefined): number {
55
+ return poa?.maxAgeDays ?? DEFAULT_POA_MAX_AGE_DAYS;
56
+ }
57
+
58
+ /** Accepted upload types — the only media kind that takes a PDF. */
59
+ export const POA_ACCEPTED_MIME_TYPES = [
60
+ 'image/jpeg',
61
+ 'image/png',
62
+ 'image/webp',
63
+ 'application/pdf',
64
+ ] as const;
65
+
66
+ /**
67
+ * Local size cap, matching the web SDK. The server allows more, but rejecting
68
+ * here gives an immediate, specific message instead of a slow 413.
69
+ */
70
+ export const POA_MAX_BYTES = 20 * 1024 * 1024;
71
+
72
+ export function isAcceptedPoaMimeType(mimeType: string | undefined): boolean {
73
+ const base = (mimeType?.split(';')[0] ?? '').trim().toLowerCase();
74
+ return (POA_ACCEPTED_MIME_TYPES as readonly string[]).includes(base);
75
+ }