@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,210 @@
1
+ import React, { useMemo, useState } from 'react';
2
+ import { Pressable, ScrollView, View } from 'react-native';
3
+
4
+ import { radius, spacing } from '../config/theme';
5
+ import { useKyc, useKycConfig, useKycStore, useTheme } from '../components/runtime';
6
+ import { MyazaText } from '../components/Typography';
7
+ import { MyazaInput } from '../components/MyazaInput';
8
+ import { CountryFlag } from '../components/CountryFlag';
9
+ import { Icon } from '../components/Icon';
10
+ import { groupCountriesByRegion, regionCountryName } from '../config/regions';
11
+ import { countrySelectOptions } from '../store/derive';
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Country selection for multi-region flows (`countries` has more than one).
15
+ //
16
+ // The pick sets the session's EFFECTIVE country, which every later step reads —
17
+ // the ID types on offer, the validators, the endpoints and chip capability all
18
+ // follow from it.
19
+ //
20
+ // Above a handful of countries a flat list stops being a chooser and becomes a
21
+ // scroll, so past SEARCH_THRESHOLD this switches to a searchable, region-grouped
22
+ // picker. Global Documents can offer ~200 countries.
23
+ // ---------------------------------------------------------------------------
24
+
25
+ /** Above this many offered countries the flat list becomes a searchable,
26
+ * region-grouped picker. Exported because the sheet has to know too: the
27
+ * picker pins its search box and therefore needs a non-scrolling body. */
28
+ export const COUNTRY_SEARCH_THRESHOLD = 5;
29
+
30
+ export const countrySelectMeta = {
31
+ title: 'Where was your ID issued?',
32
+ description: 'Choose the country that issued your identity document.',
33
+ };
34
+
35
+ export function CountrySelectStep(): React.ReactElement {
36
+ const config = useKycConfig();
37
+ const store = useKycStore();
38
+ // Only an actual PICK counts as selected. `useEffectiveCountry` falls back to
39
+ // the workflow's primary country, which on this screen would render a
40
+ // highlighted row for a choice the user has not made yet.
41
+ const selected = useKyc((s) => s.selectedCountry);
42
+
43
+ // Multi-region flows carry `countries`; the KYB applicant leg does not, so
44
+ // it offers the org's GRANTED countries instead — see countrySelectOptions.
45
+ const serverConfig = useKyc((s) => s.serverConfig);
46
+ const options = useMemo(
47
+ () => countrySelectOptions({ config, serverConfig }),
48
+ [config, serverConfig],
49
+ );
50
+
51
+ const pick = (country: string): void => {
52
+ store.getState().setCountry(country);
53
+ store.getState().nextStep();
54
+ };
55
+
56
+ if (options.length > COUNTRY_SEARCH_THRESHOLD) {
57
+ return <CountryRegionPicker countries={options} selected={selected} onPick={pick} />;
58
+ }
59
+
60
+ return (
61
+ <View>
62
+ {options.map((country) => (
63
+ <CountryRow
64
+ key={country}
65
+ code={country}
66
+ name={regionCountryName(country)}
67
+ selected={country === selected}
68
+ onPress={() => pick(country)}
69
+ />
70
+ ))}
71
+ </View>
72
+ );
73
+ }
74
+
75
+ function CountryRegionPicker({
76
+ countries,
77
+ selected,
78
+ onPick,
79
+ }: {
80
+ countries: string[];
81
+ selected: string | null;
82
+ onPick: (country: string) => void;
83
+ }): React.ReactElement {
84
+ const { colors } = useTheme();
85
+ const [query, setQuery] = useState('');
86
+
87
+ const groups = useMemo(() => {
88
+ const needle = query.trim().toLowerCase();
89
+ const filtered = needle
90
+ ? countries.filter(
91
+ (code) =>
92
+ code.toLowerCase().includes(needle) ||
93
+ regionCountryName(code).toLowerCase().includes(needle),
94
+ )
95
+ : countries;
96
+ return groupCountriesByRegion(filtered);
97
+ }, [countries, query]);
98
+
99
+ const empty = groups.length === 0;
100
+
101
+ return (
102
+ // The search box stays put and the list owns its own scroll, so a
103
+ // ~240-country flow does not scroll the search field off the top. That
104
+ // relies on the SHEET rendering this step with `fillsViewport` — inside a
105
+ // scroll view the nested list below would take an unbounded height, outgrow
106
+ // the viewport, and drag the search box off the top with it.
107
+ <View style={{ flex: 1 }}>
108
+ <MyazaInput
109
+ value={query}
110
+ onChangeText={setQuery}
111
+ placeholder="Search countries…"
112
+ autoCapitalize="none"
113
+ prefix={<Icon name="search" size={18} color={colors.textSecondary} />}
114
+ />
115
+ <View style={{ height: spacing.md }} />
116
+
117
+ {empty ? (
118
+ <MyazaText variant="bodyMedium" style={{ textAlign: 'center', marginTop: spacing.lg }}>
119
+ No countries match your search.
120
+ </MyazaText>
121
+ ) : (
122
+ // `nestedScrollEnabled` stays: Android refuses to scroll a nested
123
+ // same-direction list without it, and this is still nested on the
124
+ // flat-list path and inside any future scrolling parent.
125
+ <ScrollView
126
+ nestedScrollEnabled
127
+ keyboardShouldPersistTaps="handled"
128
+ showsVerticalScrollIndicator={false}
129
+ >
130
+ {groups.map((group) => (
131
+ <View key={group.region}>
132
+ {/* Uppercase, tracked-out section label — Flutter's region
133
+ header. Lowercase text at the default weight read as another
134
+ list row rather than a divider. */}
135
+ <MyazaText
136
+ variant="bodySmall"
137
+ color={colors.textSecondary}
138
+ style={{
139
+ fontWeight: '700',
140
+ letterSpacing: 0.6,
141
+ paddingHorizontal: spacing.xs,
142
+ paddingVertical: spacing.sm,
143
+ }}
144
+ >
145
+ {group.region.toUpperCase()}
146
+ </MyazaText>
147
+ {group.countries.map((entry) => (
148
+ <CountryRow
149
+ key={entry.code}
150
+ code={entry.code}
151
+ name={entry.name}
152
+ selected={entry.code === selected}
153
+ onPress={() => onPick(entry.code)}
154
+ />
155
+ ))}
156
+ </View>
157
+ ))}
158
+ </ScrollView>
159
+ )}
160
+ </View>
161
+ );
162
+ }
163
+
164
+ function CountryRow({
165
+ code,
166
+ name,
167
+ selected,
168
+ onPress,
169
+ }: {
170
+ code: string;
171
+ name: string;
172
+ selected: boolean;
173
+ onPress: () => void;
174
+ }): React.ReactElement {
175
+ const { colors } = useTheme();
176
+ return (
177
+ <Pressable
178
+ onPress={onPress}
179
+ accessibilityRole="radio"
180
+ accessibilityState={{ checked: selected }}
181
+ accessibilityLabel={name}
182
+ // Matches the Flutter SDK's CountryOptionTile exactly — the two had
183
+ // drifted (24px flag / 12px padding here vs 32 / 16 there), which is why
184
+ // the same screen looked like a different component on each platform. The
185
+ // spacing and radius scales are identical across the SDKs, so there is
186
+ // nothing platform-specific to reconcile: `md` means 16 on both.
187
+ style={{
188
+ flexDirection: 'row',
189
+ alignItems: 'center',
190
+ padding: spacing.md,
191
+ borderRadius: radius.md,
192
+ borderWidth: selected ? 1.5 : 1,
193
+ borderColor: selected ? colors.primary : colors.border,
194
+ backgroundColor: selected ? colors.primary50 : colors.background,
195
+ marginBottom: spacing.sm,
196
+ }}
197
+ >
198
+ <CountryFlag country={code} size={32} />
199
+ <View style={{ width: 12 }} />
200
+ <MyazaText variant="label" style={{ flex: 1, fontWeight: '500' }}>
201
+ {name}
202
+ </MyazaText>
203
+ <Icon
204
+ name="chevron-right"
205
+ size={18}
206
+ color={selected ? colors.primary : colors.textSecondary}
207
+ />
208
+ </Pressable>
209
+ );
210
+ }
@@ -1,7 +1,8 @@
1
1
  import React, { useCallback, useEffect, useRef, useState } from 'react';
