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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (283) hide show
  1. package/README.md +152 -8
  2. package/android/build.gradle +6 -0
  3. package/android/src/main/AndroidManifest.xml +14 -4
  4. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaEmrtd.kt +358 -0
  5. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +88 -1
  6. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaRectDetector.kt +34 -0
  7. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizer.kt +118 -0
  8. package/android/src/main/java/com/margelo/nitro/myazakyc/Jp2Decoder.kt +117 -0
  9. package/app.plugin.js +102 -13
  10. package/ios/HybridMyazaEmrtd.swift +447 -0
  11. package/ios/HybridMyazaFaceDetector.swift +104 -2
  12. package/ios/HybridMyazaRectDetector.swift +103 -0
  13. package/ios/HybridMyazaTextRecognizer.swift +111 -0
  14. package/nitrogen/generated/.gitattributes +1 -0
  15. package/nitrogen/generated/android/KycSdkReactNative+autolinking.cmake +87 -0
  16. package/nitrogen/generated/android/KycSdkReactNative+autolinking.gradle +27 -0
  17. package/nitrogen/generated/android/KycSdkReactNativeOnLoad.cpp +102 -0
  18. package/nitrogen/generated/android/KycSdkReactNativeOnLoad.hpp +34 -0
  19. package/nitrogen/generated/android/c++/JDetectedRect.hpp +81 -0
  20. package/nitrogen/generated/android/c++/JEmrtdApduResponse.hpp +61 -0
  21. package/nitrogen/generated/android/c++/JEmrtdTagInfo.hpp +61 -0
  22. package/nitrogen/generated/android/c++/JFaceResult.hpp +113 -0
  23. package/nitrogen/generated/android/c++/JHybridMyazaEmrtdSpec.cpp +176 -0
  24. package/nitrogen/generated/android/c++/JHybridMyazaEmrtdSpec.hpp +75 -0
  25. package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.cpp +60 -0
  26. package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.hpp +63 -0
  27. package/nitrogen/generated/android/c++/JHybridMyazaRectDetectorSpec.cpp +60 -0
  28. package/nitrogen/generated/android/c++/JHybridMyazaRectDetectorSpec.hpp +63 -0
  29. package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.cpp +80 -0
  30. package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.hpp +64 -0
  31. package/nitrogen/generated/android/c++/JTextResult.hpp +76 -0
  32. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/DetectedRect.kt +81 -0
  33. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/EmrtdApduResponse.kt +56 -0
  34. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/EmrtdTagInfo.kt +56 -0
  35. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/FaceResult.kt +121 -0
  36. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaEmrtdSpec.kt +103 -0
  37. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaFaceDetectorSpec.kt +55 -0
  38. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaRectDetectorSpec.kt +55 -0
  39. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizerSpec.kt +60 -0
  40. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/KycSdkReactNativeOnLoad.kt +35 -0
  41. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/TextResult.kt +51 -0
  42. package/nitrogen/generated/ios/KycSdkReactNative+autolinking.rb +62 -0
  43. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.cpp +143 -0
  44. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.hpp +423 -0
  45. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Umbrella.hpp +81 -0
  46. package/nitrogen/generated/ios/KycSdkReactNativeAutolinking.mm +57 -0
  47. package/nitrogen/generated/ios/KycSdkReactNativeAutolinking.swift +62 -0
  48. package/nitrogen/generated/ios/c++/HybridMyazaEmrtdSpecSwift.cpp +11 -0
  49. package/nitrogen/generated/ios/c++/HybridMyazaEmrtdSpecSwift.hpp +184 -0
  50. package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.cpp +11 -0
  51. package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.hpp +87 -0
  52. package/nitrogen/generated/ios/c++/HybridMyazaRectDetectorSpecSwift.cpp +11 -0
  53. package/nitrogen/generated/ios/c++/HybridMyazaRectDetectorSpecSwift.hpp +87 -0
  54. package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.cpp +11 -0
  55. package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.hpp +98 -0
  56. package/nitrogen/generated/ios/swift/DetectedRect.swift +59 -0
  57. package/nitrogen/generated/ios/swift/EmrtdApduResponse.swift +34 -0
  58. package/nitrogen/generated/ios/swift/EmrtdTagInfo.swift +34 -0
  59. package/nitrogen/generated/ios/swift/FaceResult.swift +99 -0
  60. package/nitrogen/generated/ios/swift/Func_void.swift +46 -0
  61. package/nitrogen/generated/ios/swift/Func_void_EmrtdApduResponse.swift +46 -0
  62. package/nitrogen/generated/ios/swift/Func_void_EmrtdTagInfo.swift +46 -0
  63. package/nitrogen/generated/ios/swift/Func_void_TextResult.swift +46 -0
  64. package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +46 -0
  65. package/nitrogen/generated/ios/swift/Func_void_std__string.swift +46 -0
  66. package/nitrogen/generated/ios/swift/HybridMyazaEmrtdSpec.swift +67 -0
  67. package/nitrogen/generated/ios/swift/HybridMyazaEmrtdSpec_cxx.swift +317 -0
  68. package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec.swift +56 -0
  69. package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec_cxx.swift +143 -0
  70. package/nitrogen/generated/ios/swift/HybridMyazaRectDetectorSpec.swift +56 -0
  71. package/nitrogen/generated/ios/swift/HybridMyazaRectDetectorSpec_cxx.swift +143 -0
  72. package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec.swift +57 -0
  73. package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec_cxx.swift +162 -0
  74. package/nitrogen/generated/ios/swift/TextResult.swift +35 -0
  75. package/nitrogen/generated/shared/c++/DetectedRect.hpp +107 -0
  76. package/nitrogen/generated/shared/c++/EmrtdApduResponse.hpp +87 -0
  77. package/nitrogen/generated/shared/c++/EmrtdTagInfo.hpp +87 -0
  78. package/nitrogen/generated/shared/c++/FaceResult.hpp +139 -0
  79. package/nitrogen/generated/shared/c++/HybridMyazaEmrtdSpec.cpp +33 -0
  80. package/nitrogen/generated/shared/c++/HybridMyazaEmrtdSpec.hpp +80 -0
  81. package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.cpp +21 -0
  82. package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.hpp +67 -0
  83. package/nitrogen/generated/shared/c++/HybridMyazaRectDetectorSpec.cpp +21 -0
  84. package/nitrogen/generated/shared/c++/HybridMyazaRectDetectorSpec.hpp +67 -0
  85. package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.cpp +22 -0
  86. package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.hpp +70 -0
  87. package/nitrogen/generated/shared/c++/TextResult.hpp +84 -0
  88. package/package.json +16 -4
  89. package/src/MyazaKYC.tsx +115 -22
  90. package/src/capture/documentIdentity.ts +153 -0
  91. package/src/capture/documentSignals.ts +177 -0
  92. package/src/capture/documentTextGate.ts +171 -0
  93. package/src/capture/hints.ts +57 -0
  94. package/src/capture/index.ts +24 -0
  95. package/src/capture/rectDetector.ts +84 -0
  96. package/src/capture/rectGate.ts +91 -0
  97. package/src/capture/useAutoCapture.ts +198 -0
  98. package/src/components/CameraViewfinder.tsx +226 -70
  99. package/src/components/ContactCodeEntry.tsx +139 -0
  100. package/src/components/CountryField.tsx +87 -0
  101. package/src/components/CountryFlag.tsx +15 -15
  102. package/src/components/DialCodePicker.tsx +186 -0
  103. package/src/components/DocumentCropper.tsx +1 -1
  104. package/src/components/DocumentReview.tsx +142 -0
  105. package/src/components/DocumentReviewSide.tsx +135 -0
  106. package/src/components/DocumentReviewZoom.tsx +119 -0
  107. package/src/components/ExpiryCountdown.tsx +52 -0
  108. package/src/components/FlashOverlay.tsx +156 -0
  109. package/src/components/Icon.tsx +70 -1
  110. package/src/components/KycFlow.tsx +66 -94
  111. package/src/components/KycSheet.tsx +148 -18
  112. package/src/components/MediaSourceSheet.tsx +163 -0
  113. package/src/components/MyazaButton.tsx +14 -2
  114. package/src/components/MyazaDateField.tsx +240 -0
  115. package/src/components/MyazaInput.tsx +73 -26
  116. package/src/components/MyazaSelect.tsx +283 -0
  117. package/src/components/MyazaWordmark.tsx +33 -0
  118. package/src/components/OptionRow.tsx +132 -0
  119. package/src/components/PhoneNumberInput.tsx +149 -0
  120. package/src/components/PoweredBy.tsx +89 -0
  121. package/src/components/ReadyPrimer.tsx +149 -0
  122. package/src/components/StepView.tsx +91 -0
  123. package/src/components/Typography.tsx +18 -3
  124. package/src/components/VerifiedNotice.tsx +38 -0
  125. package/src/components/WhatsAppIcon.tsx +29 -0
  126. package/src/components/WorkflowGate.tsx +128 -0
  127. package/src/components/brand-font.ts +113 -0
  128. package/src/components/flashHoleGeometry.ts +35 -0
  129. package/src/components/flow/FatalConfigError.tsx +56 -0
  130. package/src/components/fonts.ts +2 -19
  131. package/src/components/readyPrimerContent.ts +52 -0
  132. package/src/components/runtime.tsx +38 -43
  133. package/src/components/stepHeaderMeta.tsx +87 -0
  134. package/src/components/theme-provider.tsx +89 -0
  135. package/src/components/useWorkflowMount.ts +116 -0
  136. package/src/components/viewfinder/CardGuide.tsx +60 -0
  137. package/src/components/viewfinder/DocumentGhost.tsx +149 -0
  138. package/src/components/viewfinder/ImmersiveBottomBar.tsx +172 -0
  139. package/src/components/viewfinder/ImmersiveControls.tsx +127 -0
  140. package/src/components/viewfinder/ImmersiveGuide.tsx +174 -0
  141. package/src/components/viewfinder/ImmersiveOverlay.tsx +79 -0
  142. package/src/components/viewfinder/ViewfinderControls.tsx +154 -0
  143. package/src/config/brand.ts +97 -0
  144. package/src/config/business.ts +179 -0
  145. package/src/config/businessSteps.ts +105 -0
  146. package/src/config/contact.ts +159 -0
  147. package/src/config/countryNames.g.ts +254 -0
  148. package/src/config/currencyFlags.ts +144 -0
  149. package/src/config/font-resolve.ts +78 -0
  150. package/src/config/idTypes.ts +96 -1
  151. package/src/config/keyPeople.ts +162 -0
  152. package/src/config/phone.ts +39 -0
  153. package/src/config/proofOfAddress.ts +75 -0
  154. package/src/config/questionnaire.ts +153 -0
  155. package/src/config/regions.ts +118 -0
  156. package/src/config/registrationHint.ts +87 -0
  157. package/src/config/stepOrder.ts +118 -0
  158. package/src/config/theme.ts +38 -2
  159. package/src/config/workflowMerge.ts +124 -0
  160. package/src/emrtd/bac.ts +128 -0
  161. package/src/emrtd/bytes.ts +127 -0
  162. package/src/emrtd/crypto.ts +163 -0
  163. package/src/emrtd/der.ts +192 -0
  164. package/src/emrtd/dg2.ts +111 -0
  165. package/src/emrtd/files.ts +215 -0
  166. package/src/emrtd/index.ts +30 -0
  167. package/src/emrtd/mrzKey.ts +54 -0
  168. package/src/emrtd/native.ts +79 -0
  169. package/src/emrtd/optionalRead.ts +42 -0
  170. package/src/emrtd/read.ts +224 -0
  171. package/src/emrtd/retry.ts +98 -0
  172. package/src/emrtd/secureMessaging.ts +178 -0
  173. package/src/emrtd/session.ts +200 -0
  174. package/src/emrtd/stages.ts +122 -0
  175. package/src/index.ts +110 -0
  176. package/src/lib/calendar.ts +71 -0
  177. package/src/lib/use-keyboard-inset.ts +34 -0
  178. package/src/liveness/challengeManager.ts +27 -1
  179. package/src/liveness/faceContinuity.ts +153 -0
  180. package/src/liveness/flashDetector.ts +145 -0
  181. package/src/liveness/flashReadyGate.ts +106 -0
  182. package/src/liveness/flashRunner.ts +118 -0
  183. package/src/liveness/integritySignals.ts +60 -0
  184. package/src/liveness/livenessState.ts +139 -0
  185. package/src/liveness/speech.ts +19 -1
  186. package/src/liveness/types.ts +48 -1
  187. package/src/liveness/useFlashPhase.ts +78 -0
  188. package/src/liveness/useLiveness.ts +260 -75
  189. package/src/liveness/visionCameraFaceDetector.ts +14 -0
  190. package/src/mrz/extract.ts +101 -0
  191. package/src/mrz/parse.ts +178 -0
  192. package/src/mrz/textRecognizer.ts +149 -0
  193. package/src/screens/ApplicantRoleStep.tsx +322 -0
  194. package/src/screens/BusinessDetailsStep.tsx +212 -0
  195. package/src/screens/BusinessDocumentSlot.tsx +167 -0
  196. package/src/screens/BusinessDocumentsStep.tsx +213 -0
  197. package/src/screens/BusinessKeyPeopleStep.tsx +207 -0
  198. package/src/screens/CompanyInfoFields.tsx +83 -0
  199. package/src/screens/ConsentStep.tsx +68 -68
  200. package/src/screens/ContactChannelChoice.tsx +133 -0
  201. package/src/screens/ContactCodeStep.tsx +130 -0
  202. package/src/screens/ContactDestinationField.tsx +70 -0
  203. package/src/screens/ContactFooterNote.tsx +34 -0
  204. package/src/screens/ContactVerificationStep.tsx +199 -0
  205. package/src/screens/ContactVerifiedPanel.tsx +36 -0
  206. package/src/screens/CountrySelectStep.tsx +210 -0
  207. package/src/screens/DocumentCaptureStep.tsx +156 -132
  208. package/src/screens/IdInputStep.tsx +13 -5
  209. package/src/screens/IdTypeStep.tsx +74 -33
  210. package/src/screens/KeyPeopleInviteLinks.tsx +239 -0
  211. package/src/screens/KeyPersonCard.tsx +119 -0
  212. package/src/screens/KeyPersonForm.tsx +148 -0
  213. package/src/screens/KeyPersonSheet.tsx +184 -0
  214. package/src/screens/LivenessStep.tsx +155 -247
  215. package/src/screens/MrzScanView.tsx +173 -0
  216. package/src/screens/NfcStep.tsx +290 -0
  217. package/src/screens/ProofOfAddressStep.tsx +238 -0
  218. package/src/screens/QuestionnaireField.tsx +219 -0
  219. package/src/screens/QuestionnaireMoneyField.tsx +134 -0
  220. package/src/screens/QuestionnaireStep.tsx +76 -0
  221. package/src/screens/SubmittedStep.tsx +5 -0
  222. package/src/screens/consent/model.ts +109 -0
  223. package/src/screens/document/CameraPhase.tsx +150 -0
  224. package/src/screens/document/RequiredPill.tsx +60 -0
  225. package/src/screens/liveness/LightingBanner.tsx +50 -0
  226. package/src/screens/liveness/LivenessOutcome.tsx +109 -0
  227. package/src/screens/liveness/ProgressDots.tsx +67 -0
  228. package/src/screens/liveness/SelfiePreview.tsx +39 -0
  229. package/src/screens/liveness/constants.ts +31 -0
  230. package/src/screens/liveness/index.ts +8 -0
  231. package/src/screens/liveness/resolveGuidance.ts +27 -0
  232. package/src/screens/liveness/useSelfieUpload.ts +107 -0
  233. package/src/screens/nfc/NfcMrzPrompt.tsx +40 -0
  234. package/src/screens/nfc/NfcReadBody.tsx +63 -0
  235. package/src/screens/nfc/NfcReadProgress.tsx +96 -0
  236. package/src/screens/nfc/NfcScanIllustration.tsx +244 -0
  237. package/src/screens/nfc/NfcScannedSummary.tsx +74 -0
  238. package/src/screens/nfc/NfcSuccessPanel.tsx +162 -0
  239. package/src/screens/nfc/availability.ts +73 -0
  240. package/src/screens/nfc/readErrorMessage.ts +93 -0
  241. package/src/screens/nfc/useChipPortrait.ts +58 -0
  242. package/src/screens/nfc/useNfcAvailability.ts +82 -0
  243. package/src/screens/useFlashHole.ts +105 -0
  244. package/src/screens/useFlashSequence.ts +96 -0
  245. package/src/screens/usePoaAttach.ts +121 -0
  246. package/src/services/api-types.ts +164 -0
  247. package/src/services/api-verify-types.ts +99 -0
  248. package/src/services/api.ts +72 -102
  249. package/src/services/cardCrop.ts +25 -10
  250. package/src/services/contactErrors.ts +43 -0
  251. package/src/services/deviceMetadata.ts +1 -1
  252. package/src/services/documentPicker.ts +20 -0
  253. package/src/services/fingerprint-sources.ts +129 -0
  254. package/src/services/fingerprint.ts +101 -0
  255. package/src/services/haptics.ts +38 -0
  256. package/src/services/mediaCompress.ts +7 -2
  257. package/src/services/resolveUrl.ts +2 -2
  258. package/src/services/workflowGate.ts +138 -0
  259. package/src/specs/MyazaEmrtd.nitro.ts +114 -0
  260. package/src/specs/MyazaFaceDetector.nitro.ts +10 -0
  261. package/src/specs/MyazaRectDetector.nitro.ts +50 -0
  262. package/src/specs/MyazaTextRecognizer.nitro.ts +48 -0
  263. package/src/store/derive.ts +225 -0
  264. package/src/store/kycStore.ts +250 -149
  265. package/src/store/state.ts +253 -0
  266. package/src/store/submit.ts +101 -0
  267. package/src/store/submitApplicant.ts +81 -0
  268. package/src/types/appearance.ts +113 -0
  269. package/src/types/business.ts +131 -0
  270. package/src/types/config.ts +150 -124
  271. package/src/types/id-types.ts +57 -0
  272. package/src/types/verification.ts +5 -0
  273. package/src/types/workflow.ts +141 -0
  274. package/src/utils/amountFormat.ts +73 -0
  275. package/src/utils/tokens.ts +4 -2
  276. package/src/__tests__/cardCrop.test.ts +0 -39
  277. package/src/__tests__/deviceMetadata.test.ts +0 -34
  278. package/src/__tests__/errors.test.ts +0 -34
  279. package/src/__tests__/flow.test.ts +0 -61
  280. package/src/__tests__/gestureDetector.test.ts +0 -37
  281. package/src/__tests__/liveness.test.ts +0 -112
  282. package/src/__tests__/resolveUrl.test.ts +0 -64
  283. package/src/__tests__/validators.test.ts +0 -38
