@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
@@ -2,32 +2,32 @@ import React from 'react';
2
2
  import { View } from 'react-native';
3
3
  import { SvgXml } from 'react-native-svg';
4
4
  // Raw flag SVG strings — the SAME source the web SDK uses (`country-flag-icons`),
5
- // so the flags match. Only the supported countries are imported.
6
- import NG from 'country-flag-icons/string/3x2/NG';
7
- import GH from 'country-flag-icons/string/3x2/GH';
8
- import KE from 'country-flag-icons/string/3x2/KE';
9
- import ZA from 'country-flag-icons/string/3x2/ZA';
10
- import CI from 'country-flag-icons/string/3x2/CI';
5
+ // so the flags match. The WHOLE set is imported, not a curated subset: Global
6
+ // Documents let a workflow offer any ISO country, and a picker where some rows
7
+ // have a flag and others fall back to a grey box looks broken rather than
8
+ // incomplete. The strings are small and the bundler tree-shakes nothing here
9
+ // anyway, since the lookup is by runtime code.
10
+ import * as Flags from 'country-flag-icons/string/3x2';
11
11
 
12
12
  import { radius } from '../config/theme';
13
- import type { SupportedCountry } from '../types/config';
14
13
 
15
- // Circular country flag beside the step title — the RN mirror of the web SDK's
16
- // `CountryFlag` and Flutter's `MyazaCountryFlag`. Renders the country-flag-icons
17
- // 3:2 SVG via react-native-svg, cropped to a circle (cover-fit, like the web
18
- // SDK's `object-cover` + `rounded-full`). Works identically on iOS and Android
19
- // (no emoji-flag dependency, which Android doesn't render).
14
+ // Circular country flag — the RN mirror of the web SDK's `CountryFlag` and
15
+ // Flutter's `MyazaCountryFlag`. Renders the country-flag-icons 3:2 SVG via
16
+ // react-native-svg, cropped to a circle (cover-fit, like the web SDK's
17
+ // `object-cover` + `rounded-full`). Works identically on iOS and Android (no
18
+ // emoji-flag dependency, which Android does not render).
20
19
 
21
- const FLAGS: Record<SupportedCountry, string> = { NG, GH, KE, ZA, CI };
20
+ const FLAGS = Flags as unknown as Record<string, string | undefined>;
22
21
 
23
22
  export interface CountryFlagProps {
24
- country?: SupportedCountry | null;
23
+ /** Any ISO-3166 alpha-2 code. */
24
+ country?: string | null;
25
25
  size?: number;
26
26
  }
27
27
 
