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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (281) hide show
  1. package/README.md +111 -8
  2. package/android/build.gradle +6 -0
  3. package/android/src/main/AndroidManifest.xml +14 -4
  4. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaEmrtd.kt +358 -0
  5. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +88 -1
  6. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaRectDetector.kt +34 -0
  7. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizer.kt +118 -0
  8. package/android/src/main/java/com/margelo/nitro/myazakyc/Jp2Decoder.kt +117 -0
  9. package/app.plugin.js +102 -13
  10. package/ios/HybridMyazaEmrtd.swift +447 -0
  11. package/ios/HybridMyazaFaceDetector.swift +104 -2
  12. package/ios/HybridMyazaRectDetector.swift +103 -0
  13. package/ios/HybridMyazaTextRecognizer.swift +111 -0
  14. package/nitrogen/generated/.gitattributes +1 -0
  15. package/nitrogen/generated/android/KycSdkReactNative+autolinking.cmake +87 -0
  16. package/nitrogen/generated/android/KycSdkReactNative+autolinking.gradle +27 -0
  17. package/nitrogen/generated/android/KycSdkReactNativeOnLoad.cpp +102 -0
  18. package/nitrogen/generated/android/KycSdkReactNativeOnLoad.hpp +34 -0
  19. package/nitrogen/generated/android/c++/JDetectedRect.hpp +81 -0
  20. package/nitrogen/generated/android/c++/JEmrtdApduResponse.hpp +61 -0
  21. package/nitrogen/generated/android/c++/JEmrtdTagInfo.hpp +61 -0
  22. package/nitrogen/generated/android/c++/JFaceResult.hpp +113 -0
  23. package/nitrogen/generated/android/c++/JHybridMyazaEmrtdSpec.cpp +176 -0
  24. package/nitrogen/generated/android/c++/JHybridMyazaEmrtdSpec.hpp +75 -0
  25. package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.cpp +60 -0
  26. package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.hpp +63 -0
  27. package/nitrogen/generated/android/c++/JHybridMyazaRectDetectorSpec.cpp +60 -0
  28. package/nitrogen/generated/android/c++/JHybridMyazaRectDetectorSpec.hpp +63 -0
  29. package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.cpp +80 -0
  30. package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.hpp +64 -0
  31. package/nitrogen/generated/android/c++/JTextResult.hpp +76 -0
  32. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/DetectedRect.kt +81 -0
  33. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/EmrtdApduResponse.kt +56 -0
  34. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/EmrtdTagInfo.kt +56 -0
  35. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/FaceResult.kt +121 -0
  36. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaEmrtdSpec.kt +103 -0
  37. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaFaceDetectorSpec.kt +55 -0
  38. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaRectDetectorSpec.kt +55 -0
  39. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizerSpec.kt +60 -0
  40. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/KycSdkReactNativeOnLoad.kt +35 -0
  41. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/TextResult.kt +51 -0
  42. package/nitrogen/generated/ios/KycSdkReactNative+autolinking.rb +62 -0
  43. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.cpp +143 -0
  44. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.hpp +423 -0
  45. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Umbrella.hpp +81 -0
  46. package/nitrogen/generated/ios/KycSdkReactNativeAutolinking.mm +57 -0
  47. package/nitrogen/generated/ios/KycSdkReactNativeAutolinking.swift +62 -0
  48. package/nitrogen/generated/ios/c++/HybridMyazaEmrtdSpecSwift.cpp +11 -0
  49. package/nitrogen/generated/ios/c++/HybridMyazaEmrtdSpecSwift.hpp +184 -0
  50. package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.cpp +11 -0
  51. package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.hpp +87 -0
  52. package/nitrogen/generated/ios/c++/HybridMyazaRectDetectorSpecSwift.cpp +11 -0
  53. package/nitrogen/generated/ios/c++/HybridMyazaRectDetectorSpecSwift.hpp +87 -0
  54. package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.cpp +11 -0
  55. package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.hpp +98 -0
  56. package/nitrogen/generated/ios/swift/DetectedRect.swift +59 -0
  57. package/nitrogen/generated/ios/swift/EmrtdApduResponse.swift +34 -0
  58. package/nitrogen/generated/ios/swift/EmrtdTagInfo.swift +34 -0
  59. package/nitrogen/generated/ios/swift/FaceResult.swift +99 -0
  60. package/nitrogen/generated/ios/swift/Func_void.swift +46 -0
  61. package/nitrogen/generated/ios/swift/Func_void_EmrtdApduResponse.swift +46 -0
  62. package/nitrogen/generated/ios/swift/Func_void_EmrtdTagInfo.swift +46 -0
  63. package/nitrogen/generated/ios/swift/Func_void_TextResult.swift +46 -0
  64. package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +46 -0
  65. package/nitrogen/generated/ios/swift/Func_void_std__string.swift +46 -0
  66. package/nitrogen/generated/ios/swift/HybridMyazaEmrtdSpec.swift +67 -0
  67. package/nitrogen/generated/ios/swift/HybridMyazaEmrtdSpec_cxx.swift +317 -0
  68. package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec.swift +56 -0
  69. package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec_cxx.swift +143 -0
  70. package/nitrogen/generated/ios/swift/HybridMyazaRectDetectorSpec.swift +56 -0
  71. package/nitrogen/generated/ios/swift/HybridMyazaRectDetectorSpec_cxx.swift +143 -0
  72. package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec.swift +57 -0
  73. package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec_cxx.swift +162 -0
  74. package/nitrogen/generated/ios/swift/TextResult.swift +35 -0
  75. package/nitrogen/generated/shared/c++/DetectedRect.hpp +107 -0
  76. package/nitrogen/generated/shared/c++/EmrtdApduResponse.hpp +87 -0
  77. package/nitrogen/generated/shared/c++/EmrtdTagInfo.hpp +87 -0
  78. package/nitrogen/generated/shared/c++/FaceResult.hpp +139 -0
  79. package/nitrogen/generated/shared/c++/HybridMyazaEmrtdSpec.cpp +33 -0
  80. package/nitrogen/generated/shared/c++/HybridMyazaEmrtdSpec.hpp +80 -0
  81. package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.cpp +21 -0
  82. package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.hpp +67 -0
  83. package/nitrogen/generated/shared/c++/HybridMyazaRectDetectorSpec.cpp +21 -0
  84. package/nitrogen/generated/shared/c++/HybridMyazaRectDetectorSpec.hpp +67 -0
  85. package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.cpp +22 -0
  86. package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.hpp +70 -0
  87. package/nitrogen/generated/shared/c++/TextResult.hpp +84 -0
  88. package/package.json +16 -4
  89. package/src/MyazaKYC.tsx +115 -22
  90. package/src/capture/documentIdentity.ts +153 -0
  91. package/src/capture/documentSignals.ts +177 -0
  92. package/src/capture/documentTextGate.ts +171 -0
  93. package/src/capture/hints.ts +57 -0
  94. package/src/capture/index.ts +24 -0
  95. package/src/capture/rectDetector.ts +84 -0
  96. package/src/capture/rectGate.ts +91 -0
  97. package/src/capture/useAutoCapture.ts +198 -0
  98. package/src/components/CameraViewfinder.tsx +226 -70
  99. package/src/components/ContactCodeEntry.tsx +139 -0
  100. package/src/components/CountryField.tsx +87 -0
  101. package/src/components/CountryFlag.tsx +15 -15
  102. package/src/components/DialCodePicker.tsx +186 -0
  103. package/src/components/DocumentCropper.tsx +1 -1
  104. package/src/components/DocumentReview.tsx +142 -0
  105. package/src/components/DocumentReviewSide.tsx +135 -0
  106. package/src/components/DocumentReviewZoom.tsx +119 -0
  107. package/src/components/ExpiryCountdown.tsx +52 -0
  108. package/src/components/FlashOverlay.tsx +156 -0
  109. package/src/components/Icon.tsx +67 -1
  110. package/src/components/KycFlow.tsx +66 -94
  111. package/src/components/KycSheet.tsx +148 -18
  112. package/src/components/MediaSourceSheet.tsx +163 -0
  113. package/src/components/MyazaButton.tsx +14 -2
  114. package/src/components/MyazaDateField.tsx +240 -0
  115. package/src/components/MyazaInput.tsx +73 -26
  116. package/src/components/MyazaSelect.tsx +283 -0
  117. package/src/components/MyazaWordmark.tsx +33 -0
  118. package/src/components/OptionRow.tsx +132 -0
  119. package/src/components/PhoneNumberInput.tsx +149 -0
  120. package/src/components/PoweredBy.tsx +89 -0
  121. package/src/components/ReadyPrimer.tsx +149 -0
  122. package/src/components/StepView.tsx +91 -0
  123. package/src/components/Typography.tsx +18 -3
  124. package/src/components/VerifiedNotice.tsx +38 -0
  125. package/src/components/WhatsAppIcon.tsx +29 -0
  126. package/src/components/WorkflowGate.tsx +128 -0
  127. package/src/components/brand-font.ts +113 -0
  128. package/src/components/flashHoleGeometry.ts +35 -0
  129. package/src/components/flow/FatalConfigError.tsx +56 -0
  130. package/src/components/fonts.ts +2 -19
  131. package/src/components/readyPrimerContent.ts +52 -0
  132. package/src/components/runtime.tsx +38 -43
  133. package/src/components/stepHeaderMeta.tsx +87 -0
  134. package/src/components/theme-provider.tsx +89 -0
  135. package/src/components/useWorkflowMount.ts +116 -0
  136. package/src/components/viewfinder/CardGuide.tsx +60 -0
  137. package/src/components/viewfinder/DocumentGhost.tsx +149 -0
  138. package/src/components/viewfinder/ImmersiveBottomBar.tsx +172 -0
  139. package/src/components/viewfinder/ImmersiveControls.tsx +127 -0
  140. package/src/components/viewfinder/ImmersiveGuide.tsx +174 -0
  141. package/src/components/viewfinder/ImmersiveOverlay.tsx +79 -0
  142. package/src/components/viewfinder/ViewfinderControls.tsx +154 -0
  143. package/src/config/brand.ts +97 -0
  144. package/src/config/business.ts +179 -0
  145. package/src/config/businessSteps.ts +105 -0
  146. package/src/config/contact.ts +159 -0
  147. package/src/config/countryNames.g.ts +254 -0
  148. package/src/config/currencyFlags.ts +144 -0
  149. package/src/config/font-resolve.ts +78 -0
  150. package/src/config/idTypes.ts +96 -1
  151. package/src/config/keyPeople.ts +152 -0
  152. package/src/config/phone.ts +39 -0
  153. package/src/config/proofOfAddress.ts +75 -0
  154. package/src/config/questionnaire.ts +153 -0
  155. package/src/config/regions.ts +118 -0
  156. package/src/config/registrationHint.ts +87 -0
  157. package/src/config/stepOrder.ts +118 -0
  158. package/src/config/theme.ts +38 -2
  159. package/src/config/workflowMerge.ts +124 -0
  160. package/src/emrtd/bac.ts +128 -0
  161. package/src/emrtd/bytes.ts +127 -0
  162. package/src/emrtd/crypto.ts +163 -0
  163. package/src/emrtd/der.ts +192 -0
  164. package/src/emrtd/dg2.ts +111 -0
  165. package/src/emrtd/files.ts +215 -0
  166. package/src/emrtd/index.ts +30 -0
  167. package/src/emrtd/mrzKey.ts +54 -0
  168. package/src/emrtd/native.ts +79 -0
  169. package/src/emrtd/optionalRead.ts +42 -0
  170. package/src/emrtd/read.ts +224 -0
  171. package/src/emrtd/retry.ts +98 -0
  172. package/src/emrtd/secureMessaging.ts +178 -0
  173. package/src/emrtd/session.ts +200 -0
  174. package/src/emrtd/stages.ts +122 -0
  175. package/src/index.ts +110 -0
  176. package/src/lib/calendar.ts +71 -0
  177. package/src/lib/use-keyboard-inset.ts +34 -0
  178. package/src/liveness/challengeManager.ts +27 -1
  179. package/src/liveness/faceContinuity.ts +153 -0
  180. package/src/liveness/flashDetector.ts +145 -0
  181. package/src/liveness/flashReadyGate.ts +106 -0
  182. package/src/liveness/flashRunner.ts +118 -0
  183. package/src/liveness/integritySignals.ts +60 -0
  184. package/src/liveness/livenessState.ts +139 -0
  185. package/src/liveness/speech.ts +19 -1
  186. package/src/liveness/types.ts +48 -1
  187. package/src/liveness/useFlashPhase.ts +78 -0
  188. package/src/liveness/useLiveness.ts +260 -75
  189. package/src/liveness/visionCameraFaceDetector.ts +14 -0
  190. package/src/mrz/extract.ts +101 -0
  191. package/src/mrz/parse.ts +178 -0
  192. package/src/mrz/textRecognizer.ts +149 -0
  193. package/src/screens/ApplicantRoleStep.tsx +331 -0
  194. package/src/screens/BusinessDetailsStep.tsx +212 -0
  195. package/src/screens/BusinessDocumentSlot.tsx +167 -0
  196. package/src/screens/BusinessDocumentsStep.tsx +213 -0
  197. package/src/screens/BusinessKeyPeopleStep.tsx +151 -0
  198. package/src/screens/BusinessKeyPersonRow.tsx +170 -0
  199. package/src/screens/CompanyInfoFields.tsx +83 -0
  200. package/src/screens/ConsentStep.tsx +68 -68
  201. package/src/screens/ContactChannelChoice.tsx +133 -0
  202. package/src/screens/ContactCodeStep.tsx +130 -0
  203. package/src/screens/ContactDestinationField.tsx +70 -0
  204. package/src/screens/ContactFooterNote.tsx +34 -0
  205. package/src/screens/ContactVerificationStep.tsx +199 -0
  206. package/src/screens/ContactVerifiedPanel.tsx +36 -0
  207. package/src/screens/CountrySelectStep.tsx +210 -0
  208. package/src/screens/DocumentCaptureStep.tsx +156 -132
  209. package/src/screens/IdInputStep.tsx +13 -5
  210. package/src/screens/IdTypeStep.tsx +74 -33
  211. package/src/screens/KeyPeopleInviteLinks.tsx +239 -0
  212. package/src/screens/LivenessStep.tsx +155 -247
  213. package/src/screens/MrzScanView.tsx +173 -0
  214. package/src/screens/NfcStep.tsx +290 -0
  215. package/src/screens/ProofOfAddressStep.tsx +238 -0
  216. package/src/screens/QuestionnaireField.tsx +219 -0
  217. package/src/screens/QuestionnaireMoneyField.tsx +134 -0
  218. package/src/screens/QuestionnaireStep.tsx +76 -0
  219. package/src/screens/SubmittedStep.tsx +5 -0
  220. package/src/screens/consent/model.ts +109 -0
  221. package/src/screens/document/CameraPhase.tsx +150 -0
  222. package/src/screens/document/RequiredPill.tsx +60 -0
  223. package/src/screens/liveness/LightingBanner.tsx +50 -0
  224. package/src/screens/liveness/LivenessOutcome.tsx +109 -0
  225. package/src/screens/liveness/ProgressDots.tsx +67 -0
  226. package/src/screens/liveness/SelfiePreview.tsx +39 -0
  227. package/src/screens/liveness/constants.ts +31 -0
  228. package/src/screens/liveness/index.ts +8 -0
  229. package/src/screens/liveness/resolveGuidance.ts +27 -0
  230. package/src/screens/liveness/useSelfieUpload.ts +107 -0
  231. package/src/screens/nfc/NfcMrzPrompt.tsx +40 -0
  232. package/src/screens/nfc/NfcReadBody.tsx +63 -0
  233. package/src/screens/nfc/NfcReadProgress.tsx +96 -0
  234. package/src/screens/nfc/NfcScanIllustration.tsx +244 -0
  235. package/src/screens/nfc/NfcScannedSummary.tsx +74 -0
  236. package/src/screens/nfc/NfcSuccessPanel.tsx +162 -0
  237. package/src/screens/nfc/availability.ts +73 -0
  238. package/src/screens/nfc/readErrorMessage.ts +93 -0
  239. package/src/screens/nfc/useChipPortrait.ts +58 -0
  240. package/src/screens/nfc/useNfcAvailability.ts +82 -0
  241. package/src/screens/useFlashHole.ts +105 -0
  242. package/src/screens/useFlashSequence.ts +96 -0
  243. package/src/screens/usePoaAttach.ts +121 -0
  244. package/src/services/api-types.ts +164 -0
  245. package/src/services/api-verify-types.ts +99 -0
  246. package/src/services/api.ts +72 -102
  247. package/src/services/cardCrop.ts +25 -10
  248. package/src/services/contactErrors.ts +43 -0
  249. package/src/services/deviceMetadata.ts +1 -1
  250. package/src/services/documentPicker.ts +20 -0
  251. package/src/services/fingerprint-sources.ts +129 -0
  252. package/src/services/fingerprint.ts +101 -0
  253. package/src/services/haptics.ts +38 -0
  254. package/src/services/mediaCompress.ts +7 -2
  255. package/src/services/resolveUrl.ts +2 -2
  256. package/src/services/workflowGate.ts +138 -0
  257. package/src/specs/MyazaEmrtd.nitro.ts +114 -0
  258. package/src/specs/MyazaFaceDetector.nitro.ts +10 -0
  259. package/src/specs/MyazaRectDetector.nitro.ts +50 -0
  260. package/src/specs/MyazaTextRecognizer.nitro.ts +48 -0
  261. package/src/store/derive.ts +225 -0
  262. package/src/store/kycStore.ts +250 -149
  263. package/src/store/state.ts +253 -0
  264. package/src/store/submit.ts +101 -0
  265. package/src/store/submitApplicant.ts +81 -0
  266. package/src/types/appearance.ts +113 -0
  267. package/src/types/business.ts +131 -0
  268. package/src/types/config.ts +150 -124
  269. package/src/types/id-types.ts +57 -0
  270. package/src/types/verification.ts +5 -0
  271. package/src/types/workflow.ts +141 -0
  272. package/src/utils/amountFormat.ts +73 -0
  273. package/src/utils/tokens.ts +4 -2
  274. package/src/__tests__/cardCrop.test.ts +0 -39
  275. package/src/__tests__/deviceMetadata.test.ts +0 -34
  276. package/src/__tests__/errors.test.ts +0 -34
  277. package/src/__tests__/flow.test.ts +0 -61
  278. package/src/__tests__/gestureDetector.test.ts +0 -37
  279. package/src/__tests__/liveness.test.ts +0 -112
  280. package/src/__tests__/resolveUrl.test.ts +0 -64
  281. package/src/__tests__/validators.test.ts +0 -38