@@ -1,14 +1,18 @@
1
- import React, { useEffect } from 'react';
2
- import { ActivityIndicator, Pressable, View } from 'react-native';
3
- import Svg, { Defs, Mask, Path, Rect } from 'react-native-svg';
4
- import { Camera, useCameraDevice, usePhotoOutput, useVideoOutput } from 'react-native-vision-camera';
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { ActivityIndicator, Platform, View } from 'react-native';
3
+ import { Camera, useCameraDevice, useCameraDevices, usePhotoOutput, useVideoOutput, type CameraRef } from 'react-native-vision-camera';
5
4
 
6
5
  import { radius, spacing } from '../config/theme';
6
+ import { FRAMED_VIEW_AR } from '../services/cardCrop';
7
7
  import { DOCUMENT_VIDEO_BITRATE, VIDEO_CAPTURE_RESOLUTION } from '../config/captureSettings';
8
8
  import { useTheme } from './runtime';
9
+ import { documentHintIsAction, documentHintText, useAutoCapture } from '../capture';
10
+ import { shortDocumentLabel } from '../config/idTypes';
11
+ import type { MrzScan } from '../mrz/parse';
9
12
  import { useVideoRecorder } from './useVideoRecorder';
10
- import { MyazaText } from './Typography';
11
- import { Icon } from './Icon';
13
+ import { CardGuide } from './viewfinder/CardGuide';
14
+ import { HintBanner, OverlayButton, Shutter } from './viewfinder/ViewfinderControls';
15
+ import { ImmersiveOverlay } from './viewfinder/ImmersiveOverlay';
12
16
 