2
- import { Image, Pressable, View } from 'react-native';
2
+ import { Image, Platform, Pressable, View } from 'react-native';
3
3
  import * as ImagePicker from 'expo-image-picker';
4
4
  import { useCameraDevice, useCameraPermission } from 'react-native-vision-camera';
5
+ import { initialWindowMetrics } from 'react-native-safe-area-context';
5
6
 
6
7
  import { radius, spacing } from '../config/theme';
7
8
  import { ID_TYPES, documentGuideAspect, getScanSides } from '../config/idTypes';
@@ -11,14 +12,18 @@ import { compressDocumentImage, compressVideo, cropCardRegion } from '../service
11
12
  import { MAX_VIDEO_BYTES } from '../config/captureSettings';
12
13
  import { KYCError } from '../types/verification';
13
14
  import type { DocumentCapturePhase } from '../store/kycStore';
14
- import { useKyc, useKycConfig, useTheme } from '../components/runtime';
15
+ import { useEffectiveCountry, useKyc, useKycConfig, useKycStore, useTheme } from '../components/runtime';
15
16
  import { MyazaText } from '../components/Typography';
16
17
  import { MyazaButton } from '../components/MyazaButton';
17
18
  import { useToast } from '../components/toast';
18
19
  import { MyazaPulseLoader } from '../components/MyazaPulseLoader';