@@ -0,0 +1,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
+ }
@@ -0,0 +1,52 @@
1
+ import React, { useEffect, useState } from 'react';
2
+
3
+ import { useTheme } from './runtime';
4
+ import { MyazaText } from './Typography';
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Live "expires in m:ss" ticker for a one-time code.
8
+ //
9
+ // Ticks once a second and stops at zero, so a stale challenge reads as expired
10
+ // instead of claiming minutes that already elapsed. Mirrors the web and Flutter
11
+ // SDKs — without it the user has no idea whether to keep waiting for a code or
12
+ // ask for a new one.
13
+ // ---------------------------------------------------------------------------
14
+
15
+ export function ExpiryCountdown({
16
+ expiresAt,
17
+ expiredLabel = 'The code has expired. Request a new one.',
18
+ }: {
19
+ /** ISO timestamp from the send response. */
20
+ expiresAt: string;
21
+ expiredLabel?: string;
22
+ }): React.ReactElement | null {
23
+ const { colors } = useTheme();
24
+ const target = new Date(expiresAt).getTime();
25
+ const [remaining, setRemaining] = useState(() => Math.max(0, target - Date.now()));
26
+
27
+ useEffect(() => {
28
+ // Re-seed on a resend: the new challenge carries a later expiry.
29
+ setRemaining(Math.max(0, target - Date.now()));
30
+ const id = setInterval(() => {
31
+ const left = Math.max(0, target - Date.now());
32
+ setRemaining(left);
33
+ if (left === 0) clearInterval(id);
34
+ }, 1000);
35
+ return () => clearInterval(id);
36
+ }, [target]);
37
+
38
+ // An unparseable timestamp would render "NaN:aN" — show nothing instead.
39
+ if (!Number.isFinite(target)) return null;
40
+
41
+ const totalSeconds = Math.ceil(remaining / 1000);
42
+ const label =
43
+ remaining === 0
44
+ ? expiredLabel
45
+ : `The code expires in ${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, '0')}`;
46
+
47
+ return (
48
+ <MyazaText variant="bodySmall" color={colors.textMuted}>
49
+ {label}
50
+ </MyazaText>
51
+ );
52
+ }