13
17
  // Back-camera viewfinder for document capture — the RN mirror of the Flutter
14
18
  // SDK's _DocumentViewfinder. Shows a VisionCamera preview with an ID-card guide
@@ -17,24 +21,44 @@ import { Icon } from './Icon';
17
21
  // available" error (no device / init failure) is owned by the parent step, which
18
22
  // shows a prominent error + upload fallback. Capture is manual (no edge detection).
19
23
 
20
- // Card window within the 3:4 viewBox (centred). Height follows the per-ID guide
21
- // aspect so the overlay matches the auto-crop (cropCardRegion).
22
- const VB_W = 100;
23
- const VB_H = 133;
24
- const GX = 6;
25
- const GW = 88;
26
- const C = 7; // corner accent length
27
24
 
28
25
  export interface CameraViewfinderProps {
29
26
  active: boolean;
30
27
  side: 'front' | 'back';
31
28
  documentLabel: string;
32
29
  /** Receives the captured still + a best-effort short document video (or null). */
33
- onCapture: (uri: string, videoPath: string | null) => void;
30
+ onCapture: (
31
+ uri: string,
32
+ videoPath: string | null,
33
+ /** Aspect (w÷h) of the box the preview rendered in — what the crop must match. */
34
+ viewAr: number,
35
+ ) => void;
34
36
  /** Card-guide aspect (width ÷ height): 1.586 for ID cards, 1.42 for passports. */
35
37
  guideAspect?: number;
36
38
  /** Capture is disabled while a previous capture/compress is in flight. */
37
39
  busy?: boolean;
40
+ /** ISO-2 country + ID key — what auto-capture checks the document against. */
41
+ country?: string;
42
+ idType?: string;
43
+ /** Banked when a frame yields a check-digit-valid MRZ (the chip's key). */
44
+ onMrz?: (scan: MrzScan) => void;
45
+ /** An MRZ is already stored, so the gate need not hold out for one. */
46
+ mrzAlreadyCaptured?: boolean;
47
+ /**
48
+ * Fill the parent instead of sitting in a 3:4 box.
49
+ *
50
+ * Used by immersive capture: the camera owns the whole screen, because a
51
+ * small preview is exactly what makes users hold a document too far away for
52
+ * the MRZ to resolve.
53
+ */
54
+ fill?: boolean;
55
+ /** Drawn top-left in immersive mode, where the sheet's chrome is gone. */
56
+ onBack?: (() => void) | null;
57
+ /** The gallery escape, kept in immersive mode — it is the fallback when the
58
+ * camera simply will not read a worn or laminated document. */
59
+ onUpload?: (() => void) | null;
60
+ /** Safe-area bottom inset; the full-bleed camera runs under the indicator. */
61
+ bottomInset?: number;
38
62
  }