19
- import { CameraViewfinder } from '../components/CameraViewfinder';
20
+ import { CameraPhase } from './document/CameraPhase';
20
21
  import { CameraPermissionView, CameraUnavailableView, CameraPermissionPrimingView } from '../components/CameraPermissionView';
22
+ import { ReadyPrimer } from '../components/ReadyPrimer';
23
+ import { READY_DOCUMENT } from '../components/readyPrimerContent';
21
24
  import { DocumentCropper } from '../components/DocumentCropper';
25
+ import { DocumentReview } from '../components/DocumentReview';
26
+ import { scanMrzFromImage } from '../mrz/textRecognizer';
22
27
  import { Icon } from '../components/Icon';
23
28
 
24
29
  // Document capture — the RN mirror of the Flutter/web DocumentCaptureStep.
@@ -61,6 +66,16 @@ export function documentCaptureMeta(
61
66
  }
62
67
  }
63
68
 
69
+ /** Bound on how long Continue waits for the supplementary document video. */
70
+ const DOC_VIDEO_WAIT_MS = 8000;
71
+
72
+ /** Home-indicator / nav-bar clearance for the full-screen camera's controls. */
73
+ function cameraBottomInset(): number {
74
+ const sa = initialWindowMetrics?.insets;
75
+ if (Platform.OS === 'android') return (sa?.bottom ?? 0) + 12;
76
+ return sa?.bottom || 34;
77
+ }
78
+
64
79
  export function DocumentCaptureStep(): React.ReactElement {
65
80
  const { colors } = useTheme();
66
81
  const toast = useToast();
@@ -68,7 +83,12 @@ export function DocumentCaptureStep(): React.ReactElement {
68
83
  const selectedIdType = useKyc((s) => s.selectedIdType);
69
84
  const api = useKyc((s) => s.api);
70
85
  const setMediaId = useKyc((s) => s.setMediaId);
86
+ const setMrzScan = useKyc((s) => s.setMrzScan);
87
+ const mrzScan = useKyc((s) => s.mrzScan);
88
+ const country = useEffectiveCountry();
71
89
  const setDocumentCapturePhase = useKyc((s) => s.setDocumentCapturePhase);
90
+ const setImmersiveCapture = useKyc((s) => s.setImmersiveCapture);
91
+ const store = useKycStore();
72
92
  const nextStep = useKyc((s) => s.nextStep);
73
93
 
74
94
  const allowUpload = config.allowDocumentUpload !== false;
@@ -96,6 +116,7 @@ export function DocumentCaptureStep(): React.ReactElement {
96
116
  setDocumentCapturePhase(phase);
97
117
  }, [phase, setDocumentCapturePhase]);
98
118
 
119
+
99
120
  // ── Camera permission ──────────────────────────────────────────────────────
100
121
  // `perm` is derived from the ASYNC requestPermission result, not synchronously
101
122
  // from `hasPermission` — otherwise the brief window while the OS prompt is open
@@ -127,7 +148,23 @@ export function DocumentCaptureStep(): React.ReactElement {
127
148
  // No camera hardware at all → "Camera not available" (regardless of what the
128
149
  // permission API says — on a camera-less sim it may even report denied).
129
150
  const cameraUnavailable = cameraGrace && !device;
151
+ // Gate the camera path on the user acknowledging the primer. Shown once for
152
+ // the step, not per side — repeating it before the back of a card would be
153
+ // noise, not a warning.
154
+ const [ready, setReady] = useState(false);
130
155
  const showPrimer = perm === 'priming' && !!device;
156
+ // Tell the shell when the full-bleed camera is on screen. Scoped tightly:
157
+ // only the live preview earns the whole display — the ready primer, the
158
+ // permission screens, the previews and the review all keep their chrome.
159
+ //
160
+ // No unmount cleanup: KycFlow already scopes the flag to this step, and a
161
+ // cleanup here would fire during any remount and fight the effect that had
162
+ // just raised it.
163
+ const cameraLive =
164
+ (phase === 'front' || phase === 'back') && ready && perm === 'granted' && !!device;
165
+ useEffect(() => {
166
+ setImmersiveCapture(cameraLive);
167
+ }, [cameraLive, setImmersiveCapture]);
131
168
 
132
169
  // Reflect an externally-granted permission.
133
170
  useEffect(() => {
@@ -176,6 +213,22 @@ export function DocumentCaptureStep(): React.ReactElement {
176
213
  setUploadError(null);
177
214
  try {
178
215
  const compressed = await compressDocumentImage(rawUri);
216
+ // Read the MRZ off the FRONT while we have it. It is the key that
217
+ // unlocks the chip, and taking it from the photo the user just captured
218
+ // is what spares them scanning the same passport a second time.
219
+ // Best-effort in every sense: most documents have no MRZ, and one that
220
+ // does not read simply leaves the chip step to ask.
221
+ if (phase !== 'back') {
222
+ void scanMrzFromImage(compressed)
223
+ .then((scan) => {
224
+ if (scan) setMrzScan(scan);
225
+ })
226
+ // Explicit, even though the scanner already swallows its own
227
+ // errors: this sits inside the capture path, and the claim above
228
+ // that it is best-effort has to hold for anything that can reject
229
+ // — including a future change to the store setter.
230
+ .catch(() => undefined);
231
+ }
179
232
  if (phase === 'back') {
180
233
  setBackUri(compressed);
181
234
  setPhase('review');
@@ -187,7 +240,7 @@ export function DocumentCaptureStep(): React.ReactElement {
187
240
  setBusy(false);
188
241
  }
189
242
  },
190
- [phase, isTwoSided],
243
+ [phase, isTwoSided, setMrzScan],
191
244
  );
192
245
 
193
246
  // Gallery upload: pick the raw photo (no native editor), then open the SDK's
@@ -196,13 +249,16 @@ export function DocumentCaptureStep(): React.ReactElement {
196
249
  // (mirrors Flutter's cropCardRegion), then store. Gallery photos skip this —
197
250
  // they're already cropped by the interactive cropper.
198
251
  const captureFromCamera = useCallback(
199
- async (rawUri: string, videoPath: string | null) => {
252
+ async (rawUri: string, videoPath: string | null, viewAr: number) => {
200
253
  setBusy(true);
201
254
  try {
202
255
  // Stash the side's video (best-effort) before cropping/storing the still.
203
256
  if (phase === 'back') backVideoRef.current = videoPath;
204
257
  else frontVideoRef.current = videoPath;
205
- const carded = await cropCardRegion(rawUri, guideAspect).catch(() => rawUri);
258
+ // viewAr is the MEASURED preview box — full-screen capture shows a far
259
+ // taller slice than the framed 3:4 box the old constant assumed, which
260
+ // is why reviews came back wider than what the user framed.
261
+ const carded = await cropCardRegion(rawUri, guideAspect, viewAr).catch(() => rawUri);
206
262
  await storeCapture(carded);
207
263
  } finally {
208
264
  setBusy(false);
@@ -223,6 +279,18 @@ export function DocumentCaptureStep(): React.ReactElement {
223
279
  }, []);
224
280
 
225
281
  // Best-effort document video upload — never blocks or fails the flow.
282
+ /**
283
+ * How long the flow will WAIT for a document video before moving on.
284
+ *
285
+ * The clip is supplementary evidence, but transcoding a 4K recording and
286
+ * pushing it over a phone hotspot is slow — and it used to be awaited with no
287
+ * bound, so the step sat on a spinner indefinitely after the photo had
288
+ * already uploaded fine. The user could not reach the chip step at all.
289
+ *
290
+ * The upload is NOT cancelled at the deadline: it keeps running in the
291
+ * background and still records its media id if it lands, usually well before
292
+ * submission. Only the WAIT is bounded.
293
+ */
226
294
  const uploadDocVideo = useCallback(
227
295
  async (
228
296
  videoPath: string | null,
@@ -230,15 +298,21 @@ export function DocumentCaptureStep(): React.ReactElement {
230
298
  mediaKey: 'documentFrontVideo' | 'documentBackVideo',
231
299
  ) => {
232
300
  if (!videoPath) return;
233
- try {
234
- // Transcode the raw 4K recording down to a small evidence clip first.
235
- const small = await compressVideo(videoPath);
236
- const id = await withRetry(() => api.upload({ uri: small, type: 'video/mp4' }, type, MAX_VIDEO_BYTES));
237
- setMediaId(mediaKey, id);
238
- } catch {
239
- /* supplementary — verification proceeds without the document video
240
- (dropped if it failed to upload or exceeded the 5MB ceiling) */
241
- }
301
+ const work = (async () => {
302
+ try {
303
+ // Transcode the raw 4K recording down to a small evidence clip first.
304
+ const small = await compressVideo(videoPath);
305
+ const id = await withRetry(() => api.upload({ uri: small, type: 'video/mp4' }, type, MAX_VIDEO_BYTES));
306
+ setMediaId(mediaKey, id);
307
+ } catch {
308
+ /* supplementary — verification proceeds without the document video
309
+ (dropped if it failed to upload or exceeded the 5MB ceiling) */
310
+ }
311
+ })();
312
+ await Promise.race([
313
+ work,
314
+ new Promise<void>((resolve) => setTimeout(resolve, DOC_VIDEO_WAIT_MS)),
315
+ ]);
242
316
  },
243
317
  [api, setMediaId],
244
318
  );
@@ -312,6 +386,17 @@ export function DocumentCaptureStep(): React.ReactElement {
312
386
  </>
313
387
  );
314
388
  }
389
+ if (!ready) {
390
+ // "Here's what happens next", BEFORE the OS prompt — and shown even when
391
+ // permission is already granted, since it is about what the step asks of
392
+ // the user rather than about access.
393
+ return (
394
+ <>
395
+ {cropper}
396
+ <ReadyPrimer content={READY_DOCUMENT} onReady={() => setReady(true)} />
397
+ </>
398
+ );
399
+ }
315
400
  if (showPrimer) {
316
401
  // Primer before the OS prompt — camera not started yet.
317
402
  return (
@@ -334,57 +419,36 @@ export function DocumentCaptureStep(): React.ReactElement {
334
419
  );
335
420
  }
336
421
  const isBack = phase === 'back';
422
+
337
423
  return (
338
- <View>
424
+ <>
339
425
  {cropper}
340
- {/* Required pill: ID label + side badge + step label (two-sided) */}
341
- <RequiredPill
342
- documentLabel={documentLabel}
343
- sideBadge={isTwoSided ? (isBack ? 'Back Side' : 'Front Side') : undefined}
344
- stepLabel={isTwoSided ? (isBack ? 'Step 2 of 2' : 'Step 1 of 2') : undefined}
345
- />
346
- {isBack ? (
347
- <View style={{ flexDirection: 'row', alignItems: 'center', marginTop: spacing.sm, marginBottom: spacing.xs }}>
348
- <Icon name="credit-card" size={14} color={colors.primary} />
349
- <View style={{ width: 4 }} />
350
- <MyazaText variant="bodySmall" color={colors.primary} style={{ fontWeight: '500' }}>
351
- Flip the card over and scan the other side
352
- </MyazaText>
353
- </View>
354
- ) : null}
355
- <View style={{ height: spacing.md }} />
356
- <CameraViewfinder
426
+ <CameraPhase
427
+ isBack={isBack}
428
+ cameraLive={cameraLive}
357
429
  active={perm === 'granted'}
358
- side={isBack ? 'back' : 'front'}
359
430
  documentLabel={documentLabel}
360
431
  guideAspect={guideAspect}
361
- onCapture={(uri, videoPath) => void captureFromCamera(uri, videoPath)}
432
+ isTwoSided={isTwoSided}
362
433
  busy={busy}
434
+ allowUpload={allowUpload}
435
+ country={country}
436
+ idType={selectedIdType ?? undefined}
437
+ mrzAlreadyCaptured={!!mrzScan}
438
+ onMrz={setMrzScan}
439
+ onCapture={(uri, videoPath, viewAr) => void captureFromCamera(uri, videoPath, viewAr)}
440
+ onUpload={pickFromGallery}
441
+ onBack={() => store.getState().previousStep()}
442
+ // The camera is a TRUE full-screen modal now, so it runs under the
443
+ // home indicator and has to clear the real inset — the smaller value
444
+ // that suited the inset sheet leaves the shutter under the gesture bar.
445
+ bottomInset={cameraBottomInset()}
363
446
  />
364
- {!busy ? (
365
- <>
366
- <View style={{ height: spacing.md }} />
367
- <MyazaText variant="bodySmall" style={{ textAlign: 'center' }}>
368
- Tap the button to capture manually
369
- </MyazaText>
370
- {allowUpload ? (
371
- <Pressable onPress={pickFromGallery} style={{ marginTop: spacing.sm }}>
372
- <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center' }}>
373
- <MyazaText variant="bodySmall">Having trouble? </MyazaText>
374
- <Icon name="upload" size={14} color={colors.primary} />
375
- <View style={{ width: 4 }} />
376
- <MyazaText variant="bodySmall" color={colors.primary} style={{ fontWeight: '700' }}>
377
- Upload a photo instead
378
- </MyazaText>
379
- </View>
380
- </Pressable>
381
- ) : null}
382
- </>
383
- ) : null}
384
- </View>
447
+ </>
385
448
  );
386
449
  }
387
450
 
451
+
388
452
  // ── Front preview (two-sided) ──────────────────────────────────────────────
389
453
  if (phase === 'front-preview') {
390
454
  return (
@@ -404,84 +468,44 @@ export function DocumentCaptureStep(): React.ReactElement {
404
468
 
405
469
  // ── Review ─────────────────────────────────────────────────────────────────
406
470
  return (
407
- <View>
408
- <DocImage uri={frontUri!} label={isTwoSided ? 'Front' : undefined} uploading={uploading} />
409
- <MyazaButton label="Retake" variant="ghost" leadingIcon="refresh" onPress={() => retake('front')} disabled={uploading} />
410
- {isTwoSided && backUri ? (
471
+ <DocumentReview
472
+ frontUri={frontUri!}
473
+ backUri={isTwoSided ? backUri : null}
474
+ aspect={guideAspect}
475
+ isBusy={uploading}
476
+ busyOverlay={
477
+ // Standard upload loader — a dark scrim + the pulse-ring loader, drawn
478
+ // INSIDE the preview frame, mirroring the web/Flutter SDKs (and the
479
+ // liveness selfie review).
480
+ <View
481
+ style={{
482
+ flex: 1,
483
+ alignItems: 'center',
484
+ justifyContent: 'center',
485
+ backgroundColor: 'rgba(0,0,0,0.45)',
486
+ }}
487
+ >
488
+ <MyazaPulseLoader size={48} />
489
+ </View>
490
+ }
491
+ onRetakeFront={() => retake('front')}
492
+ onRetakeBack={() => retake('back')}
493
+ footer={
411
494
  <>
412
- <View style={{ height: spacing.md }} />
413
- <DocImage uri={backUri} label="Back" uploading={uploading} />
414
- <MyazaButton label="Retake Back" variant="ghost" leadingIcon="refresh" onPress={() => retake('back')} disabled={uploading} />
415
- </>
416
- ) : null}
417
-
418
- {retryInfo && uploading ? (
419
- <MyazaText variant="bodySmall" color={colors.warning} style={{ textAlign: 'center', marginTop: spacing.sm }}>
420
- {`Upload failed — retrying (${retryInfo.attempt}/${retryInfo.total})…`}
421
- </MyazaText>
422
- ) : null}
423
-
424
- <View style={{ height: spacing.md }} />
425
- {uploadError ? (
426
- // The error message is shown as a top toast; keep a retry action here.
427
- <MyazaButton label="Try Again" onPress={handleContinue} loading={uploading} />
428
- ) : (
429
- <MyazaButton label="Continue" onPress={handleContinue} loading={uploading} />
430
- )}
431
- </View>
432
- );
433
- }
434
-
435
- // "Required: {label}" pill with optional side badge + step label — mirrors the
436
- // Flutter SDK's _RequiredPill on the capture screen.
437
- function RequiredPill({
438
- documentLabel,
439
- sideBadge,
440
- stepLabel,
441
- }: {
442
- documentLabel: string;
443
- sideBadge?: string;
444
- stepLabel?: string;
445
- }): React.ReactElement {
446
- const { colors } = useTheme();
447
- return (
448
- <View style={{ flexDirection: 'row', alignItems: 'center' }}>
449
- <View
450
- style={{
451
- flex: 1,
452
- flexDirection: 'row',
453
- alignItems: 'center',
454
- flexWrap: 'wrap',
455
- borderWidth: 1,
456
- borderColor: `${colors.primary}33`,
457
- backgroundColor: `${colors.primary}0D`,
458
- borderRadius: radius.md,
459
- paddingHorizontal: spacing.sm + 4,
460
- paddingVertical: spacing.sm,
461
- }}
462
- >
463
- <Icon name="credit-card" size={16} color={colors.primary} />
464
- <View style={{ width: 8 }} />
465
- <MyazaText variant="bodySmall" color={colors.primary} style={{ fontWeight: '600' }}>
466
- Required:{' '}
467
- </MyazaText>
468
- <MyazaText variant="bodySmall" color={colors.primary} style={{ flexShrink: 1 }}>
469
- {documentLabel}
470
- </MyazaText>
471
- {sideBadge ? (
472
- <View style={{ backgroundColor: `${colors.primary}26`, borderRadius: radius.full, paddingHorizontal: 8, paddingVertical: 2, marginLeft: 8 }}>
473
- <MyazaText variant="bodySmall" color={colors.primary} style={{ fontWeight: '600', fontSize: 11 }}>
474
- {sideBadge}
495
+ {retryInfo && uploading ? (
496
+ <MyazaText variant="bodySmall" color={colors.warning} style={{ textAlign: 'center', marginBottom: spacing.sm }}>
497
+ {`Upload failed — retrying (${retryInfo.attempt}/${retryInfo.total})…`}
475
498
  </MyazaText>
476
- </View>
477
- ) : null}
478
- </View>
479
- {stepLabel ? (
480
- <MyazaText variant="bodySmall" color={colors.textMuted} style={{ marginLeft: spacing.sm }}>
481
- {stepLabel}
482
- </MyazaText>
483
- ) : null}
484
- </View>
499
+ ) : null}
500
+ {uploadError ? (
501
+ // The error message is shown as a top toast; keep a retry action here.
502
+ <MyazaButton label="Try Again" onPress={handleContinue} loading={uploading} />
503
+ ) : (
504
+ <MyazaButton label="Continue" onPress={handleContinue} loading={uploading} />
505
+ )}
506
+ </>
507
+ }
508
+ />
485
509
  );
486
510
  }
487
511
 
@@ -5,7 +5,7 @@ import { spacing } from '../config/theme';
5
5
  import { ID_TYPES } from '../config/idTypes';
6
6
  import { validateIdNumber } from '../services/validators';
7
7
  import type { IdType } from '../types/config';
8
- import { useKyc, useKycConfig } from '../components/runtime';
8
+ import { useEffectiveCountry, useKyc, useKycConfig } from '../components/runtime';
9
9
  import { MyazaText } from '../components/Typography';
10
10
  import { MyazaInput } from '../components/MyazaInput';
11
11
  import { MyazaButton } from '../components/MyazaButton';
@@ -24,11 +24,16 @@ function hintFor(def: { label: string; inputLabel?: string; digits?: number } |
24
24
 
25
25
  export function IdInputStep(): React.ReactElement {
26
26
  const config = useKycConfig();
27
+ const country = useEffectiveCountry();
27
28
  const selectedIdType = useKyc((s) => s.selectedIdType);
29
+ const storedIdNumber = useKyc((s) => s.idNumber);
28
30
  const setIdNumber = useKyc((s) => s.setIdNumber);
29
31
  const nextStep = useKyc((s) => s.nextStep);
30
32
 
31
- const [value, setValue] = useState('');
33
+ // Seeded from the store so a back-and-forward through the flow never loses
34
+ // what was already typed — everything persists until submission, or until
35
+ // the user themselves changes it (mirrors web + Flutter).
36
+ const [value, setValue] = useState(storedIdNumber ?? '');
32
37
  const [error, setError] = useState<string | null>(null);
33
38
 
34
39
  const def = useMemo(() => Object.values(ID_TYPES).flat().find((t) => t.key === selectedIdType) ?? null, [selectedIdType]);
@@ -36,15 +41,18 @@ export function IdInputStep(): React.ReactElement {
36
41
 
37
42
  const valid = useMemo(() => {
38
43
  if (!selectedIdType || !value.trim()) return false;
39
- return validateIdNumber(config.country, selectedIdType as IdType, value).valid;
40
- }, [config.country, selectedIdType, value]);
44
+ return validateIdNumber(country, selectedIdType as IdType, value).valid;
45
+ }, [country, selectedIdType, value]);
41
46
 
42
47
  const handleChange = (text: string) => {
43
48
  // Match Flutter: BVN/NIN are digits-only.
44
49
  const next = isDigits ? text.replace(/[^0-9]/g, '') : text;
45
50
  setValue(next);
51
+ // Write-through to the store as they type — a back-swipe mid-entry must
52
+ // not cost them the digits they already entered.
53
+ setIdNumber(next);
46
54
  if (selectedIdType && next.trim()) {
47
- const result = validateIdNumber(config.country, selectedIdType as IdType, next);
55
+ const result = validateIdNumber(country, selectedIdType as IdType, next);
48
56
  setError(result.valid ? null : result.message ?? null);
49
57
  } else {
50
58
  setError(null);