28
28
  export function CountryFlag({ country, size = 20 }: CountryFlagProps): React.ReactElement | null {
29
29
  if (!country) return null;
30
- const xml = FLAGS[country];
30
+ const xml = FLAGS[country.toUpperCase()];
31
31
  if (!xml) return null;
32
32
 
33
33
  // The flag is 3:2; to cover a square circle we render it wider than the circle
@@ -0,0 +1,186 @@
1
+ import React, { useEffect, useMemo, useState } from 'react';
2
+ import {
3
+ FlatList,
4
+ Keyboard,
5
+ Modal,
6
+ Platform,
7
+ Pressable,
8
+ useWindowDimensions,
9
+ View,
10
+ } from 'react-native';
11
+
12
+ import { radius, spacing } from '../config/theme';
13
+ import { useTheme } from './runtime';
14
+ import { MyazaText } from './Typography';
15
+ import { MyazaInput } from './MyazaInput';
16
+ import { Icon } from './Icon';
17
+ import { CountryFlag } from './CountryFlag';
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // THE country sheet — the phone field's dial-code picker, generalised.
21
+ //
22
+ // A dedicated searchable sheet rather than MyazaSelect: that control renders
23
+ // every option in a ScrollView, which is fine for four document kinds and
24
+ // unusable for ~240 countries. A FlatList keeps the list smooth on a low-end
25
+ // Android device. `dialCode` is optional: with it this is the phone picker,
26
+ // without it a plain country picker (the key-people "where their ID was
27
+ // issued" field) — SAME sheet, SAME search, so the two feel identical.
28
+ //
29
+ // Laid out to match Flutter's showDialCodePicker row for row: a MyazaInput with
30
+ // a search prefix on top, then flag / full country name / dial code, with the
31
+ // selected row tinted rather than ticked.
32
+ //
33
+ // The sheet is sized against the space left ABOVE the keyboard, like Flutter's.
34
+ // The search field autofocuses, so measuring against the full screen would put
35
+ // every result underneath the keys the moment the user started typing.
36
+ // ---------------------------------------------------------------------------
37
+
38
+ export interface DialCodeOption {
39
+ code: string;
40
+ name: string;
41
+ /** Absent ⇒ a plain country picker: no trailing code, name/ISO search only. */
42
+ dialCode?: string;
43
+ }
44
+
45
+ export function DialCodePicker({
46
+ visible,
47
+ options,
48
+ selected,
49
+ onPick,
50
+ onClose,
51
+ searchPlaceholder = 'Search country or code',
52
+ }: {
53
+ visible: boolean;
54
+ options: DialCodeOption[];
55
+ selected: string;
56
+ onPick: (code: string) => void;
57
+ onClose: () => void;
58
+ searchPlaceholder?: string;
59
+ }): React.ReactElement {
60
+ const { colors } = useTheme();
61
+ const { height: screenHeight } = useWindowDimensions();
62
+ const [query, setQuery] = useState('');
63
+ const [keyboard, setKeyboard] = useState(0);
64
+
65
+ useEffect(() => {
66
+ // iOS reports 'will' events early enough to resize before the keys land;
67
+ // Android only emits 'did'.
68
+ const show = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
69
+ const hide = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
70
+ const shown = Keyboard.addListener(show, (e) => setKeyboard(e.endCoordinates.height));
71
+ const hidden = Keyboard.addListener(hide, () => setKeyboard(0));
72
+ return () => {
73
+ shown.remove();
74
+ hidden.remove();
75
+ };
76
+ }, []);
77
+
78
+ const available = screenHeight - keyboard;
79
+ const maxHeight = Math.min(Math.max(available * 0.85, 240), screenHeight * 0.6);
80
+
81
+ const filtered = useMemo(() => {
82
+ const q = query.trim().toLowerCase();
83
+ if (!q) return options;
84
+ // Match the name, the ISO code, or the dial code with or without its '+',
85
+ // because people search for "+234", "234" and "Nigeria" in equal measure.
86
+ const bare = q.replace(/^\+/, '');
87
+ return options.filter(
88
+ (o) =>
89
+ o.name.toLowerCase().includes(q) ||
90
+ o.code.toLowerCase() === q ||
91
+ (o.dialCode != null && o.dialCode.replace(/^\+/, '').startsWith(bare)),
92
+ );
93
+ }, [options, query]);
94
+
95
+ const close = (): void => {
96
+ setQuery('');
97
+ onClose();
98
+ };
99
+
100
+ return (
101
+ <Modal visible={visible} animationType="slide" transparent onRequestClose={close}>
102
+ <Pressable style={{ flex: 1, backgroundColor: '#00000066' }} onPress={close} />
103
+ <View
104
+ style={{
105
+ maxHeight,
106
+ marginBottom: keyboard,
107
+ backgroundColor: colors.background,
108
+ borderTopLeftRadius: radius.lg,
109
+ borderTopRightRadius: radius.lg,
110
+ paddingTop: spacing.md,
111
+ paddingBottom: spacing.sm,
112
+ }}
113
+ >
114
+ <View style={{ paddingHorizontal: spacing.md, paddingBottom: spacing.sm }}>
115
+ <MyazaInput
116
+ value={query}
117
+ onChangeText={setQuery}
118
+ placeholder={searchPlaceholder}
119
+ autoFocus
120
+ prefix={<Icon name="search" size={18} color={colors.textSecondary} />}
121
+ />
122
+ </View>
123
+
124
+ <FlatList
125
+ data={filtered}
126
+ keyExtractor={(o) => o.code}
127
+ keyboardShouldPersistTaps="handled"
128
+ ListEmptyComponent={
129
+ <MyazaText variant="bodyMedium" style={{ padding: spacing.lg, textAlign: 'center' }}>
130
+ No countries match your search.
131
+ </MyazaText>
132
+ }
133
+ renderItem={({ item }) => (
134
+ <CountryRow
135
+ option={item}
136
+ isSelected={item.code === selected}
137
+ onPress={() => {
138
+ setQuery('');
139
+ onPick(item.code);
140
+ }}
141
+ />
142
+ )}
143
+ />
144
+ </View>
145
+ </Modal>
146
+ );
147
+ }
148
+
149
+ function CountryRow({
150
+ option,
151
+ isSelected,
152
+ onPress,
153
+ }: {
154
+ option: DialCodeOption;
155
+ isSelected: boolean;
156
+ onPress: () => void;
157
+ }): React.ReactElement {
158
+ const { colors } = useTheme();
159
+ return (
160
+ <Pressable
161
+ onPress={onPress}
162
+ accessibilityRole="radio"
163
+ accessibilityState={{ checked: isSelected }}
164
+ style={{
165
+ flexDirection: 'row',
166
+ alignItems: 'center',
167
+ paddingHorizontal: spacing.md,
168
+ paddingVertical: spacing.sm,
169
+ backgroundColor: isSelected ? colors.primary50 : 'transparent',
170
+ }}
171
+ >
172
+ <CountryFlag country={option.code} size={24} />
173
+ <View style={{ width: spacing.md }} />
174
+ {/* No line clamp: "Bosnia & Herzegovina" and the like must read in full,
175
+ which is what Flutter's Expanded(Text) gives. */}
176
+ <MyazaText variant="bodyMedium" style={{ flex: 1 }}>
177
+ {option.name}
178
+ </MyazaText>
179
+ {option.dialCode != null ? (
180
+ <MyazaText variant="label" color={colors.textSecondary}>
181
+ {option.dialCode}
182
+ </MyazaText>
183
+ ) : null}
184
+ </Pressable>
185
+ );
186
+ }
@@ -279,7 +279,7 @@ export function DocumentCropper({ uri, onCancel, onConfirm }: DocumentCropperPro
279
279
  <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', height: 48 }}>
280
280
  <ActivityIndicator color="#FFFFFF" />
281
281
  <View style={{ width: 10 }} />
282
- <MyazaText variant="bodyMedium" color="rgba(255,255,255,0.8)">
282
+ <MyazaText variant="bodyMedium" color="rgba(255,255,255,0.8)" style={{ flexShrink: 1 }}>
283
283
  Processing…
284
284
  </MyazaText>
285
285
  </View>
@@ -0,0 +1,142 @@
1
+ import React, { useState } from 'react';
2
+ import { View } from 'react-native';
3
+
4
+ import { useTheme } from './runtime';
5
+ import { Icon } from './Icon';
6
+ import { MyazaText } from './Typography';
7
+ import { DocumentReviewThumb, type ReviewSide } from './DocumentReviewSide';
8
+ import { DocumentReviewZoom } from './DocumentReviewZoom';
9
+ import { radius, spacing } from '../config/theme';
10
+
11
+ // ─── Document review ──────────────────────────────────────────────────────────
12
+ //
13
+ // What the user sees once their document is captured.
14
+ //
15
+ // The two-sided version used to stack the sides full-width with a retake button
16
+ // under each, which on a phone pushed the back image AND the Continue button
17
+ // below the fold — people reported not realising there was anything left to do.
18
+ // A review screen whose primary action you have to discover by scrolling is a
19
+ // dead end, so the layout is:
20
+ //
21
+ // • both sides SIDE BY SIDE at every size, so "both captured" reads at a
22
+ // glance instead of by scrolling;
23
+ // • retake as a labelled button under each thumbnail, which removes the two
24
+ // full-width buttons that cost the most vertical space;
25
+ // • the action bar at the BOTTOM of the viewport, so Continue is visible
26
+ // however tall the content gets;
27
+ // • tap a thumbnail to enlarge it — side by side means smaller, and checking
28
+ // the capture is legible is the entire point of this screen.
29
+ //
30
+ // Mirrors the web SDK's DocumentReview and Flutter's document_review.dart so
31
+ // all three behave the same.
32
+ //
33
+ // Unlike Flutter, the step here sits inside the sheet's own ScrollView (which
34
+ // already carries the bottom safe-area inset), so the footer is pushed down by
35
+ // a flexible spacer rather than pinned with a hand-computed inset.
36
+
37
+ export function DocumentReview({
38
+ frontUri,
39
+ backUri,
40
+ aspect,
41
+ isBusy,
42
+ busyOverlay,
43
+ footer,
44
+ onRetakeFront,
45
+ onRetakeBack,
46
+ }: {
47
+ frontUri: string;
48
+ backUri?: string | null;
49
+ /** Document aspect, so a half-width thumbnail still shows the whole card. */
50
+ aspect: number;
51
+ /** Uploading — dims the thumbnails and disables retake. */
52
+ isBusy: boolean;
53
+ /** Painted over each thumbnail while busy (the upload loader). */
54
+ busyOverlay?: React.ReactNode;
55
+ /** Errors, retry notices and the Continue button — below the images. */
56
+ footer: React.ReactNode;
57
+ onRetakeFront?: () => void;
58
+ onRetakeBack?: () => void;
59
+ }): React.ReactElement {
60
+ const { colors } = useTheme();
61
+ const [zoomed, setZoomed] = useState<ReviewSide | null>(null);
62
+
63
+ const sides: ReviewSide[] = [
64
+ { id: 'front', label: 'Front', uri: frontUri, onRetake: onRetakeFront },
65
+ ...(backUri ? [{ id: 'back' as const, label: 'Back', uri: backUri, onRetake: onRetakeBack }] : []),
66
+ ];
67
+ const twoSided = sides.length > 1;
68
+
69
+ return (
70
+ <View style={{ flex: 1 }}>
71
+ {/* Says the capture is COMPLETE — the thing users were scrolling to find
72
+ out. */}
73
+ <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center' }}>
74
+ <View
75
+ style={{
76
+ width: 20,
77
+ height: 20,
78
+ borderRadius: radius.full,
79
+ backgroundColor: `${colors.success}26`,
80
+ alignItems: 'center',
81
+ justifyContent: 'center',
82
+ }}
83
+ >
84
+ <Icon name="check" size={13} color={colors.success} />
85
+ </View>
86
+ <View style={{ width: spacing.xs }} />
87
+ <MyazaText variant="bodySmall" style={{ fontWeight: '600' }}>
88
+ {twoSided ? 'Both sides captured' : 'Photo captured'}
89
+ </MyazaText>
90
+ </View>
91
+
92
+ <View style={{ height: spacing.md }} />
93
+
94
+ {/* Top-aligned: the images are what the user checks first, so they sit
95
+ where the eye lands rather than floating in the middle of the screen. */}
96
+ <View style={{ flexDirection: 'row', alignItems: 'flex-start' }}>
97
+ {sides.map((side, i) => (
98
+ <React.Fragment key={side.id}>
99
+ {i > 0 ? <View style={{ width: spacing.sm }} /> : null}
100
+ <View style={{ flex: 1 }}>
101
+ <DocumentReviewThumb
102
+ side={side}
103
+ aspect={aspect}
104
+ isBusy={isBusy}
105
+ // Gated on isBusy HERE, not on the prop's existence. The tile
106
+ // rendered it whenever it was provided — and the caller always
107
+ // provides it — so the loader sat over the preview forever and
108
+ // the screen read as permanently stuck.
109
+ busyOverlay={isBusy ? busyOverlay : null}
110
+ onZoom={() => setZoomed(side)}
111
+ />
112
+ </View>
113
+ </React.Fragment>
114
+ ))}
115
+ </View>
116
+
117
+ <View style={{ height: spacing.xs }} />
118
+ <MyazaText variant="bodySmall" color={colors.textMuted} style={{ textAlign: 'center' }}>
119
+ Tap a photo to see it larger.
120
+ </MyazaText>
121
+
122
+ {/* Pushes the action bar to the bottom of the viewport so Continue can
123
+ never end up below the fold. */}
124
+ <View style={{ flex: 1, minHeight: spacing.md }} />
125
+
126
+ <View testID="kyc.review.footer">{footer}</View>
127
+
128
+ {zoomed ? (
129
+ <DocumentReviewZoom
130
+ side={zoomed}
131
+ isBusy={isBusy}
132
+ onClose={() => setZoomed(null)}
133
+ onRetake={() => {
134
+ const retake = zoomed.onRetake;
135
+ setZoomed(null);
136
+ retake?.();
137
+ }}
138
+ />
139
+ ) : null}
140
+ </View>
141
+ );
142
+ }
@@ -0,0 +1,135 @@
1
+ import React from 'react';
2
+ import { Image, Pressable, View } from 'react-native';
3
+
4
+ import { useTheme } from './runtime';
5
+ import { Icon } from './Icon';
6
+ import { MyazaText } from './Typography';
7
+ import { radius, spacing } from '../config/theme';
8
+
9
+ // ─── One captured side, and its enlarged view ─────────────────────────────────
10
+ //
11
+ // Split from DocumentReview.tsx so each file stays readable. Mirrors the
12
+ // Flutter SDK's document_review_side.dart.
13
+ //
14
+ // The photo carries only LABELS — which side it is, and that tapping enlarges
15
+ // it. The actions are named buttons underneath. An icon-only control floating
16
+ // on an image reads as decoration on a phone, where there is no hover to
17
+ // reveal what it does.
18
+
19
+ export interface ReviewSide {
20
+ /** 'front' | 'back' — also the testID suffix. */
21
+ id: 'front' | 'back';
22
+ label: string;
23
+ uri: string;
24
+ onRetake?: () => void;
25
+ }
26
+
27
+ export function DocumentReviewThumb({
28
+ side,
29
+ aspect,
30
+ isBusy,
31
+ busyOverlay,
32
+ onZoom,
33
+ }: {
34
+ side: ReviewSide;
35
+ aspect: number;
36
+ isBusy: boolean;
37
+ busyOverlay?: React.ReactNode;
38
+ onZoom: () => void;
39
+ }): React.ReactElement {
40
+ const { colors } = useTheme();
41
+ return (
42
+ <View>
43
+ <View
44
+ testID={`kyc.review.${side.id}`}
45
+ style={{ borderRadius: radius.md, overflow: 'hidden' }}
46
+ >
47
+ <Pressable onPress={onZoom} accessibilityRole="imagebutton" accessibilityLabel={`${side.label} photo — tap to enlarge`}>
48
+ {/* The whole card stays visible at half width — a cropped thumbnail
49
+ cannot be checked for legibility, which is the only reason this
50
+ screen exists. */}
51
+ <Image
52
+ source={{ uri: side.uri }}
53
+ style={{ width: '100%', aspectRatio: aspect, backgroundColor: colors.backgroundSecondary }}
54
+ resizeMode="contain"
55
+ />
56
+ </Pressable>
57
+ {/* Inset past the corner CURVE, not just past the edge: at this radius
58
+ a 6px offset puts the badge on the round, where it reads as
59
+ clipped. */}
60
+ <View style={{ position: 'absolute', left: spacing.md, top: spacing.md }}>
61
+ <Chip>
62
+ <MyazaText variant="bodySmall" color="#FFFFFF" style={CHIP_TEXT}>
63
+ {side.label}
64
+ </MyazaText>
65
+ </Chip>
66
+ </View>
67
+ {/* Enlarging is a tap on the image, which nothing announces — so the
68
+ image says so itself. */}
69
+ <View style={{ position: 'absolute', right: spacing.md, bottom: spacing.md }} pointerEvents="none">
70
+ <Chip icon>
71
+ <Icon name="maximize" size={18} color="#FFFFFF" />
72
+ </Chip>
73
+ </View>
74
+ {busyOverlay ? (
75
+ <View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}>{busyOverlay}</View>
76
+ ) : null}
77
+ </View>
78
+
79
+ {/* Retake as a LABELLED control, not an icon on the photo.
80
+ The web version hides it under the image and reveals it on hover; a
81
+ phone has no hover, so that became a small unlabelled circle people
82
+ did not read as a button. Naming it costs a few pixels and is the
83
+ difference between a discoverable action and a decoration. */}
84
+ <Pressable
85
+ testID={`kyc.review.retake.${side.id}`}
86
+ onPress={isBusy ? undefined : side.onRetake}
87
+ disabled={isBusy}
88
+ accessibilityRole="button"
89
+ accessibilityLabel={`Retake ${side.label.toLowerCase()}`}
90
+ style={{
91
+ flexDirection: 'row',
92
+ alignItems: 'center',
93
+ justifyContent: 'center',
94
+ minHeight: 36, // stays a comfortable tap target
95
+ paddingVertical: 4,
96
+ marginTop: spacing.xs,
97
+ opacity: isBusy ? 0.4 : 1,
98
+ }}>
99
+ <Icon name="refresh" size={14} color={colors.primary} />
100
+ <View style={{ width: 6 }} />
101
+ <MyazaText variant="bodySmall" color={colors.primary} style={{ flexShrink: 1 }}>
102
+ Retake
103
+ </MyazaText>
104
+ </Pressable>
105
+ </View>
106
+ );
107
+ }
108
+
109
+ export const CHIP_TEXT = { fontSize: 11, fontWeight: '600' as const };
110
+
111
+ /**
112
+ * A small dark label over the photo. Non-interactive by design — the actions on
113
+ * this screen are named buttons, not icons floating on an image.
114
+ */
115
+ export function Chip({
116
+ children,
117
+ /** Icon chips are square and need room around the glyph, not text padding. */
118
+ icon = false,
119
+ }: {
120
+ children: React.ReactNode;
121
+ icon?: boolean;
122
+ }): React.ReactElement {
123
+ return (
124
+ <View
125
+ style={{
126
+ paddingHorizontal: icon ? 8 : 10,
127
+ paddingVertical: icon ? 8 : 5,
128
+ borderRadius: radius.full,
129
+ backgroundColor: 'rgba(0,0,0,0.55)',
130
+ }}
131
+ >
132
+ {children}
133
+ </View>
134
+ );
135
+ }
@@ -0,0 +1,119 @@
1
+ import React from 'react';
2
+ import { Image, Modal, Platform, Pressable, StatusBar, View } from 'react-native';
3
+ import { initialWindowMetrics } from 'react-native-safe-area-context';
4
+
5
+ import { radius, spacing } from '../config/theme';
6
+ import { Icon } from './Icon';
7
+ import { MyazaText } from './Typography';
8
+ import { MyazaButton } from './MyazaButton';
9
+ import { Chip, CHIP_TEXT, type ReviewSide } from './DocumentReviewSide';
10
+
11
+ /**
12
+ * Full-bleed view of one side, with retake to hand — checking a capture and
13
+ * deciding to redo it are the same moment.
14
+ *
15
+ * Deliberately opaque and dark. The first version tinted the theme background
16
+ * at 97% opacity, which on the dark theme is very nearly the colour it sits on:
17
+ * tapping a photo appeared to do nothing. An overlay has to announce itself as
18
+ * a new layer, so this one is near-black regardless of theme, with a close
19
+ * control big enough to find.
20
+ */
21
+ /** Clearance for the status bar / notch above the zoom's own controls. */
22
+ function topInset(): number {
23
+ const sa = initialWindowMetrics?.insets;
24
+ if (Platform.OS === 'android') return sa?.top || StatusBar.currentHeight || 28;
25
+ // iOS presents this modal full-screen under the bar; 44 clears the island on
26
+ // the notched devices and is harmless on the ones without.
27
+ return sa?.top || 44;
28
+ }
29
+
30
+ /**
31
+ * Clearance for the home indicator / nav bar BELOW the zoom's controls.
32
+ *
33
+ * The modal runs edge to edge, so without this the Retake button sits flush
34
+ * against the bottom of the screen — clipped by the indicator and awkward to
35
+ * hit, since that strip belongs to the system gesture.
36
+ */
37
+ function bottomInset(): number {
38
+ const sa = initialWindowMetrics?.insets;
39
+ if (Platform.OS === 'android') return sa?.bottom ?? 0;
40
+ return sa?.bottom || 34;
41
+ }
42
+
43
+ export function DocumentReviewZoom({
44
+ side,
45
+ isBusy,
46
+ onClose,
47
+ onRetake,
48
+ }: {
49
+ side: ReviewSide;
50
+ isBusy: boolean;
51
+ onClose: () => void;
52
+ onRetake: () => void;
53
+ }): React.ReactElement {
54
+ return (
55
+ <Modal visible transparent animationType="fade" onRequestClose={onClose} statusBarTranslucent>
56
+ {/* The modal is statusBarTranslucent, so its content starts at y=0 —
57
+ under the notch / Dynamic Island. Without the inset the close button
58
+ renders BEHIND the island and the only way out of a full-screen image
59
+ is invisible. Same static safe-area snapshot the sheet uses. */}
60
+ <View
61
+ testID="kyc.review.zoom"
62
+ style={{
63
+ flex: 1,
64
+ backgroundColor: '#0A0A12F2',
65
+ padding: spacing.sm,
66
+ paddingTop: spacing.sm + topInset(),
67
+ paddingBottom: spacing.sm + bottomInset(),
68
+ }}
69
+ >
70
+ <View style={{ flexDirection: 'row', alignItems: 'center' }}>
71
+ <Chip>
72
+ <MyazaText variant="bodySmall" color="#FFFFFF" style={[CHIP_TEXT, { flexShrink: 1 }]}>
73
+ {side.label}
74
+ </MyazaText>
75
+ </Chip>
76
+ <View style={{ flex: 1 }} />
77
+ {/* A filled circle, not a bare glyph on a dark field: the way out of
78
+ a full-screen view has to be obvious. */}
79
+ <Pressable
80
+ testID="kyc.review.zoom.close"
81
+ onPress={onClose}
82
+ accessibilityRole="button"
83
+ accessibilityLabel="Close"
84
+ style={{
85
+ width: 40,
86
+ height: 40,
87
+ borderRadius: radius.full,
88
+ backgroundColor: 'rgba(255,255,255,0.14)',
89
+ alignItems: 'center',
90
+ justifyContent: 'center',
91
+ }}
92
+ >
93
+ <Icon name="close" size={20} color="#FFFFFF" />
94
+ </Pressable>
95
+ </View>
96
+
97
+ <View style={{ height: spacing.sm }} />
98
+ {/* Tapping the image again closes it — the same gesture that opened it,
99
+ which is what people try first. */}
100
+ <Pressable onPress={onClose} style={{ flex: 1 }} accessibilityRole="button" accessibilityLabel="Close enlarged photo">
101
+ <Image
102
+ source={{ uri: side.uri }}
103
+ style={{ flex: 1, width: '100%', borderRadius: radius.md }}
104
+ resizeMode="contain"
105
+ />
106
+ </Pressable>
107
+
108
+ <View style={{ height: spacing.sm }} />
109
+ <MyazaButton
110
+ label={`Retake ${side.label.toLowerCase()}`}
111
+ leadingIcon="refresh"
112
+ variant="outline"
113
+ disabled={isBusy}
114
+ onPress={onRetake}
115
+ />
116
+ </View>
117
+ </Modal>
118
+ );
119
+ }