39
63
 
40
64
  export function CameraViewfinder({
@@ -44,9 +68,29 @@ export function CameraViewfinder({
44
68
  onCapture,
45
69
  guideAspect = 1.586,
46
70
  busy = false,
71
+ country,
72
+ idType,
73
+ onMrz,
74
+ mrzAlreadyCaptured = false,
75
+ fill = false,
76
+ onBack,
77
+ onUpload,
78
+ bottomInset = 0,
47
79
  }: CameraViewfinderProps): React.ReactElement {
48
80
  const { colors } = useTheme();
49
- const device = useCameraDevice('back');
81
+ // Prefer a back camera that CAN torch, then fall back to the library's
82
+ // default ranking. On multi-camera phones (seen live on a Galaxy S24) the
83
+ // default pick can land on a flashless back device — CameraX then rejects
84
+ // enableTorch with "No flash unit" — while a flash-capable back camera sits
85
+ // right next to it in the list. `hasTorch` is trustworthy on the logical
86
+ // device objects (it reads CameraX's hasFlashUnit()); only the
87
+ // physical-device wrappers stub it, and those lose this find() either way.
88
+ const allDevices = useCameraDevices();
89
+ const defaultBack = useCameraDevice('back');
90
+ const device = useMemo(
91
+ () => allDevices.find((d) => d.position === 'back' && d.hasTorch) ?? defaultBack,
92
+ [allDevices, defaultBack],
93
+ );
50
94
  // v5: document-conservative still (OCR). Capture via the photo output, not a
51
95
  // camera ref. Quality is prioritised so small text stays legible.
52
96
  const photoOutput = usePhotoOutput({ qualityPrioritization: 'quality' });
@@ -75,82 +119,194 @@ export function CameraViewfinder({
75
119
  // eslint-disable-next-line react-hooks/exhaustive-deps
76
120
  }, [active, ready, side]);
77
121
 
78
- const GH = GW / guideAspect;
79
- const GY = (VB_H - GH) / 2;
80
122
 
81
- const capture = async () => {
82
- if (busy || !ready) return;
123
+ const capturingRef = useRef(false);
124
+ const capture = useCallback(async () => {
125
+ if (busy || !ready || capturingRef.current) return;
126
+ // Guarded because auto-capture and the manual shutter can now both fire —
127
+ // two concurrent captures contend for the camera and one of them fails.
128
+ capturingRef.current = true;
83
129
  try {
84
130
  // Stop the video first (and grab its path) so the still and the clip don't
85
131
  // contend for the camera, then capture the OCR still.
86
132
  const videoPath = await recorder.stop();
87
133
  const file = await photoOutput.capturePhotoToFile({ flashMode: 'off' }, {});
88
- onCapture(`file://${file.filePath}`, videoPath);
134
+ // The MEASURED box, via ref (state would be stale in this callback).
135
+ // Full-screen capture shows a much taller slice of the photo than the
136
+ // framed 3:4 box did; the crop must be told which one the user saw.
137
+ const b = boxRef.current;
138
+ const viewAr = b.width > 0 && b.height > 0 ? b.width / b.height : FRAMED_VIEW_AR;
139
+ onCapture(`file://${file.filePath}`, videoPath, viewAr);
89
140
  } catch {
90
141
  /* a failed shutter is a no-op — the user can tap again */
142
+ } finally {
143
+ capturingRef.current = false;
144
+ }
145
+ }, [busy, ready, recorder, photoOutput, onCapture]);
146
+
147
+ // ── Auto-capture ──────────────────────────────────────────────────────────
148
+ // Runs only with a document to check against; without one there is nothing to
149
+ // identify the frame as, and firing on "some text" would shoot anything.
150
+ const auto = useAutoCapture({
151
+ enabled: active && ready && !busy && !!country && !!idType,
152
+ country: country ?? '',
153
+ idType: idType ?? '',
154
+ // The back has no branding to identify itself by — the gate relaxes its
155
+ // identity and text-density demands there (see documentIdentity.ts).
156
+ side,
157
+ mrzAlreadyCaptured,
158
+ onMrz,
159
+ guideAspect,
160
+ // Front and back are the same idType; without this the gate stays latched
161
+ // from the front and the back never auto-captures.
162
+ resetKey: side,
163
+ onFire: () => void capture(),
164
+ });
165
+
166
+ // Torch — a document under glass or in a dim room is the commonest reason a
167
+ // capture reads badly, and it is the one thing the user can fix instantly.
168
+ const [torch, setTorch] = useState(false);
169
+ const cameraRef = useRef<CameraRef>(null);
170
+
171
+ // IMPERATIVE, verified toggling — not the declarative `torchMode` prop.
172
+ // The prop path fires the controller call and drops the promise, so a
173
+ // rejection (CameraX refuses enableTorch when the bound camera reports no
174
+ // flash unit) leaves the button lit and the scene dark, with nothing in any
175
+ // log. Await the call, read the REAL state back off the controller (its
176
+ // getter reads CameraX TorchState, unlike the device object, whose
177
+ // `hasTorch` is a hardcoded stub for physical devices in this VisionCamera
178
+ // version), and keep UI state honest either way.
179
+ const toggleTorch = useCallback(async () => {
180
+ const controller = cameraRef.current?.controller;
181
+ if (!controller) return;
182
+ const next = !torch;
183
+ try {
184
+ await controller.setTorchMode(next ? 'on' : 'off');
185
+ const actual = controller.torchMode === 'on';
186
+ setTorch(actual);
187
+ if (actual !== next && __DEV__) {
188
+ console.warn('[kyc.camera] torch state did not follow the request');
189
+ }
190
+ } catch (err) {
191
+ if (__DEV__) console.warn('[kyc.camera] torch toggle failed:', err);
192
+ setTorch(false);
91
193
  }
92
- };
194
+ }, [torch]);
195
+ // Honest again: the device SELECTION above prefers a torch-capable camera,
196
+ // so when this is false there genuinely is no back camera with a flash unit
197
+ // and the button would be a lie. (An earlier fix trusted Android blindly,
198
+ // which produced a visible button driving a flashless camera — CameraX
199
+ // rejected every toggle with "No flash unit".)
200
+ const hasTorch = device?.hasTorch ?? false;
201
+
202
+ const hintText = documentHintText(auto.guidance.hint, documentLabel);
203
+ // The gate's live instruction once it has a document to judge; the static
204
+ // prompt until then (it needs a country + ID type to say anything useful).
205
+ const hint =
206
+ country && idType
207
+ ? hintText
208
+ : `Align the ${side === 'back' ? 'BACK' : 'FRONT'} of your ${documentLabel}`;
209
+ const hintIsAction = documentHintIsAction(auto.guidance.hint);
210
+
211
+ // Immersive insets its own controls (there is no header to sit under).
212
+ //
213
+ // The guide is sized from the MEASURED container, not the window: on iOS the
214
+ // modal is a pageSheet, which is inset from the top of the screen, so window
215
+ // dimensions overshoot — the frame would sit low and the bottom bar would run
216
+ // off the edge. onLayout gives the box this actually renders into.
217
+ const [box, setBox] = useState({ width: 0, height: 0 });
218
+ const boxRef = useRef(box);
219
+ boxRef.current = box;
220
+ const topInset = Platform.OS === 'ios' ? 56 : 32;
93
221
 
94
222
  return (
95
- <View style={{ width: '100%', aspectRatio: 3 / 4, borderRadius: radius.lg, overflow: 'hidden', backgroundColor: '#111111' }}>
96
- {ready ? (
97
- <Camera style={{ flex: 1 }} device={device} isActive={active} outputs={[photoOutput, videoOutput]} />
223
+ <View
224
+ onLayout={
225
+ fill
226
+ ? (e) => {
227
+ const { width, height } = e.nativeEvent.layout;
228
+ if (width !== box.width || height !== box.height) setBox({ width, height });
229
+ }
230
+ : undefined
231
+ }
232
+ style={
233
+ fill
234
+ ? { flex: 1, overflow: 'hidden', backgroundColor: '#000000' }
235
+ : { width: '100%', aspectRatio: 3 / 4, borderRadius: radius.lg, overflow: 'hidden', backgroundColor: '#111111' }
236
+ }
237
+ >
238
+ {/* Mounted only once permission is GRANTED, not merely when a device
239
+ exists. VisionCamera configures its session on mount, and doing that
240
+ during the brief "requesting" window throws "Camera Permission not yet
241
+ granted!" as an uncaught rejection — `isActive={false}` does not
242
+ prevent the configuration. */}
243
+ {ready && active ? (
244
+ <Camera
245
+ ref={cameraRef}
246
+ style={{ flex: 1 }}
247
+ device={device}
248
+ isActive={active}
249
+ outputs={[photoOutput, videoOutput, auto.frameOutput]}
250
+ />
98
251
  ) : (
99
252
  <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
100
253
  <ActivityIndicator color="#FFFFFF" />
101
254
  </View>
102
255
  )}
103
256
 
104
- {/* Card-guide overlay (only over a live preview) */}
105
- {ready ? (
257
+ {ready && active && fill && box.height > 0 ? (
258
+ <ImmersiveOverlay
259
+ side={side}
260
+ country={country ?? ''}
261
+ documentLabel={shortDocumentLabel(idType ?? null, documentLabel)}
262
+ guideAspect={guideAspect}
263
+ hint={hint}
264
+ hintIsAction={hintIsAction}
265
+ progress={auto.progress}
266
+ busy={busy}
267
+ torch={torch}
268
+ hasTorch={hasTorch}
269
+ onToggleTorch={() => void toggleTorch()}
270
+ onCapture={capture}
271
+ onBack={onBack ?? null}
272
+ onUpload={onUpload ?? null}
273
+ bottomInset={bottomInset}
274
+ width={box.width}
275
+ height={box.height}
276
+ topInset={topInset}
277
+ framing={auto.guidance.framing}
278
+ showMrzBand={idType === 'passport'}
279
+ />
280
+ ) : null}
281
+
282
+ {/* Framed (in-sheet) overlay */}
283
+ {ready && active && !fill ? (
106
284
  <>
107
- <Svg style={{ position: 'absolute', inset: 0 }} viewBox={`0 0 ${VB_W} ${VB_H}`} preserveAspectRatio="none">
108
- <Defs>
109
- <Mask id="cardMask">
110
- <Rect width={VB_W} height={VB_H} fill="white" />
111
- <Rect x={GX} y={GY} width={GW} height={GH} rx={3} fill="black" />
112
- </Mask>
113
- </Defs>
114
- <Rect width={VB_W} height={VB_H} fill="rgba(0,0,0,0.55)" mask="url(#cardMask)" />
115
- <Rect x={GX} y={GY} width={GW} height={GH} rx={3} fill="none" stroke="rgba(255,255,255,0.85)" strokeWidth={0.6} strokeDasharray="5 3" />
116
- <Path d={`M${GX},${GY + C} L${GX},${GY} L${GX + C},${GY}`} stroke={colors.primary} strokeWidth={1.6} fill="none" strokeLinecap="round" />
117
- <Path d={`M${GX + GW - C},${GY} L${GX + GW},${GY} L${GX + GW},${GY + C}`} stroke={colors.primary} strokeWidth={1.6} fill="none" strokeLinecap="round" />
118
- <Path d={`M${GX + GW},${GY + GH - C} L${GX + GW},${GY + GH} L${GX + GW - C},${GY + GH}`} stroke={colors.primary} strokeWidth={1.6} fill="none" strokeLinecap="round" />
119
- <Path d={`M${GX + C},${GY + GH} L${GX},${GY + GH} L${GX},${GY + GH - C}`} stroke={colors.primary} strokeWidth={1.6} fill="none" strokeLinecap="round" />
120
- </Svg>
121
- <View style={{ position: 'absolute', top: spacing.md, left: 0, right: 0, alignItems: 'center' }}>
122
- <View style={{ backgroundColor: 'rgba(0,0,0,0.4)', paddingHorizontal: spacing.sm + 4, paddingVertical: 6, borderRadius: radius.full }}>
123
- <MyazaText variant="bodySmall" color="#FFFFFF">
124
- {`Align the ${side === 'back' ? 'BACK' : 'FRONT'} of your ${documentLabel}`}
125
- </MyazaText>
126
- </View>
127
- </View>
285
+ <CardGuide guideAspect={guideAspect} primary={colors.primary} />
286
+ <HintBanner
287
+ fill={false}
288
+ isAction={hintIsAction}
289
+ text={hint}
290
+ />
291
+
292
+ {hasTorch ? (
293
+ <OverlayButton
294
+ icon="lightbulb"
295
+ side="right"
296
+ fill={false}
297
+ active={torch}
298
+ label={torch ? 'Turn off the torch' : 'Turn on the torch'}
299
+ onPress={() => void toggleTorch()}
300
+ />
301
+ ) : null}
128
302
  </>
129
303
  ) : null}
130
304
 
131
- {/* Shutter — disabled when the camera isn't ready */}
132
- <View style={{ position: 'absolute', bottom: spacing.lg, left: 0, right: 0, alignItems: 'center' }}>
133
- <Pressable
134
- onPress={capture}
135
- disabled={busy || !ready}
136
- accessibilityRole="button"
137
- accessibilityLabel="Capture photo"
138
- style={({ pressed }) => ({
139
- width: 64,
140
- height: 64,
141
- borderRadius: radius.full,
142
- backgroundColor: ready ? '#FFFFFF' : colors.gray400,
143
- borderWidth: 4,
144
- borderColor: ready ? colors.primary : colors.gray300,
145
- alignItems: 'center',
146
- justifyContent: 'center',
147
- opacity: busy ? 0.6 : 1,
148
- transform: [{ scale: pressed ? 0.94 : 1 }],
149
- })}
150
- >
151
- {busy ? <ActivityIndicator color={colors.primary} /> : <Icon name="camera" size={24} color={ready ? colors.primary : '#FFFFFF'} />}
152
- </Pressable>
153
- </View>
305
+ {/* Immersive draws its own shutter inside BottomBar (with the torch and
306
+ the upload escape), so only the framed variant needs this one. */}
307
+ {fill ? null : (
308
+ <Shutter onPress={capture} ready={ready} busy={busy} progress={auto.progress} />
309
+ )}
154
310
  </View>
155
311
  );
156
312
  }
@@ -0,0 +1,139 @@
1
+ import React, { useRef } from 'react';
2
+ import { Pressable, TextInput, View } from 'react-native';
3
+
4
+ import { radius, spacing } from '../config/theme';
5
+ import { useTheme } from './runtime';
6
+ import { MyazaText } from './Typography';
7
+ import { MyazaInput } from './MyazaInput';
8
+ import type { OtpInputStyle } from '../types/workflow';
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // The OTP code field.
12
+ //
13
+ // Which one renders — segmented boxes or a plain field — is the ORG's choice in
14
+ // the workflow builder (`inputStyle`), not the end user's.
15
+ //
16
+ // The segmented version is ONE hidden TextInput with boxes drawn over it, not
17
+ // N inputs with focus-juggling between them. That is what makes SMS autofill
18
+ // work (the platform fills a single field), makes paste work, and makes
19
+ // backspace behave — a per-box implementation breaks all three, and they are
20
+ // the three things people actually do with a code.
21
+ // ---------------------------------------------------------------------------
22
+
23
+ export function ContactCodeEntry({
24
+ code,
25
+ onChange,
26
+ codeLength,
27
+ style,
28
+ disabled,
29
+ onComplete,
30
+ }: {
31
+ code: string;
32
+ onChange: (code: string) => void;
33
+ codeLength: number;
34
+ /** Config-driven field style ('segmented' default). */
35
+ style: OtpInputStyle;
36
+ disabled?: boolean;
37
+ /** Fires with the full code once the last slot fills (auto-submit). */
38
+ onComplete?: (code: string) => void;
39
+ }): React.ReactElement {
40
+ const handle = (raw: string): void => {
41
+ const next = raw.replace(/\D/g, '').slice(0, codeLength);
42
+ onChange(next);
43
+ if (next.length === codeLength) onComplete?.(next);
44
+ };
45
+
46
+ if (style === 'text') {
47
+ return (
48
+ <MyazaInput
49
+ label="Verification code"
50
+ value={code}
51
+ onChangeText={handle}
52
+ placeholder={'0'.repeat(codeLength)}
53
+ keyboardType="number-pad"
54
+ maxLength={codeLength}
55
+ editable={!disabled}
56
+ autoFocus
57
+ />
58
+ );
59
+ }
60
+
61
+ return <SegmentedCode code={code} onChangeText={handle} codeLength={codeLength} disabled={disabled} />;
62
+ }
63
+
64
+ function SegmentedCode({
65
+ code,
66
+ onChangeText,
67
+ codeLength,
68
+ disabled,
69
+ }: {
70
+ code: string;
71
+ onChangeText: (raw: string) => void;
72
+ codeLength: number;
73
+ disabled?: boolean;
74
+ }): React.ReactElement {
75
+ const { colors } = useTheme();
76
+ const inputRef = useRef<TextInput>(null);
77
+
78
+ return (
79
+ <View>
80
+ {/* Boxes flex to fill the width at a fixed aspect ratio, and the FILLED
81
+ ones carry the primary border — same geometry and same signal as the
82
+ Flutter SDK's OtpInput, so the step reads identically on both. */}
83
+ <Pressable
84
+ onPress={() => inputRef.current?.focus()}
85
+ accessibilityRole="none"
86
+ style={{ flexDirection: 'row', gap: spacing.xs }}
87
+ >
88
+ {Array.from({ length: codeLength }, (_, i) => {
89
+ const filled = i < code.length;
90
+ return (
91
+ <View
92
+ key={i}
93
+ style={{
94
+ flex: 1,
95
+ aspectRatio: 0.82,
96
+ borderRadius: radius.md,
97
+ borderWidth: filled ? 1.5 : 1,
98
+ borderColor: filled ? colors.primary : colors.border,
99
+ backgroundColor: colors.background,
100
+ alignItems: 'center',
101
+ justifyContent: 'center',
102
+ }}
103
+ >
104
+ <MyazaText variant="heading2">{code[i] ?? ''}</MyazaText>
105
+ </View>
106
+ );
107
+ })}
108
+ </Pressable>
109
+
110
+ {/* One real input behind the boxes: the platform's SMS autofill targets a
111
+ single field, and paste/backspace only behave against one value. It is
112
+ positioned over the boxes (not display:none) because a zero-size or
113
+ hidden input is skipped by autofill on both platforms. */}
114
+ <TextInput
115
+ ref={inputRef}
116
+ value={code}
117
+ onChangeText={onChangeText}
118
+ keyboardType="number-pad"
119
+ textContentType="oneTimeCode"
120
+ autoComplete="one-time-code"
121
+ maxLength={codeLength}
122
+ editable={!disabled}
123
+ autoFocus
124
+ caretHidden
125
+ accessibilityLabel="Verification code"
126
+ style={{
127
+ position: 'absolute',
128
+ top: 0,
129
+ left: 0,
130
+ right: 0,
131
+ bottom: 0,
132
+ opacity: 0.01,
133
+ color: 'transparent',
134
+ textAlign: 'center',
135
+ }}
136
+ />
137
+ </View>
138
+ );
139
+ }
@@ -0,0 +1,87 @@
1
+ import React, { useState } from 'react';
2
+ import { Pressable, View } from 'react-native';
3
+
4
+ import { radius, spacing } from '../config/theme';
5
+ import { useTheme } from './runtime';
6
+ import { MyazaText } from './Typography';
7
+ import { CountryFlag } from './CountryFlag';
8
+ import { DialCodePicker, type DialCodeOption } from './DialCodePicker';
9
+ import { Icon } from './Icon';
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // A country select field: a MyazaSelect-styled collapsed trigger (flag + name
13
+ // + chevron) that opens THE country sheet — the phone field's dial-code picker
14
+ // minus the dial codes (keyboard-aware, autofocused search, results pinned
15
+ // above the keys). Used wherever a country is picked from a flat list (the
16
+ // key-person "where their ID was issued", the KYB "Country of registration"),
17
+ // so every country picker in the flow feels identical.
18
+ // ---------------------------------------------------------------------------
19
+
20
+ export function CountryField({
21
+ value,
22
+ options,
23
+ onChange,
24
+ placeholder = 'Select a country',
25
+ searchPlaceholder = 'Search country',
26
+ }: {
27
+ /** ISO-2 of the current pick; empty/null shows the placeholder. */
28
+ value: string | null;
29
+ /** The pickable countries — all ISO, or a workflow's registry subset. */
30
+ options: DialCodeOption[];
31
+ onChange: (code: string) => void;
32
+ placeholder?: string;
33
+ searchPlaceholder?: string;
34
+ }): React.ReactElement {
35
+ const { colors } = useTheme();
36
+ const [open, setOpen] = useState(false);
37
+ const name = value ? (options.find((o) => o.code === value)?.name ?? value) : null;
38
+
39
+ return (
40
+ <>
41
+ <Pressable
42
+ onPress={() => setOpen(true)}
43
+ accessibilityRole="button"
44
+ accessibilityLabel={name ?? placeholder}
45
+ style={{
46
+ flexDirection: 'row',
47
+ alignItems: 'center',
48
+ paddingHorizontal: spacing.md,
49
+ paddingVertical: spacing.md,
50
+ borderRadius: radius.sm,
51
+ borderWidth: 1,
52
+ borderColor: colors.border,
53
+ backgroundColor: colors.background,
54
+ }}
55
+ >
56
+ {value ? (
57
+ <>
58
+ <CountryFlag country={value} size={20} />
59
+ <View style={{ width: spacing.sm }} />
60
+ </>
61
+ ) : null}
62
+ <MyazaText
63
+ variant="body"
64
+ color={name ? undefined : colors.textMuted}
65
+ style={{ flex: 1 }}
66
+ numberOfLines={1}
67
+ >
68
+ {name ?? placeholder}
69
+ </MyazaText>
70
+ <View style={{ width: spacing.sm }} />
71
+ <Icon name="chevron-down" size={18} color={colors.textSecondary} />
72
+ </Pressable>
73
+
74
+ <DialCodePicker
75
+ visible={open}
76
+ options={options}
77
+ selected={value ?? ''}
78
+ searchPlaceholder={searchPlaceholder}
79
+ onPick={(code) => {
80
+ setOpen(false);
81
+ onChange(code);
82
+ }}
83
+ onClose={() => setOpen(false)}
84
+ />
85
+ </>
86
+ );
87
+ }
@@ -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